diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.test.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.test.ts new file mode 100644 index 00000000000..71071b91193 --- /dev/null +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { RuntimeTerminalOrphanAdoptionRequest } from '../../shared/runtime-types' +import { collectLayoutLeafIdsInOrder } from '../../shared/workspace-layout/terminal-pane-tree' +import { + GIT_KEY, + addWorkspace, + emptySession, + leaf +} from '../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +const TAB = 'tab-1' +const HEALTHY = leaf(1) +const ORPHAN = leaf(2) + +function adopt(existingLeaves: [string, string?][]) { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { id: 'group-1', tabs: [{ id: TAB, leaves: existingLeaves }] } + ]) + const claim = { + terminal: 'terminal-orphan', + ptyId: 'pty-orphan', + incarnationId: 'incarnation-orphan', + tabId: TAB, + leafId: ORPHAN + } + const topology: NonNullable = { + tabs: [ + { + tabId: TAB, + root: { type: 'leaf', leafId: ORPHAN }, + activeLeafId: ORPHAN, + expandedLeafId: null + } + ], + groups: [{ id: 'group-1', activeTabId: TAB, tabOrder: [TAB] }] + } + const request: RuntimeTerminalOrphanAdoptionRequest = { + worktree: GIT_KEY, + expectedTopologyRevision: 0, + claims: [claim], + topology + } + return buildRuntimeTerminalOrphanSession({ + hostId: LOCAL_EXECUTION_HOST_ID, + session, + sessionWorktreeId: GIT_KEY, + worktreeId: GIT_KEY, + request, + validated: [ + { + claim, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tab already exists, so the builder never reads the PTY record. + pty: {} as RuntimePtyWorktreeRecord, + paneKey: makePaneKey(TAB, ORPHAN) + } + ], + topologyTabsById: new Map(topology.tabs.map((tab) => [tab.tabId, tab])), + topologyGroups: topology.groups, + getDisplayTitle: () => null + }) +} + +it('keeps the healthy sibling pane and its PTY when one split pane is recovered', () => { + const layout = adopt([[HEALTHY, 'pty-healthy']]).terminalLayoutsByTabId[TAB] + + expect(collectLayoutLeafIdsInOrder(layout?.root)).toEqual([HEALTHY, ORPHAN]) + expect(layout?.ptyIdsByLeafId).toEqual({ [HEALTHY]: 'pty-healthy', [ORPHAN]: 'pty-orphan' }) + expect(layout?.activeLeafId).toBe(ORPHAN) +}) + +it('keeps the saved split when it already holds the recovered pane', () => { + const layout = adopt([[HEALTHY, 'pty-healthy'], [ORPHAN]]).terminalLayoutsByTabId[TAB] + + expect(collectLayoutLeafIdsInOrder(layout?.root)).toEqual([HEALTHY, ORPHAN]) + expect(layout?.ptyIdsByLeafId).toEqual({ [HEALTHY]: 'pty-healthy', [ORPHAN]: 'pty-orphan' }) +}) diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index 7ca492c3032..6696e3ce18f 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -6,6 +6,11 @@ import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-la import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection' +import { + collectLayoutLeafIdsInOrder, + removeLayoutLeaf +} from '../../shared/workspace-layout/terminal-pane-tree' +import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' @@ -62,7 +67,7 @@ export function buildRuntimeTerminalOrphanSession(args: { next.terminalLayoutsByTabId[claim.tabId] = topologyTab ? { ...existingLayout, - root: topologyTab.root, + root: mergeAdoptedTopologyRoot(existingLayout?.root ?? null, topologyTab.root), activeLeafId: topologyTab.activeLeafId, expandedLeafId: topologyTab.expandedLeafId, ptyIdsByLeafId: { @@ -196,3 +201,29 @@ export function buildRuntimeTerminalOrphanSession(args: { loadWorkspaceLayoutWithoutReport(hostId, persisted, nameBasedLoadContext()) ) } + +/** + * The client's topology names only the panes it adopts; the tab's other panes keep their place + * beside them, so recovering one split never drops a healthy sibling. + */ +function mergeAdoptedTopologyRoot( + existingRoot: TerminalPaneLayoutNode | null, + adoptedRoot: TerminalPaneLayoutNode +): TerminalPaneLayoutNode { + if (!existingRoot) { + return adoptedRoot + } + const existingLeafIds = collectLayoutLeafIdsInOrder(existingRoot) + const adoptedLeafIds = collectLayoutLeafIdsInOrder(adoptedRoot) + if (adoptedLeafIds.every((leafId) => existingLeafIds.includes(leafId))) { + return existingRoot + } + const siblings = adoptedLeafIds.reduce( + (root, leafId) => removeLayoutLeaf(root, leafId), + existingRoot + ) + if (!siblings) { + return adoptedRoot + } + return { type: 'split', direction: 'vertical', first: siblings, second: adoptedRoot } +}