mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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:
co-authored by
Claude Opus 5.5
parent
9195d2aff5
commit
2036c59509
@@ -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
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user