mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Layout groundwork (switched off): main starts every split and new pane, the window only attaches (#31258)
* feat(terminal): the window's store shows main's pane start status (layout-owner build) A status-only frame changes one store entry and no pane tree, so the pane's view stays mounted. * refactor(terminal): keep the frame sync within its line budget * feat(terminal): every pane main starts shows 'starting' from before it is written (layout-owner build) * feat(terminal): a pane main is starting waits and attaches; a failed start shows why (layout-owner build) Fixes owner-visible row 15: a failed agent or setup start no longer opens a plain shell. * test(terminal): iterate store listeners directly * fix(terminal): an exit that reports no code keeps its pane, as lost contact (layout-owner build) * test(terminal): a pane main started attaches to main's terminal at its own grid * test(terminal): detach census tests assert main's move and frame with the switch on * test(terminal): exited-pane tests assert main's close with the switch on These 8 have failed switch-on since #31130 moved the exit close to main. * feat(terminal): main writes and starts every split of a pane it holds; the window attaches (layout-owner build) The window's split, a startup split with its command, and a CLI split all go through main's terminal.split; main's frame mounts the pane, with or without a window intent. Deletes the window's split wait and withdraw, the split appear intents, and the queued-split skip and cancel. The e2e helper uses the real split. A startup split leaves focus on its source pane. * fix(terminal): the attach-only wait leaves sleeping, resuming and held panes to their restore path * fix(terminal): a split pane attaches to the terminal main bound it to; a taken pane id is refused Also restores the proof that a split closed while it starts gets one answer and leaves no pane or start status behind. * fix(terminal): a window split main cannot start still shows its pane, failed, with why * fix(terminal): check a taken pane id first, keep the start's own error, clear a gone pane's status Three test stores gain the pane-start field the window's store always has. * test(terminal): the admission stub answers whether its pane is still written; a closed pane's failed start clears * refactor(terminal): the rebound workspaces' bindings show through one call in the bindings module * test(terminal): a pane main wrote and the user closed mid-start drops its failed status * fix(terminal): a split main starts is listed to phones and session.tabs without a window's report * fix(terminal): a reconnect shows its pane starting and why it failed; a lost owner race shows no failure; a split no one asked to see keeps the phones' tab * fix(terminal): a kept create another owner won shows no failure; the window attaches to the winner
This commit is contained in:
@@ -85,17 +85,6 @@ export function clearPaneSpawnReservation(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a window or runtime spawn of this pane has begun and not yet bound or failed. */
|
||||
export function isPaneSpawnInFlight(worktreeId: string, paneKey: string): boolean {
|
||||
for (const ownerKey of paneSpawnReservationsByOwnerKey.keys()) {
|
||||
const parsed: unknown = JSON.parse(ownerKey)
|
||||
if (Array.isArray(parsed) && parsed[1] === worktreeId && parsed[2] === paneKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function makePaneSpawnReservationKey(
|
||||
worktreeId: string | undefined,
|
||||
connectionId: string | null | undefined,
|
||||
|
||||
@@ -150,6 +150,25 @@ describe('layout-owner build: main closes an exited split pane itself', () => {
|
||||
expect(panes(session())).toEqual([LEFT])
|
||||
})
|
||||
|
||||
it('keeps a split pane whose exit came with no code, as when its socket closed', async () => {
|
||||
const { runtime, session } = splitRuntime()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty reports no exit code on a socket close, despite its types.
|
||||
await runtime.onPtyExit('pty-right', undefined as unknown as number, undefined, {
|
||||
providerExitObserved: true
|
||||
})
|
||||
expect(panes(session())).toEqual([LEFT, RIGHT])
|
||||
})
|
||||
|
||||
it("judges an older daemon's exit, which names no incarnation, by its code alone", async () => {
|
||||
const kept = splitRuntime()
|
||||
await kept.runtime.onPtyExit('pty-right', -1, undefined, { providerExitObserved: true })
|
||||
expect(panes(kept.session())).toEqual([LEFT, RIGHT])
|
||||
|
||||
const closed = splitRuntime()
|
||||
await closed.runtime.onPtyExit('pty-right', 0, undefined, { providerExitObserved: true })
|
||||
expect(panes(closed.session())).toEqual([LEFT])
|
||||
})
|
||||
|
||||
it('tells the phone a closed pane retired, and sends it nothing for a pane main keeps', async () => {
|
||||
const phone = async (runtime: OrcaRuntimeService) => {
|
||||
const tabs = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
|
||||
import { NEWER_DAEMON_HOLD_ERROR } from '../../shared/newer-daemon-hold'
|
||||
import { setupLayoutOwnerRuntime, WORKTREE_ID } from './orca-runtime-layout-owner-start-fixture'
|
||||
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
|
||||
|
||||
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
@@ -61,6 +62,18 @@ describe('layout-owner build: a start main could not make keeps its pane, showin
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no failure when another owner won the pane, whose terminal the window attaches to', async () => {
|
||||
const { runtime, spawn } = setupLayoutOwnerRuntime()
|
||||
spawn.mockRejectedValueOnce(new PaneOwnerContentionError())
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(`id:${WORKTREE_ID}`, { keepPaneOnStartFailure: true })
|
||||
).rejects.toThrow('terminal_pane_owner_changed')
|
||||
|
||||
const { tabId, leafId } = spawn.mock.calls[0]![0]
|
||||
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('still takes back the pane of a start that did not ask to keep it', async () => {
|
||||
const { runtime, spawn, session } = setupLayoutOwnerRuntime()
|
||||
spawn.mockRejectedValueOnce(new Error('shell not found'))
|
||||
@@ -88,4 +101,31 @@ describe('layout-owner build: a start main could not make keeps its pane, showin
|
||||
|
||||
expect(session().tabsByWorktree[WORKTREE_ID] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('shows every pane main starts as starting from before the pane is written, then clears it', async () => {
|
||||
const { runtime, spawn, session } = setupLayoutOwnerRuntime()
|
||||
const seen: unknown[] = []
|
||||
spawn.mockImplementationOnce(async ({ tabId, leafId }) => {
|
||||
// The pane is written by now: no frame shows it unbound without a status.
|
||||
expect(session().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([tabId])
|
||||
seen.push(runtime.startStatusOf(`${tabId}:${leafId}`))
|
||||
return { id: 'pty-1' }
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(seen).toEqual([{ status: 'starting' }])
|
||||
const { tabId, leafId } = spawn.mock.calls[0]![0]
|
||||
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the status of a start whose pane it took back', async () => {
|
||||
const { runtime, spawn } = setupLayoutOwnerRuntime()
|
||||
spawn.mockRejectedValueOnce(new Error('shell not found'))
|
||||
|
||||
await expect(runtime.createTerminal(`id:${WORKTREE_ID}`)).rejects.toThrow('shell not found')
|
||||
|
||||
const { tabId, leafId } = spawn.mock.calls[0]![0]
|
||||
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-type
|
||||
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
|
||||
@@ -223,9 +224,6 @@ function createHarness(
|
||||
}
|
||||
}
|
||||
},
|
||||
/** The split pane's terminal starting, as its window reports it. */
|
||||
registerSplitPty: (leafId: string) =>
|
||||
runtime.registerPty(SPLIT_PTY_ID, WORKTREE_ID, connectionId, { tabId: TAB_ID, leafId }),
|
||||
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
|
||||
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
|
||||
}
|
||||
@@ -234,9 +232,26 @@ function createHarness(
|
||||
describe('remote runtime terminal split authority', () => {
|
||||
it('addresses a graph-backed split by stable leaf identity across a parked remount', async () => {
|
||||
const harness = createHarness(true, { rendererMounted: true, graphOnlySource: true })
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main writes the split and starts its terminal under the new pane's stable id; no window
|
||||
// is asked, so a parked or remounting tab cannot hold the split up.
|
||||
const started = await harness.runtime.splitTerminal(harness.handle, {
|
||||
direction: 'vertical'
|
||||
})
|
||||
const startedLeafId = started.leafId ?? ''
|
||||
expect(harness.rendererSplitTerminal).not.toHaveBeenCalled()
|
||||
expect(started).toMatchObject({ tabId: TAB_ID, handle: expect.stringMatching(/^term_/) })
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: TAB_ID, leafId: startedLeafId })
|
||||
)
|
||||
expect(collectLeafIds(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root)).toEqual([
|
||||
SOURCE_LEAF_ID,
|
||||
startedLeafId
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
|
||||
// A switch-on build tells the window once main has admitted the new pane.
|
||||
await vi.waitFor(() => expect(harness.rendererSplitTerminal).toHaveBeenCalled())
|
||||
|
||||
const newLeafId = harness.rendererSplitTerminal.mock.calls[0]?.[2]?.newLeafId
|
||||
@@ -252,10 +267,6 @@ describe('remote runtime terminal split authority', () => {
|
||||
telemetrySource: undefined,
|
||||
newLeafId
|
||||
})
|
||||
// A switch-on build binds the new pane only once its terminal registered, before the graph.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
harness.registerSplitPty(newLeafId)
|
||||
}
|
||||
harness.runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
|
||||
+170
-82
@@ -24,93 +24,181 @@ import {
|
||||
spawnedPtyEnv,
|
||||
store
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('returns the exact pre-minted leaf for concurrent renderer-backed splits', async () => {
|
||||
const tabId = 'tab-concurrent-splits'
|
||||
const sourceLeafId = '11111111-1111-4111-8111-111111111111'
|
||||
const splitTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({ splitTerminal } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: sourceLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: sourceLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-source',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
|
||||
expect(sourceHandle).not.toBeNull()
|
||||
it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'returns the exact pre-minted leaf for concurrent renderer-backed splits, each started by main',
|
||||
async () => {
|
||||
const tabId = 'tab-concurrent-splits'
|
||||
const sourceLeafId = HEADLESS_LEAF_ID
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: tabId,
|
||||
ptyId: 'pty-source',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: makeHeadlessTerminalLayout({ [sourceLeafId]: 'pty-source' })
|
||||
}
|
||||
})
|
||||
)
|
||||
const splitTerminal = vi.fn()
|
||||
const spawn = vi.fn(async (args: { leafId?: string }) => ({ id: `pty-${args.leafId}` }))
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setNotifier({ splitTerminal } as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: sourceLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: sourceLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-source',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
|
||||
expect(sourceHandle).not.toBeNull()
|
||||
|
||||
const horizontal = runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' })
|
||||
const vertical = runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(2))
|
||||
const horizontalLeafId = splitTerminal.mock.calls.find(
|
||||
(call) => call[2]?.direction === 'horizontal'
|
||||
)?.[2]?.newLeafId
|
||||
const verticalLeafId = splitTerminal.mock.calls.find(
|
||||
(call) => call[2]?.direction === 'vertical'
|
||||
)?.[2]?.newLeafId
|
||||
expect(horizontalLeafId).toEqual(expect.any(String))
|
||||
expect(verticalLeafId).toEqual(expect.any(String))
|
||||
expect(horizontalLeafId).not.toBe(verticalLeafId)
|
||||
const [horizontal, vertical] = await Promise.all([
|
||||
runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' }),
|
||||
runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
|
||||
])
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: verticalLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
// Reverse publication order so a first-new-leaf heuristic would swap the receipts.
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: verticalLeafId!,
|
||||
paneRuntimeId: 3,
|
||||
ptyId: 'pty-vertical',
|
||||
paneTitle: null
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: horizontalLeafId!,
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-horizontal',
|
||||
paneTitle: null
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: sourceLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-source',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
// Main splits itself: no window is asked, and each split names its own pane.
|
||||
expect(splitTerminal).not.toHaveBeenCalled()
|
||||
expect(horizontal.leafId).not.toBe(vertical.leafId)
|
||||
const leafIds = collectLeafIds(getSession().terminalLayoutsByTabId[tabId]?.root)
|
||||
expect(leafIds).toEqual(
|
||||
expect.arrayContaining([sourceLeafId, horizontal.leafId, vertical.leafId])
|
||||
)
|
||||
// Each started under its own pane key, in the tab it split.
|
||||
for (const split of [horizontal, vertical]) {
|
||||
expect(split.tabId).toBe(tabId)
|
||||
const leafId = split.leafId ?? ''
|
||||
const call = spawn.mock.calls.find(([args]) => args.leafId === leafId)
|
||||
expect(spawnedPtyEnv(call?.[0]).ORCA_PANE_KEY).toBe(makePaneKey(tabId, leafId))
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
await expect(horizontal).resolves.toMatchObject({ leafId: horizontalLeafId })
|
||||
await expect(vertical).resolves.toMatchObject({ leafId: verticalLeafId })
|
||||
})
|
||||
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'returns the exact pre-minted leaf for concurrent renderer-backed splits',
|
||||
async () => {
|
||||
const tabId = 'tab-concurrent-splits'
|
||||
const sourceLeafId = '11111111-1111-4111-8111-111111111111'
|
||||
const splitTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({ splitTerminal } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: sourceLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: sourceLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-source',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
|
||||
expect(sourceHandle).not.toBeNull()
|
||||
|
||||
const horizontal = runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' })
|
||||
const vertical = runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(2))
|
||||
const horizontalLeafId = splitTerminal.mock.calls.find(
|
||||
(call) => call[2]?.direction === 'horizontal'
|
||||
)?.[2]?.newLeafId
|
||||
const verticalLeafId = splitTerminal.mock.calls.find(
|
||||
(call) => call[2]?.direction === 'vertical'
|
||||
)?.[2]?.newLeafId
|
||||
expect(horizontalLeafId).toEqual(expect.any(String))
|
||||
expect(verticalLeafId).toEqual(expect.any(String))
|
||||
expect(horizontalLeafId).not.toBe(verticalLeafId)
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: verticalLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
// Reverse publication order so a first-new-leaf heuristic would swap the receipts.
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: verticalLeafId!,
|
||||
paneRuntimeId: 3,
|
||||
ptyId: 'pty-vertical',
|
||||
paneTitle: null
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: horizontalLeafId!,
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-horizontal',
|
||||
paneTitle: null
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: sourceLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-source',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(horizontal).resolves.toMatchObject({ leafId: horizontalLeafId })
|
||||
await expect(vertical).resolves.toMatchObject({ leafId: verticalLeafId })
|
||||
}
|
||||
)
|
||||
|
||||
it('splits visible pty-backed terminal sessions through the parent renderer tab', async () => {
|
||||
const spawn = vi
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { spawnInAdmittedPane } from './runtime-pane-admission'
|
||||
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
|
||||
import {
|
||||
bindingOf,
|
||||
PaneStartStatuses,
|
||||
@@ -23,12 +24,17 @@ const ARGS = {
|
||||
}
|
||||
const PANE_KEY = `${ARGS.tabId}:${ARGS.leafId}`
|
||||
|
||||
function storeAnswering(admit: 'admitted' | 'exists') {
|
||||
function storeAnswering(admit: 'admitted' | 'exists', paneWritten = true) {
|
||||
const store = {
|
||||
admitTerminalPane: vi.fn(async () => admit),
|
||||
withdrawTerminalPane: vi.fn(async () => null)
|
||||
withdrawTerminalPane: vi.fn(async () => null),
|
||||
getWorkspaceSession: vi.fn(() => ({
|
||||
terminalLayoutsByTabId: paneWritten
|
||||
? { [ARGS.tabId]: { root: { type: 'leaf', leafId: ARGS.leafId } } }
|
||||
: {}
|
||||
}))
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission path calls only these two store methods.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission path calls only these three store methods.
|
||||
return { store, runtimeStore: store as unknown as RuntimeStore }
|
||||
}
|
||||
|
||||
@@ -77,6 +83,58 @@ describe('a start into a pane main holds', () => {
|
||||
expect(store.withdrawTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the status when the pane was closed before its start failed', async () => {
|
||||
const { register } = statuses()
|
||||
const { runtimeStore } = storeAnswering('exists', false)
|
||||
await expect(
|
||||
spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error('shell not found')
|
||||
})
|
||||
},
|
||||
{ ...ARGS, startStatus: { sink: register, keepOnFailure: false } }
|
||||
)
|
||||
).rejects.toThrow('shell not found')
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the status of a pane it wrote when the user closed it before its start failed', async () => {
|
||||
const { register } = statuses()
|
||||
const { store, runtimeStore } = storeAnswering('admitted', false)
|
||||
await expect(
|
||||
spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error('no pty')
|
||||
})
|
||||
},
|
||||
{ ...ARGS, startStatus: { sink: register, keepOnFailure: true } }
|
||||
)
|
||||
).rejects.toThrow('no pty')
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
|
||||
expect(store.withdrawTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows no failure when another owner won the pane it held', async () => {
|
||||
const { register } = statuses()
|
||||
const { runtimeStore } = storeAnswering('exists')
|
||||
await expect(
|
||||
spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
throw new PaneOwnerContentionError()
|
||||
})
|
||||
},
|
||||
{ ...ARGS, startStatus: { sink: register, keepOnFailure: true } }
|
||||
)
|
||||
).rejects.toThrow('terminal_pane_owner_changed')
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a pane the create wrote only when asked to; otherwise takes it back as today', async () => {
|
||||
const failing = {
|
||||
spawn: vi.fn(async () => {
|
||||
@@ -107,6 +165,63 @@ describe('a start into a pane main holds', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reconnect into a pane main already holds', () => {
|
||||
const RECONNECT = { ...ARGS, paneSpawnOperation: { mustExist: true } }
|
||||
|
||||
it('shows the pane starting without asking for admission, then clears', async () => {
|
||||
const { register } = statuses()
|
||||
const { store, runtimeStore } = storeAnswering('exists')
|
||||
let seen: unknown
|
||||
await spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
seen = register.get(PANE_KEY, UNBOUND)
|
||||
return { id: 'pty-1' }
|
||||
})
|
||||
},
|
||||
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
|
||||
)
|
||||
expect(seen).toEqual({ status: 'starting' })
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
|
||||
expect(store.admitTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the pane showing why when its start fails', async () => {
|
||||
const { register } = statuses()
|
||||
const { runtimeStore } = storeAnswering('exists')
|
||||
await expect(
|
||||
spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error('shell not found')
|
||||
})
|
||||
},
|
||||
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
|
||||
)
|
||||
).rejects.toThrow('shell not found')
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toEqual({ status: 'failed', reason: 'shell not found' })
|
||||
})
|
||||
|
||||
it('shows no failure when another owner won the pane, which attaches to the winner', async () => {
|
||||
const { register } = statuses()
|
||||
const { runtimeStore } = storeAnswering('exists')
|
||||
await expect(
|
||||
spawnInAdmittedPane(
|
||||
runtimeStore,
|
||||
{
|
||||
spawn: vi.fn(async () => {
|
||||
throw new PaneOwnerContentionError()
|
||||
})
|
||||
},
|
||||
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
|
||||
)
|
||||
).rejects.toThrow('terminal_pane_owner_changed')
|
||||
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a pane’s start status in the published layout', () => {
|
||||
const layout: PublishedWorkspaceLayout = {
|
||||
worktreeId: 'wt-1',
|
||||
|
||||
@@ -104,12 +104,25 @@ export const TERMINAL_LIFECYCLE_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: TerminalSplit,
|
||||
handler: async (params, { runtime }) => ({
|
||||
split: await runtime.splitTerminal(params.terminal, {
|
||||
direction: params.direction,
|
||||
command: params.command,
|
||||
env: params.env,
|
||||
telemetrySource: params.telemetrySource
|
||||
})
|
||||
split:
|
||||
params.pane && !params.terminal
|
||||
? await runtime.splitTerminalPane(params.pane, {
|
||||
direction: params.direction ?? 'horizontal',
|
||||
newLeafId: params.newLeafId,
|
||||
cwd: params.cwd,
|
||||
initialSize: params.initialSize,
|
||||
command: params.command,
|
||||
env: params.env,
|
||||
// The window's split shows why its start failed, as its own spawn did.
|
||||
keepPaneOnStartFailure: true
|
||||
})
|
||||
: // The schema requires a handle when no pane is named.
|
||||
await runtime.splitTerminal(params.terminal ?? '', {
|
||||
direction: params.direction,
|
||||
command: params.command,
|
||||
env: params.env,
|
||||
telemetrySource: params.telemetrySource
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
|
||||
import type {
|
||||
TerminalPaneAdmission,
|
||||
TerminalPaneAdmissionOutcome
|
||||
@@ -63,6 +64,14 @@ function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission {
|
||||
}
|
||||
}
|
||||
|
||||
function paneStillWritten(
|
||||
store: RuntimeStore | null | undefined,
|
||||
args: AdmittedSpawnArgs
|
||||
): boolean {
|
||||
const layout = store?.getWorkspaceSession?.(hostIdOf(args))?.terminalLayoutsByTabId?.[args.tabId]
|
||||
return collectLeafIds(layout?.root).includes(args.leafId)
|
||||
}
|
||||
|
||||
function hostIdOf(args: AdmittedSpawnArgs): string | undefined {
|
||||
return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||
}
|
||||
@@ -129,7 +138,8 @@ export async function startInAdmittedPane<T>(
|
||||
/**
|
||||
* Starts a runtime-originated terminal: its pane, tab-bar entry and group are written first, then
|
||||
* the process starts and binds to that pane. A start that fails, or that attaches to a session
|
||||
* already running in another pane, takes back the pane it wrote.
|
||||
* already running in another pane, takes back the pane it wrote. With `startStatus`, the pane
|
||||
* shows 'starting' from before it is written, so no view sees it unbound and starts its own.
|
||||
*/
|
||||
export async function spawnInAdmittedPane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
@@ -137,36 +147,50 @@ export async function spawnInAdmittedPane(
|
||||
{ startStatus, ...args }: AdmittedSpawnArgs & PaneStartArgs
|
||||
): ReturnType<Spawn> {
|
||||
const { groupId: _groupId, afterTabId: _afterTabId, ...spawnArgs } = args
|
||||
if (args.paneSpawnOperation?.mustExist) {
|
||||
const paneKey = `${args.tabId}:${args.leafId}`
|
||||
let admittedAs: TerminalPaneAdmissionOutcome | undefined
|
||||
startStatus?.sink.set(paneKey, { status: 'starting' })
|
||||
let run: { outcome: TerminalPaneAdmissionOutcome | undefined; result: Awaited<ReturnType<Spawn>> }
|
||||
const start = async (
|
||||
admitted: TerminalPaneAdmissionOutcome | undefined
|
||||
): Promise<Awaited<ReturnType<Spawn>>> => {
|
||||
if (admitted !== undefined && admitted !== 'admitted' && admitted !== 'exists') {
|
||||
// A tab main recorded closed, or a split whose parent pane left the layout.
|
||||
throw new Error(admitted === 'tab_closed' ? admitted : 'terminal_split_source_not_found')
|
||||
}
|
||||
admittedAs = admitted
|
||||
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
|
||||
return controller.spawn!(spawnArgs)
|
||||
}
|
||||
const { outcome, result } = await startInAdmittedPane(
|
||||
store,
|
||||
admissionFor(args),
|
||||
hostIdOf(args),
|
||||
async (admitted) => {
|
||||
if (admitted !== undefined && admitted !== 'admitted' && admitted !== 'exists') {
|
||||
// A tab main recorded closed, or a split whose parent pane left the layout.
|
||||
throw new Error(admitted === 'tab_closed' ? admitted : 'terminal_split_source_not_found')
|
||||
}
|
||||
if (!startStatus || (admitted !== 'exists' && !startStatus.keepOnFailure)) {
|
||||
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
|
||||
return controller.spawn!(spawnArgs)
|
||||
}
|
||||
const { sink } = startStatus
|
||||
const paneKey = `${args.tabId}:${args.leafId}`
|
||||
sink.set(paneKey, { status: 'starting' })
|
||||
try {
|
||||
const started = await controller.spawn!(spawnArgs)
|
||||
sink.clear(paneKey)
|
||||
return started
|
||||
} catch (error) {
|
||||
sink.set(paneKey, paneStartFailure(error))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
startStatus?.keepOnFailure
|
||||
)
|
||||
try {
|
||||
// A reconnect into its own pane skips admission: the pane is already written.
|
||||
run = args.paneSpawnOperation?.mustExist
|
||||
? { outcome: undefined, result: await start('exists') }
|
||||
: await startInAdmittedPane(
|
||||
store,
|
||||
admissionFor(args),
|
||||
hostIdOf(args),
|
||||
start,
|
||||
startStatus?.keepOnFailure
|
||||
)
|
||||
} catch (error) {
|
||||
// A pane that stays (kept to show the failure, or one main already held) shows why; one
|
||||
// closed while it started is gone, and one another owner won attaches to the winner.
|
||||
if (
|
||||
startStatus &&
|
||||
admittedAs &&
|
||||
!(error instanceof PaneOwnerContentionError) &&
|
||||
(startStatus.keepOnFailure || admittedAs === 'exists') &&
|
||||
paneStillWritten(store, args)
|
||||
) {
|
||||
startStatus.sink.set(paneKey, paneStartFailure(error))
|
||||
} else {
|
||||
startStatus?.sink.clear(paneKey)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
startStatus?.sink.clear(paneKey)
|
||||
const { outcome, result } = run
|
||||
const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner
|
||||
const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId)
|
||||
if (outcome === 'admitted' && elsewhere) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PaneStartStatuses, paneStartFailure } from '../../pane-start-statuses'
|
||||
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
|
||||
import { allocateTerminalPaneIdentity } from '../../runtime-terminal-pane-identity'
|
||||
import { OrcaRuntimeWithCreateTerminal } from './terminal-create'
|
||||
import { PaneOwnerContentionError } from '../../../ipc/pty/pane/pane-spawn-operation'
|
||||
|
||||
export type FailedStartPane = { tabId: string; leafId: string }
|
||||
|
||||
@@ -81,6 +82,10 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre
|
||||
{ title }: dependencies.TerminalCreateOptions,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof PaneOwnerContentionError) {
|
||||
// Another owner's terminal runs in the pane; the window attaches to it.
|
||||
return
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created)
|
||||
const create = { type: 'createTerminalTab' as const, workspace: workspace.id, ...pane }
|
||||
const reply = await this.applyLayoutCommand(title ? { ...create, title } : create)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Layout-owner build: a split of a pane in main's layout is written and started by main, under the
|
||||
// caller's proposed pane id; every view, the window's included, attaches to the terminal it starts.
|
||||
import { OrcaRuntimeWithSplitPtyBackedTerminal } from './terminal-split-surface'
|
||||
import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { recordPtySurface, spawnSurfaceClaimSequence } from '../../pty-recorded-surface-topology'
|
||||
import { runtimeSplitPlacement } from '../../runtime-terminal-spawn-placement'
|
||||
import { spawnInAdmittedPane } from '../../runtime-pane-admission'
|
||||
import { resolvePtyFirstSize, type PtyGridSize } from '../../../ipc/pty/pty-first-size'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { paneStartFailure } from '../../pane-start-statuses'
|
||||
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
|
||||
|
||||
export type SplitPaneSource = { worktreeId: string; tabId: string; leafId: string }
|
||||
|
||||
export type SplitPaneInMainOptions = {
|
||||
direction: 'horizontal' | 'vertical'
|
||||
/** The caller's id for the new pane; main refuses one another pane holds. */
|
||||
newLeafId?: string
|
||||
cwd?: string
|
||||
initialSize?: PtyGridSize
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
/** False for a split no one asked to see (an agent team's); phones keep their active tab. */
|
||||
activate?: boolean
|
||||
/** The window's own split keeps a pane whose start failed, showing why, as its spawn did. */
|
||||
keepPaneOnStartFailure?: boolean
|
||||
}
|
||||
|
||||
export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSplitPtyBackedTerminal {
|
||||
/** Whether main's layout holds the tab, so main writes and starts its splits. */
|
||||
protected ownsTabLayout(worktreeId: string, tabId: string): boolean {
|
||||
const [hostId] = this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId)
|
||||
return Boolean(hostId && parseExecutionHostId(hostId)?.kind !== 'runtime')
|
||||
}
|
||||
|
||||
/** A window split whose start fails before main could start it still shows, with why. */
|
||||
private async keepSplitThatCannotStart(
|
||||
source: SplitPaneSource,
|
||||
opts: SplitPaneInMainOptions,
|
||||
error: unknown
|
||||
): Promise<never> {
|
||||
if (opts.keepPaneOnStartFailure && opts.newLeafId && isTerminalLeafId(opts.newLeafId)) {
|
||||
const reply = await this.applyLayoutCommand({
|
||||
type: 'splitPane',
|
||||
workspace: source.worktreeId,
|
||||
tabId: source.tabId,
|
||||
leafId: source.leafId,
|
||||
direction: opts.direction,
|
||||
newLeafId: opts.newLeafId
|
||||
}).catch((writeError: unknown) => {
|
||||
// The caller still hears why the start failed, not why showing it did.
|
||||
console.warn('[terminal-split] could not keep a split that failed to start:', writeError)
|
||||
return null
|
||||
})
|
||||
if (reply?.ok) {
|
||||
this.paneStartStatuses?.set(
|
||||
makePaneKey(source.tabId, opts.newLeafId),
|
||||
paneStartFailure(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
private holdsLeaf(worktreeId: string, leafId: string): boolean {
|
||||
const layouts = this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId ?? {}
|
||||
return Object.values(layouts).some((layout) => collectLeafIds(layout?.root).includes(leafId))
|
||||
}
|
||||
|
||||
/** `terminal.split` from a view that names its pane: main writes the split and starts it. */
|
||||
async splitTerminalPane(
|
||||
source: SplitPaneSource,
|
||||
opts: SplitPaneInMainOptions
|
||||
): Promise<RuntimeTerminalSplit> {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN || !this.ownsTabLayout(source.worktreeId, source.tabId)) {
|
||||
// The window then splits it as before (a paired server's tab, or the window-owned build).
|
||||
throw new Error('host_not_owned')
|
||||
}
|
||||
return this.splitPaneInMain(source, opts)
|
||||
}
|
||||
|
||||
protected async splitPaneInMain(
|
||||
source: SplitPaneSource,
|
||||
opts: SplitPaneInMainOptions
|
||||
): Promise<RuntimeTerminalSplit> {
|
||||
const leafId =
|
||||
opts.newLeafId && isTerminalLeafId(opts.newLeafId) ? opts.newLeafId : randomUUID()
|
||||
// A named pane id another pane holds is refused: the start must not land in a live pane.
|
||||
if (opts.newLeafId && this.holdsLeaf(source.worktreeId, leafId)) {
|
||||
throw new Error('pane_exists')
|
||||
}
|
||||
const controller = this.ptyController
|
||||
if (!controller?.spawn) {
|
||||
return this.keepSplitThatCannotStart(source, opts, new Error('runtime_unavailable'))
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
`id:${source.worktreeId}`
|
||||
).catch((error: unknown) => this.keepSplitThatCannotStart(source, opts, error))
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const result = await spawnInAdmittedPane(this.store, controller, {
|
||||
...resolvePtyFirstSize({ pane: opts.initialSize }),
|
||||
cwd: opts.cwd ?? workspace.path,
|
||||
command: opts.command,
|
||||
commandDelivery: 'provider',
|
||||
env: this.buildTerminalWorkspaceEnv(workspace, {
|
||||
...(workspace.connectionId ? {} : this.accounts.hostLaunchEnvFor(null)),
|
||||
...opts.env
|
||||
}),
|
||||
envToDelete: opts.envToDelete,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId: source.tabId,
|
||||
leafId,
|
||||
persistHostSessionBinding: true,
|
||||
placement: runtimeSplitPlacement(source.leafId, opts.direction),
|
||||
...this.paneStartStatusArgs(
|
||||
opts.keepPaneOnStartFailure ? { keepPaneOnStartFailure: true } : {}
|
||||
)
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId)
|
||||
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (!createdPty) {
|
||||
throw new Error('terminal_exited')
|
||||
}
|
||||
recordPtySurface(
|
||||
createdPty,
|
||||
source.tabId,
|
||||
makePaneKey(source.tabId, leafId),
|
||||
spawnSurfaceClaimSequence(this.graphSequence)
|
||||
)
|
||||
// Phones and session.tabs list the pane without a window's report, as the other splits do.
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
|
||||
tabId: source.tabId,
|
||||
leafId,
|
||||
title: null,
|
||||
activate: opts.activate !== false,
|
||||
split: { splitFromLeafId: source.leafId, direction: opts.direction }
|
||||
})
|
||||
return {
|
||||
handle: this.issuePtyHandle(createdPty),
|
||||
tabId: source.tabId,
|
||||
paneRuntimeId: -1,
|
||||
leafId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService, makePaneKey } from '../../orca-runtime-test-mocks.spec'
|
||||
import { makePaneKey } from '../../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_PATH,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
} from '../../orca-runtime-test-fixtures.spec'
|
||||
import { createMobileCreateTestNotifier } from '../../orca-runtime-test-scenario-builders.spec'
|
||||
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import {
|
||||
clearPaneSpawnReservation,
|
||||
makePaneSpawnReservationKey,
|
||||
reservePaneSpawn
|
||||
} from '../../../ipc/pty/pane/spawn-reservation'
|
||||
import { ProvisioningRuntime } from '../../orca-runtime-layout-owner-start-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const TAB_ID = 'host-tab'
|
||||
const NEW_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function graph(leafIds: string[], unboundLeafIds: string[] = []) {
|
||||
function graph() {
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: leafIds[0]!,
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [...leafIds, ...unboundLeafIds].map((leafId, index) => ({
|
||||
tabId: TAB_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: index + 1,
|
||||
ptyId: index < leafIds.length ? `pty-${index}` : null,
|
||||
paneTitle: null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
recordPtyWorktree: (
|
||||
ptyId: string,
|
||||
worktreeId: string,
|
||||
opts: { connected: boolean; tabId: string; paneKey: string }
|
||||
) => void
|
||||
}
|
||||
|
||||
function internalsOf(runtime: OrcaRuntimeService): RuntimeInternals {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is declared on OrcaRuntimeService's inheritance chain.
|
||||
return runtime as unknown as RuntimeInternals
|
||||
}
|
||||
|
||||
function graphWithSplit(newLeafId: string) {
|
||||
const next = graph([HEADLESS_LEAF_ID, newLeafId])
|
||||
return {
|
||||
...next,
|
||||
tabs: next.tabs.map((tab) => ({
|
||||
...tab,
|
||||
layout: {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf' as const, leafId: HEADLESS_LEAF_ID },
|
||||
second: { type: 'leaf' as const, leafId: newLeafId }
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-0',
|
||||
paneTitle: null
|
||||
}
|
||||
}))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
function setup() {
|
||||
const store = makeRuntimeStoreWithWorkspaceSession(makeWorkspaceSessionWithHeadlessTerminal())
|
||||
// The production store's write notice, which the fixture lacks.
|
||||
const written = new Set<() => void>()
|
||||
const setSession = store.runtimeStore.setWorkspaceSession.getMockImplementation()!
|
||||
store.runtimeStore.setWorkspaceSession.mockImplementation((session, hostId) => {
|
||||
setSession(session, hostId)
|
||||
for (const listener of written) {
|
||||
listener()
|
||||
}
|
||||
})
|
||||
Reflect.set(store.runtimeStore, 'onWorkspaceSessionWritten', (listener: () => void) => {
|
||||
written.add(listener)
|
||||
return () => written.delete(listener)
|
||||
})
|
||||
const notifier = { ...createMobileCreateTestNotifier(vi.fn()), closeTerminalPane: vi.fn() }
|
||||
const runtime = new OrcaRuntimeService(store.runtimeStore)
|
||||
const runtime = new ProvisioningRuntime(store.runtimeStore)
|
||||
runtime.setNotifier(notifier)
|
||||
runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID]))
|
||||
Object.assign(runtime, {
|
||||
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
|
||||
id: TEST_WORKTREE_ID,
|
||||
path: TEST_WORKTREE_PATH,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
})
|
||||
const statusesSeen: unknown[] = []
|
||||
const spawn = vi.fn(async (args: { tabId?: string; leafId?: string }) => {
|
||||
statusesSeen.push(runtime.startStatusOf(`${args.tabId}:${args.leafId}`))
|
||||
return { id: `pty-split-${spawn.mock.calls.length}` }
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
getCwd: async () => '/repo/src'
|
||||
})
|
||||
runtime.syncWindowGraph(1, graph())
|
||||
const handle = runtime.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))
|
||||
const leafIds = () =>
|
||||
collectLayoutLeafIdsInOrder(store.getSession().terminalLayoutsByTabId[TAB_ID]!.root!)
|
||||
@@ -97,168 +75,123 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
...store,
|
||||
notifier,
|
||||
runtime,
|
||||
spawn,
|
||||
statusesSeen,
|
||||
handle: handle!,
|
||||
splitTerminal: vi.mocked(notifier.splitTerminal),
|
||||
leafIds
|
||||
}
|
||||
}
|
||||
|
||||
it('writes the new pane in main before the window mounts it, and takes it back when its terminal never starts', async () => {
|
||||
const { runtime, handle, splitTerminal, leafIds, notifier } = setup()
|
||||
it('main writes the pane and starts its terminal there, in the source pane’s folder; the window is not asked', async () => {
|
||||
const { runtime, handle, splitTerminal, leafIds, spawn, statusesSeen } = setup()
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical', command: 'make test' })
|
||||
const rejected = expect(split).rejects.toThrow(
|
||||
/^split_pane_not_started: .*did not start within 10 s.*closed/
|
||||
const split = await runtime.splitTerminal(handle, {
|
||||
direction: 'vertical',
|
||||
command: 'make test'
|
||||
})
|
||||
|
||||
expect(splitTerminal).not.toHaveBeenCalled()
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, split.leafId])
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn.mock.calls[0]![0]).toMatchObject({
|
||||
tabId: TAB_ID,
|
||||
leafId: split.leafId,
|
||||
cwd: '/repo/src',
|
||||
command: 'make test',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
// Views see the pane as starting while it starts, so none starts its own; then it clears.
|
||||
expect(statusesSeen).toEqual([{ status: 'starting' }])
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, split.leafId ?? ''))).toBeNull()
|
||||
expect(split.tabId).toBe(TAB_ID)
|
||||
})
|
||||
|
||||
it('lists the new pane to phones and session.tabs with no window report', async () => {
|
||||
const { runtime, handle } = setup()
|
||||
// A phone already lists the tab, so the new pane must be published, not just hydrated.
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
const split = await runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(
|
||||
listed.tabs.some(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' && tab.parentTabId === TAB_ID && tab.leafId === split.leafId
|
||||
)
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const notice = splitTerminal.mock.calls[0]![2]!
|
||||
expect(notice).toMatchObject({ command: 'make test', sourceLeafId: HEADLESS_LEAF_ID })
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, notice.newLeafId])
|
||||
|
||||
// Its terminal never starts: main takes the pane back, and the window drops it too.
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
await rejected
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
expect(notifier.closeTerminalPane).toHaveBeenCalledWith(TAB_ID, notice.newLeafId)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a pane whose terminal bound before the wait ran out, though the window reported it late', async () => {
|
||||
const { runtime, runtimeStore, handle, splitTerminal, leafIds, notifier } = setup()
|
||||
it('a split no one asked to see leaves the phones’ active tab alone; one asked for takes it', async () => {
|
||||
const { runtime, handle } = setup()
|
||||
const listed = () => runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const before = (await listed()).activeTabId
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
|
||||
await vi.advanceTimersByTimeAsync(9_900 - (Date.now() - startedAt))
|
||||
// Main binds the started terminal at 9.9 s; the busy window's report lands after 10 s.
|
||||
await runtimeStore.persistPtyBinding({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: TAB_ID,
|
||||
leafId: newLeafId,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
internalsOf(runtime).recordPtyWorktree('pty-1', TEST_WORKTREE_ID, {
|
||||
connected: true,
|
||||
tabId: TAB_ID,
|
||||
paneKey: makePaneKey(TAB_ID, newLeafId)
|
||||
})
|
||||
runtime.syncWindowGraph(1, graphWithSplit(newLeafId))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await expect(split).resolves.toMatchObject({ tabId: TAB_ID, leafId: newLeafId })
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId])
|
||||
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
await runtime.splitTerminal(handle, { direction: 'vertical', activate: false })
|
||||
expect((await listed()).activeTabId).toBe(before)
|
||||
|
||||
const shown = await runtime.splitTerminal(handle, { direction: 'horizontal' })
|
||||
expect((await listed()).activeTabId).toBe(`${TAB_ID}::${shown.leafId}`)
|
||||
})
|
||||
|
||||
it('keeps the pane of a start still under way when the wait runs out, with the plain timeout', async () => {
|
||||
const { runtime, handle, splitTerminal, leafIds, notifier } = setup()
|
||||
const reservations: (() => void)[] = []
|
||||
it('the window’s own split keeps its proposed pane id, folder and size', async () => {
|
||||
const { runtime, leafIds, spawn } = setup()
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow(/^Timed out waiting for split pane handle$/)
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
|
||||
// A slow (SSH-style) start of the pane's terminal is under way and has not bound yet.
|
||||
const reservationKey = makePaneSpawnReservationKey(
|
||||
TEST_WORKTREE_ID,
|
||||
'ssh-1',
|
||||
makePaneKey(TAB_ID, newLeafId)
|
||||
)!
|
||||
const reservation = reservePaneSpawn(reservationKey)
|
||||
reservations.push(() => clearPaneSpawnReservation(reservationKey, reservation))
|
||||
const split = await runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{
|
||||
direction: 'horizontal',
|
||||
newLeafId: NEW_LEAF_ID,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
initialSize: { cols: 90, rows: 20 },
|
||||
keepPaneOnStartFailure: true
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
await rejected
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId])
|
||||
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
reservations.forEach((release) => release())
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(split.leafId).toBe(NEW_LEAF_ID)
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
|
||||
expect(spawn.mock.calls[0]![0]).toMatchObject({
|
||||
leafId: NEW_LEAF_ID,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
cols: 90,
|
||||
rows: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('closes a CLI split before its terminal starts, and the CLI hears so at once', async () => {
|
||||
const { runtime, handle, splitTerminal, leafIds } = setup()
|
||||
it('a window split whose terminal fails to start keeps its pane, showing why', async () => {
|
||||
const { runtime, leafIds, spawn } = setup()
|
||||
spawn.mockRejectedValueOnce(new Error('shell not found'))
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow('split_pane_closed')
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('shell not found')
|
||||
|
||||
await runtime.closeTerminalSurfaceFromRenderer({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
target: { kind: 'pane', tabId: TAB_ID, leafId: newLeafId },
|
||||
reason: 'user'
|
||||
})
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
|
||||
// Settled by the close, well before the 10 s wait.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await rejected
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'shell not found'
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a CLI split whose tab main closes before its terminal starts', async () => {
|
||||
const { runtime, handle, splitTerminal, getSession } = setup()
|
||||
it('a CLI split whose terminal fails to start leaves no pane, and the CLI hears why', async () => {
|
||||
const { runtime, handle, leafIds, spawn, notifier } = setup()
|
||||
spawn.mockRejectedValueOnce(new Error('shell not found'))
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow('split_pane_closed')
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).rejects.toThrow(
|
||||
'shell not found'
|
||||
)
|
||||
|
||||
await runtime.closeTerminalSurfaceFromRenderer({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
target: { kind: 'tab', tabId: TAB_ID },
|
||||
reason: 'user'
|
||||
})
|
||||
expect(getSession().terminalLayoutsByTabId[TAB_ID]).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps waiting through writes that keep the pane', async () => {
|
||||
const { runtime, runtimeStore, handle, splitTerminal } = setup()
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow('split_pane_not_started')
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
// An unrelated write while the pane waits for its terminal.
|
||||
runtimeStore.setWorkspaceSession(runtimeStore.getWorkspaceSession())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('splits nothing when main cannot write the pane', async () => {
|
||||
const { runtime, runtimeStore, handle, splitTerminal, leafIds } = setup()
|
||||
it('starts nothing when main cannot write the pane', async () => {
|
||||
const { runtime, runtimeStore, handle, splitTerminal, leafIds, spawn } = setup()
|
||||
Reflect.set(
|
||||
runtimeStore,
|
||||
'admitTerminalPane',
|
||||
@@ -266,9 +199,118 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
)
|
||||
|
||||
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).rejects.toThrow(
|
||||
'split_not_written: pane_not_found'
|
||||
'terminal_split_source_not_found'
|
||||
)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(splitTerminal).not.toHaveBeenCalled()
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
})
|
||||
|
||||
it('starts nothing for a pane-named split of a tab main does not hold', async () => {
|
||||
const { runtime, spawn } = setup()
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: 'tab-elsewhere', leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical' }
|
||||
)
|
||||
).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The real spawn commit refuses a pane that left the layout while it started (spawn-commit.ts).
|
||||
function closeWhileStarting(setupResult: ReturnType<typeof setup>, close: () => Promise<void>) {
|
||||
const { spawn, getSession } = setupResult
|
||||
spawn.mockImplementationOnce(async (args: { tabId?: string; leafId?: string }) => {
|
||||
await close()
|
||||
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
|
||||
if (!root || !collectLayoutLeafIdsInOrder(root).includes(args.leafId!)) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
return { id: 'pty-late' }
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
['its pane', { kind: 'pane', tabId: TAB_ID, leafId: NEW_LEAF_ID }],
|
||||
['its tab', { kind: 'tab', tabId: TAB_ID }]
|
||||
] as const)(
|
||||
'answers a split once, and leaves nothing, when the user closes %s while it starts',
|
||||
async (_, target) => {
|
||||
const context = setup()
|
||||
const { runtime, getSession } = context
|
||||
closeWhileStarting(context, () =>
|
||||
runtime.closeTerminalSurfaceFromRenderer({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
target,
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('terminal_split_source_not_found')
|
||||
|
||||
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
|
||||
expect(root ? collectLayoutLeafIdsInOrder(root) : []).not.toContain(NEW_LEAF_ID)
|
||||
// Nothing is left showing a start for a pane that is gone.
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a named pane id another pane already holds, starting nothing', async () => {
|
||||
const { runtime, spawn } = setup()
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID }
|
||||
)
|
||||
).rejects.toThrow('pane_exists')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
|
||||
})
|
||||
|
||||
it('a window split main cannot start at all still shows its pane, failed, with why', async () => {
|
||||
const { runtime, leafIds } = setup()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('runtime_unavailable')
|
||||
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'runtime_unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a taken pane id before anything else, even when main cannot start', async () => {
|
||||
const { runtime } = setup()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('pane_exists')
|
||||
// The live pane is not marked as a failed start.
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../../session-lifecycle/projection/prune-mobile-session-tab-group-layout'
|
||||
import { OrcaRuntimeWithSplitPtyBackedTerminal } from './terminal-split-surface'
|
||||
import { OrcaRuntimeWithSplitPaneInMain } from './terminal-split-in-main'
|
||||
import { resolveTerminalSessionWorktreeId } from '../../runtime-worktree-path-identity'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership'
|
||||
@@ -15,7 +15,7 @@ import { applyClaudeEnvPatch } from '../../../claude-accounts/environment'
|
||||
import { worktreeIdsEqual } from '../../../../shared/worktree/id'
|
||||
import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout'
|
||||
|
||||
export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPtyBackedTerminal {
|
||||
export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPaneInMain {
|
||||
abstract getTerminalHandleForPaneKey(
|
||||
...args: Parameters<
|
||||
OrcaRuntimeWithPruneMobileSessionTabGroupLayout['getTerminalHandleForPaneKey']
|
||||
|
||||
@@ -81,6 +81,8 @@ export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeW
|
||||
leafId,
|
||||
persistHostSessionBinding: true,
|
||||
placement: runtimeSplitPlacement(parsedPaneKey.leafId, direction),
|
||||
// Layout-owner build: views mounting the pane from main's frame attach, never spawn.
|
||||
...this.paneStartStatusArgs({}),
|
||||
...(sourceAuthority.persisted
|
||||
? {
|
||||
expectedSourceBinding: {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import type { TerminalSplitOptions } from './terminal-split-surface'
|
||||
import type { SplitPaneInMainOptions, SplitPaneSource } from './terminal-split-in-main'
|
||||
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
|
||||
import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from '../../session-tabs/explicit-terminal-close-stops'
|
||||
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
|
||||
import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { admitRuntimePane, withdrawAdmittedPane } from '../../runtime-pane-admission'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { isPaneSpawnInFlight } from '../../../ipc/pty/pane/spawn-reservation'
|
||||
import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout'
|
||||
|
||||
const SPLIT_PANE_START_TIMEOUT_MS = 10_000
|
||||
|
||||
export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys {
|
||||
protected abstract splitPtyBackedTerminal(
|
||||
@@ -23,6 +15,13 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
|
||||
createdWorktree?: Worktree
|
||||
): Promise<RuntimeTerminalSplit>
|
||||
|
||||
protected abstract ownsTabLayout(worktreeId: string, tabId: string): boolean
|
||||
|
||||
protected abstract splitPaneInMain(
|
||||
source: SplitPaneSource,
|
||||
opts: SplitPaneInMainOptions
|
||||
): Promise<RuntimeTerminalSplit>
|
||||
|
||||
protected abstract waitForLeafInTab(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
@@ -52,92 +51,22 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
|
||||
this.assertGraphReady()
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
const direction = opts.direction ?? 'horizontal'
|
||||
// Switch point: main writes the split first; the window then mounts main's pane.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const [hostId] = this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId)
|
||||
if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
const newLeafId = randomUUID()
|
||||
// No pane written, no split: nothing else would put it in main's copy. A pane whose
|
||||
// terminal never starts is taken back below, so the CLI's failure is the truth.
|
||||
const outcome = await admitRuntimePane(
|
||||
this.store,
|
||||
{
|
||||
type: 'splitPane',
|
||||
workspace: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
direction,
|
||||
newLeafId
|
||||
},
|
||||
hostId
|
||||
)
|
||||
if (outcome !== 'admitted' && outcome !== 'exists') {
|
||||
throw new Error(`split_not_written: ${outcome ?? 'unknown'}`)
|
||||
}
|
||||
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
|
||||
// Switch point: main writes the split and starts its terminal; the window attaches to it.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && this.ownsTabLayout(leaf.worktreeId, leaf.tabId)) {
|
||||
const cwd = leaf.ptyId
|
||||
? await this.ptyController?.getCwd?.(leaf.ptyId).catch(() => null)
|
||||
: null
|
||||
return this.splitPaneInMain(
|
||||
{ worktreeId: leaf.worktreeId, tabId: leaf.tabId, leafId: leaf.leafId },
|
||||
{
|
||||
direction,
|
||||
...(cwd ? { cwd } : {}),
|
||||
command: opts.command,
|
||||
worktreeId: leaf.worktreeId,
|
||||
sourceLeafId: leaf.leafId,
|
||||
telemetrySource: opts.telemetrySource,
|
||||
newLeafId
|
||||
})
|
||||
const newHandle = await settledByPaneClose(
|
||||
this.store,
|
||||
() => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId),
|
||||
leaf.tabId,
|
||||
newLeafId,
|
||||
this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS)
|
||||
).catch(async (error: unknown) => {
|
||||
if (
|
||||
outcome !== 'admitted' ||
|
||||
!(error instanceof Error) ||
|
||||
error.message !== SPLIT_PANE_WAIT_TIMEOUT_MESSAGE
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
const taken = await withdrawAdmittedPane(
|
||||
this.store,
|
||||
{
|
||||
type: 'splitPane',
|
||||
workspace: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
direction,
|
||||
newLeafId
|
||||
},
|
||||
hostId,
|
||||
// Read in the write queue: a start under way (a slow SSH host) keeps its pane.
|
||||
() => isPaneSpawnInFlight(leaf.worktreeId, makePaneKey(leaf.tabId, newLeafId))
|
||||
).catch((withdrawError: unknown) => {
|
||||
console.warn('[layout] an unstarted split pane stayed in main:', withdrawError)
|
||||
return 'threw' as const
|
||||
})
|
||||
if (taken === 'bound') {
|
||||
// Bound just before the take-back: the window's late report brings its handle.
|
||||
return settledByPaneClose(
|
||||
this.store,
|
||||
() => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId),
|
||||
leaf.tabId,
|
||||
newLeafId,
|
||||
this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS)
|
||||
)
|
||||
}
|
||||
if (taken !== null) {
|
||||
throw error
|
||||
}
|
||||
this.notifier?.closeTerminalPane?.(leaf.tabId, newLeafId)
|
||||
throw new Error(
|
||||
`split_pane_not_started: the new pane's terminal did not start within ${SPLIT_PANE_START_TIMEOUT_MS / 1000} s, so the pane was closed${opts.command ? ' and its command was not run' : ''}`
|
||||
)
|
||||
})
|
||||
return {
|
||||
handle: newHandle,
|
||||
tabId: leaf.tabId,
|
||||
paneRuntimeId: leaf.paneRuntimeId,
|
||||
leafId: newLeafId
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
activate: opts.activate
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const newLeafId = randomUUID()
|
||||
@@ -160,31 +89,3 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Main's own close of the pane settles the wait at once: no terminal will start there. */
|
||||
function settledByPaneClose(
|
||||
store: RuntimeStore | null | undefined,
|
||||
hostIds: () => readonly ExecutionHostId[],
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
waiting: Promise<string>
|
||||
): Promise<string> {
|
||||
if (!store?.getWorkspaceSession || !store.onWorkspaceSessionWritten) {
|
||||
return waiting
|
||||
}
|
||||
const getSession = store.getWorkspaceSession.bind(store)
|
||||
let stopWatching: (() => void) | undefined
|
||||
const closed = new Promise<never>((_resolve, reject) => {
|
||||
const check = (): void => {
|
||||
const held = hostIds().some((hostId) =>
|
||||
collectLeafIds(getSession(hostId)?.terminalLayoutsByTabId?.[tabId]?.root).includes(leafId)
|
||||
)
|
||||
if (!held) {
|
||||
reject(new Error('split_pane_closed'))
|
||||
}
|
||||
}
|
||||
stopWatching = store.onWorkspaceSessionWritten?.(check)
|
||||
check()
|
||||
})
|
||||
return Promise.race([waiting, closed]).finally(() => stopWatching?.())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
|
||||
import type { TerminalRunFactsRegister } from '../../terminal-run-facts'
|
||||
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
|
||||
|
||||
export type ExitedSplitPaneFacts = {
|
||||
exitCode: number
|
||||
@@ -48,7 +49,8 @@ export function exitedSplitPaneFacts(
|
||||
): ExitedSplitPaneFacts {
|
||||
const run = runs.read(ptyId, pty?.incarnationId)
|
||||
return {
|
||||
exitCode,
|
||||
// node-pty can report no code at all (its socket closed): that is lost contact, not a finish.
|
||||
exitCode: Number.isFinite(exitCode) ? exitCode : UNVERIFIED_PROCESS_EXIT_CODE,
|
||||
freshSpawn: run.freshSpawn,
|
||||
inputSeen: run.firstUserInputAt !== null || runs.readLastInputAt(ptyId) !== null,
|
||||
outputSeen: pty?.lastOutputAt != null,
|
||||
|
||||
+16
-12
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
@@ -7,7 +8,6 @@ import { applyClosedTerminalLeafNotice } from './closed-terminal-leaf-notice'
|
||||
import {
|
||||
_resetTerminalPaneSplitRequestRoutingForTests,
|
||||
hasTerminalPaneSplitMountLease,
|
||||
queueTerminalPaneSplitRequest,
|
||||
takeQueuedTerminalPaneSplitRequests
|
||||
} from './terminal-pane-split-request-routing'
|
||||
|
||||
@@ -16,22 +16,26 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('a pane main closed while its split waited for the tab to mount', () => {
|
||||
it('drops the waiting split, so the tab mounts without the closed pane', () => {
|
||||
vi.stubGlobal('window', new EventTarget())
|
||||
queueTerminalPaneSplitRequest({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneRuntimeId: 1,
|
||||
sourceLeafId: '11111111-1111-4111-8111-111111111111',
|
||||
direction: 'vertical',
|
||||
newLeafId: '22222222-2222-4222-8222-222222222222',
|
||||
command: 'make test'
|
||||
describe('a pane main closed before its tab mounted, in a layout-owner build', () => {
|
||||
// Main writes and starts every split itself, so no split waits in the window to mount the pane:
|
||||
// the tab mounts from main's layout, which no longer holds it.
|
||||
it('leaves nothing waiting to mount it, and drops it from a mounted tab as before', () => {
|
||||
const target = new EventTarget()
|
||||
vi.stubGlobal('window', target)
|
||||
const closed = vi.fn()
|
||||
target.addEventListener(CLOSE_TERMINAL_PANE_EVENT, (event) => {
|
||||
if (event instanceof CustomEvent) {
|
||||
closed(event.detail)
|
||||
}
|
||||
})
|
||||
|
||||
applyClosedTerminalLeafNotice('tab-1', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
expect(takeQueuedTerminalPaneSplitRequests('tab-1', 'wt-1')).toEqual([])
|
||||
expect(hasTerminalPaneSplitMountLease('tab-1')).toBe(false)
|
||||
expect(closed).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
leafId: '22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { collapseParkedTerminalLeaf } from './terminal-parked-pty-watcher'
|
||||
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
|
||||
import { cancelQueuedTerminalPaneSplitForLeaf } from './terminal-pane-split-request-routing'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
|
||||
/**
|
||||
* Drops a split pane main already closed. Addressed by leaf, so it is a no-op once the pane's own
|
||||
* exit handling removed it, and the exit is a no-op after it: whichever lands first wins.
|
||||
*/
|
||||
export function applyClosedTerminalLeafNotice(tabId: string, leafId: string): void {
|
||||
// Switch point: main wrote a split's pane before its tab mounted; closed meanwhile, it mounts never.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
cancelQueuedTerminalPaneSplitForLeaf(tabId, leafId)
|
||||
}
|
||||
if (isTerminalTabParked(tabId)) {
|
||||
collapseParkedTerminalLeaf(tabId, leafId)
|
||||
return
|
||||
|
||||
+1
@@ -357,6 +357,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
|
||||
activeWorktreeId: 'wt-1',
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'fish-pty' }] },
|
||||
ptyIdsByTabId: { 'tab-1': ['fish-pty'] },
|
||||
terminalPaneStartsByPaneKey: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Layout-owner build: a pane main is starting a terminal for never starts one of its own. It waits,
|
||||
// then attaches to main's terminal; a start that failed shows why.
|
||||
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createMockTransport,
|
||||
createPane,
|
||||
createManager,
|
||||
leafIdForPane,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import type { TerminalPaneStart } from '@/store/terminals/terminal-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const TAB = 'tab-main-start'
|
||||
const PANE_KEY = `${TAB}:${leafIdForPane(1)}`
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
function setStore(patch: Partial<StoreState>): void {
|
||||
mockStoreState = { ...mockStoreState, ...patch }
|
||||
for (const listener of storeSubscribers) {
|
||||
listener(mockStoreState)
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWithStart(start: TerminalPaneStart | undefined) {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
setStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: null }] },
|
||||
terminalPaneStartsByPaneKey: start ? { [PANE_KEY]: start } : {}
|
||||
})
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, { tabId: TAB })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
|
||||
const args = [createPane(1), createManager(1), deps] as unknown as Parameters<
|
||||
typeof connectPanePty
|
||||
>
|
||||
connectPanePty(...args)
|
||||
await flushAsyncTicks()
|
||||
return { transport, deps }
|
||||
}
|
||||
|
||||
describe('a pane main is starting a terminal for', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
bindPtyConnectionTestMocks({
|
||||
getState: () => mockStoreState,
|
||||
getTransportQueue: () => transportFactoryQueue,
|
||||
getTransportOptions: () => [],
|
||||
getStoreSubscribers: () => storeSubscribers,
|
||||
setStoreSubscribers: (subscribers) => {
|
||||
storeSubscribers = subscribers
|
||||
}
|
||||
})
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('starts nothing while main starts it, then attaches to main’s terminal', async () => {
|
||||
const { transport } = await connectWithStart({ status: 'starting' })
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
|
||||
// Main's start ends as the pane binds to main's terminal.
|
||||
const leafId = leafIdForPane(1)
|
||||
setStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: 'pty-main' }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-main' }
|
||||
}
|
||||
},
|
||||
terminalPaneStartsByPaneKey: {}
|
||||
})
|
||||
await flushAsyncTicks()
|
||||
|
||||
// It attaches to main's terminal at the pane's own grid, and starts none of its own.
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'pty-main', cols: 120, rows: 40 })
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches a split pane to the terminal main bound it to, not its tab’s', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const newLeaf = leafIdForPane(2)
|
||||
setStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: 'pty-source' }] },
|
||||
terminalPaneStartsByPaneKey: { [`${TAB}:${newLeaf}`]: { status: 'starting' } }
|
||||
})
|
||||
// The source pane already shows the tab's terminal.
|
||||
const source = createMockTransport('pty-source')
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
tabId: TAB,
|
||||
paneTransportsRef: { current: new Map([[1, source]]) }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
|
||||
const args = [createPane(2), createManager(2), deps] as unknown as Parameters<
|
||||
typeof connectPanePty
|
||||
>
|
||||
connectPanePty(...args)
|
||||
await flushAsyncTicks()
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
|
||||
setStore({
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: leafIdForPane(1) },
|
||||
second: { type: 'leaf', leafId: newLeaf }
|
||||
},
|
||||
activeLeafId: newLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafIdForPane(1)]: 'pty-source', [newLeaf]: 'pty-split' }
|
||||
}
|
||||
},
|
||||
terminalPaneStartsByPaneKey: {}
|
||||
})
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'pty-split' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows why a failed start failed and starts nothing', async () => {
|
||||
const { transport, deps } = await connectWithStart({
|
||||
status: 'failed',
|
||||
reason: 'agent runtime needs repair'
|
||||
})
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(
|
||||
1,
|
||||
'The terminal could not start: agent runtime needs repair'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['sleeping', 'resuming', 'held'] as const)(
|
||||
'leaves a %s pane to its restore path, as before',
|
||||
async (status) => {
|
||||
const { transport } = await connectWithStart({ status })
|
||||
|
||||
expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('starts a pane with no start from main as before', async () => {
|
||||
const { transport } = await connectWithStart(undefined)
|
||||
|
||||
expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
+17
-10
@@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { expectMainFrameClosesExitedSplitPaneOnce } from './pty-connection-test-exited-split-pane-close'
|
||||
import {
|
||||
LEAF_1,
|
||||
LEAF_2,
|
||||
@@ -260,12 +261,14 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
|
||||
expect(onPtyExit).toBeTypeOf('function')
|
||||
|
||||
onPtyExit?.('pty-pane-2')
|
||||
// A switch-on build closes it on main's answer.
|
||||
await layoutApplyTestTransport.settle()
|
||||
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2')
|
||||
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => {
|
||||
@@ -763,10 +766,12 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
|
||||
|
||||
capturedDataCallback.current?.('shell prompt')
|
||||
onPtyExit?.('pty-pane-2')
|
||||
// A switch-on build closes it on main's answer.
|
||||
await layoutApplyTestTransport.settle()
|
||||
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the agent row when an established PTY exits through EOF (#12907)', async () => {
|
||||
@@ -814,10 +819,12 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
|
||||
|
||||
terminalInputHandler?.('exit\r')
|
||||
onPtyExit?.('pty-pane-2')
|
||||
// A switch-on build closes it on main's answer.
|
||||
await layoutApplyTestTransport.settle()
|
||||
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { expectMainFrameClosesExitedSplitPaneOnce } from './pty-connection-test-exited-split-pane-close'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
|
||||
@@ -107,7 +108,12 @@ export function registerPtyConnectionSessionLivenessTests(): void {
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(hasPty).toHaveBeenCalledWith('pty-pane-2')
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
// Switch on: the host's authoritative absence is a confirmed end; main closes, the frame shows it.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not close when targeted liveness is live or unknown', async () => {
|
||||
@@ -229,7 +235,11 @@ export function registerPtyConnectionSessionLivenessTests(): void {
|
||||
binding.reconcileIfSessionMissing(async () => false, afterSpawn + 1)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('routes the last pane through onPtyExitRef when its session is dead', async () => {
|
||||
@@ -349,11 +359,13 @@ export function registerPtyConnectionSessionLivenessTests(): void {
|
||||
// Restart rebinds the pane to a new live PTY; its later real exit must not be ignored by a stale guard.
|
||||
transport.getPtyId.mockReturnValue('pty-pane-2-restarted')
|
||||
onPtyExit?.('pty-pane-2-restarted')
|
||||
// A switch-on build closes it on main's answer.
|
||||
await layoutApplyTestTransport.settle()
|
||||
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a split pane exactly once across reconcile + a racing real exit', async () => {
|
||||
@@ -385,8 +397,14 @@ export function registerPtyConnectionSessionLivenessTests(): void {
|
||||
// A racing real/synthetic pty:exit for the SAME id must not close twice.
|
||||
onPtyExit?.('pty-pane-2')
|
||||
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// The racing exit must not re-run the teardown that registered main's close.
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledTimes(1)
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', async () => {
|
||||
@@ -419,8 +437,13 @@ export function registerPtyConnectionSessionLivenessTests(): void {
|
||||
onPtyExit?.('pty-pane-2')
|
||||
|
||||
// Observable outcome: single close, pane was treated as dead (closed).
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledTimes(1)
|
||||
await expectMainFrameClosesExitedSplitPaneOnce(manager)
|
||||
} else {
|
||||
expect(manager.closePane).toHaveBeenCalledTimes(1)
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import { LEAF_1, LEAF_2, type MockPaneManager } from './pty-connection-test-pane-fixtures'
|
||||
|
||||
/**
|
||||
* Switch on: tab-1's pane 2 ended, so the window leaves its close to main: it neither closes the
|
||||
* pane nor sends a close, and main's frame without the leaf takes it once, as an exit.
|
||||
* Imports are dynamic so they share the module graph the test's `import('./pty-connection')` used.
|
||||
*/
|
||||
export async function expectMainFrameClosesExitedSplitPaneOnce(
|
||||
manager: MockPaneManager
|
||||
): Promise<void> {
|
||||
const { layoutApplyTestTransport } =
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
const { reconcileMountedPanesToLayout } = await import('./terminal-pane-frame-reconciliation')
|
||||
await layoutApplyTestTransport.settle()
|
||||
expect(manager.closePane).not.toHaveBeenCalled()
|
||||
expect(layoutApplyTestTransport.sentOfType('closePane')).toEqual([])
|
||||
|
||||
const frameLayout: TerminalLayoutSnapshot = {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null
|
||||
}
|
||||
const closePane = vi.fn()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock manager implements every PaneManager member the frame reconciler reads.
|
||||
const framedManager = { ...manager, applyLayoutGeometry: vi.fn() } as unknown as Parameters<
|
||||
typeof reconcileMountedPanesToLayout
|
||||
>[0]['manager']
|
||||
// A repeated frame (the stream re-sends main's tab) must not close it again.
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
reconcileMountedPanesToLayout({
|
||||
tabId: 'tab-1',
|
||||
manager: framedManager,
|
||||
layout: frameLayout,
|
||||
closePane,
|
||||
isExpanded: false
|
||||
})
|
||||
}
|
||||
expect(closePane).toHaveBeenCalledTimes(1)
|
||||
expect(closePane).toHaveBeenCalledWith(2, LEAF_2, 'exit')
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['tab-pty']
|
||||
},
|
||||
terminalPaneStartsByPaneKey: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
@@ -92,7 +93,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
|
||||
}),
|
||||
markTerminalBell: vi.fn(),
|
||||
markAgentAttentionUnread: vi.fn()
|
||||
} as StoreState
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReattachPaneTitleState(current: StoreState, title: string): StoreState {
|
||||
@@ -104,7 +105,7 @@ export function buildReattachPaneTitleState(current: StoreState, title: string):
|
||||
runtimePaneTitlesByTabId: {
|
||||
'tab-1': { 1: title }
|
||||
}
|
||||
} as StoreState
|
||||
}
|
||||
}
|
||||
|
||||
// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status.
|
||||
|
||||
@@ -21,6 +21,7 @@ export type StoreState = {
|
||||
>
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
|
||||
terminalPaneStartsByPaneKey?: AppState['terminalPaneStartsByPaneKey']
|
||||
/** Pty id → the agent its pane restarts as once the account change lands. */
|
||||
pendingPaneAccountRestarts?: AppState['pendingPaneAccountRestarts']
|
||||
unreadTerminalTabs?: Record<string, true>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Layout-owner build: a pane main is starting a terminal for waits and then attaches to it, and a
|
||||
// start that failed shows why. Neither starts a terminal of its own.
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import type { PaneStartStatus } from '../../../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { resolveTerminalTabId } from './terminal-tab-id'
|
||||
|
||||
const waiting = new WeakSet<ConnectPanePtySession>()
|
||||
|
||||
/** The starts this gate covers; a sleeping, resuming or held pane keeps its restore path (C1c). */
|
||||
const gated = (status: PaneStartStatus | undefined): boolean =>
|
||||
status === 'starting' || status === 'failed'
|
||||
|
||||
/** Whether the pane waits on main's start; `connect` runs once main's start for it ends. */
|
||||
export function waitsForMainPaneStart(
|
||||
session: ConnectPanePtySession,
|
||||
connect: () => void
|
||||
): boolean {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return false
|
||||
}
|
||||
const start = useAppStore.getState().terminalPaneStartsByPaneKey[session.cacheKey]
|
||||
if (!start || !gated(start.status)) {
|
||||
return false
|
||||
}
|
||||
if (waiting.has(session)) {
|
||||
return true
|
||||
}
|
||||
waiting.add(session)
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> WAIT FOR MAIN START (${start.status})`)
|
||||
let shown = start
|
||||
const showFailure = (): void => {
|
||||
if (shown.status === 'failed' && !session.disposed) {
|
||||
session.deps.onPtyErrorRef?.current?.(
|
||||
session.pane.id,
|
||||
`The terminal could not start: ${shown.reason ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
showFailure()
|
||||
const unsubscribe = useAppStore.subscribe((state) => {
|
||||
const found = state.terminalPaneStartsByPaneKey[session.cacheKey]
|
||||
const next = found && gated(found.status) ? found : undefined
|
||||
if (next === shown) {
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
shown = next
|
||||
showFailure()
|
||||
return
|
||||
}
|
||||
// Main's start ended: the pane is bound to its terminal now, or main took it back.
|
||||
adoptMainBinding(session, state)
|
||||
unsubscribe()
|
||||
waiting.delete(session)
|
||||
const index = session.waitTeardowns.indexOf(unsubscribe)
|
||||
if (index !== -1) {
|
||||
session.waitTeardowns.splice(index, 1)
|
||||
}
|
||||
if (!session.disposed) {
|
||||
connect()
|
||||
}
|
||||
})
|
||||
session.waitTeardowns.push(unsubscribe)
|
||||
return true
|
||||
}
|
||||
|
||||
/** The terminal main bound the pane to, as the pane's restore id: the connect then attaches it. */
|
||||
function adoptMainBinding(
|
||||
session: ConnectPanePtySession,
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): void {
|
||||
const tabId = resolveTerminalTabId(state, session.deps.tabId)
|
||||
const leafId = session.pane.leafId
|
||||
const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
|
||||
if (ptyId) {
|
||||
session.deps.restoredLeafId = leafId
|
||||
session.deps.restoredPtyIdByLeafId = { ...session.deps.restoredPtyIdByLeafId, [leafId]: ptyId }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detect
|
||||
import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent'
|
||||
|
||||
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import { waitsForMainPaneStart } from './main-pane-start-wait'
|
||||
import { findTerminalTabForPane } from './terminal-tab-id'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
@@ -132,6 +133,14 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
|
||||
session.connectFrame = requestAnimationFrame(session.runDeferredConnect)
|
||||
return
|
||||
}
|
||||
if (waitsForMainPaneStart(session, session.runDeferredConnect)) {
|
||||
session.cancelScheduledConnectFrame()
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
clearTimeout(session.connectFallbackTimer)
|
||||
session.connectFallbackTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
session.connectStarted = true
|
||||
releaseHideWait?.()
|
||||
session.cancelScheduledConnectFrame()
|
||||
|
||||
+1
@@ -452,6 +452,7 @@ describe('remote hidden-output restore outcomes', () => {
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['tab-pty']
|
||||
},
|
||||
terminalPaneStartsByPaneKey: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
|
||||
+1
@@ -481,6 +481,7 @@ describe('remote hidden-output restore abandonment (issue2-hidden-output-skip)',
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['tab-pty']
|
||||
},
|
||||
terminalPaneStartsByPaneKey: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import type { splitPaneInMain } from './terminal-pane-split-in-main'
|
||||
import { runBootstrapSplitInMain } from './terminal-pane-bootstrap-split-in-main'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
splitPaneInMain: vi.fn<typeof splitPaneInMain>(),
|
||||
runTerminalPaneBootstrapSplits: vi.fn()
|
||||
}))
|
||||
vi.mock('./terminal-pane-split-in-main', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
splitPaneInMain: mocks.splitPaneInMain
|
||||
}))
|
||||
vi.mock('./terminal-pane-mount-bootstrap', () => ({
|
||||
runTerminalPaneBootstrapSplits: mocks.runTerminalPaneBootstrapSplits
|
||||
}))
|
||||
|
||||
const SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup split reads only the target pane.
|
||||
const manager = {
|
||||
getActivePane: () => ({ id: 1, leafId: SOURCE, terminal: { cols: 120, rows: 30 } }),
|
||||
getPanes: () => [{ id: 1, leafId: SOURCE, terminal: { cols: 120, rows: 30 } }]
|
||||
} as unknown as PaneManager
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: passed through to today's split only.
|
||||
const ptyDeps = {} as PtyConnectionDeps
|
||||
const issue = { command: 'issue runner', env: { ISSUE: '42' } }
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.splitPaneInMain.mockReset()
|
||||
mocks.runTerminalPaneBootstrapSplits.mockReset()
|
||||
})
|
||||
|
||||
function run(): void {
|
||||
runBootstrapSplitInMain({
|
||||
manager,
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
ptyDeps,
|
||||
issueCommandSplit: issue,
|
||||
isActive: true
|
||||
})
|
||||
}
|
||||
|
||||
it('asks main to split the tab and start its startup command at half the source pane, keeping focus there', () => {
|
||||
mocks.splitPaneInMain.mockReturnValue(true)
|
||||
|
||||
run()
|
||||
|
||||
expect(mocks.splitPaneInMain).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
sourceLeafId: SOURCE,
|
||||
direction: 'vertical',
|
||||
initialSize: { cols: 60, rows: 30 },
|
||||
keepSourceFocused: true,
|
||||
command: 'issue runner',
|
||||
env: { ISSUE: '42' }
|
||||
},
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.runTerminalPaneBootstrapSplits).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('splits as today where main does not own the tab', () => {
|
||||
mocks.splitPaneInMain.mockReturnValue(false)
|
||||
|
||||
run()
|
||||
|
||||
expect(mocks.runTerminalPaneBootstrapSplits).toHaveBeenCalledWith({
|
||||
manager,
|
||||
ptyDeps,
|
||||
issueCommandSplit: issue,
|
||||
isActive: true
|
||||
})
|
||||
})
|
||||
|
||||
it('splits as today when main answers that a paired server holds the tab', () => {
|
||||
mocks.splitPaneInMain.mockReturnValue(true)
|
||||
|
||||
run()
|
||||
mocks.splitPaneInMain.mock.calls[0]![1]()
|
||||
|
||||
expect(mocks.runTerminalPaneBootstrapSplits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { TerminalPaneIssueCommandSplit } from './terminal-pane-lifecycle-types'
|
||||
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
|
||||
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
|
||||
/**
|
||||
* A tab's startup split in a layout-owner build: main writes it and starts its command; main's
|
||||
* frame draws the pane. A paired server's tab splits in the window as today.
|
||||
*/
|
||||
export function runBootstrapSplitInMain(args: {
|
||||
manager: PaneManager
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
ptyDeps: PtyConnectionDeps
|
||||
issueCommandSplit: TerminalPaneIssueCommandSplit | null | undefined
|
||||
isActive: boolean
|
||||
}): void {
|
||||
const { manager, issueCommandSplit } = args
|
||||
const targetPane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
const asToday = (): void =>
|
||||
runTerminalPaneBootstrapSplits({
|
||||
manager: args.manager,
|
||||
ptyDeps: args.ptyDeps,
|
||||
issueCommandSplit,
|
||||
isActive: args.isActive
|
||||
})
|
||||
if (!issueCommandSplit || !targetPane) {
|
||||
return
|
||||
}
|
||||
const sentToMain = splitPaneInMain(
|
||||
{
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: args.tabId,
|
||||
sourceLeafId: targetPane.leafId,
|
||||
direction: 'vertical',
|
||||
initialSize: splitPaneFirstSize(targetPane.terminal, 'vertical'),
|
||||
keepSourceFocused: true,
|
||||
command: issueCommandSplit.command,
|
||||
...(issueCommandSplit.env ? { env: issueCommandSplit.env } : {})
|
||||
},
|
||||
asToday
|
||||
)
|
||||
if (!sentToMain) {
|
||||
asToday()
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { recordingBootstrapSplits } from './terminal-pane-bootstrap-split-record'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ recordLayoutApply: vi.fn<typeof recordLayoutApply>() }))
|
||||
vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.recordLayoutApply }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
|
||||
function leafId(value: string): TerminalLeafId {
|
||||
if (!isTerminalLeafId(value)) {
|
||||
throw new Error(`not a pane id: ${value}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const SOURCE = leafId('11111111-1111-4111-8111-111111111111')
|
||||
const CREATED = leafId('22222222-2222-4222-8222-222222222222')
|
||||
|
||||
function fakeManager(created: Pick<ManagedPane, 'id' | 'leafId'> | null) {
|
||||
const splitPane = vi.fn(() => created)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder reads only these members.
|
||||
const manager = {
|
||||
getLeafId: (paneId: number) => (paneId === 1 ? SOURCE : null),
|
||||
splitPane
|
||||
} as unknown as PaneManager
|
||||
return { manager, splitPane }
|
||||
}
|
||||
|
||||
beforeEach(() => mocks.recordLayoutApply.mockReset())
|
||||
|
||||
it('draws a startup split at once, then records it in main under the window’s pane id', () => {
|
||||
const { manager, splitPane } = fakeManager({ id: 2, leafId: CREATED })
|
||||
|
||||
const pane = recordingBootstrapSplits(manager, 'wt-1', 'tab-1').splitPane(1, 'horizontal')
|
||||
|
||||
expect(pane).toEqual({ id: 2, leafId: CREATED })
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'horizontal', undefined)
|
||||
expect(mocks.recordLayoutApply).toHaveBeenCalledWith({}, 'wt-1', {
|
||||
type: 'splitPane',
|
||||
tabId: 'tab-1',
|
||||
leafId: SOURCE,
|
||||
direction: 'horizontal',
|
||||
newLeafId: CREATED
|
||||
})
|
||||
})
|
||||
|
||||
it('records nothing for a split the manager did not make', () => {
|
||||
const { manager } = fakeManager(null)
|
||||
|
||||
recordingBootstrapSplits(manager, 'wt-1', 'tab-1').splitPane(1, 'vertical')
|
||||
|
||||
expect(mocks.recordLayoutApply).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
|
||||
export type BootstrapSplitManager = Pick<
|
||||
PaneManager,
|
||||
'getActivePane' | 'getPanes' | 'setActivePane' | 'splitPane'
|
||||
>
|
||||
|
||||
/**
|
||||
* The manager a tab's startup splits use in a layout-owner build: each split is drawn at once
|
||||
* under the window's pane id (its startup command rides on the split), then recorded in main.
|
||||
*/
|
||||
export function recordingBootstrapSplits(
|
||||
manager: PaneManager,
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): BootstrapSplitManager {
|
||||
return {
|
||||
getActivePane: () => manager.getActivePane(),
|
||||
getPanes: () => manager.getPanes(),
|
||||
setActivePane: (paneId, opts) => manager.setActivePane(paneId, opts),
|
||||
splitPane: (paneId, direction, options) => {
|
||||
const sourceLeafId = manager.getLeafId(paneId)
|
||||
const created = manager.splitPane(paneId, direction, options)
|
||||
if (created && sourceLeafId) {
|
||||
recordDrawnPaneSplit(worktreeId, tabId, sourceLeafId, direction, created.leafId)
|
||||
}
|
||||
return created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a split the window drew under its pane id; main writes it, or finds it already held. */
|
||||
export function recordDrawnPaneSplit(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
sourceLeafId: string,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
newLeafId: string
|
||||
): void {
|
||||
recordLayoutApply(useAppStore.getState(), worktreeId, {
|
||||
type: 'splitPane',
|
||||
tabId,
|
||||
leafId: sourceLeafId,
|
||||
direction,
|
||||
newLeafId
|
||||
})
|
||||
}
|
||||
+55
-43
@@ -4,9 +4,8 @@ import type { PaneManagerOptions } from '@/lib/pane-manager/pane-manager-types'
|
||||
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
_resetLayoutOwnerPaneIntentsForTests,
|
||||
expectPaneToAppear,
|
||||
expectPaneToClose,
|
||||
takePaneAppearIntent
|
||||
expectPaneToMountUnfocused
|
||||
} from '@/runtime/layout-owner-pane-intents'
|
||||
import { serializePaneTree } from './layout-serialization'
|
||||
|
||||
@@ -15,8 +14,6 @@ vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => {
|
||||
const actual: object = await importOriginal()
|
||||
return { ...actual, openTerminal: vi.fn() }
|
||||
})
|
||||
const recordCreatedTerminalPaneSplit = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./terminal-pane-split-completion', () => ({ recordCreatedTerminalPaneSplit }))
|
||||
|
||||
const { PaneManager } = await import('@/lib/pane-manager/pane-manager')
|
||||
const { reconcileMountedPanesToLayout } = await import('./terminal-pane-frame-reconciliation')
|
||||
@@ -40,11 +37,20 @@ describe('reconcileMountedPanesToLayout', () => {
|
||||
let onPaneCreated: Mock<NonNullable<PaneManagerOptions['onPaneCreated']>>
|
||||
const closePane = vi.fn()
|
||||
|
||||
const reconcile = (layout: TerminalPaneLayoutNode, isExpanded = false): boolean =>
|
||||
const reconcile = (
|
||||
layout: TerminalPaneLayoutNode,
|
||||
isExpanded = false,
|
||||
ptyIdsByLeafId?: Record<string, string>
|
||||
): boolean =>
|
||||
reconcileMountedPanesToLayout({
|
||||
tabId: TAB,
|
||||
manager,
|
||||
layout: { root: layout, activeLeafId: null, expandedLeafId: null },
|
||||
layout: {
|
||||
root: layout,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {})
|
||||
},
|
||||
closePane,
|
||||
isExpanded
|
||||
})
|
||||
@@ -57,7 +63,6 @@ describe('reconcileMountedPanesToLayout', () => {
|
||||
beforeEach(() => {
|
||||
_resetLayoutOwnerPaneIntentsForTests()
|
||||
closePane.mockReset()
|
||||
recordCreatedTerminalPaneSplit.mockReset()
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
root = document.createElement('div')
|
||||
@@ -75,35 +80,58 @@ describe('reconcileMountedPanesToLayout', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('mounts the window’s own split where main placed it, with the cwd it asked for', () => {
|
||||
expectPaneToAppear(C, {
|
||||
tabId: TAB,
|
||||
cwd: '/repo',
|
||||
telemetry: { source: 'keyboard', direction: 'horizontal' }
|
||||
})
|
||||
it('mounts a pane main is starting where main placed it, starting nothing of its own', () => {
|
||||
const layout = split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C), 0.4), 0.3)
|
||||
|
||||
expect(reconcile(layout)).toBe(true)
|
||||
|
||||
expect(serialized()).toEqual(layout)
|
||||
expect(onPaneCreated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ leafId: C }),
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
)
|
||||
expect(recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ leafId: C }),
|
||||
{ source: 'keyboard', direction: 'horizontal' }
|
||||
)
|
||||
expect(takePaneAppearIntent(TAB, C)).toBeNull()
|
||||
// No spawn input: the pane waits on main's start, then attaches (main-pane-start-wait).
|
||||
const [created, hints] = onPaneCreated.mock.calls[0]!
|
||||
expect(created).toMatchObject({ leafId: C })
|
||||
expect(hints?.cwd).toBeUndefined()
|
||||
expect(hints?.cwdPromise).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a pane main added without the window’s ask to main’s notice', () => {
|
||||
it('mounts a pane main added that the window never asked for, attached to main’s terminal', () => {
|
||||
// Main's own split, a CLI split or another client's: the pane sets agree, none pending.
|
||||
const layout = split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C)))
|
||||
|
||||
expect(reconcile(layout)).toBe(false)
|
||||
expect(reconcile(layout, false, { [C]: 'pty-main-c' })).toBe(true)
|
||||
|
||||
expect(leaves()).toEqual([A, B])
|
||||
expect(onPaneCreated).not.toHaveBeenCalled()
|
||||
expect(leaves()).toEqual([A, B, C])
|
||||
expect(onPaneCreated).toHaveBeenCalledTimes(1)
|
||||
expect(onPaneCreated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ leafId: C }),
|
||||
expect.objectContaining({ ptyId: 'pty-main-c' })
|
||||
)
|
||||
// The next frame finds the sets agreeing and mounts nothing again.
|
||||
expect(reconcile(layout, false, { [C]: 'pty-main-c' })).toBe(false)
|
||||
expect(onPaneCreated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses a pane main added, as a split does', () => {
|
||||
manager.setActivePane(manager.getNumericIdForLeaf(A)!, { focus: false })
|
||||
|
||||
reconcile(split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C))))
|
||||
|
||||
expect(manager.getActivePane()?.leafId).toBe(C)
|
||||
})
|
||||
|
||||
it('leaves focus on the source pane for the window’s startup split, as switch-off does', () => {
|
||||
manager.setActivePane(manager.getNumericIdForLeaf(A)!, { focus: false })
|
||||
expectPaneToMountUnfocused(TAB, C)
|
||||
|
||||
reconcile(split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C))))
|
||||
|
||||
expect(leaves()).toEqual([A, B, C])
|
||||
expect(manager.getActivePane()?.leafId).toBe(A)
|
||||
// Asked once: a later pane main adds takes focus again.
|
||||
const D = '44444444-4444-4444-8444-444444444444'
|
||||
reconcile(
|
||||
split('vertical', leaf(A), split('horizontal', leaf(B), split('vertical', leaf(C), leaf(D))))
|
||||
)
|
||||
expect(manager.getActivePane()?.leafId).toBe(D)
|
||||
})
|
||||
|
||||
it('closes a pane main dropped only when the window asked, with the close it asked for', () => {
|
||||
@@ -126,17 +154,6 @@ describe('reconcileMountedPanesToLayout', () => {
|
||||
expect(leaves()).toEqual([A])
|
||||
})
|
||||
|
||||
it('ignores an intent for another tab', () => {
|
||||
expectPaneToAppear(C, {
|
||||
tabId: 'tab-2',
|
||||
telemetry: { source: 'keyboard', direction: 'vertical' }
|
||||
})
|
||||
|
||||
reconcile(split('vertical', leaf(A), split('vertical', leaf(B), leaf(C))))
|
||||
|
||||
expect(leaves()).toEqual([A, B])
|
||||
})
|
||||
|
||||
it('takes main’s ratios, then another client’s move, without recreating a pane', () => {
|
||||
const before = manager.getPanes().map((pane) => pane.container)
|
||||
|
||||
@@ -150,12 +167,7 @@ describe('reconcileMountedPanesToLayout', () => {
|
||||
expect(onPaneCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a zoomed tab’s shape for later, still mounting its own split', () => {
|
||||
expectPaneToAppear(C, {
|
||||
tabId: TAB,
|
||||
telemetry: { source: 'keyboard', direction: 'vertical' }
|
||||
})
|
||||
|
||||
it('leaves a zoomed tab’s shape for later, still mounting main’s new pane', () => {
|
||||
reconcile(split('horizontal', leaf(B), split('vertical', leaf(A), leaf(C))), true)
|
||||
|
||||
expect(leaves()).toEqual([A, B, C])
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
expectPaneToAppear,
|
||||
type PaneCloseKind,
|
||||
takePaneAppearIntent,
|
||||
takePaneCloseIntent
|
||||
takePaneCloseIntent,
|
||||
takePaneMountUnfocused
|
||||
} from '@/runtime/layout-owner-pane-intents'
|
||||
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
|
||||
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
|
||||
/**
|
||||
* A layout-owner build's mounted tab drawn from main's tree (the store's layout, which frames set).
|
||||
* The window's own split and close show here, with the inputs and close it asked for; a pane main
|
||||
* added or dropped without such an intent is left to main's notice. Then the panes take main's
|
||||
* shape (by moving, never recreating) and ratios. True when panes were added or closed.
|
||||
* Every pane main added is mounted, whoever split it: main starts its terminal, and the pane
|
||||
* attaches to it. The window's own close shows here with the close it asked for; a pane main
|
||||
* dropped without one is left to main's notice. Then the panes take main's shape (by moving,
|
||||
* never recreating) and ratios. True when panes were added or closed.
|
||||
*/
|
||||
export function reconcileMountedPanesToLayout(args: {
|
||||
tabId: string
|
||||
@@ -49,16 +48,14 @@ export function reconcileMountedPanesToLayout(args: {
|
||||
if (sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId) !== null) {
|
||||
continue
|
||||
}
|
||||
const intent = takePaneAppearIntent(tabId, insertion.newLeafId)
|
||||
if (!intent) {
|
||||
continue
|
||||
}
|
||||
const ratio =
|
||||
insertion.ratio === undefined
|
||||
? undefined
|
||||
: insertion.placement === 'before'
|
||||
? 1 - insertion.ratio
|
||||
: insertion.ratio
|
||||
const ptyId = layout.ptyIdsByLeafId?.[insertion.newLeafId]
|
||||
const activeBefore = manager.getActivePane()
|
||||
const created = manager.splitPaneAroundLeafIds(
|
||||
insertion.sourceLeafIds,
|
||||
sourcePaneId,
|
||||
@@ -67,16 +64,18 @@ export function reconcileMountedPanesToLayout(args: {
|
||||
leafId: insertion.newLeafId,
|
||||
placement: insertion.placement,
|
||||
...(ratio !== undefined && { ratio }),
|
||||
...(intent.cwd ? { cwd: intent.cwd } : {}),
|
||||
...(intent.cwdPromise ? { cwdPromise: intent.cwdPromise } : {})
|
||||
// Bound already: the pane attaches to main's terminal; unbound, it waits on main's start.
|
||||
...(ptyId ? { ptyId } : {})
|
||||
}
|
||||
)
|
||||
if (!created) {
|
||||
expectPaneToAppear(insertion.newLeafId, intent)
|
||||
continue
|
||||
if (created) {
|
||||
changed = true
|
||||
// As switch-off's startup split: the new pane mounts, focus stays on the source pane.
|
||||
if (activeBefore && takePaneMountUnfocused(tabId, insertion.newLeafId)) {
|
||||
const hadFocus = created.container.contains(document.activeElement)
|
||||
manager.setActivePane(activeBefore.id, { focus: hadFocus })
|
||||
}
|
||||
}
|
||||
recordCreatedTerminalPaneSplit(created, intent.telemetry)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (args.isExpanded) {
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-re
|
||||
import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { resolveParkRevealLiveTuiLeafIds } from './park-reveal-live-tui-leaves'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { withoutQueuedMainSplits } from './terminal-pane-split-request-routing'
|
||||
import {
|
||||
mapRestoredPaneTitlesByPaneId,
|
||||
replayLayoutWithOneShotParkIntent
|
||||
@@ -30,15 +28,7 @@ export function restoreTerminalPaneLayout(args: {
|
||||
initialLayoutRef.current.ptyIdsByLeafId
|
||||
)
|
||||
const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () =>
|
||||
replayTerminalLayout(
|
||||
manager,
|
||||
// Switch point: main's frames already hold a split main asked for; its queued request mounts
|
||||
// it, with its startup command.
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? withoutQueuedMainSplits(initialLayoutRef.current, tabId, worktreeId)
|
||||
: initialLayoutRef.current,
|
||||
isActive
|
||||
)
|
||||
replayTerminalLayout(manager, initialLayoutRef.current, isActive)
|
||||
)
|
||||
const localOnlyBuffers = useAppStore.getState().localOnlyScrollbackByTabId[tabId]
|
||||
const restoredBuffers = resolveLeafScrollbackBuffers({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
|
||||
import {
|
||||
runTerminalPaneBootstrapSplits,
|
||||
type BootstrapSplitManager
|
||||
} from './terminal-pane-mount-bootstrap'
|
||||
|
||||
// Only startup is read by the bootstrap; the full PTY dependency bag belongs to mounting.
|
||||
function startupDeps(): PtyConnectionDeps {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import type { TerminalPaneIssueCommandSplit } from './terminal-pane-lifecycle-types'
|
||||
import { splitPaneWithOneShotStartup } from './terminal-pane-lifecycle-primitives'
|
||||
|
||||
export type BootstrapSplitManager = Pick<
|
||||
PaneManager,
|
||||
'getActivePane' | 'getPanes' | 'setActivePane' | 'splitPane'
|
||||
>
|
||||
|
||||
export function runTerminalPaneBootstrapSplits(args: {
|
||||
manager: BootstrapSplitManager
|
||||
ptyDeps: PtyConnectionDeps
|
||||
|
||||
+1
-43
@@ -6,9 +6,7 @@ import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
|
||||
import {
|
||||
_resetTerminalPaneSplitRequestRoutingForTests,
|
||||
dispatchTerminalPaneSplitRequest,
|
||||
queueTerminalPaneSplitRequest,
|
||||
withoutQueuedMainSplits
|
||||
dispatchTerminalPaneSplitRequest
|
||||
} from './terminal-pane-split-request-routing'
|
||||
import {
|
||||
_resetLayoutFramePositionForTests,
|
||||
@@ -117,44 +115,4 @@ describe('a split main asked for, mounted in a layout-owner build', () => {
|
||||
expect(sendLayoutApply).not.toHaveBeenCalled()
|
||||
uninstall()
|
||||
})
|
||||
|
||||
it('mounts a split into an unmounted tab with its command, not as a fresh shell', () => {
|
||||
// The split lands while the tab is not mounted: its request waits; main's frame already
|
||||
// holds the pane.
|
||||
queueTerminalPaneSplitRequest({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneRuntimeId: 1,
|
||||
sourceLeafId: SOURCE,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW,
|
||||
command: 'make test'
|
||||
})
|
||||
const framed = {
|
||||
root: {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
first: { type: 'leaf' as const, leafId: SOURCE },
|
||||
second: { type: 'leaf' as const, leafId: NEW }
|
||||
},
|
||||
activeLeafId: SOURCE,
|
||||
expandedLeafId: null
|
||||
}
|
||||
|
||||
// The tab's first mount replays its layout without the pane...
|
||||
expect(withoutQueuedMainSplits(framed, 'tab-1', 'wt-1').root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: SOURCE
|
||||
})
|
||||
// ...and the waiting request mounts it, starting its command.
|
||||
const ptyDeps: { startup?: unknown } = {}
|
||||
const { manager, splitPane, startups } = mountedTab(ptyDeps)
|
||||
const uninstall = install(manager, ptyDeps)
|
||||
|
||||
expect(splitPane).toHaveBeenCalledTimes(1)
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { leafId: NEW })
|
||||
expect(startups).toEqual([{ command: 'make test' }])
|
||||
expect(sendLayoutApply).not.toHaveBeenCalled()
|
||||
uninstall()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
|
||||
|
||||
const mocks = vi.hoisted(
|
||||
(): {
|
||||
callRuntimeRpc: ReturnType<typeof vi.fn>
|
||||
pairedServer: string | null
|
||||
actsAsToday: boolean
|
||||
recordCreatedTerminalPaneSplit: ReturnType<typeof vi.fn>
|
||||
} => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
pairedServer: null,
|
||||
actsAsToday: false,
|
||||
recordCreatedTerminalPaneSplit: vi.fn()
|
||||
})
|
||||
)
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
vi.mock('@/lib/resolve-owner', () => ({
|
||||
runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer
|
||||
}))
|
||||
vi.mock('@/runtime/layout-apply-send', () => ({ refusalActsAsToday: () => mocks.actsAsToday }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
vi.mock('./terminal-pane-split-completion', () => ({
|
||||
recordCreatedTerminalPaneSplit: mocks.recordCreatedTerminalPaneSplit
|
||||
}))
|
||||
|
||||
const REQUEST = {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
sourceLeafId: 'leaf-1',
|
||||
direction: 'vertical' as const
|
||||
}
|
||||
|
||||
describe('a window split in a layout-owner build', () => {
|
||||
beforeEach(() => {
|
||||
mocks.callRuntimeRpc.mockReset()
|
||||
mocks.recordCreatedTerminalPaneSplit.mockReset()
|
||||
mocks.pairedServer = null
|
||||
mocks.actsAsToday = false
|
||||
})
|
||||
|
||||
it('asks main to write and start the pane under the window’s id, with its folder and size', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ split: {} })
|
||||
const asToday = vi.fn()
|
||||
|
||||
expect(
|
||||
splitPaneInMain(
|
||||
{
|
||||
...REQUEST,
|
||||
cwd: Promise.resolve('/repo/src'),
|
||||
initialSize: { cols: 40, rows: 24 },
|
||||
command: 'make test',
|
||||
telemetry: { source: 'keyboard' }
|
||||
},
|
||||
asToday
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalled())
|
||||
const [target, method, params] = mocks.callRuntimeRpc.mock.calls[0]!
|
||||
expect(target).toEqual({ kind: 'local' })
|
||||
expect(method).toBe('terminal.split')
|
||||
expect(params).toEqual({
|
||||
pane: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' },
|
||||
newLeafId: expect.any(String),
|
||||
direction: 'vertical',
|
||||
cwd: '/repo/src',
|
||||
initialSize: { cols: 40, rows: 24 },
|
||||
command: 'make test'
|
||||
})
|
||||
expect(isTerminalLeafId(params.newLeafId)).toBe(true)
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(true, {
|
||||
source: 'keyboard',
|
||||
direction: 'vertical'
|
||||
})
|
||||
expect(asToday).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('splits as today when main finds the tab is a paired server’s twin', async () => {
|
||||
mocks.actsAsToday = true
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('host_not_owned'))
|
||||
const asToday = vi.fn()
|
||||
|
||||
splitPaneInMain(REQUEST, asToday)
|
||||
|
||||
await vi.waitFor(() => expect(asToday).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a startup split’s source focused until the pane mounts, or forgets it on failure', async () => {
|
||||
const { takePaneMountUnfocused } = await import('@/runtime/layout-owner-pane-intents')
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ split: {} })
|
||||
splitPaneInMain({ ...REQUEST, keepSourceFocused: true }, vi.fn())
|
||||
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
|
||||
const leafId = mocks.callRuntimeRpc.mock.calls[0]![2].newLeafId
|
||||
expect(takePaneMountUnfocused('tab-1', leafId)).toBe(true)
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('shell not found'))
|
||||
splitPaneInMain({ ...REQUEST, keepSourceFocused: true }, vi.fn())
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
const failedLeafId = mocks.callRuntimeRpc.mock.calls[1]![2].newLeafId
|
||||
expect(takePaneMountUnfocused('tab-1', failedLeafId)).toBe(false)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('draws nothing itself when main refuses or the start fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('shell not found'))
|
||||
const asToday = vi.fn()
|
||||
|
||||
splitPaneInMain(REQUEST, asToday)
|
||||
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
expect(asToday).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves a paired server’s workspace to the caller', () => {
|
||||
mocks.pairedServer = 'env-1'
|
||||
|
||||
expect(splitPaneInMain(REQUEST, vi.fn())).toBe(false)
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sizes the new pane as its share of the source pane', () => {
|
||||
expect(splitPaneFirstSize({ cols: 81, rows: 24 }, 'vertical')).toEqual({ cols: 40, rows: 24 })
|
||||
expect(splitPaneFirstSize({ cols: 80, rows: 25 }, 'horizontal')).toEqual({
|
||||
cols: 80,
|
||||
rows: 12
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// Layout-owner build: the window's splits are written and started by main under the window's pane
|
||||
// id. Main's frame draws the pane, which attaches to main's terminal (frame reconciliation).
|
||||
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id'
|
||||
import { refusalActsAsToday } from '@/runtime/layout-apply-send'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { expectPaneToMountUnfocused } from '@/runtime/layout-owner-pane-intents'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
|
||||
export type SplitInMainRequest = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
sourceLeafId: string
|
||||
direction: 'vertical' | 'horizontal'
|
||||
cwd?: string | Promise<string>
|
||||
initialSize?: { cols: number; rows: number }
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
/** The user's split; startup splits record no interaction. */
|
||||
telemetry?: { source: TerminalPaneSplitSource }
|
||||
/** Focus stays on the source pane when the new pane mounts, as a startup split's does. */
|
||||
keepSourceFocused?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks main to split. False when a paired server owns the workspace's layout: the caller splits
|
||||
* as today, as it does through `asToday` when main finds the tab is a paired server's twin.
|
||||
*/
|
||||
export function splitPaneInMain(request: SplitInMainRequest, asToday: () => void): boolean {
|
||||
const { worktreeId, tabId, sourceLeafId, direction, telemetry } = request
|
||||
if (runtimeEnvironmentIdUnlessTwin(useAppStore.getState(), worktreeId)) {
|
||||
return false
|
||||
}
|
||||
const newLeafId = mintStablePaneId()
|
||||
const forgetFocus = request.keepSourceFocused
|
||||
? expectPaneToMountUnfocused(tabId, newLeafId)
|
||||
: () => {}
|
||||
void (async () => {
|
||||
const cwd = await request.cwd
|
||||
await callRuntimeRpc({ kind: 'local' }, 'terminal.split', {
|
||||
pane: { worktreeId, tabId, leafId: sourceLeafId },
|
||||
newLeafId,
|
||||
direction,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(request.initialSize ? { initialSize: request.initialSize } : {}),
|
||||
...(request.command ? { command: request.command } : {}),
|
||||
...(request.env ? { env: request.env } : {})
|
||||
})
|
||||
})().then(
|
||||
() => {
|
||||
if (telemetry) {
|
||||
recordCreatedTerminalPaneSplit(true, { source: telemetry.source, direction })
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
forgetFocus()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (
|
||||
message === 'host_not_owned' &&
|
||||
refusalActsAsToday(useAppStore.getState(), worktreeId, 'host_not_owned')
|
||||
) {
|
||||
asToday()
|
||||
return
|
||||
}
|
||||
// A start that failed keeps its pane, which shows why; anything else left no pane.
|
||||
console.warn('[layout-owner] split refused:', message)
|
||||
}
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/** A first size for the new pane: its share of the source pane's grid, until it is laid out. */
|
||||
export function splitPaneFirstSize(
|
||||
source: { cols: number; rows: number },
|
||||
direction: 'vertical' | 'horizontal'
|
||||
): { cols: number; rows: number } {
|
||||
return direction === 'vertical'
|
||||
? { cols: Math.max(1, Math.floor(source.cols / 2)), rows: source.rows }
|
||||
: { cols: source.cols, rows: Math.max(1, Math.floor(source.rows / 2)) }
|
||||
}
|
||||
+1
-77
@@ -5,7 +5,6 @@ import type { SplitTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { BACKGROUND_WORKTREE_MEASURE_WINDOW_MS } from '../terminal/background-terminal-worktree-visibility'
|
||||
import {
|
||||
_resetTerminalPaneSplitRequestRoutingForTests,
|
||||
cancelQueuedTerminalPaneSplitForLeaf,
|
||||
cancelQueuedTerminalPaneSplitRequests,
|
||||
dispatchTerminalPaneSplitRequest,
|
||||
hasTerminalPaneSplitMountLease,
|
||||
@@ -13,8 +12,7 @@ import {
|
||||
registerTerminalPaneSplitRequestHandler,
|
||||
resolveTerminalPaneSplitSourceId,
|
||||
takeQueuedTerminalPaneSplitRequests,
|
||||
TERMINAL_PANE_SPLIT_QUEUE_CAPACITY,
|
||||
withoutQueuedMainSplits
|
||||
TERMINAL_PANE_SPLIT_QUEUE_CAPACITY
|
||||
} from './terminal-pane-split-request-routing'
|
||||
|
||||
const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
@@ -138,77 +136,3 @@ describe('parked terminal split request routing', () => {
|
||||
unregisterSecond()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutQueuedMainSplits', () => {
|
||||
const SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
const NEW = '22222222-2222-4222-8222-222222222222'
|
||||
const layout = {
|
||||
root: {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
first: { type: 'leaf' as const, leafId: SOURCE },
|
||||
second: { type: 'leaf' as const, leafId: NEW }
|
||||
},
|
||||
activeLeafId: SOURCE,
|
||||
expandedLeafId: null
|
||||
}
|
||||
|
||||
it('leaves out a pane whose queued split main asked for, so that request mounts it with its command', () => {
|
||||
_resetTerminalPaneSplitRequestRoutingForTests()
|
||||
queueTerminalPaneSplitRequest({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneRuntimeId: 1,
|
||||
sourceLeafId: SOURCE,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW,
|
||||
command: 'make test'
|
||||
})
|
||||
|
||||
expect(withoutQueuedMainSplits(layout, 'tab-1', 'wt-1').root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: SOURCE
|
||||
})
|
||||
expect(withoutQueuedMainSplits(layout, 'tab-2', 'wt-1')).toBe(layout)
|
||||
expect(takeQueuedTerminalPaneSplitRequests('tab-1', 'wt-1')[0]).toMatchObject({
|
||||
newLeafId: NEW,
|
||||
command: 'make test'
|
||||
})
|
||||
expect(withoutQueuedMainSplits(layout, 'tab-1', 'wt-1')).toBe(layout)
|
||||
_resetTerminalPaneSplitRequestRoutingForTests()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelQueuedTerminalPaneSplitForLeaf', () => {
|
||||
const split = (newLeafId: string): SplitTerminalPaneDetail => ({
|
||||
...splitRequest('tab-parked'),
|
||||
worktreeId: 'wt-1',
|
||||
newLeafId
|
||||
})
|
||||
|
||||
it('drops only the closed pane’s split, and the lease with the tab’s last one', () => {
|
||||
queueTerminalPaneSplitRequest(split('leaf-a'))
|
||||
queueTerminalPaneSplitRequest(split('leaf-b'))
|
||||
|
||||
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-a')
|
||||
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
|
||||
cancelQueuedTerminalPaneSplitForLeaf('tab-other', 'leaf-b')
|
||||
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
|
||||
|
||||
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-b')
|
||||
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(false)
|
||||
expect(takeQueuedTerminalPaneSplitRequests('tab-parked', 'wt-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the lease of another split still waiting there', () => {
|
||||
queueTerminalPaneSplitRequest(split('leaf-a'))
|
||||
queueTerminalPaneSplitRequest(split('leaf-b'))
|
||||
|
||||
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-b')
|
||||
|
||||
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
|
||||
expect(
|
||||
takeQueuedTerminalPaneSplitRequests('tab-parked', 'wt-1').map((r) => r.newLeafId)
|
||||
).toEqual(['leaf-a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { SPLIT_TERMINAL_PANE_EVENT, type SplitTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { BACKGROUND_WORKTREE_MEASURE_WINDOW_MS } from '../terminal/background-terminal-worktree-visibility'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import { removeLayoutLeaf } from '../../../../shared/workspace-layout/terminal-pane-tree'
|
||||
|
||||
// Why 32: ordinary use has one request; the cap tolerates automation bursts without letting a stalled renderer retain unbounded commands or mount leases.
|
||||
export const TERMINAL_PANE_SPLIT_QUEUE_CAPACITY = 32
|
||||
@@ -125,50 +123,11 @@ export function takeQueuedTerminalPaneSplitRequests(
|
||||
return requests
|
||||
}
|
||||
|
||||
/** The tab's layout without the panes of splits main asked for that wait here, which mount them. */
|
||||
export function withoutQueuedMainSplits(
|
||||
layout: TerminalLayoutSnapshot,
|
||||
tabId: string,
|
||||
worktreeId: string
|
||||
): TerminalLayoutSnapshot {
|
||||
let root = layout.root
|
||||
for (const leafId of queuedMainSplitLeafIds(tabId, worktreeId)) {
|
||||
root = removeLayoutLeaf(root, leafId) ?? root
|
||||
}
|
||||
return root === layout.root ? layout : { ...layout, root }
|
||||
}
|
||||
|
||||
function queuedMainSplitLeafIds(tabId: string, worktreeId: string): string[] {
|
||||
return queuedRequests.flatMap((request) =>
|
||||
request.tabId === tabId &&
|
||||
request.newLeafId &&
|
||||
(request.worktreeId === undefined || request.worktreeId === worktreeId)
|
||||
? [request.newLeafId]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
export function cancelQueuedTerminalPaneSplitRequests(tabId: string, worktreeId?: string): void {
|
||||
removeQueuedRequestsForTarget(tabId, worktreeId)
|
||||
releaseSplitMountLease(tabId, worktreeId)
|
||||
}
|
||||
|
||||
/** Drops a waiting split whose pane closed before its tab mounted; the tab's lease goes with its last one. */
|
||||
export function cancelQueuedTerminalPaneSplitForLeaf(tabId: string, leafId: string): void {
|
||||
const index = queuedRequests.findIndex(
|
||||
(request) => request.tabId === tabId && request.newLeafId === leafId
|
||||
)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
const [{ worktreeId }] = queuedRequests.splice(index, 1)
|
||||
if (
|
||||
!queuedRequests.some((request) => request.tabId === tabId && request.worktreeId === worktreeId)
|
||||
) {
|
||||
releaseSplitMountLease(tabId, worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTerminalPaneSplitMountLease(tabId: string, worktreeId?: string): boolean {
|
||||
return [...splitMountLeasesByTarget.values()].some(
|
||||
(lease) =>
|
||||
|
||||
+35
-71
@@ -4,18 +4,13 @@ import type { PtyTransport } from './pty-transport-types'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { createDeferred } from './pty-connection-test-async'
|
||||
import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import { endLayoutFramePositions, noteLayoutFramePosition } from '@/runtime/layout-frame-position'
|
||||
import {
|
||||
_resetLayoutOwnerPaneIntentsForTests,
|
||||
takePaneAppearIntent
|
||||
} from '@/runtime/layout-owner-pane-intents'
|
||||
import type { splitPaneInMain } from './terminal-pane-split-in-main'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
layoutOwnerIsMain: false,
|
||||
recordCreatedTerminalPaneSplit: vi.fn(),
|
||||
resolveSplitCwd: vi.fn(),
|
||||
sendLayoutCommandToOwner: vi.fn<typeof sendLayoutCommandToOwner>(),
|
||||
splitPaneInMain: vi.fn<typeof splitPaneInMain>(),
|
||||
splitWebRuntimeTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -25,8 +20,9 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/layout-owner-command', () => ({
|
||||
sendLayoutCommandToOwner: mocks.sendLayoutCommandToOwner
|
||||
vi.mock('./terminal-pane-split-in-main', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
splitPaneInMain: mocks.splitPaneInMain
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-terminal-actions', () => ({
|
||||
@@ -53,11 +49,17 @@ function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
|
||||
return { splitPane, getNumericIdForLeaf } as unknown as PaneManager
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the split reads only the pane's ids and grid size.
|
||||
const LAYOUT_OWNER_SOURCE_PANE = {
|
||||
id: 1,
|
||||
leafId: 'leaf-1',
|
||||
terminal: { cols: 80, rows: 24 }
|
||||
} as unknown as ManagedPane
|
||||
|
||||
describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
beforeEach(() => {
|
||||
_resetLayoutOwnerPaneIntentsForTests()
|
||||
mocks.layoutOwnerIsMain = false
|
||||
mocks.sendLayoutCommandToOwner.mockReset()
|
||||
mocks.splitPaneInMain.mockReset()
|
||||
mocks.recordCreatedTerminalPaneSplit.mockReset()
|
||||
mocks.resolveSplitCwd.mockReset()
|
||||
mocks.splitWebRuntimeTerminal.mockReset()
|
||||
@@ -254,81 +256,43 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
expect(mocks.sendLayoutCommandToOwner).not.toHaveBeenCalled()
|
||||
expect(mocks.splitPaneInMain).not.toHaveBeenCalled()
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
|
||||
})
|
||||
|
||||
it('in a layout-owner build, asks main for the split and leaves drawing it to main’s frame', () => {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
mocks.sendLayoutCommandToOwner.mockReturnValue(true)
|
||||
const splitPane = vi.fn(() => ({ id: 2 }))
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
manager: makeManager(splitPane),
|
||||
paneTransports: new Map(),
|
||||
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
|
||||
direction: 'vertical',
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
|
||||
expect(command).toEqual({
|
||||
type: 'splitPane',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
direction: 'vertical',
|
||||
newLeafId: expect.any(String)
|
||||
})
|
||||
noteLayoutFramePosition(1)
|
||||
handlers?.apply({})
|
||||
endLayoutFramePositions()
|
||||
expect(splitPane).not.toHaveBeenCalled()
|
||||
|
||||
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toEqual({
|
||||
tabId: 'tab-1',
|
||||
cwd: '/cached',
|
||||
telemetry: { source: 'keyboard', direction: 'vertical' }
|
||||
})
|
||||
})
|
||||
|
||||
it('in a layout-owner build, draws main’s pane itself when no frame is coming', () => {
|
||||
it('in a layout-owner build, asks main to split and start the pane, and leaves drawing it to main’s frame', () => {
|
||||
const splitPane = vi.fn(() => ({ id: 2 }))
|
||||
splitInLayoutOwnerBuild(makeManager(splitPane))
|
||||
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
|
||||
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
|
||||
|
||||
handlers?.apply({})
|
||||
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached', leafId: newLeafId })
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
|
||||
})
|
||||
|
||||
it('in a layout-owner build, forgets the pane when main refuses', () => {
|
||||
splitInLayoutOwnerBuild(makeManager(vi.fn()))
|
||||
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
|
||||
handlers?.refused?.('pane_not_found')
|
||||
|
||||
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
|
||||
expect(mocks.splitPaneInMain).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
sourceLeafId: 'leaf-1',
|
||||
direction: 'vertical',
|
||||
cwd: '/cached',
|
||||
// Half the source pane's 80x24 grid, side by side.
|
||||
initialSize: { cols: 40, rows: 24 },
|
||||
telemetry: { source: 'keyboard' }
|
||||
},
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(splitPane).not.toHaveBeenCalled()
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('in a layout-owner build, splits here when main answers as today', () => {
|
||||
const splitPane = vi.fn(() => ({ id: 2 }))
|
||||
splitInLayoutOwnerBuild(makeManager(splitPane))
|
||||
|
||||
mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].asToday()
|
||||
mocks.splitPaneInMain.mock.calls[0]?.[1]()
|
||||
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
|
||||
})
|
||||
|
||||
it('in a layout-owner build, splits as today where a paired server owns the layout', () => {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
mocks.sendLayoutCommandToOwner.mockReturnValue(false)
|
||||
mocks.splitPaneInMain.mockReturnValue(false)
|
||||
const splitPane = vi.fn(() => ({ id: 2 }))
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
@@ -338,7 +302,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
paneTransports: new Map(),
|
||||
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
|
||||
pane: LAYOUT_OWNER_SOURCE_PANE,
|
||||
direction: 'horizontal',
|
||||
source: 'keyboard'
|
||||
})
|
||||
@@ -348,7 +312,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
|
||||
function splitInLayoutOwnerBuild(manager: PaneManager): void {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
mocks.sendLayoutCommandToOwner.mockReturnValue(true)
|
||||
mocks.splitPaneInMain.mockReturnValue(true)
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
@@ -356,7 +320,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
paneTransports: new Map(),
|
||||
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
|
||||
pane: LAYOUT_OWNER_SOURCE_PANE,
|
||||
direction: 'vertical',
|
||||
source: 'keyboard'
|
||||
})
|
||||
|
||||
+12
-36
@@ -5,10 +5,7 @@ import type { PtyTransport } from './pty-transport-types'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import { expectPaneToAppear } from '@/runtime/layout-owner-pane-intents'
|
||||
import { layoutFramesFlowing } from '@/runtime/layout-frame-position'
|
||||
import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id'
|
||||
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
|
||||
|
||||
type SplitArgs = {
|
||||
worktreeId: string
|
||||
@@ -34,44 +31,26 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void {
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Switch point: main writes the split; the window draws it from main's frame, with its cwd.
|
||||
// Switch point: main writes the split and starts its terminal; main's frame draws the pane.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const newLeafId = mintStablePaneId()
|
||||
const cwd = splitCwd(args, ptyId)
|
||||
const forget = expectPaneToAppear(newLeafId, {
|
||||
tabId: args.tabId,
|
||||
...cwd,
|
||||
telemetry: { source: args.source, direction: args.direction }
|
||||
})
|
||||
const asToday = (): void => splitLocally(args, () => cwd)
|
||||
if (
|
||||
sendLayoutCommandToOwner(
|
||||
args.worktreeId,
|
||||
splitPaneInMain(
|
||||
{
|
||||
type: 'splitPane',
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: args.tabId,
|
||||
leafId: args.pane.leafId,
|
||||
sourceLeafId: args.pane.leafId,
|
||||
direction: args.direction,
|
||||
newLeafId
|
||||
cwd: 'cwd' in cwd ? cwd.cwd : cwd.cwdPromise,
|
||||
initialSize: splitPaneFirstSize(args.pane.terminal, args.direction),
|
||||
telemetry: { source: args.source }
|
||||
},
|
||||
{
|
||||
// No frame is coming (the stream is down): the window draws main's pane itself.
|
||||
apply: () => {
|
||||
if (!layoutFramesFlowing()) {
|
||||
forget()
|
||||
splitLocally(args, () => cwd, newLeafId)
|
||||
}
|
||||
},
|
||||
asToday: () => {
|
||||
forget()
|
||||
splitLocally(args, () => cwd)
|
||||
},
|
||||
refused: forget
|
||||
}
|
||||
asToday
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
forget()
|
||||
}
|
||||
splitLocally(args, () => splitCwd(args, ptyId))
|
||||
}
|
||||
@@ -97,15 +76,12 @@ function splitCwd(args: SplitArgs, ptyId: string | null): SplitCwd {
|
||||
}
|
||||
|
||||
/** `cwd` is read only once a manager is there to split. */
|
||||
function splitLocally(args: SplitArgs, cwd: () => SplitCwd, leafId?: string): void {
|
||||
function splitLocally(args: SplitArgs, cwd: () => SplitCwd): void {
|
||||
const manager = args.getManager ? args.getManager() : args.manager
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
const createdPane = manager.splitPane(args.pane.id, args.direction, {
|
||||
...cwd(),
|
||||
...(leafId ? { leafId } : {})
|
||||
})
|
||||
const createdPane = manager.splitPane(args.pane.id, args.direction, cwd())
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
|
||||
+115
-98
@@ -14,6 +14,11 @@ import {
|
||||
unboundSplitLayout,
|
||||
WORKTREE_ID
|
||||
} from './terminal-pane-tab-detach-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
|
||||
// Main answers through today's IPC; the layout-owner build's equivalents (main's command plus its
|
||||
// frame) are in terminal-pane-tab-detach-main-commit.test.ts.
|
||||
const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
|
||||
|
||||
const toastErrorMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
|
||||
@@ -97,31 +102,34 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => {
|
||||
let paneIds = [1, 2]
|
||||
const manager = managerWithPanes(() => paneIds)
|
||||
const commitMove = vi.fn(
|
||||
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
|
||||
paneIds = [1]
|
||||
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
|
||||
return moved
|
||||
}
|
||||
)
|
||||
const store = createStore()
|
||||
itSwitchOff(
|
||||
'closes main’s new tab, without a toast, when the user closed the pane meanwhile',
|
||||
async () => {
|
||||
let paneIds = [1, 2]
|
||||
const manager = managerWithPanes(() => paneIds)
|
||||
const commitMove = vi.fn(
|
||||
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
|
||||
paneIds = [1]
|
||||
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
|
||||
return moved
|
||||
}
|
||||
)
|
||||
const store = createStore()
|
||||
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
|
||||
|
||||
const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId
|
||||
expect(closeTerminalSurface).toHaveBeenCalledWith({
|
||||
worktreeId: WORKTREE_ID,
|
||||
target: { kind: 'tab', tabId: targetTabId },
|
||||
reason: 'cleanup'
|
||||
})
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId
|
||||
expect(closeTerminalSurface).toHaveBeenCalledWith({
|
||||
worktreeId: WORKTREE_ID,
|
||||
target: { kind: 'tab', tabId: targetTabId },
|
||||
reason: 'cleanup'
|
||||
})
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('closes main’s new tab when the user closed the source tab meanwhile', async () => {
|
||||
itSwitchOff('closes main’s new tab when the user closed the source tab meanwhile', async () => {
|
||||
const store = createStore()
|
||||
const commitMove = vi.fn(
|
||||
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
|
||||
@@ -138,7 +146,7 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finds the pane by its leaf when its pane id changed meanwhile', async () => {
|
||||
itSwitchOff('finds the pane by its leaf when its pane id changed meanwhile', async () => {
|
||||
const manager = managerWithPanes(() => [1, 7])
|
||||
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : LEAF_2))
|
||||
const commitMove = vi.fn(
|
||||
@@ -155,89 +163,98 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
|
||||
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('closes main’s new tab when the pane manager refuses to detach the pane', async () => {
|
||||
const manager = managerWithPanes()
|
||||
manager.detachPaneForExternalMove.mockReturnValue(false)
|
||||
const commitMove = mainAnswering([moved])
|
||||
const store = createStore()
|
||||
itSwitchOff(
|
||||
'closes main’s new tab when the pane manager refuses to detach the pane',
|
||||
async () => {
|
||||
const manager = managerWithPanes()
|
||||
manager.detachPaneForExternalMove.mockReturnValue(false)
|
||||
const commitMove = mainAnswering([moved])
|
||||
const store = createStore()
|
||||
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
|
||||
|
||||
expect(closeTerminalSurface).toHaveBeenCalledWith({
|
||||
worktreeId: WORKTREE_ID,
|
||||
target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId },
|
||||
reason: 'cleanup'
|
||||
})
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(closeTerminalSurface).toHaveBeenCalledWith({
|
||||
worktreeId: WORKTREE_ID,
|
||||
target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId },
|
||||
reason: 'cleanup'
|
||||
})
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => {
|
||||
const store = createStore()
|
||||
let paneIds = [1, 2]
|
||||
const manager = managerWithPanes(() => paneIds)
|
||||
const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => {
|
||||
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
|
||||
paneIds = [2]
|
||||
store.terminalLayoutsByTabId[SOURCE_TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
|
||||
}
|
||||
return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult
|
||||
})
|
||||
itSwitchOff(
|
||||
'moves the last pane, after a sibling closed meanwhile, without killing its PTY',
|
||||
async () => {
|
||||
const store = createStore()
|
||||
let paneIds = [1, 2]
|
||||
const manager = managerWithPanes(() => paneIds)
|
||||
const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => {
|
||||
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
|
||||
paneIds = [2]
|
||||
store.terminalLayoutsByTabId[SOURCE_TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
|
||||
}
|
||||
return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult
|
||||
})
|
||||
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({
|
||||
leafId: LEAF_2,
|
||||
ptyId: 'pty-right'
|
||||
})
|
||||
await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({
|
||||
leafId: LEAF_2,
|
||||
ptyId: 'pty-right'
|
||||
})
|
||||
|
||||
expect(closeTerminalSurface).not.toHaveBeenCalled()
|
||||
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
|
||||
expect(store.createTab).toHaveBeenCalledOnce()
|
||||
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' })
|
||||
)
|
||||
expect(store.closeTab).toHaveBeenCalledWith(
|
||||
SOURCE_TAB_ID,
|
||||
expect.objectContaining({ localPtyTeardownOwnedExternally: true })
|
||||
)
|
||||
})
|
||||
expect(closeTerminalSurface).not.toHaveBeenCalled()
|
||||
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
|
||||
expect(store.createTab).toHaveBeenCalledOnce()
|
||||
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' })
|
||||
)
|
||||
expect(store.closeTab).toHaveBeenCalledWith(
|
||||
SOURCE_TAB_ID,
|
||||
expect.objectContaining({ localPtyTeardownOwnedExternally: true })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('commits the move in main before the target tab exists, using the same tab id', async () => {
|
||||
const store = createStore()
|
||||
let release!: () => void
|
||||
const commitMove = vi.fn(
|
||||
(_request: TerminalLeafMoveRequest) =>
|
||||
new Promise<TerminalLeafMoveResult>((resolve) => {
|
||||
release = () => resolve(moved)
|
||||
})
|
||||
)
|
||||
itSwitchOff(
|
||||
'commits the move in main before the target tab exists, using the same tab id',
|
||||
async () => {
|
||||
const store = createStore()
|
||||
let release!: () => void
|
||||
const commitMove = vi.fn(
|
||||
(_request: TerminalLeafMoveRequest) =>
|
||||
new Promise<TerminalLeafMoveResult>((resolve) => {
|
||||
release = () => resolve(moved)
|
||||
})
|
||||
)
|
||||
|
||||
const detaching = detach({ commitMove, store })
|
||||
await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce())
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
release()
|
||||
await detaching
|
||||
const detaching = detach({ commitMove, store })
|
||||
await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce())
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
release()
|
||||
await detaching
|
||||
|
||||
const request = commitMove.mock.calls[0]?.[0]
|
||||
expect(request).toEqual({
|
||||
worktreeId: WORKTREE_ID,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetTabId: expect.any(String),
|
||||
leafId: LEAF_2,
|
||||
ptyId: expect.any(String)
|
||||
})
|
||||
expect(store.createTab).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
TARGET_GROUP_ID,
|
||||
'powershell.exe',
|
||||
expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 })
|
||||
)
|
||||
})
|
||||
const request = commitMove.mock.calls[0]?.[0]
|
||||
expect(request).toEqual({
|
||||
worktreeId: WORKTREE_ID,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetTabId: expect.any(String),
|
||||
leafId: LEAF_2,
|
||||
ptyId: expect.any(String)
|
||||
})
|
||||
expect(store.createTab).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
TARGET_GROUP_ID,
|
||||
'powershell.exe',
|
||||
expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores a repeat drop of a pane whose move is still committing', async () => {
|
||||
itSwitchOff('ignores a repeat drop of a pane whose move is still committing', async () => {
|
||||
const store = createStore()
|
||||
let release!: () => void
|
||||
const commitMove = vi.fn(
|
||||
@@ -257,7 +274,7 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
|
||||
itSwitchOff('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
|
||||
const store = createStore(splitLayout())
|
||||
const saved = store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.ptyIdsByLeafId?.[LEAF_2]
|
||||
expect(saved).toBeTruthy()
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// Layout-owner build: terminal-pane-tab-detach-commit.test.ts's roll-forward outcomes, with main's
|
||||
// `movePaneToNewTab` answered (and its frame drawn) after what the user did meanwhile.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { layoutApplyTestTransport as main } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness'
|
||||
import { LEAF_1, LEAF_2, splitLayout } from './terminal-pane-tab-detach-fixture'
|
||||
import {
|
||||
detachFrom,
|
||||
GROUP,
|
||||
mainMovedTabId,
|
||||
openMainFrames,
|
||||
seedDetachStore,
|
||||
sentMove,
|
||||
SOURCE,
|
||||
twoPaneManager,
|
||||
WT,
|
||||
type DetachStore
|
||||
} from './terminal-pane-tab-detach-main.test-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn(() => [])
|
||||
}))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
const closeTerminalSurface = vi.fn(async (_args: unknown) => {})
|
||||
const moveLeafToNewTab = vi.fn()
|
||||
let api: ReturnType<typeof createStoreCascadesMockApi>
|
||||
let closeFrames: () => void = () => {}
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockClear()
|
||||
closeTerminalSurface.mockClear()
|
||||
moveLeafToNewTab.mockClear()
|
||||
api = createStoreCascadesMockApi()
|
||||
Object.assign(api, { session: { closeTerminalSurface } })
|
||||
Object.assign(api.pty, { moveLeafToNewTab })
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
closeFrames()
|
||||
// Main moved through layout.apply, never today's IPC.
|
||||
expect(moveLeafToNewTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
async function seeded(layout = splitLayout()): Promise<DetachStore> {
|
||||
const store = seedDetachStore(layout)
|
||||
closeFrames = await openMainFrames(store)
|
||||
return store
|
||||
}
|
||||
|
||||
/** Holds main's answer to the move until `answer`, which applies it and draws its frame. */
|
||||
async function moveHeldByMain<T>(detaching: () => Promise<T>) {
|
||||
main.answerWith('pending')
|
||||
const result = detaching()
|
||||
await vi.waitFor(() => expect(main.pendingCount).toBe(1))
|
||||
return {
|
||||
result,
|
||||
answer: async (): Promise<T> => {
|
||||
main.answerWith('apply')
|
||||
main.answerPending()
|
||||
await main.settle()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every tab the window shows is one main's model holds: the window made none of its own. */
|
||||
function expectOnlyMainTabs(store: DetachStore): void {
|
||||
const mainIds = main.mainModel?.workspaces[WT]?.tabs.map((tab) => tab.id) ?? []
|
||||
const shown = store.getState().tabsByWorktree[WT]?.map((tab) => tab.id) ?? []
|
||||
expect(shown.filter((id) => !mainIds.includes(id))).toEqual([])
|
||||
}
|
||||
|
||||
function expectMainTabClosed(): void {
|
||||
expect(closeTerminalSurface).toHaveBeenCalledWith({
|
||||
worktreeId: WT,
|
||||
target: { kind: 'tab', tabId: mainMovedTabId() },
|
||||
reason: 'cleanup'
|
||||
})
|
||||
}
|
||||
|
||||
describe('detachTerminalPaneToTab rolls main’s committed move forward (layout-owner build)', () => {
|
||||
it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => {
|
||||
const store = await seeded()
|
||||
let paneIds = [1, 2]
|
||||
const manager = twoPaneManager()
|
||||
manager.getPanes.mockImplementation(() => paneIds.map((id) => ({ id })))
|
||||
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
|
||||
paneIds = [1]
|
||||
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
|
||||
|
||||
await expect(move.answer()).resolves.toBeNull()
|
||||
|
||||
expectMainTabClosed()
|
||||
expectOnlyMainTabs(store)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes main’s new tab when the user closed the source tab meanwhile', async () => {
|
||||
const store = await seeded()
|
||||
const move = await moveHeldByMain(() => detachFrom(store))
|
||||
// A tab close drops its row and its layout.
|
||||
store.setState((state) => {
|
||||
const { [SOURCE]: _closed, ...terminalLayoutsByTabId } = state.terminalLayoutsByTabId
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
[WT]: (state.tabsByWorktree[WT] ?? []).filter((tab) => tab.id !== SOURCE)
|
||||
},
|
||||
terminalLayoutsByTabId
|
||||
}
|
||||
})
|
||||
|
||||
await expect(move.answer()).resolves.toBeNull()
|
||||
|
||||
expect(closeTerminalSurface).toHaveBeenCalledOnce()
|
||||
expectMainTabClosed()
|
||||
expectOnlyMainTabs(store)
|
||||
})
|
||||
|
||||
it('finds the pane by its leaf when its pane id changed meanwhile', async () => {
|
||||
const store = await seeded()
|
||||
const manager = twoPaneManager()
|
||||
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
|
||||
manager.getPanes.mockImplementation(() => [{ id: 1 }, { id: 7 }])
|
||||
manager.getLeafId.mockImplementation((paneId) =>
|
||||
paneId === 1 ? LEAF_1 : paneId === 7 ? LEAF_2 : null
|
||||
)
|
||||
|
||||
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2 })
|
||||
|
||||
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('closes main’s new tab when the pane manager refuses to detach the pane', async () => {
|
||||
const store = await seeded()
|
||||
const manager = twoPaneManager()
|
||||
manager.detachPaneForExternalMove.mockReturnValue(false)
|
||||
|
||||
await expect(detachFrom(store, { manager })).resolves.toBeNull()
|
||||
|
||||
expectMainTabClosed()
|
||||
expectOnlyMainTabs(store)
|
||||
})
|
||||
|
||||
it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => {
|
||||
const store = await seeded({
|
||||
...splitLayout(),
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-left', [LEAF_2]: 'pty-right' }
|
||||
})
|
||||
let paneIds = [1, 2]
|
||||
const manager = twoPaneManager()
|
||||
manager.getPanes.mockImplementation(() => paneIds.map((id) => ({ id })))
|
||||
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
|
||||
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
|
||||
paneIds = [2]
|
||||
store.getState().setTabLayout(SOURCE, {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
|
||||
})
|
||||
|
||||
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2, ptyId: 'pty-right' })
|
||||
|
||||
const movedId = mainMovedTabId()
|
||||
expect(closeTerminalSurface).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { kind: 'tab', tabId: movedId } })
|
||||
)
|
||||
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
|
||||
expectOnlyMainTabs(store)
|
||||
const state = store.getState()
|
||||
expect(state.tabsByWorktree[WT]?.some((tab) => tab.id === SOURCE)).toBe(false)
|
||||
expect(state.terminalLayoutsByTabId[movedId]?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF_2]: 'pty-right'
|
||||
})
|
||||
expect(state.ptyIdsByTabId[movedId]).toEqual(['pty-right'])
|
||||
expect(api.pty.kill).not.toHaveBeenCalledWith('pty-right', expect.anything())
|
||||
expect(api.pty.kill).not.toHaveBeenCalledWith('pty-right')
|
||||
})
|
||||
|
||||
it('commits the move in main before the target tab exists, then shows main’s tab', async () => {
|
||||
const store = await seeded()
|
||||
const tabsBefore = store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)
|
||||
const move = await moveHeldByMain(() => detachFrom(store))
|
||||
|
||||
expect(sentMove()).toEqual({
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: LEAF_2,
|
||||
groupId: GROUP,
|
||||
windowDetach: { ptyId: 'remote:env-1@@terminal-1' }
|
||||
})
|
||||
expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(tabsBefore)
|
||||
|
||||
const result = await move.answer()
|
||||
const movedId = mainMovedTabId()
|
||||
expect(result?.tab.id).toBe(movedId)
|
||||
expect(store.getState().terminalLayoutsByTabId[movedId]?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: LEAF_2
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a repeat drop of a pane whose move is still committing', async () => {
|
||||
const store = await seeded()
|
||||
const move = await moveHeldByMain(() => detachFrom(store))
|
||||
|
||||
await expect(detachFrom(store)).resolves.toBeNull()
|
||||
|
||||
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2 })
|
||||
expect(main.sentOfType('movePaneToNewTab')).toHaveLength(1)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
|
||||
const store = await seeded()
|
||||
const saved = store.getState().terminalLayoutsByTabId[SOURCE]?.ptyIdsByLeafId?.[LEAF_2]
|
||||
expect(saved).toBeTruthy()
|
||||
|
||||
const result = await detachFrom(store, { livePtyId: 'pty-respawned' })
|
||||
|
||||
expect(sentMove().windowDetach).toEqual({ ptyId: 'pty-respawned' })
|
||||
expect(result?.ptyId).toBe('pty-respawned')
|
||||
expect(store.getState().terminalLayoutsByTabId[mainMovedTabId()]?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF_2]: 'pty-respawned'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
// Layout-owner build: terminal-pane-tab-detach.test.ts's drag-out outcomes, reached through
|
||||
// main's `movePaneToNewTab` and the frame that draws its result.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness'
|
||||
import { LEAF_1, LEAF_2, splitLayout, unboundSplitLayout } from './terminal-pane-tab-detach-fixture'
|
||||
import {
|
||||
detachFrom,
|
||||
GROUP,
|
||||
mainMovedTabId,
|
||||
openMainFrames,
|
||||
OTHER_1,
|
||||
OTHER_2,
|
||||
seedDetachStore,
|
||||
sentMove,
|
||||
SOURCE,
|
||||
twoPaneManager,
|
||||
WT,
|
||||
type DetachStore
|
||||
} from './terminal-pane-tab-detach-main.test-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn(() => [])
|
||||
}))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
let closeFrames: () => void = () => {}
|
||||
beforeEach(() => {
|
||||
createStoreCascadesMockApi()
|
||||
})
|
||||
afterEach(() => {
|
||||
closeFrames()
|
||||
})
|
||||
|
||||
async function seeded(...args: Parameters<typeof seedDetachStore>): Promise<DetachStore> {
|
||||
const store = seedDetachStore(...args)
|
||||
closeFrames = await openMainFrames(store)
|
||||
return store
|
||||
}
|
||||
|
||||
function movedRow(store: DetachStore) {
|
||||
return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === mainMovedTabId())
|
||||
}
|
||||
|
||||
describe('detachTerminalPaneToTab, main moving the pane (layout-owner build)', () => {
|
||||
it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', async (chatLeafId) => {
|
||||
const store = await seeded({ ...splitLayout(), chatLeafId })
|
||||
|
||||
await detachFrom(store)
|
||||
|
||||
const movedId = mainMovedTabId()
|
||||
const state = store.getState()
|
||||
const movedTab = state.unifiedTabsByWorktree[WT]?.find((tab) => tab.entityId === movedId)
|
||||
expect(movedTab?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
|
||||
expect(state.terminalLayoutsByTabId[movedId]?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_2 ? LEAF_2 : undefined
|
||||
)
|
||||
expect(state.terminalLayoutsByTabId[SOURCE]?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_1 ? LEAF_1 : undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('shows main’s new terminal tab holding the moved pane and its terminal', async () => {
|
||||
const store = await seeded(splitLayout(), { shellOverride: 'powershell.exe', sshRepo: true })
|
||||
const manager = twoPaneManager()
|
||||
const persistLayoutSnapshot = vi.fn()
|
||||
|
||||
const result = await detachFrom(store, {
|
||||
manager,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
}
|
||||
})
|
||||
|
||||
expect(sentMove()).toMatchObject({
|
||||
workspace: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: LEAF_2,
|
||||
groupId: GROUP,
|
||||
windowDetach: { ptyId: 'remote:env-1@@terminal-1' }
|
||||
})
|
||||
const movedId = mainMovedTabId()
|
||||
expect(result).toMatchObject({ tab: { id: movedId }, ptyId: 'remote:env-1@@terminal-1' })
|
||||
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2)
|
||||
expect(movedRow(store)).toMatchObject({ shellOverride: 'powershell.exe' })
|
||||
const state = store.getState()
|
||||
expect(state.terminalLayoutsByTabId[SOURCE]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
|
||||
})
|
||||
expect(state.terminalLayoutsByTabId[movedId]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
})
|
||||
// The pane's scrollback is keyed by its leaf, so the moved tab saves it.
|
||||
const saved = buildWorkspaceSessionPayload(state).terminalLayoutsByTabId[movedId]
|
||||
expect(saved?.buffersByLeafId).toEqual({ [LEAF_2]: 'remote-buffer' })
|
||||
expect(state.ptyIdsByTabId[movedId]).toEqual(['remote:env-1@@terminal-1'])
|
||||
expect(state.ptyIdsByTabId[SOURCE]).toEqual(['pty-left'])
|
||||
expect(state.activeTabIdByWorktree[WT]).toBe(movedId)
|
||||
expect(state.activeTabTypeByWorktree[WT]).toBe('terminal')
|
||||
expect(persistLayoutSnapshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['powershell.exe', 'wsl.exe'])(
|
||||
'preserves the moved PTY shell override when the source uses %s',
|
||||
async (shellOverride) => {
|
||||
const store = await seeded(splitLayout(), { shellOverride })
|
||||
|
||||
await detachFrom(store, { sourcePaneId: 1 })
|
||||
|
||||
expect(sentMove()).toMatchObject({ leafId: LEAF_1, windowDetach: { ptyId: 'pty-left' } })
|
||||
expect(movedRow(store)).toMatchObject({ shellOverride })
|
||||
expect(store.getState().ptyIdsByTabId[mainMovedTabId()]).toEqual(['pty-left'])
|
||||
}
|
||||
)
|
||||
|
||||
it('syncs PTY ownership when the primary source pane is detached', async () => {
|
||||
const store = await seeded(splitLayout())
|
||||
|
||||
const result = await detachFrom(store, { sourcePaneId: 1 })
|
||||
|
||||
const movedId = mainMovedTabId()
|
||||
expect(result?.ptyId).toBe('pty-left')
|
||||
const state = store.getState()
|
||||
expect(state.terminalLayoutsByTabId[SOURCE]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
})
|
||||
expect(state.terminalLayoutsByTabId[movedId]?.root).toEqual({ type: 'leaf', leafId: LEAF_1 })
|
||||
expect(state.ptyIdsByTabId[SOURCE]).toEqual(['remote:env-1@@terminal-1'])
|
||||
expect(state.ptyIdsByTabId[movedId]).toEqual(['pty-left'])
|
||||
})
|
||||
|
||||
it('moves the detached tab into the requested group slot', async () => {
|
||||
const store = await seeded(splitLayout())
|
||||
|
||||
await detachFrom(store, { targetIndex: 1 })
|
||||
|
||||
expect(sentMove()).toMatchObject({ groupId: GROUP, index: 1 })
|
||||
expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([
|
||||
OTHER_1,
|
||||
mainMovedTabId(),
|
||||
OTHER_2,
|
||||
SOURCE
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the live transport PTY id when the snapshot has not persisted it yet', async () => {
|
||||
const store = await seeded(unboundSplitLayout())
|
||||
|
||||
await detachFrom(store, {
|
||||
livePtyId: 'remote:env-2@@terminal-9',
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
}
|
||||
})
|
||||
|
||||
expect(sentMove().windowDetach).toMatchObject({ ptyId: 'remote:env-2@@terminal-9' })
|
||||
expect(store.getState().terminalLayoutsByTabId[mainMovedTabId()]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
|
||||
const store = await seeded(unboundSplitLayout(), { shellOverride: 'powershell.exe' })
|
||||
|
||||
const result = await detachFrom(store, {
|
||||
sourcePaneCwd: { cwd: '/remote/repo/packages/app', confirmed: false }
|
||||
})
|
||||
|
||||
expect(sentMove().windowDetach).toEqual({
|
||||
ptyId: null,
|
||||
startupCwd: '/remote/repo/packages/app'
|
||||
})
|
||||
expect(result?.ptyId).toBeNull()
|
||||
expect(movedRow(store)).toMatchObject({
|
||||
ptyId: null,
|
||||
shellOverride: 'powershell.exe',
|
||||
startupCwd: '/remote/repo/packages/app',
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
expect(store.getState().activeTabIdByWorktree[WT]).toBe(mainMovedTabId())
|
||||
})
|
||||
})
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// A drag-out in a layout-owner build, end to end: a real store, main's model answering the
|
||||
// window's `movePaneToNewTab` through the layout transport, and main's frame drawing the result.
|
||||
// The importing test file installs the transport mocks (see layout-apply-test-transport).
|
||||
|
||||
import { expect, vi } from 'vitest'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture'
|
||||
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
createTestStore,
|
||||
makeWorktree,
|
||||
seedStore,
|
||||
TEST_REPO
|
||||
} from '../../store/slices/store-test-helpers'
|
||||
import { detachTerminalPaneToTab } from './terminal-pane-tab-detach'
|
||||
import { LEAF_1, LEAF_2 } from './terminal-pane-tab-detach-fixture'
|
||||
|
||||
export const WT = 'repo1::/path/wt'
|
||||
export const SOURCE = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
export const OTHER_1 = 'cccccccc-dddd-4eee-8fff-000000000001'
|
||||
export const OTHER_2 = 'cccccccc-dddd-4eee-8fff-000000000002'
|
||||
export const GROUP = 'group-1'
|
||||
|
||||
export type DetachStore = ReturnType<typeof createTestStore>
|
||||
|
||||
function row(id: string, ptyId: string | null, createdAt: number, shellOverride?: string) {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: WT,
|
||||
title: `Terminal ${createdAt}`,
|
||||
defaultTitle: `Terminal ${createdAt}`,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: createdAt,
|
||||
createdAt,
|
||||
...(shellOverride ? { shellOverride } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function entry(id: string, createdAt: number, viewMode?: 'chat') {
|
||||
return {
|
||||
id,
|
||||
entityId: id,
|
||||
groupId: GROUP,
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal' as const,
|
||||
label: `Terminal ${createdAt}`,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: createdAt,
|
||||
createdAt,
|
||||
...(viewMode ? { viewMode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function soloLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
}
|
||||
|
||||
/** The source tab with `layout`, after two other tabs in its group: [OTHER_1, OTHER_2, SOURCE]. */
|
||||
export function seedDetachStore(
|
||||
layout: TerminalLayoutSnapshot,
|
||||
/** `sshRepo`: the window keeps the pane's scrollback, as for any SSH terminal. */
|
||||
options: { shellOverride?: string; sshRepo?: true } = {}
|
||||
): DetachStore {
|
||||
const store = createTestStore()
|
||||
const sourcePty = layout.ptyIdsByLeafId?.[LEAF_1] ?? null
|
||||
seedStore(store, {
|
||||
...(options.sshRepo ? { repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] } : {}),
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt' })] },
|
||||
tabsByWorktree: {
|
||||
[WT]: [
|
||||
row(OTHER_1, 'pty-other-1', 1),
|
||||
row(OTHER_2, 'pty-other-2', 2),
|
||||
row(SOURCE, sourcePty, 3, options.shellOverride)
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
entry(OTHER_1, 1),
|
||||
entry(OTHER_2, 2),
|
||||
entry(SOURCE, 3, layout.chatLeafId ? 'chat' : undefined)
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: GROUP,
|
||||
worktreeId: WT,
|
||||
activeTabId: SOURCE,
|
||||
tabOrder: [OTHER_1, OTHER_2, SOURCE],
|
||||
recentTabIds: [SOURCE]
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [WT]: GROUP },
|
||||
activeTabIdByWorktree: { [WT]: SOURCE },
|
||||
terminalLayoutsByTabId: {
|
||||
[OTHER_1]: soloLayout('aaaaaaaa-1111-4111-8111-111111111111', 'pty-other-1'),
|
||||
[OTHER_2]: soloLayout('bbbbbbbb-2222-4222-8222-222222222222', 'pty-other-2'),
|
||||
[SOURCE]: layout
|
||||
}
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
/** Main loads the store's session as its model, and the window draws main's frames from now on. */
|
||||
export async function openMainFrames(store: DetachStore): Promise<() => void> {
|
||||
layoutApplyTestTransport.reset()
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
layoutApplyTestTransport.useMainModel(
|
||||
loadMainLayoutModel('local', buildWorkspaceSessionPayload(store.getState()))
|
||||
)
|
||||
const frames = openWorkspaceLayoutFrameSync(store)
|
||||
frames.start()
|
||||
await vi.waitFor(() => expect(store.getState().tabsByWorktree[WT]).toBeDefined())
|
||||
return () => frames.close()
|
||||
}
|
||||
|
||||
export type DetachArgs = Partial<Parameters<typeof detachTerminalPaneToTab>[0]>
|
||||
|
||||
/** The pane manager of a two-pane source tab: pane 1 is LEAF_1, pane 2 is LEAF_2. */
|
||||
export function twoPaneManager() {
|
||||
return {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn((paneId: number): string | null => (paneId === 2 ? LEAF_2 : LEAF_1)),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
}
|
||||
|
||||
export function detachFrom(store: DetachStore, args: DetachArgs = {}) {
|
||||
return detachTerminalPaneToTab({
|
||||
getStore: () => store.getState(),
|
||||
manager: twoPaneManager(),
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE,
|
||||
targetGroupId: GROUP,
|
||||
worktreeId: WT,
|
||||
...args
|
||||
})
|
||||
}
|
||||
|
||||
/** The one `movePaneToNewTab` the window sent main. */
|
||||
export function sentMove() {
|
||||
const sent = layoutApplyTestTransport.sentOfType('movePaneToNewTab')
|
||||
expect(sent).toHaveLength(1)
|
||||
return sent[0]!
|
||||
}
|
||||
|
||||
/** The tab main minted for the move, as main's model holds it. */
|
||||
export function mainMovedTabId(): string {
|
||||
const workspace = layoutApplyTestTransport.mainModel?.workspaces[WT]
|
||||
const ids = (workspace?.tabs ?? [])
|
||||
.map((tab) => tab.id)
|
||||
.filter((id) => ![SOURCE, OTHER_1, OTHER_2].includes(id))
|
||||
expect(ids).toHaveLength(1)
|
||||
return ids[0]!
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
unboundSplitLayout,
|
||||
WORKTREE_ID
|
||||
} from './terminal-pane-tab-detach-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
|
||||
// The window makes the new tab itself; the layout-owner build's equivalents (main's command plus
|
||||
// its frame) are in terminal-pane-tab-detach-main-move.test.ts.
|
||||
const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
|
||||
|
||||
const toastErrorMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
|
||||
@@ -177,32 +182,35 @@ describe('resolveTerminalTabStripDropTarget', () => {
|
||||
})
|
||||
|
||||
describe('detachTerminalPaneToTab', () => {
|
||||
it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', async (chatLeafId) => {
|
||||
const store = createStore({ ...splitLayout(), chatLeafId })
|
||||
await detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager: {
|
||||
getPanes: () => [{ id: 1 }, { id: 2 }],
|
||||
getLeafId: () => LEAF_2,
|
||||
detachPaneForExternalMove: () => true
|
||||
},
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
const options = vi.mocked(store.createTab).mock.calls[0]?.[3]
|
||||
expect(options?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
|
||||
expect(store.terminalLayoutsByTabId['tab-detached']?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_2 ? LEAF_2 : undefined
|
||||
)
|
||||
expect(store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_1 ? LEAF_1 : undefined
|
||||
)
|
||||
})
|
||||
itSwitchOff.each([LEAF_1, LEAF_2])(
|
||||
'moves chat mode only with its owning leaf %s',
|
||||
async (chatLeafId) => {
|
||||
const store = createStore({ ...splitLayout(), chatLeafId })
|
||||
await detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager: {
|
||||
getPanes: () => [{ id: 1 }, { id: 2 }],
|
||||
getLeafId: () => LEAF_2,
|
||||
detachPaneForExternalMove: () => true
|
||||
},
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
const options = vi.mocked(store.createTab).mock.calls[0]?.[3]
|
||||
expect(options?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
|
||||
expect(store.terminalLayoutsByTabId['tab-detached']?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_2 ? LEAF_2 : undefined
|
||||
)
|
||||
expect(store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.chatLeafId).toBe(
|
||||
chatLeafId === LEAF_1 ? LEAF_1 : undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('creates a new terminal tab with the detached leaf layout and PTY id', async () => {
|
||||
itSwitchOff('creates a new terminal tab with the detached leaf layout and PTY id', async () => {
|
||||
const store = createStore()
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
@@ -267,7 +275,7 @@ describe('detachTerminalPaneToTab', () => {
|
||||
expect(persistLayoutSnapshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['powershell.exe', 'wsl.exe'])(
|
||||
itSwitchOff.each(['powershell.exe', 'wsl.exe'])(
|
||||
'preserves the moved PTY shell override when the source uses %s',
|
||||
async (shellOverride) => {
|
||||
const store = createStore(splitLayout(), [EXISTING_TAB_1], shellOverride)
|
||||
@@ -296,7 +304,7 @@ describe('detachTerminalPaneToTab', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('syncs PTY ownership when the primary source pane is detached', async () => {
|
||||
itSwitchOff('syncs PTY ownership when the primary source pane is detached', async () => {
|
||||
const store = createStore()
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
@@ -339,7 +347,7 @@ describe('detachTerminalPaneToTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the detached tab into the requested group slot', async () => {
|
||||
itSwitchOff('moves the detached tab into the requested group slot', async () => {
|
||||
const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2])
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
@@ -365,47 +373,50 @@ describe('detachTerminalPaneToTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the live transport PTY id when the snapshot has not persisted it yet', async () => {
|
||||
const store = createStore({
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null
|
||||
})
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn(() => LEAF_2),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
itSwitchOff(
|
||||
'uses the live transport PTY id when the snapshot has not persisted it yet',
|
||||
async () => {
|
||||
const store = createStore({
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null
|
||||
})
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn(() => LEAF_2),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
|
||||
await detachTerminalPaneToTab({
|
||||
livePtyId: 'remote:env-2@@terminal-9',
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
},
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
|
||||
})
|
||||
}
|
||||
|
||||
await detachTerminalPaneToTab({
|
||||
livePtyId: 'remote:env-2@@terminal-9',
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneCwd: {
|
||||
cwd: '/remote/repo',
|
||||
confirmed: false,
|
||||
deferredSplitSpawn: true,
|
||||
pendingCwd: Promise.resolve('/remote/repo/packages/app')
|
||||
},
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
it('rejects a deferred split while inherited cwd is pending', async () => {
|
||||
await expectDeferredSplitDetachRejected({
|
||||
@@ -430,7 +441,7 @@ describe('detachTerminalPaneToTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
|
||||
itSwitchOff('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
|
||||
const store = createStore(unboundSplitLayout())
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { prepareTerminalPaneMount } from './terminal-pane-mount-preparation'
|
||||
import { createTerminalPaneManagerOptions } from './terminal-pane-manager-options'
|
||||
import { restoreTerminalPaneLayout } from './terminal-pane-layout-restore'
|
||||
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
|
||||
import { recordingBootstrapSplits } from './terminal-pane-bootstrap-split-record'
|
||||
import { runBootstrapSplitInMain } from './terminal-pane-bootstrap-split-in-main'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
|
||||
import { cleanupTerminalPaneMount } from './terminal-pane-mount-cleanup'
|
||||
@@ -85,15 +85,24 @@ export function useTerminalPaneMountLifecycle(
|
||||
ptyDeps,
|
||||
initialLayoutHadBuffers: preparation.initialLayoutHadBuffers
|
||||
})
|
||||
runTerminalPaneBootstrapSplits({
|
||||
// Switch point: startup splits are recorded in main under the window's pane ids.
|
||||
manager: ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? recordingBootstrapSplits(manager, deps.worktreeId, deps.tabId)
|
||||
: manager,
|
||||
ptyDeps,
|
||||
issueCommandSplit: deps.issueCommandSplit,
|
||||
isActive: deps.isActive
|
||||
})
|
||||
// Switch point: main writes a startup split and starts its command; its frame draws the pane.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
runBootstrapSplitInMain({
|
||||
manager,
|
||||
worktreeId: deps.worktreeId,
|
||||
tabId: deps.tabId,
|
||||
ptyDeps,
|
||||
issueCommandSplit: deps.issueCommandSplit,
|
||||
isActive: deps.isActive
|
||||
})
|
||||
} else {
|
||||
runTerminalPaneBootstrapSplits({
|
||||
manager,
|
||||
ptyDeps,
|
||||
issueCommandSplit: deps.issueCommandSplit,
|
||||
isActive: deps.isActive
|
||||
})
|
||||
}
|
||||
shouldPersistLayout = true
|
||||
preparation.syncCanExpandState()
|
||||
preparation.syncPaneCount()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_resetLayoutOwnerPaneIntentsForTests,
|
||||
expectPaneToAppear,
|
||||
expectPaneToClose,
|
||||
expectTabRowFields,
|
||||
holdTabPaneTree,
|
||||
@@ -9,23 +8,16 @@ import {
|
||||
onTabPaneTreeRedraw,
|
||||
panesLeftAfterPendingCloses,
|
||||
requestTabPaneTreeRedraw,
|
||||
takePaneAppearIntent,
|
||||
takePaneCloseIntent,
|
||||
takeTabRowFields
|
||||
} from './layout-owner-pane-intents'
|
||||
|
||||
const telemetry = { source: 'keyboard', direction: 'vertical' } as const
|
||||
|
||||
describe('layout-owner pane intents', () => {
|
||||
beforeEach(() => _resetLayoutOwnerPaneIntentsForTests())
|
||||
|
||||
it('hands an intent out once, and only for its own tab', () => {
|
||||
expectPaneToAppear('leaf-new', { tabId: 'tab-1', cwd: '/repo', telemetry })
|
||||
expectPaneToClose('tab-1', 'leaf-old', 'user')
|
||||
|
||||
expect(takePaneAppearIntent('tab-2', 'leaf-new')).toBeNull()
|
||||
expect(takePaneAppearIntent('tab-1', 'leaf-new')).toMatchObject({ cwd: '/repo' })
|
||||
expect(takePaneAppearIntent('tab-1', 'leaf-new')).toBeNull()
|
||||
expect(takePaneCloseIntent('tab-2', 'leaf-old')).toBeNull()
|
||||
expect(takePaneCloseIntent('tab-1', 'leaf-old')).toBe('user')
|
||||
expect(takePaneCloseIntent('tab-1', 'leaf-old')).toBeNull()
|
||||
@@ -42,11 +34,11 @@ describe('layout-owner pane intents', () => {
|
||||
|
||||
it('keeps only the newest intents', () => {
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
expectPaneToAppear(`leaf-${index}`, { tabId: 'tab-1', telemetry })
|
||||
expectPaneToClose('tab-1', `leaf-${index}`, 'exit')
|
||||
}
|
||||
|
||||
expect(takePaneAppearIntent('tab-1', 'leaf-0')).toBeNull()
|
||||
expect(takePaneAppearIntent('tab-1', 'leaf-64')).not.toBeNull()
|
||||
expect(takePaneCloseIntent('tab-1', 'leaf-0')).toBeNull()
|
||||
expect(takePaneCloseIntent('tab-1', 'leaf-64')).toBe('exit')
|
||||
})
|
||||
|
||||
it('hands out a new row’s fields once, until released', () => {
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
// What this window asked main to do to a tab's panes, in a layout-owner build: main's frames say
|
||||
// which panes a tab has, these say what the window wants done when one appears or goes (its
|
||||
// spawn inputs, its close, the selection of a tab it moved) and keep a tab's drawn tree while the
|
||||
// window's own command is in flight. A pane a frame adds or drops with no intent here is left to
|
||||
// main's notice to the window.
|
||||
// which panes a tab has, these say how the window wants a pane closed when one goes and the
|
||||
// selection of a tab it moved, and keep a tab's drawn tree while the window's own command is in
|
||||
// flight. A pane a frame drops with no intent here is left to main's notice to the window; every
|
||||
// pane a frame adds is mounted, and takes focus unless the window's own split asked to keep focus
|
||||
// where it was.
|
||||
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry'
|
||||
|
||||
export type PaneAppearIntent = {
|
||||
tabId: string
|
||||
cwd?: string
|
||||
cwdPromise?: Promise<string>
|
||||
telemetry: { source: TerminalPaneSplitSource; direction: 'vertical' | 'horizontal' }
|
||||
}
|
||||
|
||||
/** Intents outlive their frame only when a tab never mounts again; keep the newest few. */
|
||||
const MAX_INTENTS = 64
|
||||
|
||||
const appearing = new Map<string, PaneAppearIntent>()
|
||||
/** 'user': the user's close, with its status and launch clean-up. 'exit': its terminal ended. */
|
||||
export type PaneCloseKind = 'user' | 'exit'
|
||||
|
||||
@@ -31,24 +23,6 @@ function remember<T>(map: Map<string, T>, key: string, value: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function expectPaneToAppear(leafId: string, intent: PaneAppearIntent): () => void {
|
||||
remember(appearing, leafId, intent)
|
||||
return () => {
|
||||
if (appearing.get(leafId) === intent) {
|
||||
appearing.delete(leafId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function takePaneAppearIntent(tabId: string, leafId: string): PaneAppearIntent | null {
|
||||
const intent = appearing.get(leafId)
|
||||
if (intent?.tabId !== tabId) {
|
||||
return null
|
||||
}
|
||||
appearing.delete(leafId)
|
||||
return intent
|
||||
}
|
||||
|
||||
export function expectPaneToClose(tabId: string, leafId: string, kind: PaneCloseKind): () => void {
|
||||
const intent = { tabId, kind }
|
||||
remember(closing, leafId, intent)
|
||||
@@ -59,6 +33,26 @@ export function expectPaneToClose(tabId: string, leafId: string, kind: PaneClose
|
||||
}
|
||||
}
|
||||
|
||||
const unfocused = new Map<string, string>()
|
||||
|
||||
/** A split of the window's that leaves focus on its source pane (a tab's startup split). */
|
||||
export function expectPaneToMountUnfocused(tabId: string, leafId: string): () => void {
|
||||
remember(unfocused, leafId, tabId)
|
||||
return () => {
|
||||
if (unfocused.get(leafId) === tabId) {
|
||||
unfocused.delete(leafId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function takePaneMountUnfocused(tabId: string, leafId: string): boolean {
|
||||
if (unfocused.get(leafId) !== tabId) {
|
||||
return false
|
||||
}
|
||||
unfocused.delete(leafId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Of these panes, how many no close of this window's is about to take. */
|
||||
export function panesLeftAfterPendingCloses(tabId: string, leafIds: readonly string[]): number {
|
||||
return leafIds.filter((leafId) => closing.get(leafId)?.tabId !== tabId).length
|
||||
@@ -171,8 +165,8 @@ export function onTabPaneTreeRedraw(listener: (tabId: string) => void): () => vo
|
||||
}
|
||||
|
||||
export function _resetLayoutOwnerPaneIntentsForTests(): void {
|
||||
appearing.clear()
|
||||
closing.clear()
|
||||
unfocused.clear()
|
||||
rowFields.clear()
|
||||
movedTabSelections.clear()
|
||||
heldTabs.clear()
|
||||
|
||||
@@ -253,3 +253,68 @@ describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
|
||||
/** The frame with one pane's start status set, or cleared when `status` is absent. */
|
||||
function started(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
leafId: string,
|
||||
status?: 'starting' | 'failed',
|
||||
reason?: string
|
||||
): PublishedWorkspaceLayout {
|
||||
const next = structuredClone(layout)
|
||||
const pane = next.tabs
|
||||
.find((tab) => tab.entityId === 'tab-shell')!
|
||||
.terminal!.panes.find((candidate) => candidate.leafId === leafId)!
|
||||
delete pane.status
|
||||
delete pane.statusReason
|
||||
if (status) {
|
||||
pane.status = status
|
||||
}
|
||||
if (reason) {
|
||||
pane.statusReason = reason
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
describe('a frame that changes only a pane’s start status', () => {
|
||||
it('shows the status for that one pane, with no build and the pane tree untouched', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const layoutsBefore = store.getState().terminalLayoutsByTabId
|
||||
const unbound = rebound(first, 'tab-shell', leaf(1), undefined)
|
||||
await send(unbound)
|
||||
const layouts = store.getState().terminalLayoutsByTabId
|
||||
check.forgetBuilds()
|
||||
|
||||
await send(started(unbound, leaf(1), 'starting'))
|
||||
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({
|
||||
[`tab-shell:${leaf(1)}`]: { status: 'starting' }
|
||||
})
|
||||
await send(started(unbound, leaf(1), 'failed', 'shell not found'))
|
||||
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
// Same tree objects: the pane's view is not remounted.
|
||||
expect(store.getState().terminalLayoutsByTabId).toBe(layouts)
|
||||
expect(layouts['tab-shell']!.root).toBe(layoutsBefore['tab-shell']!.root)
|
||||
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({
|
||||
[`tab-shell:${leaf(1)}`]: { status: 'failed', reason: 'shell not found' }
|
||||
})
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('drops the status once main binds the pane', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const unbound = rebound(first, 'tab-shell', leaf(1), undefined)
|
||||
await send(started(unbound, leaf(1), 'starting'))
|
||||
expect(Object.keys(store.getState().terminalPaneStartsByPaneKey)).toEqual([
|
||||
`tab-shell:${leaf(1)}`
|
||||
])
|
||||
|
||||
await send(rebound(unbound, 'tab-shell', leaf(1), 'pty-fresh'))
|
||||
|
||||
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({})
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe(
|
||||
'pty-fresh'
|
||||
)
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal
|
||||
import type { AppState } from '@/store/types'
|
||||
import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
|
||||
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
|
||||
|
||||
@@ -70,3 +71,21 @@ export function terminalLayoutsWithFrameBindings(
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Every rebound workspace's bindings shown, and the workspaces only a build shows so. */
|
||||
export function terminalLayoutsWithReboundFrames(
|
||||
layouts: TerminalLayouts,
|
||||
cache: WorkspaceLayoutCache,
|
||||
rebound: ReadonlyMap<string, ReadonlySet<string>>
|
||||
): { layouts: TerminalLayouts; unshown: string[] } {
|
||||
const unshown: string[] = []
|
||||
for (const [key, tabIds] of rebound) {
|
||||
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
|
||||
if (next) {
|
||||
layouts = next
|
||||
} else {
|
||||
unshown.push(key)
|
||||
}
|
||||
}
|
||||
return { layouts, unshown }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Main's pane starts, as the window's store shows them: a status-only frame changes this map and no
|
||||
// pane tree, so the pane's view stays mounted.
|
||||
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { TerminalPaneStart } from '@/store/terminals/terminal-state'
|
||||
import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
|
||||
type PaneStarts = AppState['terminalPaneStartsByPaneKey']
|
||||
|
||||
function startsOf(layout: PublishedWorkspaceLayout | undefined): Map<string, TerminalPaneStart> {
|
||||
const starts = new Map<string, TerminalPaneStart>()
|
||||
for (const tab of layout?.tabs ?? []) {
|
||||
for (const pane of tab.terminal?.panes ?? []) {
|
||||
if (pane.status) {
|
||||
starts.set(paneKeyOf(tab.entityId, pane.leafId), {
|
||||
status: pane.status,
|
||||
...(pane.statusReason ? { reason: pane.statusReason } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
const sameStart = (a: TerminalPaneStart | undefined, b: TerminalPaneStart): boolean =>
|
||||
a?.status === b.status && a.reason === b.reason
|
||||
|
||||
/**
|
||||
* `current` with each of `keys`' panes showing what its workspace's frame says, the same object
|
||||
* when nothing changed. `shownByKey` holds the pane keys each workspace last showed, so a start
|
||||
* that ended (the pane bound, or left) drops from the map; it is updated in place.
|
||||
*/
|
||||
export function paneStartsWithFrames(
|
||||
current: PaneStarts,
|
||||
keys: Iterable<string>,
|
||||
frames: Readonly<Record<string, PublishedWorkspaceLayout | undefined>>,
|
||||
shownByKey: Map<string, ReadonlySet<string>>
|
||||
): PaneStarts {
|
||||
let next = current
|
||||
const write = (): PaneStarts => (next === current ? { ...current } : next)
|
||||
for (const key of keys) {
|
||||
const now = startsOf(frames[key])
|
||||
for (const paneKey of shownByKey.get(key) ?? []) {
|
||||
if (!now.has(paneKey) && paneKey in next) {
|
||||
next = write()
|
||||
delete next[paneKey]
|
||||
}
|
||||
}
|
||||
for (const [paneKey, start] of now) {
|
||||
if (!sameStart(next[paneKey], start)) {
|
||||
next = write()
|
||||
next[paneKey] = start
|
||||
}
|
||||
}
|
||||
if (now.size > 0) {
|
||||
shownByKey.set(key, new Set(now.keys()))
|
||||
} else {
|
||||
shownByKey.delete(key)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Shows the starts of the `changed` workspaces, and drops those of workspaces main stopped sending. */
|
||||
export function showFramePaneStarts(
|
||||
store: { getState: () => AppState; setState: (patch: Partial<AppState>) => void },
|
||||
changed: readonly string[],
|
||||
cache: Readonly<Record<string, PublishedWorkspaceLayout | undefined>>,
|
||||
shownByKey: Map<string, ReadonlySet<string>>
|
||||
): void {
|
||||
const starts = store.getState().terminalPaneStartsByPaneKey
|
||||
const gone = [...shownByKey.keys()].filter((key) => !(key in cache))
|
||||
const next = paneStartsWithFrames(starts, [...changed, ...gone], cache, shownByKey)
|
||||
if (next !== starts) {
|
||||
store.setState({ terminalPaneStartsByPaneKey: next })
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { liveTitleUpdates } from './workspace-layout-frame-changes'
|
||||
import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings'
|
||||
import { terminalLayoutsWithReboundFrames } from './workspace-layout-frame-bindings'
|
||||
import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees'
|
||||
import { showFramePaneStarts } from './workspace-layout-frame-pane-starts'
|
||||
import {
|
||||
fullLayoutFrameBuildPatch,
|
||||
workspaceLayoutFrameBuildPatch,
|
||||
@@ -93,6 +94,7 @@ export function openWorkspaceLayoutFrameSync(
|
||||
/** Workspaces whose pane trees the store may still lack: a build ran for them, or a tab the
|
||||
* window's own command held skipped one. */
|
||||
let paneTreeKeys = new Set<string>()
|
||||
const paneStartsShown = new Map<string, ReadonlySet<string>>()
|
||||
|
||||
/** The moved tabs' selections this flush's writes took. */
|
||||
let selectionsTaken: MovedTabSelection[] = []
|
||||
@@ -134,18 +136,13 @@ export function openWorkspaceLayoutFrameSync(
|
||||
// Only panes' bindings changed: the pane trees take them, unless only a build shows them so.
|
||||
const showFrameBindings = (rebound: ReadonlyMap<string, ReadonlySet<string>>): string[] => {
|
||||
const state = store.getState()
|
||||
let layouts = state.terminalLayoutsByTabId
|
||||
const unshown: string[] = []
|
||||
for (const [key, tabIds] of rebound) {
|
||||
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
|
||||
if (next) {
|
||||
layouts = next
|
||||
} else {
|
||||
unshown.push(key)
|
||||
}
|
||||
}
|
||||
show(terminalLayoutsPatch(state, layouts))
|
||||
return unshown
|
||||
const shownBindings = terminalLayoutsWithReboundFrames(
|
||||
state.terminalLayoutsByTabId,
|
||||
cache,
|
||||
rebound
|
||||
)
|
||||
show(terminalLayoutsPatch(state, shownBindings.layouts))
|
||||
return shownBindings.unshown
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
@@ -222,6 +219,7 @@ export function openWorkspaceLayoutFrameSync(
|
||||
if (titleUpdates.length > 0) {
|
||||
store.getState().updateTabTitles(titleUpdates)
|
||||
}
|
||||
showFramePaneStarts(store, changed, cache, paneStartsShown)
|
||||
for (const key of [...fullBuilt, ...workspaceBuilt]) {
|
||||
shown.add(key)
|
||||
// Against the store, not the frame: a held tab's tree may lag the pane trees drawn since.
|
||||
|
||||
@@ -39,6 +39,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
expandedPaneByTabId: {},
|
||||
canExpandPaneByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPaneStartsByPaneKey: {},
|
||||
localOnlyScrollbackByTabId: {},
|
||||
pendingStartupByTabId: {},
|
||||
pendingInitialCwdByTabId: {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ClosedTerminalTabTombstonesByTabId } from '../../../../shared/closed-terminal-tab-tombstones'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { PaneStartStatus } from '../../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type {
|
||||
AgentProviderSessionMetadata,
|
||||
@@ -38,6 +39,9 @@ export type TerminalState = {
|
||||
expandedPaneByTabId: Record<string, boolean>
|
||||
canExpandPaneByTabId: Record<string, boolean>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
/** Main's start of a pane's terminal, by pane key, as its layout frames publish it (layout-owner
|
||||
* build). Live state, never saved: a pane with an entry has no terminal of its own yet. */
|
||||
terminalPaneStartsByPaneKey: Record<string, TerminalPaneStart>
|
||||
/** Ordinary-park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see
|
||||
* WorkspaceSessionState.localOnlyScrollbackByTabId. Read via resolveLeafScrollbackBuffers only. */
|
||||
localOnlyScrollbackByTabId: Record<string, Record<string, string>>
|
||||
@@ -145,3 +149,5 @@ export type TerminalSlice = TerminalState & TerminalActions & PairedTerminalTabC
|
||||
|
||||
export type TerminalStoreSet = Parameters<StateCreator<AppState, [], [], AppState>>[0]
|
||||
export type TerminalStoreGet = Parameters<StateCreator<AppState, [], [], AppState>>[1]
|
||||
|
||||
export type TerminalPaneStart = { status: PaneStartStatus; reason?: string }
|
||||
|
||||
@@ -236,16 +236,41 @@ export const TerminalCreateParams = z.object({
|
||||
shell: WindowsShellOverrideParam.optional()
|
||||
})
|
||||
|
||||
export const TerminalSplit = TerminalHandle.extend({
|
||||
direction: z
|
||||
.unknown()
|
||||
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
|
||||
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
|
||||
.optional(),
|
||||
command: OptionalString,
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
|
||||
})
|
||||
export const TerminalSplit = z
|
||||
.object({
|
||||
// Required unless `pane` names the source; the refinement keeps the handle's message.
|
||||
terminal: OptionalString,
|
||||
expectedIncarnationId: TerminalHandle.shape.expectedIncarnationId,
|
||||
direction: z
|
||||
.unknown()
|
||||
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
|
||||
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
|
||||
.optional(),
|
||||
command: OptionalString,
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional(),
|
||||
// Layout-owner build: a window names its own pane; main writes the split and starts it.
|
||||
pane: z
|
||||
.object({
|
||||
worktreeId: requiredString('Missing worktree'),
|
||||
tabId: requiredString('Missing tab'),
|
||||
leafId: requiredString('Missing pane')
|
||||
})
|
||||
.optional(),
|
||||
newLeafId: OptionalString,
|
||||
cwd: OptionalString,
|
||||
initialSize: z
|
||||
.object({
|
||||
cols: z.number().int().min(1).max(1000),
|
||||
rows: z.number().int().min(1).max(500)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if (!params.terminal && !params.pane) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Missing terminal handle', path: ['terminal'] })
|
||||
}
|
||||
})
|
||||
|
||||
export const TerminalStop = z.object({
|
||||
worktree: requiredString('Missing worktree selector')
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
readPaneIdentitySnapshot,
|
||||
resolveActiveTabId
|
||||
} from './terminal-pane-identity'
|
||||
import { REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } from '../../../src/renderer/src/constants/terminal'
|
||||
import {
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT,
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT
|
||||
} from '../../../src/renderer/src/constants/terminal'
|
||||
|
||||
export async function readTerminalPaneDomLeafOrder(page: Page): Promise<string[]> {
|
||||
const snapshot = await readPaneIdentitySnapshot(page)
|
||||
@@ -99,25 +102,16 @@ export async function splitActiveTerminalPane(
|
||||
throw new Error('splitActiveTerminalPane: no active terminal tab')
|
||||
}
|
||||
await page.evaluate(
|
||||
({ tabId, direction }) => {
|
||||
const paneManagers = window.__paneManagers
|
||||
if (!paneManagers) {
|
||||
throw new Error('splitActiveTerminalPane: terminal store/manager unavailable')
|
||||
}
|
||||
|
||||
const manager = paneManagers.get(tabId)
|
||||
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!manager?.splitPane || !activePane) {
|
||||
({ tabId, direction, splitEvent }) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager?.getActivePane?.() && !manager?.getPanes?.()[0]) {
|
||||
throw new Error('splitActiveTerminalPane: active pane manager not ready')
|
||||
}
|
||||
|
||||
// Why: Electron key delivery to the terminal pane layer is flaky in E2E
|
||||
// even when the visible pane tree is mounted. Driving the active
|
||||
// PaneManager directly still exercises the real split/layout/PTY path
|
||||
// without depending on window-focus timing.
|
||||
manager.splitPane(activePane.id, direction)
|
||||
// Why: Electron key delivery to the terminal pane layer is flaky in E2E. The pane menu's
|
||||
// split request takes the user's split path, which a layout-owner build sends to main.
|
||||
window.dispatchEvent(new CustomEvent(splitEvent, { detail: { tabId, direction } }))
|
||||
},
|
||||
{ tabId, direction }
|
||||
{ tabId, direction, splitEvent: REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,96 +88,20 @@ async function handleFor(
|
||||
return handle
|
||||
}
|
||||
|
||||
// Main closes the split's tab while the window is too busy to mount the split: the CLI hears so
|
||||
// once the close lands, not after its 10 s wait.
|
||||
test('a CLI split whose tab closes before it mounts answers promptly', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const client = new RuntimeClient(userDataDir, 30_000)
|
||||
const tabId = await createActiveTab(orcaPage, worktreeId)
|
||||
const source = await handleFor(client, worktreeId, tabId)
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
|
||||
await orcaPage.evaluate(() => {
|
||||
window.addEventListener(
|
||||
'orca-background-mount-terminal-worktree',
|
||||
() => {
|
||||
const until = performance.now() + 2_500
|
||||
while (performance.now() < until) {
|
||||
// Busy renderer.
|
||||
}
|
||||
},
|
||||
{ once: true, capture: true }
|
||||
)
|
||||
})
|
||||
const startedAt = Date.now()
|
||||
const split = runCliSplit(userDataDir, source, 'echo never')
|
||||
// The CLI starts and main writes the pane while the window stalls on the split's mount.
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const closeStartedAt = Date.now()
|
||||
const closed = await client.call('terminal.closeTab', { terminal: source }).then(
|
||||
() => 'ok',
|
||||
(error: unknown) => String(error)
|
||||
)
|
||||
const outcome = await split
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const report = { closed, closeAfterMs: closeStartedAt - startedAt, elapsedMs, outcome }
|
||||
await testInfo.attach('split-closed-tab.json', {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
expect(outcome.ok).toBe(false)
|
||||
// Failed because of the close, not some earlier CLI failure.
|
||||
expect(outcome.ok ? '' : outcome.error).toContain('split_pane_closed')
|
||||
expect(elapsedMs).toBeLessThan(6_000)
|
||||
})
|
||||
/** Keeps the renderer busy for `ms`, as a window too busy to mount anything. */
|
||||
async function stallRenderer(page: Page, ms: number): Promise<void> {
|
||||
await page.evaluate((stallMs) => {
|
||||
setTimeout(() => {
|
||||
const until = performance.now() + stallMs
|
||||
while (performance.now() < until) {
|
||||
// Busy renderer.
|
||||
}
|
||||
}, 0)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
// The window is too busy to start the split's terminal within the CLI's 10 s: main takes the
|
||||
// pane back, so the CLI's failure is the truth and the startup command never runs.
|
||||
test('a CLI split whose terminal does not start within 10 s leaves no pane', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const client = new RuntimeClient(userDataDir, 30_000)
|
||||
const tabId = await createActiveTab(orcaPage, worktreeId)
|
||||
const source = await handleFor(client, worktreeId, tabId)
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
|
||||
await orcaPage.evaluate(() => {
|
||||
window.addEventListener(
|
||||
'orca-background-mount-terminal-worktree',
|
||||
() => {
|
||||
const until = performance.now() + 11_000
|
||||
while (performance.now() < until) {
|
||||
// Busy renderer.
|
||||
}
|
||||
},
|
||||
{ once: true, capture: true }
|
||||
)
|
||||
})
|
||||
// A file the command writes: a terminal that ran it and was then killed still leaves it.
|
||||
const marker = testInfo.outputPath(`split-ran-${Date.now()}`)
|
||||
mkdirSync(path.dirname(marker), { recursive: true })
|
||||
const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`)
|
||||
// Past the stall: the window has handled the split and main's take-back.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
const tabTerminals = await client
|
||||
.call<RuntimeTerminalListResult>('terminal.list', { worktree: `id:${worktreeId}`, limit: 200 })
|
||||
.then((listed) => listed.result.terminals.filter((t) => t.tabId === tabId))
|
||||
const windowLeafIds = await orcaPage.evaluate((id) => {
|
||||
function windowLeafIdsOf(page: Page, tabId: string): Promise<string[]> {
|
||||
return page.evaluate((id) => {
|
||||
const collect = (node: unknown): string[] => {
|
||||
if (typeof node !== 'object' || node === null) {
|
||||
return []
|
||||
@@ -192,15 +116,75 @@ test('a CLI split whose terminal does not start within 10 s leaves no pane', asy
|
||||
}
|
||||
return collect(window.__store?.getState().terminalLayoutsByTabId[id]?.root)
|
||||
}, tabId)
|
||||
const report = { outcome, tabTerminals: tabTerminals.map((t) => t.leafId), windowLeafIds }
|
||||
await testInfo.attach('split-taken-back.json', {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
}
|
||||
|
||||
// Main writes the split and starts its terminal itself, so a busy window never holds the CLI up.
|
||||
test('a CLI split of a parked tab answers promptly while the window is busy', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const client = new RuntimeClient(userDataDir, 30_000)
|
||||
const tabId = await createActiveTab(orcaPage, worktreeId)
|
||||
const source = await handleFor(client, worktreeId, tabId)
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
|
||||
await stallRenderer(orcaPage, 8_000)
|
||||
const startedAt = Date.now()
|
||||
const outcome = await runCliSplit(userDataDir, source, 'echo started-by-main')
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
await testInfo.attach('split-busy-window.json', {
|
||||
body: JSON.stringify({ elapsedMs, outcome }, null, 2),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
expect(outcome.ok ? '' : outcome.error).toContain(
|
||||
"split_pane_not_started: the new pane's terminal did not start within 10 s, so the pane was closed and its command was not run"
|
||||
)
|
||||
expect(tabTerminals).toHaveLength(1)
|
||||
expect(windowLeafIds).toEqual([tabTerminals[0]!.leafId])
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
expect(outcome).toEqual({ ok: true })
|
||||
expect(elapsedMs).toBeLessThan(6_000)
|
||||
})
|
||||
|
||||
// The window is stalled past the old 10 s wait: main still starts the split's terminal, its
|
||||
// command runs, and the window shows the pane from main's layout once it is free.
|
||||
test('a CLI split runs its command while the window is stalled, and the window shows the pane', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const client = new RuntimeClient(userDataDir, 30_000)
|
||||
const tabId = await createActiveTab(orcaPage, worktreeId)
|
||||
const source = await handleFor(client, worktreeId, tabId)
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
|
||||
await stallRenderer(orcaPage, 11_000)
|
||||
const marker = testInfo.outputPath(`split-ran-${Date.now()}`)
|
||||
mkdirSync(path.dirname(marker), { recursive: true })
|
||||
const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`)
|
||||
expect(outcome).toEqual({ ok: true })
|
||||
await expect.poll(() => existsSync(marker), { timeout: 30_000 }).toBe(true)
|
||||
let tabLeafIds: string[] = []
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const listed = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
limit: 200
|
||||
})
|
||||
tabLeafIds = listed.result.terminals
|
||||
.filter((t) => t.tabId === tabId)
|
||||
.map((t) => t.leafId)
|
||||
.sort()
|
||||
return tabLeafIds.length
|
||||
})
|
||||
.toBe(2)
|
||||
// Past the stall, the window holds both panes main has.
|
||||
await expect
|
||||
.poll(async () => (await windowLeafIdsOf(orcaPage, tabId)).sort(), { timeout: 30_000 })
|
||||
.toEqual(tabLeafIds)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user