refactor(store): route worktree, repo and SSH layout writes through tabs-slice transitions (#28892)

Worktree removal, purge, rename, activation wake and SSH target cleanup now
build their tab/group/pane layout changes with named transitions in
tabs/worktree-layout-lifecycle.ts, folded into the same single store update.
Project removal drops its layout writes: purgeWorktreeTerminalState, run just
before in the same call, already cleared every one of them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-14 20:36:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9195d2aff5
commit 2036c59509
8 changed files with 229 additions and 176 deletions
+2 -6
View File
@@ -51,7 +51,6 @@
1 src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts
2 src/renderer/src/runtime/web-session-terminal-orphan-topology.ts
3 src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.ts
9 src/renderer/src/store/repos/repo-removal.ts
10 src/renderer/src/store/slices/browser/browser-close-actions.ts
6 src/renderer/src/store/slices/browser/browser-hydration-actions.ts
5 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts
@@ -81,7 +80,6 @@
2 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts
1 src/renderer/src/store/slices/editor/file-ids/open-file-path-rekey.ts
4 src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts
2 src/renderer/src/store/slices/ssh-target-cleanup.ts
2 src/renderer/src/store/slices/tab-group-reference-repair.ts
4 src/renderer/src/store/slices/tab-group-state.ts
2 src/renderer/src/store/slices/tabs-hydration.ts
@@ -98,14 +96,12 @@
4 src/renderer/src/store/slices/tabs/tabs-session-actions.ts
1 src/renderer/src/store/slices/tabs/tabs-split-ratio-actions.ts
5 src/renderer/src/store/slices/tabs/tabs-surface.ts
33 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts
6 src/renderer/src/store/slices/terminal-orphan-helpers.ts
4 src/renderer/src/store/slices/terminal-tab-title-batch.ts
6 src/renderer/src/store/slices/terminals.ts
7 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts
15 src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts
5 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts
2 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
18 src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts
18 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts
5 src/renderer/src/store/terminals/terminal-layout-state.ts
2 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
3 src/renderer/src/store/terminals/terminal-pty-bindings.ts
+1 -38
View File
@@ -165,11 +165,9 @@ export function createRepoRemovalActions(
.flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : []))
]
: []
const killedTabIds = new Set<string>()
for (const wId of worktreeIds) {
const tabs = get().tabsByWorktree[wId] ?? []
for (const tab of tabs) {
killedTabIds.add(tab.id)
for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) {
if (!isRemoteRuntimePtyId(ptyId)) {
window.api.pty.kill(ptyId)
@@ -205,35 +203,11 @@ export function createRepoRemovalActions(
delete nextDetectedWorktrees[projectId]
}
}
const nextTabs = { ...s.tabsByWorktree }
const nextLayouts = { ...s.terminalLayoutsByTabId }
const nextLocalOnlyScrollback = { ...s.localOnlyScrollbackByTabId }
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
for (const wId of worktreeIds) {
delete nextTabs[wId]
}
for (const tabId of killedTabIds) {
delete nextLayouts[tabId]
delete nextLocalOnlyScrollback[tabId]
delete nextPtyIdsByTabId[tabId]
delete nextRuntimePaneTitlesByTabId[tabId]
}
// Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save.
// Why no tab/layout/editor writes here: purgeWorktreeTerminalState above already dropped them.
const worktreeIdSet = new Set(worktreeIds)
const removedVisitKeys = new Set(
worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId))
)
const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId))
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
for (const wId of worktreeIds) {
delete nextActiveFileIdByWorktree[wId]
delete nextActiveTabTypeByWorktree[wId]
}
const activeFileCleared = s.activeFileId
? s.openFiles.some((f) => f.id === s.activeFileId && worktreeIdSet.has(f.worktreeId))
: false
const nextRepos = s.repos.filter(
(r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)
)
@@ -268,17 +242,6 @@ export function createRepoRemovalActions(
filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId),
worktreesByRepo: nextWorktrees,
detectedWorktreesByRepo: nextDetectedWorktrees,
tabsByWorktree: nextTabs,
ptyIdsByTabId: nextPtyIdsByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
terminalLayoutsByTabId: nextLayouts,
localOnlyScrollbackByTabId: nextLocalOnlyScrollback,
activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId,
openFiles: nextOpenFiles,
activeFileIdByWorktree: nextActiveFileIdByWorktree,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId,
folderWorkspacePathStatuses: {},
sortEpoch: s.sortEpoch + 1,
@@ -3,6 +3,7 @@ import type { SshTarget, SshTargetSummary } from '../../../../shared/ssh-types'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation'
import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope'
import { unbindTerminalTabPtys } from './tabs/worktree-layout-lifecycle'
export function sshTargetLabelsEqual(
labels: Map<string, string>,
@@ -134,8 +135,7 @@ function clearSshTargetTabPtyState(
| 'pendingCodexPaneRestartIds'
| 'codexRestartNoticeByPtyId'
> & { changed: boolean } {
let nextTabsByWorktree = state.tabsByWorktree
const nextPtyIdsByTabId = { ...state.ptyIdsByTabId }
const unboundTabIds = new Set<string>()
const nextLastKnownRelayPtyIdByTabId = {
...state.lastKnownRelayPtyIdByTabId
}
@@ -143,11 +143,9 @@ function clearSshTargetTabPtyState(
...state.pendingCodexPaneRestartIds
}
const nextCodexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId }
let changed = false
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
let nextTabs = tabs
for (const [index, tab] of tabs.entries()) {
for (const tabs of Object.values(state.tabsByWorktree)) {
for (const tab of tabs) {
const lastKnownPtyId = state.lastKnownRelayPtyIdByTabId[tab.id]
const ptyIds = [
...new Set([
@@ -164,32 +162,18 @@ function clearSshTargetTabPtyState(
if (!tab.ptyId && ptyIds.length === 0 && nextLastKnownRelayPtyIdByTabId[tab.id] == null) {
continue
}
changed = true
if (nextTabs === tabs) {
nextTabs = [...tabs]
}
const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab
void _pendingActivationSpawn
nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null }
nextPtyIdsByTabId[tab.id] = []
unboundTabIds.add(tab.id)
delete nextLastKnownRelayPtyIdByTabId[tab.id]
for (const ptyId of ptyIds) {
delete nextPendingCodexPaneRestartIds[ptyId]
delete nextCodexRestartNoticeByPtyId[ptyId]
}
}
if (nextTabs !== tabs) {
if (nextTabsByWorktree === state.tabsByWorktree) {
nextTabsByWorktree = { ...nextTabsByWorktree }
}
nextTabsByWorktree[worktreeId] = nextTabs
}
}
return {
changed,
tabsByWorktree: nextTabsByWorktree,
ptyIdsByTabId: nextPtyIdsByTabId,
changed: unboundTabIds.size > 0,
...unbindTerminalTabPtys(state, unboundTabIds),
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds,
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId
@@ -0,0 +1,195 @@
import type { AppState } from '../../types'
import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import type { TerminalTab } from '../../../../../shared/terminal-tab-types'
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
import { getTerminalActivationSpawnSuppression } from '../terminal-activation-spawn-suppression'
// Layout transitions that worktree, repo and SSH lifecycle code folds into its own store update.
// Pure patches, not actions: each caller's update is one atomic set that also clears non-layout state.
type WorktreeLayoutPatch = Pick<
AppState,
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'ptyIdsByTabId'
| 'unifiedTabsByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
| 'activeGroupIdByWorktree'
| 'tabBarOrderByWorktree'
| 'browserTabsByWorktree'
| 'openFiles'
| 'activeTabIdByWorktree'
| 'activeFileIdByWorktree'
| 'activeBrowserTabIdByWorktree'
| 'activeTabTypeByWorktree'
| 'activeTabId'
| 'activeFileId'
| 'activeBrowserTabId'
| 'activeTabType'
>
/** Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into them. */
export function removeWorktreeLayout(
s: AppState,
removed: {
worktreeIds: ReadonlySet<string>
tabIds: ReadonlySet<string>
activeFileCleared: boolean
}
): WorktreeLayoutPatch {
const { worktreeIds, tabIds, activeFileCleared } = removed
const omitByWorktree = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, worktreeIds)
const omitByTabId = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, tabIds)
const removedActiveWorktree = s.activeWorktreeId != null && worktreeIds.has(s.activeWorktreeId)
return {
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
// Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable.
unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree),
groupsByWorktree: omitByWorktree(s.groupsByWorktree),
layoutByWorktree: omitByWorktree(s.layoutByWorktree),
activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree),
// Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained.
tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree),
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
// Why guarded: a removed worktree usually has no open file, and an unconditional
// filter would hand openFiles a new identity anyway.
openFiles: s.openFiles.some((f) => worktreeIds.has(f.worktreeId))
? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId))
: s.openFiles,
activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree),
activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree),
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree),
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId,
activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType
}
}
function rekeyWorktreeEntry<T>(
map: Record<string, T> | undefined,
oldWorktreeId: string,
newWorktreeId: string,
mapValue: (value: T) => T = (value) => value
): Record<string, T> | undefined {
if (!map || !(oldWorktreeId in map)) {
return undefined
}
const next = { ...map }
next[newWorktreeId] = mapValue(map[oldWorktreeId])
delete next[oldWorktreeId]
return next
}
/** Moves a renamed worktree's tabs, groups, pane layouts and selection to its new id. */
export function renameWorktreeLayoutKeys(
s: AppState,
oldWorktreeId: string,
newWorktreeId: string,
renameBrowserWorkspace: (workspace: BrowserWorkspace) => BrowserWorkspace
): Partial<WorktreeLayoutPatch> {
const withNewWorktreeId = <T extends { worktreeId: string }>(value: T): T =>
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
const rekey = <T>(map: Record<string, T> | undefined, mapValue?: (value: T) => T) =>
rekeyWorktreeEntry(map, oldWorktreeId, newWorktreeId, mapValue)
const patch: Partial<WorktreeLayoutPatch> = {}
const assign = <K extends keyof WorktreeLayoutPatch>(
key: K,
value: WorktreeLayoutPatch[K] | undefined
): void => {
if (value !== undefined) {
patch[key] = value
}
}
assign(
'tabsByWorktree',
rekey(s.tabsByWorktree, (tabs) => tabs.map(withNewWorktreeId))
)
assign(
'browserTabsByWorktree',
rekey(s.browserTabsByWorktree, (workspaces) => workspaces.map(renameBrowserWorkspace))
)
assign('activeBrowserTabIdByWorktree', rekey(s.activeBrowserTabIdByWorktree))
assign('activeFileIdByWorktree', rekey(s.activeFileIdByWorktree))
assign('activeTabTypeByWorktree', rekey(s.activeTabTypeByWorktree))
assign('activeTabIdByWorktree', rekey(s.activeTabIdByWorktree))
assign('tabBarOrderByWorktree', rekey(s.tabBarOrderByWorktree))
assign(
'unifiedTabsByWorktree',
rekey(s.unifiedTabsByWorktree, (tabs) => tabs.map(withNewWorktreeId))
)
assign(
'groupsByWorktree',
rekey(s.groupsByWorktree, (groups) => groups.map(withNewWorktreeId))
)
assign('layoutByWorktree', rekey(s.layoutByWorktree))
assign('activeGroupIdByWorktree', rekey(s.activeGroupIdByWorktree))
if (s.openFiles?.some((f) => f.worktreeId === oldWorktreeId)) {
assign('openFiles', s.openFiles.map(withNewWorktreeId))
}
return patch
}
/**
* Tags a worktree's wakeable terminal tabs for an activation spawn, optionally bumping their
* generation so dead panes remount.
*/
export function markWorktreeTabsForActivationSpawn(
s: AppState,
worktreeId: string,
options: { isWakeable: (tab: TerminalTab) => boolean; bumpGeneration: boolean }
): Pick<AppState, 'tabsByWorktree'> {
const { isWakeable, bumpGeneration } = options
return {
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((tab) =>
!isWakeable(tab)
? tab
: {
...tab,
...(bumpGeneration ? { generation: (tab.generation ?? 0) + 1 } : {}),
// Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent.
pendingActivationSpawn: getTerminalActivationSpawnSuppression(
s.terminalLayoutsByTabId[tab.id]
)
}
)
}
}
}
/** Detaches terminal tabs from their PTYs, leaving the tabs in place to respawn. */
export function unbindTerminalTabPtys(
s: AppState,
tabIds: ReadonlySet<string>
): Pick<AppState, 'tabsByWorktree' | 'ptyIdsByTabId'> {
let nextTabsByWorktree = s.tabsByWorktree
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) {
let nextTabs = tabs
for (const [index, tab] of tabs.entries()) {
if (!tabIds.has(tab.id)) {
continue
}
if (nextTabs === tabs) {
nextTabs = [...tabs]
}
const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab
void _pendingActivationSpawn
nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null }
nextPtyIdsByTabId[tab.id] = []
}
if (nextTabs !== tabs) {
if (nextTabsByWorktree === s.tabsByWorktree) {
nextTabsByWorktree = { ...nextTabsByWorktree }
}
nextTabsByWorktree[worktreeId] = nextTabs
}
}
return { tabsByWorktree: nextTabsByWorktree, ptyIdsByTabId: nextPtyIdsByTabId }
}
@@ -9,7 +9,7 @@ import {
ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS,
ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS
} from '../listing/worktree-slice-constants'
import { getTerminalActivationSpawnSuppression } from '../../terminal-activation-spawn-suppression'
import { markWorktreeTabsForActivationSpawn } from '../../tabs/worktree-layout-lifecycle'
import {
isWorkspaceKey,
parseWorkspaceKey,
@@ -114,22 +114,7 @@ export function createSetActiveWorktree(
: s.activeRepoId
const tabsByWorktreeUpdate =
allDead && worktreeId != null
? {
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: tabs.map((tab) =>
!isWakeable(tab)
? tab
: {
...tab,
generation: (tab.generation ?? 0) + 1,
pendingActivationSpawn: getTerminalActivationSpawnSuppression(
s.terminalLayoutsByTabId[tab.id]
)
}
)
}
}
? markWorktreeTabsForActivationSpawn(s, worktreeId, { isWakeable, bumpGeneration: true })
: {}
const nextActiveTabTypeByWorktree =
@@ -199,23 +184,10 @@ export function createSetActiveWorktree(
if (!allDead && !shouldTagTerminalTabs) {
return s
}
return {
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: tabs.map((tab) =>
!isWakeable(tab)
? tab
: {
...tab,
...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}),
// Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent.
pendingActivationSpawn: getTerminalActivationSpawnSuppression(
s.terminalLayoutsByTabId[tab.id]
)
}
)
}
}
return markWorktreeTabsForActivationSpawn(s, worktreeId, {
isWakeable,
bumpGeneration: allDead
})
})
}
@@ -11,30 +11,21 @@ import {
remapClosedTerminalTabSnapshotCwds,
type ClosedTerminalTabSnapshot
} from '../../recently-closed-tabs'
import { renameWorktreeLayoutKeys } from '../../tabs/worktree-layout-lifecycle'
// Every worktree-id-keyed store map the rename path re-keys, so a new `*ByWorktree` map isn't silently missed.
// Tab-id/file-id-keyed maps are deliberately excluded: tabs and files keep their ids across a rename.
// Layout maps are re-keyed by renameWorktreeLayoutKeys. Tab-id/file-id-keyed maps are deliberately
// excluded: tabs and files keep their ids across a rename.
const WORKTREE_ID_KEYED_MAP_KEYS = [
'worktreeLineageById',
'tabsByWorktree',
'deleteStateByWorktreeId',
'baseStatusByWorktreeId',
'remoteBranchConflictByWorktreeId',
'fileSearchStateByWorktree',
'browserTabsByWorktree',
'recentlyClosedBrowserTabsByWorktree',
'activeBrowserTabIdByWorktree',
'activeFileIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'tabBarOrderByWorktree',
'pendingReconnectTabByWorktree',
'rightSidebarTabByWorktree',
'rightSidebarExplorerViewByWorktree',
'unifiedTabsByWorktree',
'groupsByWorktree',
'layoutByWorktree',
'activeGroupIdByWorktree',
'gitStatusByWorktree',
'gitStatusHeadByWorktree',
'gitBranchLineTotalByWorktree',
@@ -100,9 +91,6 @@ export function buildWorktreeRenameState(
: renamedValue
}
const renameValueByKey: Partial<Record<(typeof WORKTREE_ID_KEYED_MAP_KEYS)[number], unknown>> = {
tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId),
browserTabsByWorktree: (workspaces: BrowserWorkspace[]) =>
workspaces.map(withNewBrowserWorktreeId),
recentlyClosedBrowserTabsByWorktree: (
snapshots: { workspace: BrowserWorkspace; pages: BrowserPage[] }[]
) =>
@@ -114,9 +102,7 @@ export function buildWorktreeRenameState(
fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({
...searchState,
resultOwner: searchState.resultOwner ? withNewWorktreeId(searchState.resultOwner) : null
}),
unifiedTabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId),
groupsByWorktree: (groups: { worktreeId: string }[]) => groups.map(withNewWorktreeId)
})
}
for (const key of WORKTREE_ID_KEYED_MAP_KEYS) {
renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined)
@@ -151,11 +137,6 @@ export function buildWorktreeRenameState(
)
renameKey('remoteStatusesByWorktree')
const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId)
? s.openFiles.map((f) =>
f.worktreeId === oldWorktreeId ? { ...f, worktreeId: newWorktreeId } : f
)
: s.openFiles
const currentBrowserPagesByWorkspace = s.browserPagesByWorkspace ?? {}
const browserPagesByWorkspace = Object.values(currentBrowserPagesByWorkspace).some((pages) =>
pages.some(
@@ -243,8 +224,8 @@ export function buildWorktreeRenameState(
: s.sleepingAgentSessionsByPaneKey
return {
...renameWorktreeLayoutKeys(s, oldWorktreeId, newWorktreeId, withNewBrowserWorktreeId),
...(renamed as Partial<AppState>),
...(openFiles !== s.openFiles ? { openFiles } : {}),
...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}),
...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace
? { recentlyClosedBrowserPagesByWorkspace }
@@ -5,6 +5,7 @@ import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
import { omitRecordKeys } from './record-key-omission'
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
/** Clears worktree-owned renderer state after successful removal so a reused identity cannot inherit stale preferences. */
export function applyRemoveWorktreeSuccessState(
@@ -36,31 +37,27 @@ export function applyRemoveWorktreeSuccessState(
}
}
const omitByFileId = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, removedFileIds)
// Why guarded: a removed worktree usually has no open file, and an unconditional
// filter would hand openFiles a new identity anyway — the sibling purge path
// already does this.
const nextOpenFiles = s.openFiles.some((f) => f.worktreeId === worktreeId)
? s.openFiles.filter((f) => f.worktreeId !== worktreeId)
: s.openFiles
// 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)
: false
const removedActiveWorktree = s.activeWorktreeId === worktreeId
return {
...removeWorktreeLayout(s, {
worktreeIds: new Set(worktreeIds),
tabIds,
activeFileCleared
}),
worktreesByRepo: nextWorktreesByRepo,
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
workspaceLineageByChildKey: omitRecordKeys(s.workspaceLineageByChildKey, [
worktreeWorkspaceKey(worktreeId)
]),
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds),
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
// Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags.
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
@@ -86,24 +83,10 @@ export function applyRemoveWorktreeSuccessState(
activeWorkspaceExecutionHostId: removedActiveWorktree
? null
: s.activeWorkspaceExecutionHostId,
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
openFiles: nextOpenFiles,
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
// Why: closeBrowserTab records a Cmd+Shift+T undo snapshot, but a deleted worktree's tabs can't be restored; purge it.
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree),
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree),
rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree),
activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree),
// Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained.
tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree),
pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree),
// Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable.
unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree),
groupsByWorktree: omitByWorktree(s.groupsByWorktree),
layoutByWorktree: omitByWorktree(s.layoutByWorktree),
activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree),
editorDrafts: omitByFileId(s.editorDrafts),
markdownViewMode: omitByFileId(s.markdownViewMode),
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
@@ -129,9 +112,6 @@ export function applyRemoveWorktreeSuccessState(
gitBranchCompareRequestStatusHeadByWorktree: omitByWorktree(
s.gitBranchCompareRequestStatusHeadByWorktree
),
activeFileId: activeFileCleared ? null : s.activeFileId,
activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId,
activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType,
everActivatedWorktreeIds: s.everActivatedWorktreeIds.has(worktreeId)
? new Set([...s.everActivatedWorktreeIds].filter((id) => id !== worktreeId))
: s.everActivatedWorktreeIds,
@@ -25,6 +25,7 @@ import {
structuredAgentSessionTargetForHost
} from '@/runtime/structured-agent-session-owner'
import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions'
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
/** Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual terminal teardown. */
export function buildWorktreePurgeState(
@@ -115,13 +116,8 @@ export function buildWorktreePurgeState(
omitByFileId
} = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
const nextOpenFiles = s.openFiles.some((f) => worktreeIdSet.has(f.worktreeId))
? s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId))
: s.openFiles
const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId)
const activeFileCleared = s.activeFileId != null && doomed.removedFileIds.has(s.activeFileId)
const activeTabCleared = s.activeTabId != null && doomed.doomedTabIds.has(s.activeTabId)
const nextEverActivatedWorktreeIds = (() => {
let hit = false
@@ -143,13 +139,15 @@ export function buildWorktreePurgeState(
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
return {
...removeWorktreeLayout(s, {
worktreeIds: worktreeIdSet,
tabIds: doomed.doomedTabIds,
activeFileCleared
}),
// Worktree-scoped terminal/tab state
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey),
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
@@ -195,10 +193,8 @@ export function buildWorktreePurgeState(
// File search
fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree),
// Browser state
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
// Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees.
browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId),
browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId),
@@ -212,18 +208,9 @@ export function buildWorktreePurgeState(
s.recentlyClosedBrowserPagesByWorkspace
),
// Editor state
activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree),
activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree),
activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree),
tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree),
pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree),
rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(),
rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree ?? {}),
// Split-tab / unified tab state
unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree),
groupsByWorktree: omitByWorktree(s.groupsByWorktree),
layoutByWorktree: omitByWorktree(s.layoutByWorktree),
activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree),
// Git status caches
gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree),
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry).
@@ -257,7 +244,6 @@ export function buildWorktreePurgeState(
recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree),
recentlyClosedTabKindsByWorktree: omitByWorktree(s.recentlyClosedTabKindsByWorktree),
// Top-level actives
openFiles: nextOpenFiles,
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets(
s.lastVisitedAtByWorktreeId,
@@ -277,10 +263,6 @@ export function buildWorktreePurgeState(
return activeScope?.type === 'worktree' && worktreeIdSet.has(activeScope.worktreeId)
? null
: s.activeWorkspaceKey
})(),
activeFileId: activeFileCleared ? null : s.activeFileId,
activeBrowserTabId: removedActive ? null : s.activeBrowserTabId,
activeTabId: activeTabCleared ? null : s.activeTabId,
activeTabType: removedActive || activeFileCleared ? 'terminal' : s.activeTabType
})()
}
}