mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(worktrees): a host-scoped purge leaves a surviving twin's tabs and edits alone (#30030)
This commit is contained in:
@@ -459,21 +459,18 @@
|
||||
1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts
|
||||
3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts
|
||||
3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts
|
||||
7 src/renderer/src/store/slices/worktrees.ts
|
||||
5 src/renderer/src/store/slices/worktrees.ts
|
||||
1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts
|
||||
1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts
|
||||
2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts
|
||||
2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts
|
||||
1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts
|
||||
2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts
|
||||
2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts
|
||||
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts
|
||||
3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts
|
||||
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts
|
||||
1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
|
||||
3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts
|
||||
1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts
|
||||
1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts
|
||||
1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts
|
||||
2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
|
||||
1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
|
||||
1 src/renderer/src/store/terminals/terminal-shutdown.ts
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { replaceRuntimeEnvironmentRevisions } from '../../runtime/runtime-environment-revision'
|
||||
import { advanceRuntimeEnvironmentConnectionGeneration } from '../../store/slices/runtime-status-connection-generation'
|
||||
import { createWorktreeEventRuntime } from './worktree-event-runtime'
|
||||
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
beginWorktreeLineageRefresh,
|
||||
invalidateRemoteWorktreeLineage,
|
||||
@@ -219,7 +220,7 @@ describe('remote worktree event lineage refresh', () => {
|
||||
expect(store.getState().fetchWorktreeLineage).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().setActiveWorktree).toHaveBeenCalledWith('repo-rename::/new')
|
||||
expect(store.getState().purgeWorktreeTerminalState).toHaveBeenCalledWith([
|
||||
{ id: deleted.id, hostId: 'runtime:env-1' }
|
||||
hostWorktreeTarget({ id: deleted.id, hostId: 'runtime:env-1' })
|
||||
])
|
||||
expect(store.getState().removeWorkspaceSpaceWorktrees).toHaveBeenCalledWith([deleted.id])
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
|
||||
import type { AppState } from '../../store/types'
|
||||
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { WorktreeSortOrderUpdate } from '../../../../shared/worktree/sort-order-update'
|
||||
import {
|
||||
hasHostRowsForSortOrders,
|
||||
@@ -189,7 +190,7 @@ export function createWorktreeEventRuntime(
|
||||
// A forced-local refresh owns only the local host's panes.
|
||||
(options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined)
|
||||
afterState.purgeWorktreeTerminalState(
|
||||
purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed
|
||||
purgeHostId ? removed.map((id) => hostWorktreeTarget({ id, hostId: purgeHostId })) : removed
|
||||
)
|
||||
afterState.removeWorkspaceSpaceWorktrees(removed)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing'
|
||||
import type { FolderWorkspaceUpdateTicket } from '../slices/folder-workspace-update-coordinator'
|
||||
import { callRuntimeRpc } from '../../runtime/runtime-rpc-client'
|
||||
@@ -267,7 +268,12 @@ export async function reconcileFailedFolderWorkspaceUpdate(args: {
|
||||
: {})
|
||||
}))
|
||||
if (!refreshed) {
|
||||
args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)])
|
||||
args.get().purgeWorktreeTerminalState([
|
||||
hostWorktreeTarget({
|
||||
id: folderWorkspaceKey(args.folderWorkspaceId),
|
||||
hostId: args.ownerHostId
|
||||
})
|
||||
])
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to reconcile folder workspace after update failure:', err)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
@@ -149,7 +150,9 @@ const TWIN_ACTIONS: readonly TwinAction[] = [
|
||||
run: async (f, target) =>
|
||||
f.store
|
||||
.getState()
|
||||
.purgeWorktreeTerminalState([{ id: f.workspaceKey, hostId: target.hostId }]),
|
||||
.purgeWorktreeTerminalState([
|
||||
hostWorktreeTarget({ id: f.workspaceKey, hostId: target.hostId })
|
||||
]),
|
||||
applied: (f, target) =>
|
||||
expect(f.store.getState().ptyIdsByTabId[target.terminalTabId]).toBeUndefined()
|
||||
},
|
||||
@@ -202,10 +205,6 @@ function everyCell(actionId: string, reason: string, kinds = TWIN_KINDS): [strin
|
||||
|
||||
/** `<action> | <kind> | <target>` → why the survivor is touched today (tracker Stage 1 worklist). */
|
||||
const KNOWN_TWIN_LEAKS: Readonly<Record<string, string>> = Object.fromEntries([
|
||||
...everyCell(
|
||||
'purge terminal state',
|
||||
'the purge drops every bucket under the raw id; the target host only scopes visit recency'
|
||||
),
|
||||
...everyCell('kill terminals', 'takes a raw worktree id, so it stops both twins’ PTYs'),
|
||||
...everyCell('sleep', 'takes a raw worktree id, so it sleeps both twins'),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { getWorktreeVisitKey } from '@/lib/worktree-visit-recency'
|
||||
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { resetHostTwinModuleStores } from './host-twin-state-projection.test-fixture'
|
||||
import {
|
||||
createHostTwinFixture,
|
||||
TWIN_RUNTIME_ENV,
|
||||
type HostTwinFixture
|
||||
} from './host-twin.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
let current: HostTwinFixture | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', { api: { pty: { kill: vi.fn() } } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (current) {
|
||||
resetHostTwinModuleStores(current)
|
||||
}
|
||||
current = null
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function twin(kind: Parameters<typeof createHostTwinFixture>[0] = 'local+ssh'): HostTwinFixture {
|
||||
const fixture = createHostTwinFixture(kind)
|
||||
current = fixture
|
||||
const key = fixture.workspaceKey
|
||||
fixture.store.setState({
|
||||
gitStatusByWorktree: { [key]: [] },
|
||||
activeWorktreeId: key,
|
||||
activeWorkspaceExecutionHostId: fixture.remote.hostId,
|
||||
lastVisitedAtByWorktreeId: {
|
||||
[getWorktreeVisitKey(key, fixture.local.hostId)]: 1,
|
||||
[getWorktreeVisitKey(key, fixture.remote.hostId)]: 2
|
||||
}
|
||||
})
|
||||
return fixture
|
||||
}
|
||||
|
||||
function keepOnlyRemoteRow(fixture: HostTwinFixture): void {
|
||||
fixture.store.setState((s) => ({
|
||||
worktreesByRepo: {
|
||||
[fixture.projectId]: (s.worktreesByRepo[fixture.projectId] ?? []).filter(
|
||||
(row) => row.hostId === fixture.remote.hostId
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
describe('purgeWorktreeTerminalState with a surviving twin', () => {
|
||||
it("purges only the target host's attributable state", () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
|
||||
f.local.terminalTabId,
|
||||
f.dormantTabId
|
||||
])
|
||||
expect(s.ptyIdsByTabId[f.remote.terminalTabId]).toBeUndefined()
|
||||
expect(s.terminalLayoutsByTabId[f.remote.terminalTabId]).toBeUndefined()
|
||||
expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId])
|
||||
expect(s.unifiedTabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
|
||||
f.local.terminalTabId,
|
||||
f.local.fileId,
|
||||
f.dormantTabId
|
||||
])
|
||||
expect(s.groupsByWorktree[key]?.[0]?.tabOrder).toEqual([
|
||||
f.local.terminalTabId,
|
||||
f.local.fileId,
|
||||
f.dormantTabId
|
||||
])
|
||||
expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId])
|
||||
expect(Object.keys(s.editorDrafts)).toEqual([f.local.fileId])
|
||||
expect(Object.keys(s.sleepingAgentSessionsByPaneKey)).toEqual([f.local.paneKey])
|
||||
// Shared buckets and raw-id module stores cannot name a host, so the twin keeps them.
|
||||
expect(s.gitStatusByWorktree[key]).toEqual([])
|
||||
expect(hasWorktreeSleepIntent(key)).toBe(true)
|
||||
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
|
||||
getWorktreeVisitKey(key, f.local.hostId)
|
||||
])
|
||||
expect(s.activeWorktreeId).toBe(key)
|
||||
expect(s.activeWorkspaceExecutionHostId).toBe(f.local.hostId)
|
||||
})
|
||||
|
||||
it('keeps PTY-keyed state for a purged PTY that a kept tab still binds', () => {
|
||||
const f = twin()
|
||||
const shared = f.remote.ptyId
|
||||
// A mixed-host split: the kept local tab also binds the purged copy's PTY.
|
||||
f.store.setState((s) => ({
|
||||
ptyIdsByTabId: { ...s.ptyIdsByTabId, [f.local.terminalTabId]: [f.local.ptyId, shared] },
|
||||
suppressedPtyExitIds: { [shared]: true },
|
||||
pendingPaneAccountRestarts: { [shared]: 'codex' }
|
||||
}))
|
||||
|
||||
f.store
|
||||
.getState()
|
||||
.purgeWorktreeTerminalState([hostWorktreeTarget({ id: f.workspaceKey, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId, shared])
|
||||
expect(s.suppressedPtyExitIds).toEqual({ [shared]: true })
|
||||
expect(s.pendingPaneAccountRestarts).toEqual({ [shared]: 'codex' })
|
||||
})
|
||||
|
||||
it('collapses a split the purged copy emptied, handing selection to the survivor', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
f.store.setState((s) => {
|
||||
const [group] = s.groupsByWorktree[key] ?? []
|
||||
if (!group) {
|
||||
throw new Error('fixture has no tab group')
|
||||
}
|
||||
const remoteIds = [f.remote.terminalTabId, f.remote.fileId]
|
||||
return {
|
||||
groupsByWorktree: {
|
||||
[key]: [
|
||||
{ ...group, tabOrder: group.tabOrder.filter((id) => !remoteIds.includes(id)) },
|
||||
{ ...group, id: 'remote-group', tabOrder: remoteIds, activeTabId: remoteIds[0] }
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[key]: (s.unifiedTabsByWorktree[key] ?? []).map((tab) =>
|
||||
remoteIds.includes(tab.id) ? { ...tab, groupId: 'remote-group' } : tab
|
||||
)
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[key]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: group.id },
|
||||
second: { type: 'leaf', groupId: 'remote-group' }
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { [key]: 'remote-group' }
|
||||
}
|
||||
})
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
const survivingGroupIds = (s.groupsByWorktree[key] ?? []).map((group) => group.id)
|
||||
expect(survivingGroupIds).not.toContain('remote-group')
|
||||
expect(survivingGroupIds).toHaveLength(1)
|
||||
expect(s.layoutByWorktree[key]).not.toEqual(expect.objectContaining({ type: 'split' }))
|
||||
expect(s.activeGroupIdByWorktree[key]).toBe(survivingGroupIds[0])
|
||||
})
|
||||
|
||||
it('keeps an untouched active group when it collapses a split the purge emptied', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
const remoteIds = [f.remote.terminalTabId, f.remote.fileId]
|
||||
f.store.setState((s) => {
|
||||
const [group] = s.groupsByWorktree[key] ?? []
|
||||
if (!group) {
|
||||
throw new Error('fixture has no tab group')
|
||||
}
|
||||
const localIds = group.tabOrder.filter((id) => !remoteIds.includes(id))
|
||||
return {
|
||||
groupsByWorktree: {
|
||||
[key]: [
|
||||
{ ...group, id: 'a', tabOrder: localIds.slice(0, 1), activeTabId: localIds[0] },
|
||||
{ ...group, id: 'b', tabOrder: localIds.slice(1), activeTabId: localIds[1] },
|
||||
{ ...group, id: 'c', tabOrder: remoteIds, activeTabId: remoteIds[0] }
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[key]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'a' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', groupId: 'b' },
|
||||
second: { type: 'leaf', groupId: 'c' }
|
||||
}
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { [key]: 'a' }
|
||||
}
|
||||
})
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect((s.groupsByWorktree[key] ?? []).map((group) => group.id)).toEqual(['a', 'b'])
|
||||
expect(s.activeGroupIdByWorktree[key]).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps the runtime twin when the local copy is purged', () => {
|
||||
const f = twin('local+runtime')
|
||||
const key = f.workspaceKey
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.local })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
|
||||
f.remote.terminalTabId,
|
||||
f.dormantTabId
|
||||
])
|
||||
expect(s.openFiles.map((file) => file.id)).toEqual([f.remote.fileId])
|
||||
})
|
||||
|
||||
it('purges everything when no other host publishes the id', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
keepOnlyRemoteRow(f)
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.tabsByWorktree[key]).toBeUndefined()
|
||||
expect(s.openFiles).toEqual([])
|
||||
expect(s.gitStatusByWorktree[key]).toBeUndefined()
|
||||
expect(hasWorktreeSleepIntent(key)).toBe(false)
|
||||
// Recency stays host-scoped, as before.
|
||||
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
|
||||
getWorktreeVisitKey(key, f.local.hostId)
|
||||
])
|
||||
})
|
||||
|
||||
it('purges everything when every publishing host is targeted', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
|
||||
f.store
|
||||
.getState()
|
||||
.purgeWorktreeTerminalState([
|
||||
hostWorktreeTarget({ id: key, ...f.remote }),
|
||||
hostWorktreeTarget({ id: key, ...f.local })
|
||||
])
|
||||
|
||||
expect(f.store.getState().tabsByWorktree[key]).toBeUndefined()
|
||||
expect(f.store.getState().gitStatusByWorktree[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the raw-id purge reaching both twins', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([key])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.tabsByWorktree[key]).toBeUndefined()
|
||||
expect(s.openFiles).toEqual([])
|
||||
expect(s.lastVisitedAtByWorktreeId).toEqual({})
|
||||
})
|
||||
|
||||
it("drops a tabless sleeping record that names the purged SSH host, not the twin's", () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
const record = f.store.getState().sleepingAgentSessionsByPaneKey[f.remote.paneKey]
|
||||
f.store.setState((s) => ({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
'gone-ssh:leaf': { ...record, paneKey: 'gone-ssh:leaf' },
|
||||
'gone-local:leaf': { ...record, paneKey: 'gone-local:leaf', connectionId: null }
|
||||
}
|
||||
}))
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
expect(Object.keys(f.store.getState().sleepingAgentSessionsByPaneKey).sort()).toEqual(
|
||||
['gone-local:leaf', f.local.paneKey].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('selects a surviving tab when the purged terminal was active', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
f.store.setState((s) => ({
|
||||
activeTabId: f.remote.terminalTabId,
|
||||
activeTabType: 'terminal',
|
||||
groupsByWorktree: {
|
||||
[key]: (s.groupsByWorktree[key] ?? []).map((group) => ({
|
||||
...group,
|
||||
activeTabId: f.remote.terminalTabId,
|
||||
recentTabIds: [f.local.fileId, f.remote.terminalTabId]
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.groupsByWorktree[key]?.[0]?.activeTabId).toBe(f.local.fileId)
|
||||
expect(s.activeTabType).toBe('editor')
|
||||
expect(s.activeFileId).toBe(f.local.fileId)
|
||||
})
|
||||
|
||||
it("attributes a paired runtime's purge to the SSH copy it reaches", () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
const runtimeHostId = `runtime:${TWIN_RUNTIME_ENV}` as const
|
||||
f.store.setState((s) => ({
|
||||
worktreesByRepo: {
|
||||
[f.projectId]: (s.worktreesByRepo[f.projectId] ?? []).map((row) =>
|
||||
row.hostId === f.remote.hostId
|
||||
? { ...row, runtimeOwnerEnvironmentId: TWIN_RUNTIME_ENV }
|
||||
: row
|
||||
)
|
||||
},
|
||||
lastVisitedAtByWorktreeId: {
|
||||
...s.lastVisitedAtByWorktreeId,
|
||||
[getWorktreeVisitKey(key, runtimeHostId)]: 3
|
||||
}
|
||||
}))
|
||||
|
||||
f.store
|
||||
.getState()
|
||||
.purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, hostId: runtimeHostId })])
|
||||
|
||||
const s = f.store.getState()
|
||||
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
|
||||
f.local.terminalTabId,
|
||||
f.dormantTabId
|
||||
])
|
||||
expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId])
|
||||
// Recency written under either identity of the purged copy goes; the twin's stays.
|
||||
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
|
||||
getWorktreeVisitKey(key, f.local.hostId)
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an untouched legacy selection when another host is purged', () => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
f.store.setState({
|
||||
activeWorkspaceExecutionHostId: f.local.hostId,
|
||||
activeTabId: f.local.terminalTabId,
|
||||
activeTabType: 'terminal',
|
||||
activeFileIdByWorktree: { [key]: f.local.fileId },
|
||||
// A legacy workspace: no tab groups, so derivation prefers a remembered editor.
|
||||
groupsByWorktree: {}
|
||||
})
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
expect(f.store.getState().activeTabId).toBe(f.local.terminalTabId)
|
||||
expect(f.store.getState().activeTabType).toBe('terminal')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['drops', 'remote-source.md', 'local-source.md', false],
|
||||
['keeps', 'shared-source.md', 'shared-source.md', true]
|
||||
] as const)(
|
||||
"%s a purged preview's source state by whether a surviving file still uses it",
|
||||
(_verb, remoteSource, localSource, kept) => {
|
||||
const f = twin()
|
||||
const key = f.workspaceKey
|
||||
f.store.setState((s) => ({
|
||||
openFiles: s.openFiles.map((file) => ({
|
||||
...file,
|
||||
markdownPreviewSourceFileId: file.id === f.remote.fileId ? remoteSource : localSource
|
||||
})),
|
||||
editorDrafts: { ...s.editorDrafts, [remoteSource]: 'source draft' }
|
||||
}))
|
||||
|
||||
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
|
||||
|
||||
expect(remoteSource in f.store.getState().editorDrafts).toBe(kept)
|
||||
expect(f.store.getState().editorDrafts[f.local.fileId]).toBeDefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { buildTerminalTabRetirementPlans } from '../slices/terminal-tab-retireme
|
||||
import { omitSleepingAgentSessionsForWorktrees } from '../slices/worktrees/teardown/sleeping-agent-session-omission'
|
||||
import type { RecentlyClosedTabKind } from '../slices/recently-closed-tabs'
|
||||
import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission'
|
||||
import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/worktree-purge-state'
|
||||
import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/purged-structured-launch-retirement'
|
||||
import {
|
||||
belongsToRemovedHost,
|
||||
collectRemovedHostTwinState,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AppState } from '../../types'
|
||||
import { toVisibleTabType, type Tab, type TabGroup } from '../../../../../shared/tab-types'
|
||||
import { sanitizeRecentTabIds } from '../tab-group-state'
|
||||
|
||||
/** Surfaces leaving workspaces that stay, such as one host's tabs under an id its twin publishes. */
|
||||
export type KeptWorktreeSurfaceRemoval = {
|
||||
worktreeIds: ReadonlySet<string>
|
||||
terminalTabIds: ReadonlySet<string>
|
||||
fileIds: ReadonlySet<string>
|
||||
browserTabIds: ReadonlySet<string>
|
||||
/** Unified tab ids of non-file surfaces such as chats. */
|
||||
unifiedTabIds: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function isRemovedUnifiedTab(tab: Tab, removed: KeptWorktreeSurfaceRemoval): boolean {
|
||||
if (removed.unifiedTabIds.has(tab.id)) {
|
||||
return true
|
||||
}
|
||||
if (tab.contentType === 'terminal') {
|
||||
return removed.terminalTabIds.has(tab.entityId)
|
||||
}
|
||||
if (tab.contentType === 'browser') {
|
||||
return removed.browserTabIds.has(tab.entityId)
|
||||
}
|
||||
return toVisibleTabType(tab.contentType) === 'editor' && removed.fileIds.has(tab.entityId)
|
||||
}
|
||||
|
||||
function pruneGroup(group: TabGroup, removedIds: ReadonlySet<string>): TabGroup {
|
||||
if (!group.tabOrder.some((id) => removedIds.has(id))) {
|
||||
return group
|
||||
}
|
||||
const tabOrder = group.tabOrder.filter((id) => !removedIds.has(id))
|
||||
const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder)
|
||||
// Why MRU first: closing the active tab lands on the previously active one, as closeUnifiedTab does.
|
||||
const activeTabId =
|
||||
group.activeTabId && removedIds.has(group.activeTabId)
|
||||
? (recentTabIds.at(-1) ?? tabOrder[0] ?? null)
|
||||
: group.activeTabId
|
||||
return {
|
||||
...group,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
...(group.recentTabIds ? { recentTabIds } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrites only the kept workspaces' entries, keeping the map's identity when none changes. */
|
||||
function pruneEntries<T>(
|
||||
map: Record<string, T>,
|
||||
worktreeIds: ReadonlySet<string>,
|
||||
prune: (value: T) => T | undefined
|
||||
): Record<string, T> {
|
||||
let next: Record<string, T> | null = null
|
||||
for (const worktreeId of worktreeIds) {
|
||||
if (!(worktreeId in map)) {
|
||||
continue
|
||||
}
|
||||
const value = prune(map[worktreeId])
|
||||
if (value === map[worktreeId]) {
|
||||
continue
|
||||
}
|
||||
next ??= { ...map }
|
||||
if (value === undefined) {
|
||||
delete next[worktreeId]
|
||||
} else {
|
||||
next[worktreeId] = value
|
||||
}
|
||||
}
|
||||
return next ?? map
|
||||
}
|
||||
|
||||
function withoutIds<T extends { id: string }>(items: T[], ids: ReadonlySet<string>): T[] {
|
||||
return items.some((item) => ids.has(item.id)) ? items.filter((item) => !ids.has(item.id)) : items
|
||||
}
|
||||
|
||||
/** Per-field pruners for workspaces that stay while some of their surfaces leave. */
|
||||
export function createKeptWorktreeSurfacePruners(
|
||||
s: Pick<AppState, 'unifiedTabsByWorktree'>,
|
||||
removed: KeptWorktreeSurfaceRemoval
|
||||
) {
|
||||
const { worktreeIds } = removed
|
||||
const removedUnifiedIds = new Set(
|
||||
[...worktreeIds].flatMap((worktreeId) =>
|
||||
(s.unifiedTabsByWorktree[worktreeId] ?? [])
|
||||
.filter((tab) => isRemovedUnifiedTab(tab, removed))
|
||||
.map((tab) => tab.id)
|
||||
)
|
||||
)
|
||||
const removedIds = new Set([
|
||||
...removedUnifiedIds,
|
||||
...removed.terminalTabIds,
|
||||
...removed.fileIds,
|
||||
...removed.browserTabIds
|
||||
])
|
||||
// Why dropped, not repointed: selection derivation falls back to a surviving surface.
|
||||
const dropSelectionOf =
|
||||
<T>(ids: ReadonlySet<string>) =>
|
||||
(map: Record<string, T>): Record<string, T> =>
|
||||
pruneEntries(map, worktreeIds, (value) =>
|
||||
typeof value === 'string' && ids.has(value) ? undefined : value
|
||||
)
|
||||
return {
|
||||
tabs: (map: AppState['tabsByWorktree']) =>
|
||||
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.terminalTabIds)),
|
||||
unifiedTabs: (map: AppState['unifiedTabsByWorktree']) =>
|
||||
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removedUnifiedIds)),
|
||||
groups: (map: AppState['groupsByWorktree']) =>
|
||||
pruneEntries(map, worktreeIds, (groups) => {
|
||||
const next = groups.map((group) => pruneGroup(group, removedIds))
|
||||
return next.every((group, index) => group === groups[index]) ? groups : next
|
||||
}),
|
||||
tabBarOrder: (map: AppState['tabBarOrderByWorktree']) =>
|
||||
pruneEntries(map, worktreeIds, (order) =>
|
||||
order.some((id) => removedIds.has(id)) ? order.filter((id) => !removedIds.has(id)) : order
|
||||
),
|
||||
browserTabs: (map: AppState['browserTabsByWorktree']) =>
|
||||
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.browserTabIds)),
|
||||
files: (files: AppState['openFiles']) => withoutIds(files, removed.fileIds),
|
||||
activeTabIds: dropSelectionOf<AppState['activeTabIdByWorktree'][string]>(
|
||||
removed.terminalTabIds
|
||||
),
|
||||
activeFileIds: dropSelectionOf<AppState['activeFileIdByWorktree'][string]>(removed.fileIds),
|
||||
activeBrowserTabIds: dropSelectionOf<AppState['activeBrowserTabIdByWorktree'][string]>(
|
||||
removed.browserTabIds
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-t
|
||||
import type { TerminalTab } from '../../../../../shared/terminal-tab-types'
|
||||
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
|
||||
import { getTerminalActivationSpawnSuppression } from '../terminal-activation-spawn-suppression'
|
||||
import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree'
|
||||
import {
|
||||
createKeptWorktreeSurfacePruners,
|
||||
type KeptWorktreeSurfaceRemoval
|
||||
} from './kept-worktree-surface-pruning'
|
||||
|
||||
// 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.
|
||||
@@ -29,39 +34,108 @@ type WorktreeLayoutPatch = Pick<
|
||||
| 'activeTabType'
|
||||
>
|
||||
|
||||
/** Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into them. */
|
||||
/**
|
||||
* Splits a kept workspace's purge emptied, collapsed the way closing their last tab collapses
|
||||
* them, so the surviving twin's group takes the space and the selection.
|
||||
*/
|
||||
function collapseEmptiedKeptGroups(
|
||||
s: Pick<AppState, 'groupsByWorktree' | 'layoutByWorktree' | 'activeGroupIdByWorktree'>,
|
||||
groupsByWorktree: AppState['groupsByWorktree'],
|
||||
worktreeIds: ReadonlySet<string>
|
||||
): {
|
||||
groups: AppState['groupsByWorktree']
|
||||
layout: AppState['layoutByWorktree']
|
||||
activeGroups: AppState['activeGroupIdByWorktree']
|
||||
} {
|
||||
let groupsOut = groupsByWorktree
|
||||
let layoutOut = s.layoutByWorktree
|
||||
let activeOut = s.activeGroupIdByWorktree
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const before = s.groupsByWorktree[worktreeId] ?? []
|
||||
let groups = groupsOut[worktreeId] ?? []
|
||||
const emptied = groups.filter(
|
||||
(group) =>
|
||||
group.tabOrder.length === 0 &&
|
||||
(before.find((prior) => prior.id === group.id)?.tabOrder.length ?? 0) > 0
|
||||
)
|
||||
for (const group of emptied) {
|
||||
if (groups.length <= 1 || !layoutOut[worktreeId]) {
|
||||
break
|
||||
}
|
||||
groups = groups.filter((candidate) => candidate.id !== group.id)
|
||||
const collapsed = collapseGroupLayout(
|
||||
layoutOut,
|
||||
activeOut,
|
||||
worktreeId,
|
||||
group.id,
|
||||
groups[0]?.id ?? null
|
||||
)
|
||||
layoutOut = collapsed.layoutByWorktree
|
||||
// Why: only the collapsed group's own focus moves; an untouched active group keeps it.
|
||||
const activeGroupId = activeOut[worktreeId]
|
||||
activeOut =
|
||||
activeGroupId === undefined || activeGroupId === group.id
|
||||
? collapsed.activeGroupIdByWorktree
|
||||
: { ...collapsed.activeGroupIdByWorktree, [worktreeId]: activeGroupId }
|
||||
}
|
||||
if (groups !== (groupsOut[worktreeId] ?? [])) {
|
||||
groupsOut = { ...groupsOut, [worktreeId]: groups }
|
||||
}
|
||||
}
|
||||
return { groups: groupsOut, layout: layoutOut, activeGroups: activeOut }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into
|
||||
* them. `kept` names surfaces leaving workspaces that stay, whose entries are pruned instead.
|
||||
*/
|
||||
export function removeWorktreeLayout(
|
||||
s: AppState,
|
||||
removed: {
|
||||
worktreeIds: ReadonlySet<string>
|
||||
tabIds: ReadonlySet<string>
|
||||
activeFileCleared: boolean
|
||||
kept?: KeptWorktreeSurfaceRemoval
|
||||
}
|
||||
): 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)
|
||||
const kept = removed.kept ? createKeptWorktreeSurfacePruners(s, removed.kept) : null
|
||||
const keep = <T>(value: T, prune: ((value: T) => T) | undefined): T =>
|
||||
prune ? prune(value) : value
|
||||
const groupLayout = removed.kept
|
||||
? collapseEmptiedKeptGroups(
|
||||
s,
|
||||
keep(omitByWorktree(s.groupsByWorktree), kept?.groups),
|
||||
removed.kept.worktreeIds
|
||||
)
|
||||
: null
|
||||
const remainingFiles = s.openFiles.some((f) => worktreeIds.has(f.worktreeId))
|
||||
? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId))
|
||||
: s.openFiles
|
||||
return {
|
||||
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
|
||||
tabsByWorktree: keep(omitByWorktree(s.tabsByWorktree), kept?.tabs),
|
||||
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),
|
||||
unifiedTabsByWorktree: keep(omitByWorktree(s.unifiedTabsByWorktree), kept?.unifiedTabs),
|
||||
groupsByWorktree: groupLayout?.groups ?? omitByWorktree(s.groupsByWorktree),
|
||||
layoutByWorktree: omitByWorktree(groupLayout?.layout ?? s.layoutByWorktree),
|
||||
activeGroupIdByWorktree: omitByWorktree(groupLayout?.activeGroups ?? 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),
|
||||
tabBarOrderByWorktree: keep(omitByWorktree(s.tabBarOrderByWorktree), kept?.tabBarOrder),
|
||||
browserTabsByWorktree: keep(omitByWorktree(s.browserTabsByWorktree), kept?.browserTabs),
|
||||
// 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),
|
||||
openFiles: keep(remainingFiles, kept?.files),
|
||||
activeTabIdByWorktree: keep(omitByWorktree(s.activeTabIdByWorktree), kept?.activeTabIds),
|
||||
activeFileIdByWorktree: keep(omitByWorktree(s.activeFileIdByWorktree), kept?.activeFileIds),
|
||||
activeBrowserTabIdByWorktree: keep(
|
||||
omitByWorktree(s.activeBrowserTabIdByWorktree),
|
||||
kept?.activeBrowserTabIds
|
||||
),
|
||||
activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree),
|
||||
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
|
||||
activeFileId: activeFileCleared ? null : s.activeFileId,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
|
||||
import {
|
||||
isRemoteRuntimePtyId,
|
||||
parseRemoteRuntimePtyId
|
||||
} from '../../../../shared/remote-runtime-pty-id'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type { AppState } from '../types'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
/** A purge target with optional host ownership for host-scoped state. */
|
||||
/** A purge target after normalization: a raw id, or one host's copy of it. */
|
||||
export type WorktreePurgeTarget = Pick<Worktree, 'id' | 'hostId'>
|
||||
|
||||
/** Strings remain supported for callers that only have a raw worktree id. */
|
||||
export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[]
|
||||
/**
|
||||
* A raw id purges every bucket under it. A host target purges only that host's attributable
|
||||
* state while another host still publishes the id, and everything otherwise.
|
||||
*/
|
||||
export type WorktreePurgeTargets = readonly (string | HostWorktreeTarget)[]
|
||||
|
||||
export function normalizeWorktreePurgeTarget(
|
||||
target: string | HostWorktreeTarget
|
||||
): WorktreePurgeTarget {
|
||||
return typeof target === 'string'
|
||||
? { id: target }
|
||||
: { id: target.worktreeId, hostId: target.hostId }
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type WorktreeListingMergeOutcome
|
||||
} from './detected-worktree-refresh-admission'
|
||||
import { buildWorktreePurgeState } from '../teardown/worktree-purge-state'
|
||||
import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import {
|
||||
@@ -271,7 +272,7 @@ export function mergeFetchedWorktrees(
|
||||
...(removedIds.length > 0
|
||||
? buildWorktreePurgeState(
|
||||
s,
|
||||
removedIds.map((id) => ({ id, hostId: args.hostId }))
|
||||
removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId }))
|
||||
)
|
||||
: {})
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function createPurgeWorktreeTerminalState(
|
||||
): WorktreeSlice['purgeWorktreeTerminalState'] {
|
||||
return (worktreeTargets) => {
|
||||
const purgeableWorktreeTargets = worktreeTargets.filter((target) => {
|
||||
const worktreeId = typeof target === 'string' ? target : target.id
|
||||
const worktreeId = typeof target === 'string' ? target : target.worktreeId
|
||||
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
|
||||
})
|
||||
if (purgeableWorktreeTargets.length === 0) {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { AppState } from '../../../types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import type { WorktreePurgeTarget } from '../../worktree-purge-target'
|
||||
import { getFolderWorkspaceHostId } from '../../../folder-workspaces/folder-workspace-catalog'
|
||||
import { getProjectGroupHostId } from '../../project-group-owner-routing'
|
||||
import {
|
||||
collectRemovedHostTwinState,
|
||||
removedHostCanonicalizer
|
||||
} from '../../../repos/repo-removal-host-twin-attribution'
|
||||
import { twinSelectionPatch } from '../../../repos/repo-removal-host-twin-teardown'
|
||||
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
|
||||
import { buildActiveSurfacePatch } from '../../tabs/tabs-surface'
|
||||
import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership'
|
||||
import { collectSurfacePurgeDoomedIds } from './worktree-purge-doomed-ids'
|
||||
import { collectPtyIdsForTab } from '../../terminal-tab-retirement'
|
||||
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
|
||||
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
|
||||
import {
|
||||
cancelStructuredLaunchesForWorktrees,
|
||||
retirePurgedTabLaunches
|
||||
} from './purged-structured-launch-retirement'
|
||||
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
|
||||
|
||||
type PublisherRow = {
|
||||
id: string
|
||||
hostId?: ExecutionHostId
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* For one targeted id: hosts outside the purge that still publish it, and the host each purged
|
||||
* target's state is attributed to. Rows match by the listing's host matching, so an unstamped
|
||||
* legacy row or a paired runtime's SSH row counts as the purged copy, not a twin.
|
||||
*/
|
||||
function resolvePurgedPublishers(
|
||||
s: AppState,
|
||||
workspaceId: string,
|
||||
purgedHosts: ReadonlySet<ExecutionHostId>
|
||||
): { survivors: ExecutionHostId[]; attributedHostIds: Map<ExecutionHostId, ExecutionHostId> } {
|
||||
const scope = parseWorkspaceKey(workspaceId)
|
||||
const attributedHostIds = new Map([...purgedHosts].map((hostId) => [hostId, hostId]))
|
||||
if (scope?.type === 'folder') {
|
||||
const groups = s.projectGroups ?? []
|
||||
const hosts = (s.folderWorkspaces ?? [])
|
||||
.filter((folder) => folder.id === scope.folderWorkspaceId)
|
||||
.flatMap((folder) => {
|
||||
const groupHosts = groups
|
||||
.filter((group) => group.id === folder.projectGroupId)
|
||||
.map(getProjectGroupHostId)
|
||||
// Why every group host: an unstamped folder under a group two hosts publish may be either's.
|
||||
return folder.executionHostId || folder.connectionId || groupHosts.length === 0
|
||||
? [getFolderWorkspaceHostId(folder, groups)]
|
||||
: groupHosts
|
||||
})
|
||||
return {
|
||||
survivors: [...new Set(hosts)].filter((hostId) => !purgedHosts.has(hostId)),
|
||||
attributedHostIds
|
||||
}
|
||||
}
|
||||
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const rows: PublisherRow[] = [
|
||||
...(s.worktreesByRepo[repoId] ?? []),
|
||||
...(s.detectedWorktreesByRepo[repoId]?.worktrees ?? [])
|
||||
].filter((row) => row.id === worktreeId)
|
||||
const survivors = new Set<ExecutionHostId>()
|
||||
for (const row of rows) {
|
||||
const purgedHost = [...purgedHosts].find((hostId) =>
|
||||
worktreeMatchesHost(row, hostId, worktreeHostMatchOptions(s, repoId, hostId))
|
||||
)
|
||||
if (purgedHost) {
|
||||
// Why: a copy reached through a paired runtime stamps its tabs with its own SSH host.
|
||||
if (row.hostId && !purgedHosts.has(row.hostId)) {
|
||||
attributedHostIds.set(purgedHost, row.hostId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
survivors.add(
|
||||
row.hostId ??
|
||||
(row.runtimeOwnerEnvironmentId
|
||||
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
|
||||
: LOCAL_EXECUTION_HOST_ID)
|
||||
)
|
||||
}
|
||||
return {
|
||||
survivors: [...survivors].filter((hostId) => !purgedHosts.has(hostId)),
|
||||
attributedHostIds
|
||||
}
|
||||
}
|
||||
|
||||
export type HostTwinPurgeScopes = {
|
||||
/** Host identities of every host target whose id a twin still publishes. */
|
||||
twinTargetKeys: Set<HostScopedWorktreeKey>
|
||||
/** Per attributed host, its twin ids with the hosts that still publish them. */
|
||||
byHost: Map<ExecutionHostId, Map<string, ExecutionHostId[]>>
|
||||
}
|
||||
|
||||
/** Host targets whose id a host outside the purge still publishes. A raw id for the same id wins. */
|
||||
export function collectHostTwinPurgeScopes(
|
||||
s: AppState,
|
||||
targets: readonly WorktreePurgeTarget[]
|
||||
): HostTwinPurgeScopes {
|
||||
const rawIds = new Set(targets.filter((target) => !target.hostId).map((target) => target.id))
|
||||
const purgedHostsById = new Map<string, Set<ExecutionHostId>>()
|
||||
for (const { id, hostId } of targets) {
|
||||
if (hostId && !rawIds.has(id)) {
|
||||
purgedHostsById.set(id, (purgedHostsById.get(id) ?? new Set()).add(hostId))
|
||||
}
|
||||
}
|
||||
const scopes: HostTwinPurgeScopes = { twinTargetKeys: new Set(), byHost: new Map() }
|
||||
for (const [id, purgedHosts] of purgedHostsById) {
|
||||
const { survivors, attributedHostIds } = resolvePurgedPublishers(s, id, purgedHosts)
|
||||
if (survivors.length === 0) {
|
||||
continue
|
||||
}
|
||||
for (const hostId of purgedHosts) {
|
||||
scopes.twinTargetKeys.add(composeWorktreeHostIdentity(hostId, id))
|
||||
const attributedHostId = attributedHostIds.get(hostId) ?? hostId
|
||||
const scope = scopes.byHost.get(attributedHostId) ?? new Map<string, ExecutionHostId[]>()
|
||||
scopes.byHost.set(attributedHostId, scope.set(id, survivors))
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges one host's attributable surfaces under ids a twin still publishes. Worktree-keyed
|
||||
* buckets, raw-id module stores and surfaces with no host evidence stay: they may be the twin's.
|
||||
*/
|
||||
export function buildHostTwinPurgeState(
|
||||
s: AppState,
|
||||
hostId: ExecutionHostId,
|
||||
twinHostIds: ReadonlyMap<string, readonly ExecutionHostId[]>
|
||||
): Partial<AppState> {
|
||||
const twinIds = new Set(twinHostIds.keys())
|
||||
const removed = collectRemovedHostTwinState(s, twinIds, hostId, twinHostIds)
|
||||
const canonical = (worktreeId: string) =>
|
||||
removedHostCanonicalizer(s, worktreeId, hostId, twinHostIds.get(worktreeId) ?? [])
|
||||
const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(
|
||||
twinIds,
|
||||
(worktreeId, launchHostId) => canonical(worktreeId)(launchHostId) === hostId
|
||||
)
|
||||
const terminalTabs: { id: string; ptyId: string | null }[] = []
|
||||
for (const worktreeId of twinIds) {
|
||||
const legacyTabs = (s.tabsByWorktree[worktreeId] ?? []).filter((tab) =>
|
||||
removed.terminalTabIds.has(tab.id)
|
||||
)
|
||||
terminalTabs.push(...legacyTabs)
|
||||
retirePurgedTabLaunches(
|
||||
s,
|
||||
worktreeId,
|
||||
legacyTabs,
|
||||
(s.unifiedTabsByWorktree[worktreeId] ?? []).filter((tab) => removed.otherTabIds.has(tab.id)),
|
||||
cancelledSessionIds
|
||||
)
|
||||
}
|
||||
// Why: a hydrated terminal can exist only as a unified tab, with no legacy row.
|
||||
for (const tabId of removed.terminalTabIds) {
|
||||
if (!terminalTabs.some((tab) => tab.id === tabId)) {
|
||||
terminalTabs.push({ id: tabId, ptyId: null })
|
||||
}
|
||||
}
|
||||
// Why: a preview keys its source's editor state, unless a surviving file still uses that source.
|
||||
const survivingFiles = s.openFiles.filter((file) => !removed.fileIds.has(file.id))
|
||||
const previewSourceIds = s.openFiles.flatMap((file) =>
|
||||
removed.fileIds.has(file.id) &&
|
||||
file.markdownPreviewSourceFileId &&
|
||||
!survivingFiles.some(
|
||||
(other) =>
|
||||
other.id === file.markdownPreviewSourceFileId ||
|
||||
other.markdownPreviewSourceFileId === file.markdownPreviewSourceFileId
|
||||
)
|
||||
? [file.markdownPreviewSourceFileId]
|
||||
: []
|
||||
)
|
||||
const doomed = collectSurfacePurgeDoomedIds(s, {
|
||||
tabs: terminalTabs,
|
||||
browserWorkspaceIds: removed.browserTabIds,
|
||||
fileIds: [...removed.fileIds, ...previewSourceIds]
|
||||
})
|
||||
// Why: a PTY a kept tab still binds stays live, so its PTY-keyed state must stay with it.
|
||||
for (const worktreeId of twinIds) {
|
||||
for (const tab of s.tabsByWorktree[worktreeId] ?? []) {
|
||||
if (!doomed.doomedTabIds.has(tab.id)) {
|
||||
for (const ptyId of collectPtyIdsForTab(s, tab.id, tab.ptyId)) {
|
||||
doomed.doomedPtyIds.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const omitters = createWorktreePurgeOmitters(s, new Set(), doomed)
|
||||
const tabPrefixedSleeping = {
|
||||
sleepingAgentSessionsByPaneKey: omitters.omitByPaneKeyTabPrefix(
|
||||
s.sleepingAgentSessionsByPaneKey
|
||||
),
|
||||
agentLaunchConfigByPaneKey: omitters.omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey)
|
||||
}
|
||||
// Why SSH only: a record whose tab is gone names its host just by connection; null is ambiguous.
|
||||
const sshRecordPaneKeys = Object.entries(s.sleepingAgentSessionsByPaneKey ?? {}).flatMap(
|
||||
([paneKey, record]) =>
|
||||
twinIds.has(record.worktreeId) &&
|
||||
record.connectionId &&
|
||||
canonical(record.worktreeId)(toSshExecutionHostId(record.connectionId)) === hostId
|
||||
? [paneKey]
|
||||
: []
|
||||
)
|
||||
const layout = removeWorktreeLayout(s, {
|
||||
worktreeIds: new Set(),
|
||||
tabIds: doomed.doomedTabIds,
|
||||
activeFileCleared: s.activeFileId != null && removed.fileIds.has(s.activeFileId),
|
||||
kept: {
|
||||
worktreeIds: twinIds,
|
||||
terminalTabIds: removed.terminalTabIds,
|
||||
fileIds: removed.fileIds,
|
||||
browserTabIds: removed.browserTabIds,
|
||||
unifiedTabIds: removed.otherTabIds
|
||||
}
|
||||
})
|
||||
const active = s.activeWorktreeId && twinIds.has(s.activeWorktreeId) ? s.activeWorktreeId : null
|
||||
// Why only then: re-deriving an untouched selection could switch the visible surface.
|
||||
const selectionRemoved =
|
||||
active !== null &&
|
||||
((s.activeTabId != null && removed.terminalTabIds.has(s.activeTabId)) ||
|
||||
(s.activeFileId != null && removed.fileIds.has(s.activeFileId)) ||
|
||||
(s.activeBrowserTabId != null && removed.browserTabIds.has(s.activeBrowserTabId)) ||
|
||||
(layout.groupsByWorktree[active] ?? []).some(
|
||||
(group, index) => group.activeTabId !== s.groupsByWorktree[active]?.[index]?.activeTabId
|
||||
))
|
||||
return {
|
||||
...layout,
|
||||
// Why: a group may now show a surviving tab of another kind, so selection is derived again.
|
||||
...(selectionRemoved ? buildActiveSurfacePatch({ ...s, ...layout }, active) : {}),
|
||||
...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, {
|
||||
...tabPrefixedSleeping,
|
||||
...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, new Set(), sshRecordPaneKeys)
|
||||
}),
|
||||
...twinSelectionPatch(s, twinHostIds, hostId)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
dropWorktreeRowsForRemovedRuntimeEnvironments,
|
||||
isRemovedRuntimeHostId
|
||||
} from '../../stale-runtime-host-rows'
|
||||
import { buildWorktreePurgeState } from './worktree-purge-state'
|
||||
import { buildFullWorktreePurgeState } from './worktree-purge-state'
|
||||
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
|
||||
import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up'
|
||||
|
||||
@@ -211,7 +211,9 @@ export function createPurgeStaleRuntimeHostState(
|
||||
const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId)
|
||||
return targets.length > 0 ? targets : [{ id: worktreeId }]
|
||||
})
|
||||
const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {}
|
||||
// Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's.
|
||||
const purgeState =
|
||||
purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {}
|
||||
const visitPurgeTargets = [
|
||||
...removedWorktreeTargets,
|
||||
...purgeTargets.filter(
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { TerminalTab } from '../../../../../../shared/terminal-tab-types'
|
||||
import type { Tab } from '../../../../../../shared/tab-types'
|
||||
import type { AppState } from '../../../types'
|
||||
import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start'
|
||||
import {
|
||||
getStructuredAgentSessionLaunchOwner,
|
||||
markStructuredAgentSessionLaunchCancelledSilently,
|
||||
shouldRetainStructuredAgentSessionLaunchTab,
|
||||
structuredLaunchStates
|
||||
} from '@/lib/structured-agent-session-launch-registry'
|
||||
import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt'
|
||||
import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry'
|
||||
import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent'
|
||||
import {
|
||||
structuredAgentSessionFocusOwner,
|
||||
structuredAgentSessionTargetForTab,
|
||||
structuredAgentSessionTargetForHost
|
||||
} from '@/runtime/structured-agent-session-owner'
|
||||
|
||||
/** Settles launch waits and retires chat ownership for purged tabs of one workspace. */
|
||||
export function retirePurgedTabLaunches(
|
||||
s: AppState,
|
||||
worktreeId: string,
|
||||
terminalTabs: readonly TerminalTab[],
|
||||
unifiedTabs: readonly Tab[],
|
||||
cancelledSessionIds: ReadonlySet<string>
|
||||
): void {
|
||||
// A launch tab that never spawned has no spawn left to answer its prompt's wait.
|
||||
for (const tab of terminalTabs) {
|
||||
if (tab.agentLaunchPane) {
|
||||
settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null)
|
||||
}
|
||||
}
|
||||
for (const tab of unifiedTabs) {
|
||||
if (tab.contentType === 'agent-session') {
|
||||
const target = structuredAgentSessionTargetForTab(s, tab)
|
||||
if (target) {
|
||||
retireStructuredAgentSessionReadOwner(tab.entityId, target)
|
||||
}
|
||||
}
|
||||
// A retained launch here survived a reload, so its persisted record names its host.
|
||||
const owner =
|
||||
tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId)
|
||||
? getStructuredAgentSessionLaunchOwner(tab.entityId)
|
||||
: undefined
|
||||
const target = structuredAgentSessionTargetForHost(owner)
|
||||
if (owner && target && shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) {
|
||||
markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner)
|
||||
discardStructuredAgentSessionChatSends(tab.entityId)
|
||||
clearWebSessionFocusIntentIfMatches(
|
||||
structuredAgentSessionFocusOwner(target),
|
||||
worktreeId,
|
||||
`agent-session:${tab.entityId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */
|
||||
export function cancelStructuredLaunchesForWorktrees(
|
||||
worktreeIdSet: ReadonlySet<string>,
|
||||
ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean
|
||||
): Set<string> {
|
||||
const cancelledSessionIds = new Set<string>()
|
||||
for (const launch of structuredLaunchStates()) {
|
||||
const worktreeId = launch.intent.worktreeId
|
||||
if (
|
||||
worktreeIdSet.has(worktreeId) &&
|
||||
(!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) &&
|
||||
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId)
|
||||
) {
|
||||
markStructuredAgentSessionLaunchCancelledSilently(
|
||||
worktreeId,
|
||||
launch.intent.sessionId,
|
||||
launch.intent.executionHostId
|
||||
)
|
||||
discardStructuredAgentSessionChatSends(launch.intent.sessionId)
|
||||
retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target)
|
||||
clearWebSessionFocusIntentIfMatches(
|
||||
structuredAgentSessionFocusOwner(launch.intent.target),
|
||||
worktreeId,
|
||||
`agent-session:${launch.intent.sessionId}`
|
||||
)
|
||||
cancelledSessionIds.add(launch.intent.sessionId)
|
||||
}
|
||||
}
|
||||
return cancelledSessionIds
|
||||
}
|
||||
@@ -16,6 +16,37 @@ export type WorktreePurgeDoomedIds = {
|
||||
export function collectWorktreePurgeDoomedIds(
|
||||
s: AppState,
|
||||
worktreeIdSet: Set<string>
|
||||
): WorktreePurgeDoomedIds {
|
||||
const tabs: { id: string; ptyId: string | null }[] = []
|
||||
const browserWorkspaceIds: string[] = []
|
||||
const fileIds: string[] = []
|
||||
for (const id of worktreeIdSet) {
|
||||
tabs.push(...(s.tabsByWorktree[id] ?? []))
|
||||
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
|
||||
browserWorkspaceIds.push(workspace.id)
|
||||
}
|
||||
// Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session.
|
||||
forgetDetachedHeadAutoDerivedDisplayName(id)
|
||||
}
|
||||
for (const file of s.openFiles) {
|
||||
if (worktreeIdSet.has(file.worktreeId)) {
|
||||
fileIds.push(file.id)
|
||||
if (file.markdownPreviewSourceFileId) {
|
||||
fileIds.push(file.markdownPreviewSourceFileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return collectSurfacePurgeDoomedIds(s, { tabs, browserWorkspaceIds, fileIds })
|
||||
}
|
||||
|
||||
/** Doomed ids for named surfaces, which may sit in a workspace that stays (a surviving twin's). */
|
||||
export function collectSurfacePurgeDoomedIds(
|
||||
s: AppState,
|
||||
surfaces: {
|
||||
tabs: readonly { id: string; ptyId: string | null }[]
|
||||
browserWorkspaceIds: Iterable<string>
|
||||
fileIds: Iterable<string>
|
||||
}
|
||||
): WorktreePurgeDoomedIds {
|
||||
// Collect every tab id (and removed file id) we are about to orphan.
|
||||
const doomedTabIds = new Set<string>()
|
||||
@@ -37,22 +68,15 @@ export function collectWorktreePurgeDoomedIds(
|
||||
addDoomedPtyId(ptyId)
|
||||
}
|
||||
}
|
||||
const doomedBrowserWorkspaceIds = new Set<string>()
|
||||
const doomedBrowserWorkspaceIds = new Set(surfaces.browserWorkspaceIds)
|
||||
const doomedPageIds = new Set<string>()
|
||||
const removedFileIds = new Set<string>()
|
||||
for (const id of worktreeIdSet) {
|
||||
for (const tab of s.tabsByWorktree[id] ?? []) {
|
||||
doomedTabIds.add(tab.id)
|
||||
// Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}).
|
||||
addDoomedTabPtyIds(tab.id, tab.ptyId)
|
||||
// Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0).
|
||||
forgetAgentHibernationTabOutput(tab.id)
|
||||
}
|
||||
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
|
||||
doomedBrowserWorkspaceIds.add(workspace.id)
|
||||
}
|
||||
// Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session.
|
||||
forgetDetachedHeadAutoDerivedDisplayName(id)
|
||||
const removedFileIds = new Set(surfaces.fileIds)
|
||||
for (const tab of surfaces.tabs) {
|
||||
doomedTabIds.add(tab.id)
|
||||
// Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}).
|
||||
addDoomedTabPtyIds(tab.id, tab.ptyId)
|
||||
// Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0).
|
||||
forgetAgentHibernationTabOutput(tab.id)
|
||||
}
|
||||
// Why: same rationale for doomed tabs' foreground last-seen timestamps and agent-startup delivery guards — retired tab ids never recur.
|
||||
forgetForegroundTerminalTabs(doomedTabIds)
|
||||
@@ -66,14 +90,6 @@ export function collectWorktreePurgeDoomedIds(
|
||||
doomedPageIds.add(page.id)
|
||||
}
|
||||
}
|
||||
for (const file of s.openFiles) {
|
||||
if (worktreeIdSet.has(file.worktreeId)) {
|
||||
removedFileIds.add(file.id)
|
||||
if (file.markdownPreviewSourceFileId) {
|
||||
removedFileIds.add(file.markdownPreviewSourceFileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
doomedTabIds,
|
||||
doomedPtyIds,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { AppState } from '../../../types'
|
||||
import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers'
|
||||
import { normalizeWorktreePurgeTarget } from '../../worktree-purge-target'
|
||||
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
|
||||
@@ -8,72 +8,79 @@ import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids'
|
||||
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
|
||||
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
|
||||
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
|
||||
import { omitRecordKeys } from './record-key-omission'
|
||||
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
|
||||
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
|
||||
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start'
|
||||
import {
|
||||
getStructuredAgentSessionLaunchOwner,
|
||||
markStructuredAgentSessionLaunchCancelledSilently,
|
||||
shouldRetainStructuredAgentSessionLaunchTab,
|
||||
structuredLaunchStates
|
||||
} from '@/lib/structured-agent-session-launch-registry'
|
||||
import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt'
|
||||
import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry'
|
||||
import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent'
|
||||
import {
|
||||
structuredAgentSessionFocusOwner,
|
||||
structuredAgentSessionTargetForTab,
|
||||
structuredAgentSessionTargetForHost
|
||||
} from '@/runtime/structured-agent-session-owner'
|
||||
import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions'
|
||||
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
|
||||
import {
|
||||
cancelStructuredLaunchesForWorktrees,
|
||||
retirePurgedTabLaunches
|
||||
} from './purged-structured-launch-retirement'
|
||||
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
|
||||
import { buildHostTwinPurgeState, collectHostTwinPurgeScopes } from './host-twin-worktree-purge'
|
||||
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
/** Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual terminal teardown. */
|
||||
/**
|
||||
* Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual
|
||||
* terminal teardown. A host target whose id another host still publishes purges only that host's
|
||||
* attributable state; everything else purges every bucket under the id.
|
||||
*/
|
||||
export function buildWorktreePurgeState(
|
||||
s: AppState,
|
||||
worktreeTargets: WorktreePurgeTargets
|
||||
): Partial<AppState> {
|
||||
const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) =>
|
||||
typeof target === 'string' ? { id: target } : target
|
||||
const targets = worktreeTargets.map(normalizeWorktreePurgeTarget)
|
||||
const twinScopes = collectHostTwinPurgeScopes(s, targets)
|
||||
if (twinScopes.byHost.size === 0) {
|
||||
return buildFullWorktreePurgeState(s, targets)
|
||||
}
|
||||
const fullTargets = targets.filter(
|
||||
(target) =>
|
||||
!target.hostId ||
|
||||
!twinScopes.twinTargetKeys.has(composeWorktreeHostIdentity(target.hostId, target.id))
|
||||
)
|
||||
let patch = fullTargets.length > 0 ? buildFullWorktreePurgeState(s, fullTargets) : {}
|
||||
for (const [hostId, twinHostIds] of twinScopes.byHost) {
|
||||
patch = { ...patch, ...buildHostTwinPurgeState({ ...s, ...patch }, hostId, twinHostIds) }
|
||||
}
|
||||
// Why both hosts: a paired runtime's copy writes recency and delete state under either identity.
|
||||
const hostKeys = [
|
||||
...targets.filter((target) => !fullTargets.includes(target)),
|
||||
...[...twinScopes.byHost].flatMap(([hostId, twins]) =>
|
||||
[...twins.keys()].map((id) => ({ id, hostId }))
|
||||
)
|
||||
].flatMap((target) => (target.hostId ? [{ id: target.id, hostId: target.hostId }] : []))
|
||||
const next = { ...s, ...patch }
|
||||
return {
|
||||
...patch,
|
||||
lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets(
|
||||
next.lastVisitedAtByWorktreeId,
|
||||
hostKeys
|
||||
),
|
||||
deleteStateByWorktreeId: omitRecordKeys(
|
||||
next.deleteStateByWorktreeId,
|
||||
hostKeys.map((target) => composeWorktreeHostIdentity(target.hostId, target.id))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every bucket under the targets' ids; visit recency alone stays host-scoped. */
|
||||
export function buildFullWorktreePurgeState(
|
||||
s: AppState,
|
||||
normalizedTargets: readonly WorktreePurgeTarget[]
|
||||
): Partial<AppState> {
|
||||
const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id))
|
||||
// Why: bulk purges bypass close actions, so provisional ownership must die before tab state does.
|
||||
const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(worktreeIdSet)
|
||||
for (const worktreeId of worktreeIdSet) {
|
||||
// A launch tab that never spawned has no spawn left to answer its prompt's wait.
|
||||
for (const tab of s.tabsByWorktree[worktreeId] ?? []) {
|
||||
if (tab.agentLaunchPane) {
|
||||
settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null)
|
||||
}
|
||||
}
|
||||
for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) {
|
||||
if (tab.contentType === 'agent-session') {
|
||||
const target = structuredAgentSessionTargetForTab(s, tab)
|
||||
if (target) {
|
||||
retireStructuredAgentSessionReadOwner(tab.entityId, target)
|
||||
}
|
||||
}
|
||||
// A retained launch here survived a reload, so its persisted record names its host.
|
||||
const owner =
|
||||
tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId)
|
||||
? getStructuredAgentSessionLaunchOwner(tab.entityId)
|
||||
: undefined
|
||||
const target = structuredAgentSessionTargetForHost(owner)
|
||||
if (
|
||||
owner &&
|
||||
target &&
|
||||
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)
|
||||
) {
|
||||
markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner)
|
||||
discardStructuredAgentSessionChatSends(tab.entityId)
|
||||
clearWebSessionFocusIntentIfMatches(
|
||||
structuredAgentSessionFocusOwner(target),
|
||||
worktreeId,
|
||||
`agent-session:${tab.entityId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
retirePurgedTabLaunches(
|
||||
s,
|
||||
worktreeId,
|
||||
s.tabsByWorktree[worktreeId] ?? [],
|
||||
s.unifiedTabsByWorktree[worktreeId] ?? [],
|
||||
cancelledSessionIds
|
||||
)
|
||||
}
|
||||
pruneHostedReviewLinkMutationGenerations(worktreeIdSet)
|
||||
// Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep.
|
||||
@@ -85,17 +92,13 @@ export function buildWorktreePurgeState(
|
||||
forgetAmbiguousOwnerWarnings(worktreeIdSet)
|
||||
|
||||
const doomed = collectWorktreePurgeDoomedIds(s, worktreeIdSet)
|
||||
const omitters = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
|
||||
const {
|
||||
omitByWorktree,
|
||||
omitWorkspaceLineageByWorktree,
|
||||
pruneRightSidebarTabByWorktree,
|
||||
omitByTabId,
|
||||
omitByPtyId,
|
||||
omitByPaneKeyTabPrefix,
|
||||
omitByBrowserWorkspaceId,
|
||||
omitByPageId,
|
||||
omitByFileId
|
||||
} = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
|
||||
omitByPaneKeyTabPrefix
|
||||
} = omitters
|
||||
|
||||
const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId)
|
||||
const activeFileCleared = s.activeFileId != null && doomed.removedFileIds.has(s.activeFileId)
|
||||
@@ -117,7 +120,6 @@ export function buildWorktreePurgeState(
|
||||
}
|
||||
return next
|
||||
})()
|
||||
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
|
||||
const tabPrefixedSleeping = {
|
||||
sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey),
|
||||
agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey)
|
||||
@@ -134,44 +136,10 @@ export function buildWorktreePurgeState(
|
||||
tabIds: doomed.doomedTabIds,
|
||||
activeFileCleared
|
||||
}),
|
||||
...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, nextSleeping),
|
||||
// Worktree-scoped terminal/tab state
|
||||
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
|
||||
workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey),
|
||||
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
|
||||
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
|
||||
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
|
||||
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
|
||||
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
|
||||
// Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself.
|
||||
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
|
||||
canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId),
|
||||
// Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees.
|
||||
lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId),
|
||||
// Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness.
|
||||
pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId),
|
||||
unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds),
|
||||
deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId),
|
||||
pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId),
|
||||
pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId),
|
||||
pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId),
|
||||
codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId),
|
||||
migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId),
|
||||
suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds),
|
||||
pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts),
|
||||
// Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge).
|
||||
// retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync).
|
||||
agentStatusByPaneKey: nextAgentStatusByPaneKey,
|
||||
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
|
||||
? { agentStatusEpoch: s.agentStatusEpoch + 1 }
|
||||
: {}),
|
||||
agentLaunchConfigByPaneKey: nextSleeping.agentLaunchConfigByPaneKey,
|
||||
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
|
||||
activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey),
|
||||
manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey),
|
||||
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
|
||||
sleepingAgentSessionsByPaneKey: nextSleeping.sleepingAgentSessionsByPaneKey,
|
||||
...removeTabsAttention(s, doomed.doomedTabIds),
|
||||
lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey),
|
||||
// Delete state
|
||||
deleteStateByWorktreeId: removeDeleteStatesForWorktreeIds(
|
||||
s.deleteStateByWorktreeId,
|
||||
@@ -181,21 +149,7 @@ export function buildWorktreePurgeState(
|
||||
remoteBranchConflictByWorktreeId: omitByWorktree(s.remoteBranchConflictByWorktreeId),
|
||||
// File search
|
||||
fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree),
|
||||
// Browser state
|
||||
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
|
||||
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
|
||||
// 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),
|
||||
remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId),
|
||||
pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId),
|
||||
// createBrowserTab writes both the workspace id and the page id into this map.
|
||||
pendingAddressBarFocusByTabId: omitByPageId(
|
||||
omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId)
|
||||
),
|
||||
recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId(
|
||||
s.recentlyClosedBrowserPagesByWorkspace
|
||||
),
|
||||
// Editor state
|
||||
pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree),
|
||||
rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(),
|
||||
@@ -220,14 +174,6 @@ export function buildWorktreePurgeState(
|
||||
explorerDisplayRootByWorktree: omitByWorktree(s.explorerDisplayRootByWorktree),
|
||||
showDotfilesByWorktree: omitByWorktree(s.showDotfilesByWorktree),
|
||||
expandedDirs: omitByWorktree(s.expandedDirs),
|
||||
// Per-file editor state for removed files
|
||||
editorDrafts: omitByFileId(s.editorDrafts),
|
||||
markdownViewMode: omitByFileId(s.markdownViewMode),
|
||||
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
|
||||
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
|
||||
// Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file.
|
||||
editorCursorLine: omitByFileId(s.editorCursorLine),
|
||||
editorViewMode: omitByFileId(s.editorViewMode),
|
||||
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (editor-undo / Cmd+Shift+T snapshots).
|
||||
recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree),
|
||||
recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree),
|
||||
@@ -255,34 +201,3 @@ export function buildWorktreePurgeState(
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */
|
||||
export function cancelStructuredLaunchesForWorktrees(
|
||||
worktreeIdSet: ReadonlySet<string>,
|
||||
ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean
|
||||
): Set<string> {
|
||||
const cancelledSessionIds = new Set<string>()
|
||||
for (const launch of structuredLaunchStates()) {
|
||||
const worktreeId = launch.intent.worktreeId
|
||||
if (
|
||||
worktreeIdSet.has(worktreeId) &&
|
||||
(!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) &&
|
||||
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId)
|
||||
) {
|
||||
markStructuredAgentSessionLaunchCancelledSilently(
|
||||
worktreeId,
|
||||
launch.intent.sessionId,
|
||||
launch.intent.executionHostId
|
||||
)
|
||||
discardStructuredAgentSessionChatSends(launch.intent.sessionId)
|
||||
retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target)
|
||||
clearWebSessionFocusIntentIfMatches(
|
||||
structuredAgentSessionFocusOwner(launch.intent.target),
|
||||
worktreeId,
|
||||
`agent-session:${launch.intent.sessionId}`
|
||||
)
|
||||
cancelledSessionIds.add(launch.intent.sessionId)
|
||||
}
|
||||
}
|
||||
return cancelledSessionIds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AppState } from '../../../types'
|
||||
import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions'
|
||||
import type { createWorktreePurgeOmitters } from './worktree-purge-omitters'
|
||||
|
||||
type WorktreePurgeOmitters = ReturnType<typeof createWorktreePurgeOmitters>
|
||||
|
||||
/** The paneKey-keyed sleeping maps after the caller's own omission policy. */
|
||||
export type WorktreePurgeSleepingMaps = Pick<
|
||||
AppState,
|
||||
'sleepingAgentSessionsByPaneKey' | 'agentLaunchConfigByPaneKey'
|
||||
>
|
||||
|
||||
/** Tab-, PTY-, pane-, page- and file-keyed state of purged surfaces; never a worktree-keyed bucket. */
|
||||
export function buildWorktreePurgeSurfacePatch(
|
||||
s: AppState,
|
||||
omitters: WorktreePurgeOmitters,
|
||||
doomedTabIds: ReadonlySet<string>,
|
||||
sleeping: WorktreePurgeSleepingMaps
|
||||
): Partial<AppState> {
|
||||
const {
|
||||
omitByTabId,
|
||||
omitByPtyId,
|
||||
omitByPaneKeyTabPrefix,
|
||||
omitByBrowserWorkspaceId,
|
||||
omitByPageId,
|
||||
omitByFileId
|
||||
} = omitters
|
||||
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
|
||||
return {
|
||||
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
|
||||
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
|
||||
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
|
||||
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
|
||||
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
|
||||
// Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself.
|
||||
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
|
||||
canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId),
|
||||
// Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees.
|
||||
lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId),
|
||||
// Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness.
|
||||
pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId),
|
||||
unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds),
|
||||
deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId),
|
||||
pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId),
|
||||
pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId),
|
||||
pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId),
|
||||
codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId),
|
||||
migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId),
|
||||
suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds),
|
||||
pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts),
|
||||
// Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge).
|
||||
// retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync).
|
||||
agentStatusByPaneKey: nextAgentStatusByPaneKey,
|
||||
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
|
||||
? { agentStatusEpoch: s.agentStatusEpoch + 1 }
|
||||
: {}),
|
||||
agentLaunchConfigByPaneKey: sleeping.agentLaunchConfigByPaneKey,
|
||||
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
|
||||
activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey),
|
||||
manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey),
|
||||
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
|
||||
sleepingAgentSessionsByPaneKey: sleeping.sleepingAgentSessionsByPaneKey,
|
||||
...removeTabsAttention(s, doomedTabIds),
|
||||
lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey),
|
||||
// Browser state
|
||||
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
|
||||
// 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),
|
||||
remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId),
|
||||
pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId),
|
||||
// createBrowserTab writes both the workspace id and the page id into this map.
|
||||
pendingAddressBarFocusByTabId: omitByPageId(
|
||||
omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId)
|
||||
),
|
||||
recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId(
|
||||
s.recentlyClosedBrowserPagesByWorkspace
|
||||
),
|
||||
// Per-file editor state for removed files
|
||||
editorDrafts: omitByFileId(s.editorDrafts),
|
||||
markdownViewMode: omitByFileId(s.markdownViewMode),
|
||||
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
|
||||
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
|
||||
// Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file.
|
||||
editorCursorLine: omitByFileId(s.editorCursorLine),
|
||||
editorViewMode: omitByFileId(s.editorViewMode)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
hostWorktreeTarget,
|
||||
type HostScopedWorktreeKey,
|
||||
type HostWorktreeTarget
|
||||
} from './host-qualified-identity'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/work/orca'
|
||||
@@ -120,3 +122,18 @@ describe('worktree host identity golden bytes', () => {
|
||||
expect([raw, forged]).toEqual([key, WORKTREE_ID])
|
||||
})
|
||||
})
|
||||
|
||||
describe('host worktree target', () => {
|
||||
it('carries the row id and its concrete host', () => {
|
||||
const target = hostWorktreeTarget({ id: WORKTREE_ID, hostId: toSshExecutionHostId('box') })
|
||||
expect(target).toEqual({ hostId: 'ssh:box', worktreeId: WORKTREE_ID })
|
||||
})
|
||||
|
||||
it('cannot be forged from a plain object or built without a host', () => {
|
||||
// @ts-expect-error only hostWorktreeTarget mints the brand
|
||||
const forged: HostWorktreeTarget = { hostId: 'local', worktreeId: WORKTREE_ID }
|
||||
// @ts-expect-error an unqualified row has no host to scope to
|
||||
hostWorktreeTarget({ id: WORKTREE_ID, hostId: undefined })
|
||||
expect(forged.worktreeId).toBe(WORKTREE_ID)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,23 @@ declare const hostScopedWorktreeKeyBrand: unique symbol
|
||||
// Why: for new code only; existing raw-id stores stay as they are (D7).
|
||||
export type HostScopedWorktreeKey = string & { readonly [hostScopedWorktreeKeyBrand]: true }
|
||||
|
||||
declare const hostWorktreeTargetBrand: unique symbol
|
||||
|
||||
/** One host's copy of a workspace, for destructive entry points that must not reach its twin. */
|
||||
export type HostWorktreeTarget = {
|
||||
readonly hostId: ExecutionHostId
|
||||
readonly worktreeId: string
|
||||
readonly [hostWorktreeTargetBrand]: true
|
||||
}
|
||||
|
||||
export function hostWorktreeTarget(row: {
|
||||
id: string
|
||||
hostId: ExecutionHostId
|
||||
}): HostWorktreeTarget {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the only constructor of the brand; `hostId` is a concrete host by the parameter type.
|
||||
return { hostId: row.hostId, worktreeId: row.id } as HostWorktreeTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable key for one workspace on one host (STA-4343).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user