From 2036c595099f84bbd2dfe6c14ced62895f0fbaec Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:36:54 -0400 Subject: [PATCH] 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 --- config/renderer-layout-writer-baseline.txt | 8 +- src/renderer/src/store/repos/repo-removal.ts | 39 +--- .../src/store/slices/ssh-target-cleanup.ts | 30 +-- .../slices/tabs/worktree-layout-lifecycle.ts | 195 ++++++++++++++++++ .../worktrees/session/set-active-worktree.ts | 40 +--- .../session/worktree-identity-rename-state.ts | 29 +-- .../teardown/remove-worktree-store-cleanup.ts | 32 +-- .../teardown/worktree-purge-state.ts | 32 +-- 8 files changed, 229 insertions(+), 176 deletions(-) create mode 100644 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index 50775b2cb2b..7203ba31ba6 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -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 diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 9f39a8f4db6..13ad0478827 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -165,11 +165,9 @@ export function createRepoRemovalActions( .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) ] : [] - const killedTabIds = new Set() 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, diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index ed620cf1e31..57823f6c6f1 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -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, @@ -134,8 +135,7 @@ function clearSshTargetTabPtyState( | 'pendingCodexPaneRestartIds' | 'codexRestartNoticeByPtyId' > & { changed: boolean } { - let nextTabsByWorktree = state.tabsByWorktree - const nextPtyIdsByTabId = { ...state.ptyIdsByTabId } + const unboundTabIds = new Set() 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 diff --git a/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts b/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts new file mode 100644 index 00000000000..6e47fddb79c --- /dev/null +++ b/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts @@ -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 + tabIds: ReadonlySet + activeFileCleared: boolean + } +): WorktreeLayoutPatch { + const { worktreeIds, tabIds, activeFileCleared } = removed + const omitByWorktree = (m: Record | undefined) => omitRecordKeys(m, worktreeIds) + const omitByTabId = (m: Record | 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( + map: Record | undefined, + oldWorktreeId: string, + newWorktreeId: string, + mapValue: (value: T) => T = (value) => value +): Record | 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 { + const withNewWorktreeId = (value: T): T => + value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const rekey = (map: Record | undefined, mapValue?: (value: T) => T) => + rekeyWorktreeEntry(map, oldWorktreeId, newWorktreeId, mapValue) + const patch: Partial = {} + const assign = ( + 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 { + 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 +): Pick { + 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 } +} diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 913620ba43a..eafdd0e40fe 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -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 + }) }) } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index 7cfbd6f674c..0fa6af89996 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -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> = { - 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), - ...(openFiles !== s.openFiles ? { openFiles } : {}), ...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}), ...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace ? { recentlyClosedBrowserPagesByWorkspace } 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 202ad814a45..4355a889e2d 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 @@ -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 = (m: Record | 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, diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 22077d75162..6470ffcb550 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -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 + })() } }