mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Drag a pane out to a new tab through main (dark, build switch off by default) (#29351)
* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the window's layout command to the stored partition through the shared applyLayoutCommand, minting new ids. A user split then waits for main's answer and mounts the pane id main minted. Every other build substitutes 'window', so neither branch is compiled in. * fix(layout): write only the records a layout.apply command touched A load-then-save rewrite also normalizes records the command never touched. The command's effect is now the diff between the save before and after it, written onto the stored partition record by record. A split whose main reply says a paired server holds the tab splits as today. * fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result * feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER In a layout-owner build the window's pane close, title change and equalize go through layout.apply and the window shows main's answer. A window pane close keeps the legacy close's rules: the tab's last pane is refused, a pane restarted since main read the close is left alone, every partition holding the tab closes it, and the tab's remote session id follows the remaining pane. The rules sit behind a command field only layout.apply sets. * fix(layout): close the tab when a racing pane close leaves the window one pane * fix(layout): leave an unmounted tab alone when main refuses its last pane * feat(layout): move panes and save divider ratios through main behind ORCA_LAYOUT_OWNER Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): a taken pane drop whose pane closed does nothing Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): pin the window's pane move to main's saved tree, and the drops main cannot take Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * fix(layout): close a user's pane even if its terminal restarted, as today Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): pin the restarted-pane close directly, not through a made-up profile state Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * fix(layout): leave a split to today's path only when the workspace's row names a paired server The split switch point asked which host to route to with a helper that falls back to the focused server, so a focused server could claim a workspace whose row names no owner. Use the same owner test the tab mirror uses. * test(layout): type the split owner mock without a cast * test(layout): type the layout-owner command mocks with the real signature * test(layout): type the gesture command mock with the real signature * fix(layout): close the dark path's gaps: unmounted split panes, refused or failed splits and moves, ratios during a move, release guard - A pane main saved but the window could not mount (source pane or manager gone during the await) is closed again in main, so it does not come back on restart. - A split main refuses because its copy lacks the workspace, tab or pane, or a failed call, splits as today; a refused or failed move moves as today. - Divider ratios committed while a move is in flight wait for the window's move, then go to main. - The orcad bundle gets the ORCA_LAYOUT_OWNER define when the switch is on. - Packaging and the orcad template refuse ORCA_LAYOUT_OWNER=main and any output a dark build marked. - workspace-layout-record-patch.ts: no non-null assertions; plain comment for the map depth. * test(layout): count a split pane as mounted from the split mock's own results * fix(layout): act as today whenever main lacks the target or the call throws, not just for splits - sendPaneCommandToLayoutOwner runs asToday for workspace/tab/pane_not_found and a thrown call, so close, rename and equalize fall back like split and move; a handler that throws no longer runs the fallback too. - A close main refuses as its last pane, while this window still has others, closes as today. - A move whose local step throws still releases held divider ratios. - Drop the orcad output marker: nothing read it; the template build refuses the switch itself. * test(layout): plain names for the last-pane fallback test and the marker comment * docs(layout): say plainly what an indeterminate write can leave behind * fix(layout): finish the main merge with this branch's versions of the layout-owner files * test(build): copy the layout-owner build guard into the compiler-cache checkout fixture * feat(layout): send tab rename, color and pin through main behind the layout-owner build switch In an ORCA_LAYOUT_OWNER=main build, the tab bar's rename, color and pin ask main's layout.apply first; the window then runs today's action. When main lacks the tab or the call fails the window acts as today; a paired server's workspace keeps today's path. Pinning a tab now also ends its preview in main's copy, as the tab bar's pin does. Other builds compile the switch out. * fix(layout): leave a pinned tab's preview mark to the window until main opens files Changing the shared pin command would change what a paired client's pin does in every build. * fix(layout): write a folded legacy row in its loaded form when a layout.apply command touches its claim A legacy terminal row the load folds into a tab-bar tab stays stored as is. Closing the tab it repeats, or giving it a tab-bar entry by moving it, let the next load resolve the claim the other way: the row came back or took the other tab's terminal. Such a command now writes the row as the load had it; every other record still stays as stored. * feat(layout): accept tab moves, group splits and group sizes on layout.apply Group sizes have no tab, so they go to the workspace's routed partition. * refactor(layout): move the window's layout.apply sender out of the terminal pane folder Tab groups send through it too; it now also acts as today when main lacks the group. * feat(layout): reorder, move and split tab groups, and save group sizes, through main behind ORCA_LAYOUT_OWNER A tab drop and Move Tab to Split wait for main's answer and then show main's placement, with main's new group id; group divider sizes are drawn while dragging and saved by main when the drag ends. Closing an empty group needs no command: main never holds an empty group. * docs(layout): wrap the layout-owner sender's doc comment * fix(layout): review fixes for tab moves through main - Read a folded row's terminals with the loader's own claim rule, so rows sharing only an SSH session are covered too, and drop the row's remote session entry with it. - The record-patch test allows a folded row only in its loaded form. - A tab drag ends at once; main's answer shows the move after. - A lone group main moved whole is placed so only when this window's group is lone too. * refactor(layout): adopt only a folded row's pane layout beside its row A row's remote session entry is carried through load and save unchanged, so adopting it from the load changed nothing (the earlier commit's message overstated it). * feat(layout): record a window's terminal tab close through layout.apply closeTabs can now record each terminal tab it closes, or does not find, as closed for the given reason (pruned as a partition's records are), so a start that commits later is refused, as today's close records it. layout.apply takes one tab's close with the window's reason: main forces it in every holder partition, marks a user close's launches, stops no terminal (the window already did, sparing any another tab shares), and leaves a workspace it lacks to today's close. A sibling the load supplied that the close only renumbered is no longer written. * feat(layout): send the window's terminal tab close to main behind ORCA_LAYOUT_OWNER The window still closes the tab and stops its terminals itself; main records the close through layout.apply instead of the close intent, which stays for paired servers and when main lacks the workspace. * fix(layout): review fixes for the tab close through main - The window's close sends through a store-free sender, so the store no longer imports itself. - Main marks a user close's launches by the terminal's own id. - A recorded close prunes the whole partition's close records, as today, not one workspace's. * feat(layout): move a window's dragged-out pane into a new tab through layout.apply behind ORCA_LAYOUT_OWNER * refactor(layout): keep the topology commit under its line limit * fix(layout): review fixes for the window's pane drag-out through main * refactor(layout): re-key a moved pane from the move alone
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { getPtyIpc } from '../../pty-host-bindings'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { agentHookServer } from '../../../agent-hooks/server'
|
||||
import { forgetFailedAgentLaunches } from '../../../agent-launch/agent-launch-pane-attachment'
|
||||
import { rekeyMovedPaneOwners } from '../../../runtime/moved-pane-owner-rekey'
|
||||
import {
|
||||
isTerminalLeafMoveRequest,
|
||||
terminalLeafMovePaneKeys,
|
||||
type TerminalLeafMoveRequest,
|
||||
type TerminalLeafMoveResult
|
||||
} from '../../../../shared/terminal-leaf-move'
|
||||
@@ -22,32 +20,7 @@ export async function commitLeafMoveAndRekey(
|
||||
if (result.status !== 'moved') {
|
||||
return result
|
||||
}
|
||||
const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request)
|
||||
forgetFailedAgentLaunches(request.worktreeId, {
|
||||
kind: 'pane',
|
||||
tabId: request.sourceTabId,
|
||||
leafId: request.leafId
|
||||
})
|
||||
try {
|
||||
// The process keeps the pane key baked into its env, so status must alias old to new.
|
||||
agentHookServer.transferPaneAuthority(
|
||||
fromPaneKey,
|
||||
toPaneKey,
|
||||
result.ptyId ?? undefined,
|
||||
Date.now(),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[pty] moved pane kept its old agent-status key:', error)
|
||||
}
|
||||
try {
|
||||
deps.runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({
|
||||
fromPaneKey,
|
||||
toPaneKey
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[pty] moved pane kept its old orchestration resource key:', error)
|
||||
}
|
||||
rekeyMovedPaneOwners(deps.runtime, { ...request, ptyId: result.ptyId })
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -31,3 +31,32 @@ export function followRemoteSessionAfterPaneClose(
|
||||
}
|
||||
return { ...written, remoteSessionIdsByTabId: next }
|
||||
}
|
||||
|
||||
/** A window's drag-out: the source tab's remote session id follows the moved pane's terminal. */
|
||||
export function followRemoteSessionAfterPaneDetach(
|
||||
command: LayoutCommand,
|
||||
written: WorkspaceSessionState
|
||||
): WorkspaceSessionState {
|
||||
const targetTabId = command.type === 'movePaneToNewTab' && command.windowDetach?.tabId
|
||||
const remoteSessionIds = written.remoteSessionIdsByTabId
|
||||
if (!targetTabId || !remoteSessionIds) {
|
||||
return written
|
||||
}
|
||||
const sourceTabId =
|
||||
written.unifiedTabs?.[command.workspace]?.find((entry) => entry.id === command.tabId)
|
||||
?.entityId ?? command.tabId
|
||||
const ptyId = written.terminalLayoutsByTabId[targetTabId]?.ptyIdsByLeafId?.[command.leafId]
|
||||
if (!ptyId || remoteSessionIds[sourceTabId] !== ptyId) {
|
||||
return written
|
||||
}
|
||||
const remaining = Object.values(
|
||||
written.terminalLayoutsByTabId[sourceTabId]?.ptyIdsByLeafId ?? {}
|
||||
)[0]
|
||||
const next = { ...remoteSessionIds, [targetTabId]: ptyId }
|
||||
if (remaining) {
|
||||
next[sourceTabId] = remaining
|
||||
} else {
|
||||
delete next[sourceTabId]
|
||||
}
|
||||
return { ...written, remoteSessionIdsByTabId: next }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */
|
||||
export function writeRestorable<V>(read: () => V, write: (value: V) => void, next: V): () => void {
|
||||
const prior = read()
|
||||
write(next)
|
||||
return () => {
|
||||
if (read() === next) {
|
||||
write(prior)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
|
||||
import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement'
|
||||
import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields'
|
||||
import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization'
|
||||
import { writeRestorable } from './restorable-write'
|
||||
import type { TerminalTopologyCommitContext } from './terminal-topology-commit'
|
||||
import {
|
||||
advanceTerminalTopologyRevision,
|
||||
isTerminalOwnerPartition,
|
||||
@@ -52,6 +54,21 @@ function hasTabId(session: WorkspaceSessionState, tabId: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** A tab other than the source holds the leaf; a layout left behind by a removed row owns nothing. */
|
||||
export function leafHeldByOtherTab(
|
||||
session: WorkspaceSessionState,
|
||||
sourceTabId: string,
|
||||
leafId: string
|
||||
): boolean {
|
||||
const tabIds = liveTabIds(session)
|
||||
return Object.entries(session.terminalLayoutsByTabId ?? {}).some(
|
||||
([tabId, layout]) =>
|
||||
tabId !== sourceTabId &&
|
||||
tabIds.has(tabId) &&
|
||||
layoutContainsLeafId(layout?.root ?? null, leafId)
|
||||
)
|
||||
}
|
||||
|
||||
type SourceHolder = TerminalSessionPartition & {
|
||||
sourceTab: TerminalTab
|
||||
sourceLayout: TerminalLayoutSnapshot
|
||||
@@ -154,17 +171,7 @@ export function planTerminalLeafMove(
|
||||
if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) {
|
||||
return refuse('target_tab_exists')
|
||||
}
|
||||
// A layout left behind by a removed tab row owns nothing.
|
||||
const leafElsewhere = owners.some(({ session }) => {
|
||||
const tabIds = liveTabIds(session)
|
||||
return Object.entries(session.terminalLayoutsByTabId ?? {}).some(
|
||||
([tabId, layout]) =>
|
||||
tabId !== sourceTabId &&
|
||||
tabIds.has(tabId) &&
|
||||
layoutContainsLeafId(layout?.root ?? null, leafId)
|
||||
)
|
||||
})
|
||||
if (leafElsewhere) {
|
||||
if (owners.some(({ session }) => leafHeldByOtherTab(session, sourceTabId, leafId))) {
|
||||
return refuse('leaf_in_other_tab')
|
||||
}
|
||||
const holders = owners.flatMap(({ hostId, session }) => {
|
||||
@@ -232,3 +239,34 @@ export function rekeyMovedLeafProfileRecords(
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Pane-keyed UI marks and SSH leases follow a moved leaf; returns their restores. */
|
||||
export function writeMovedLeafProfileRecords(
|
||||
request: TerminalLeafMoveRequest,
|
||||
context: TerminalTopologyCommitContext
|
||||
): (() => void)[] {
|
||||
const { state } = context
|
||||
const restores: (() => void)[] = []
|
||||
const rekeyed = rekeyMovedLeafProfileRecords(state, request)
|
||||
if (rekeyed.ui) {
|
||||
restores.push(
|
||||
writeRestorable(
|
||||
() => state.ui,
|
||||
(ui) => (state.ui = ui),
|
||||
rekeyed.ui
|
||||
)
|
||||
)
|
||||
context.markDirty('ui')
|
||||
}
|
||||
if (rekeyed.sshRemotePtyLeases) {
|
||||
restores.push(
|
||||
writeRestorable(
|
||||
() => state.sshRemotePtyLeases,
|
||||
(leases) => (state.sshRemotePtyLeases = leases),
|
||||
rekeyed.sshRemotePtyLeases
|
||||
)
|
||||
)
|
||||
context.markDirty('sshRemotePtyLeases')
|
||||
}
|
||||
return restores
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
} from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
|
||||
import { patchStoredSession } from '../../../shared/workspace-layout/workspace-layout-record-patch'
|
||||
import { followRemoteSessionAfterPaneClose } from './pane-close-remote-session'
|
||||
import {
|
||||
followRemoteSessionAfterPaneClose,
|
||||
followRemoteSessionAfterPaneDetach
|
||||
} from './pane-remote-session-follow'
|
||||
import { pruneClosedTerminalTabTombstones } from '../../../shared/closed-terminal-tab-tombstones'
|
||||
|
||||
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
|
||||
@@ -77,7 +80,10 @@ export function applyCommandToSession(
|
||||
const patched = patchStoredSession(session, saveWorkspaceLayout(loaded), after)
|
||||
return patched === session
|
||||
? null
|
||||
: followRemoteSessionAfterPaneClose(command, session, prunedOnRecord(command, patched))
|
||||
: followRemoteSessionAfterPaneDetach(
|
||||
command,
|
||||
followRemoteSessionAfterPaneClose(command, session, prunedOnRecord(command, patched))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ import type {
|
||||
StoreRuntimeState
|
||||
} from '../loading-store/store-runtime-state'
|
||||
import type { SessionHostPartitionOperations } from '../loading-store/session-host-partitions'
|
||||
import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move'
|
||||
import { planTerminalLeafMove, writeMovedLeafProfileRecords } from './terminal-leaf-move'
|
||||
import { writeRestorable } from './restorable-write'
|
||||
import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
|
||||
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
|
||||
import { resolveWindowPaneDetach, type WindowPaneDetachCommand } from './window-pane-detach'
|
||||
|
||||
// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and
|
||||
// the pane move; the close transform still lives in runtime/ and other writers move here later.
|
||||
@@ -118,7 +120,14 @@ export function applyLayout(
|
||||
): () => DurableProfileStateMutation<LayoutApplyOutcome> {
|
||||
return traced(
|
||||
'apply_layout',
|
||||
() => commitLayoutCommand(hostIds, command, context),
|
||||
() =>
|
||||
command.type === 'movePaneToNewTab' && command.windowDetach
|
||||
? commitWindowPaneDetach(
|
||||
hostIds,
|
||||
{ ...command, windowDetach: command.windowDetach },
|
||||
context
|
||||
)
|
||||
: commitLayoutCommand(hostIds, command, context),
|
||||
(outcome) => (outcome.ok ? undefined : outcome.code)
|
||||
)
|
||||
}
|
||||
@@ -190,7 +199,6 @@ export function topologyCommitContext({
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */
|
||||
// A paused WSL move keeps its local rows as the quiesce left them.
|
||||
function keepFrozen(
|
||||
hostId: ExecutionHostId,
|
||||
@@ -202,16 +210,6 @@ function keepFrozen(
|
||||
: session
|
||||
}
|
||||
|
||||
function writeRestorable<V>(read: () => V, write: (value: V) => void, next: V): () => void {
|
||||
const prior = read()
|
||||
write(next)
|
||||
return () => {
|
||||
if (read() === next) {
|
||||
write(prior)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commitLeafMove(
|
||||
request: TerminalLeafMoveRequest,
|
||||
context: TerminalTopologyCommitContext
|
||||
@@ -231,33 +229,50 @@ function commitLeafMove(
|
||||
keepFrozen(hostId, session, context)
|
||||
)
|
||||
)
|
||||
const rekeyed = rekeyMovedLeafProfileRecords(state, request)
|
||||
if (rekeyed.ui) {
|
||||
restores.push(
|
||||
writeRestorable(
|
||||
() => state.ui,
|
||||
(ui) => (state.ui = ui),
|
||||
rekeyed.ui
|
||||
)
|
||||
)
|
||||
context.markDirty('ui')
|
||||
}
|
||||
if (rekeyed.sshRemotePtyLeases) {
|
||||
restores.push(
|
||||
writeRestorable(
|
||||
() => state.sshRemotePtyLeases,
|
||||
(leases) => (state.sshRemotePtyLeases = leases),
|
||||
rekeyed.sshRemotePtyLeases
|
||||
)
|
||||
)
|
||||
context.markDirty('sshRemotePtyLeases')
|
||||
}
|
||||
restores.push(...writeMovedLeafProfileRecords(request, context))
|
||||
return {
|
||||
value: planned.result,
|
||||
rollback: () => restores.forEach((restore) => restore())
|
||||
}
|
||||
}
|
||||
|
||||
/** A window's drag-out: checked across every partition, then moved in each holder as one write. */
|
||||
function commitWindowPaneDetach(
|
||||
hostIds: readonly ExecutionHostId[],
|
||||
command: WindowPaneDetachCommand,
|
||||
context: TerminalTopologyCommitContext
|
||||
): DurableProfileStateMutation<LayoutApplyOutcome> {
|
||||
const resolved = resolveWindowPaneDetach(
|
||||
context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })),
|
||||
hostIds,
|
||||
command
|
||||
)
|
||||
if (resolved && 'code' in resolved) {
|
||||
return { value: { ok: false, code: resolved.code }, persist: false }
|
||||
}
|
||||
const committed = commitLayoutCommand(hostIds, resolved?.command ?? command, context)
|
||||
if (!resolved || !committed.value.ok || committed.persist === false) {
|
||||
return committed
|
||||
}
|
||||
const restores = writeMovedLeafProfileRecords(
|
||||
{
|
||||
worktreeId: command.workspace,
|
||||
sourceTabId: resolved.sourceEntityId,
|
||||
targetTabId: resolved.command.windowDetach.tabId,
|
||||
leafId: command.leafId,
|
||||
ptyId: resolved.command.windowDetach.ptyId
|
||||
},
|
||||
context
|
||||
)
|
||||
return {
|
||||
value: committed.value,
|
||||
rollback: () => {
|
||||
restores.forEach((restore) => restore())
|
||||
committed.rollback?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commitLayoutCommand(
|
||||
hostIds: readonly ExecutionHostId[],
|
||||
command: LayoutCommand,
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { makeRepo } from '../../persistence-test-harness'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import {
|
||||
closeMoveTestStores,
|
||||
FROM,
|
||||
LEFT,
|
||||
MOVED,
|
||||
newDataFile,
|
||||
openStore,
|
||||
ownersOf,
|
||||
seedSplitSource,
|
||||
sleeping,
|
||||
SOURCE,
|
||||
TARGET,
|
||||
tabsHoldingLeaf,
|
||||
TO,
|
||||
WT
|
||||
} from './terminal-leaf-move-fixture'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => tmpdir(),
|
||||
getName: () => 'orca-test',
|
||||
getVersion: () => '0.0.0-test',
|
||||
isPackaged: false,
|
||||
on: () => {},
|
||||
whenReady: () => Promise.resolve()
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString()
|
||||
},
|
||||
ipcMain: { on: () => {}, handle: () => {} },
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
|
||||
afterEach(closeMoveTestStores)
|
||||
|
||||
/** The window's drag-out as `layout.apply` sends it, with the tab id pinned for the assertions. */
|
||||
function detach(
|
||||
store: Store,
|
||||
ptyId: string | null,
|
||||
hostIds: ExecutionHostId[] = ['local']
|
||||
): ReturnType<Store['applyLayoutCommand']> {
|
||||
return store.applyLayoutCommand(
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: MOVED,
|
||||
windowDetach: { ptyId, tabId: TARGET }
|
||||
},
|
||||
hostIds
|
||||
)
|
||||
}
|
||||
|
||||
// The desktop's move rules (terminal-leaf-move.test.ts), through layout.apply's pane move.
|
||||
describe('a window drag-out through layout.apply', () => {
|
||||
it('moves the leaf and its binding in one write and re-keys pane-keyed records', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
await seedSplitSource(store)
|
||||
store.setWorkspaceSession({
|
||||
...store.getWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: { [FROM]: sleeping(FROM, SOURCE) }
|
||||
})
|
||||
store.updateUI({
|
||||
acknowledgedAgentsByPaneKey: { [FROM]: 10 },
|
||||
activityClearedAtByPaneKey: { [FROM]: 11 },
|
||||
manuallyUnreadTurnsByPaneKey: { [FROM]: 12 }
|
||||
})
|
||||
|
||||
await expect(detach(store, 'pty-agent')).resolves.toEqual({
|
||||
ok: true,
|
||||
result: { tabId: TARGET, ptyId: 'pty-agent' }
|
||||
})
|
||||
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(session.terminalLayoutsByTabId[SOURCE]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: LEFT },
|
||||
ptyIdsByLeafId: { [LEFT]: 'pty-left' }
|
||||
})
|
||||
expect(session.terminalLayoutsByTabId[TARGET]).toMatchObject({
|
||||
root: { type: 'leaf', leafId: MOVED },
|
||||
ptyIdsByLeafId: { [MOVED]: 'pty-agent' }
|
||||
})
|
||||
expect(session.tabsByWorktree[WT]?.map((tab) => [tab.id, tab.ptyId])).toEqual([
|
||||
[SOURCE, 'pty-left'],
|
||||
[TARGET, 'pty-agent']
|
||||
])
|
||||
expect(session.terminalPtyIncarnationsByPaneKey).toEqual({
|
||||
[`${SOURCE}:${LEFT}`]: 'inc-left',
|
||||
[TO]: 'inc-1'
|
||||
})
|
||||
expect(session.sleepingAgentSessionsByPaneKey).toEqual({
|
||||
[TO]: expect.objectContaining({ paneKey: TO, tabId: TARGET })
|
||||
})
|
||||
expect(store.getUI()).toMatchObject({
|
||||
acknowledgedAgentsByPaneKey: { [TO]: 10 },
|
||||
activityClearedAtByPaneKey: { [TO]: 11 },
|
||||
manuallyUnreadTurnsByPaneKey: { [TO]: 12 }
|
||||
})
|
||||
expect(ownersOf(session, 'pty-agent')).toEqual([TO])
|
||||
})
|
||||
|
||||
it('re-keys the SSH lease within the partition that holds the tab', async () => {
|
||||
const dataFile = newDataFile()
|
||||
writeFileSync(
|
||||
dataFile,
|
||||
JSON.stringify({
|
||||
sshRemotePtyLeases: [
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
ptyId: 'pty-agent',
|
||||
worktreeId: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: MOVED,
|
||||
state: 'attached'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const store = openStore(dataFile)
|
||||
const hostId = toSshExecutionHostId('ssh-1')
|
||||
await seedSplitSource(store, hostId)
|
||||
|
||||
await expect(detach(store, 'pty-agent', [hostId])).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET])
|
||||
expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined()
|
||||
expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([
|
||||
expect.objectContaining({ ptyId: 'pty-agent', tabId: TARGET, leafId: MOVED })
|
||||
])
|
||||
})
|
||||
|
||||
it('moves the last pane of main’s copy, leaving the tab for the window’s other panes', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: MOVED,
|
||||
ptyId: 'pty-agent',
|
||||
incarnationId: 'inc-1'
|
||||
})
|
||||
|
||||
await expect(detach(store, 'pty-agent')).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET])
|
||||
expect(session.tabsByWorktree[WT]?.find((tab) => tab.id === SOURCE)?.ptyId).toBeNull()
|
||||
// The window's sibling binds into the source tab later.
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WT,
|
||||
tabId: SOURCE,
|
||||
leafId: LEFT,
|
||||
ptyId: 'pty-left',
|
||||
incarnationId: 'inc-left'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(), LEFT)).toEqual([SOURCE])
|
||||
})
|
||||
|
||||
it('refuses a terminal no copy names, and a leaf another tab holds', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
await seedSplitSource(store)
|
||||
await expect(detach(store, 'pty-other')).resolves.toEqual({ ok: false, code: 'pty_mismatch' })
|
||||
const session = store.getWorkspaceSession()
|
||||
store.setWorkspaceSession({
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[WT]: [
|
||||
...(session.tabsByWorktree[WT] ?? []),
|
||||
{ ...(session.tabsByWorktree[WT] ?? [])[0]!, id: 'tab-earlier-move' }
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
'tab-earlier-move': {
|
||||
root: { type: 'leaf', leafId: MOVED },
|
||||
activeLeafId: MOVED,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
await expect(detach(store, 'pty-agent')).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'pane_in_other_tab'
|
||||
})
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toContain(SOURCE)
|
||||
})
|
||||
|
||||
it('takes the one bound terminal when the window has none', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
await seedSplitSource(store)
|
||||
await expect(detach(store, null)).resolves.toEqual({
|
||||
ok: true,
|
||||
result: { tabId: TARGET, ptyId: 'pty-agent' }
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the remote session id with the moved terminal', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
await seedSplitSource(store)
|
||||
store.setWorkspaceSession({
|
||||
...store.getWorkspaceSession(),
|
||||
remoteSessionIdsByTabId: { [SOURCE]: 'pty-agent' }
|
||||
})
|
||||
|
||||
await detach(store, 'pty-agent')
|
||||
|
||||
expect(store.getWorkspaceSession().remoteSessionIdsByTabId).toEqual({
|
||||
[SOURCE]: 'pty-left',
|
||||
[TARGET]: 'pty-agent'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a window drag-out of an SSH pane held by both partitions', () => {
|
||||
it('moves every copy, so the target reattach and the next relay reattach both bind', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hostId = toSshExecutionHostId('ssh-1')
|
||||
await seedSplitSource(store, hostId)
|
||||
const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' }
|
||||
await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' })
|
||||
|
||||
await expect(detach(store, 'pty-agent', [hostId, 'local'])).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET])
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET])
|
||||
expect(
|
||||
await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
...relay,
|
||||
tabId: TARGET,
|
||||
origin: 'relay_reattach',
|
||||
mayReviveRetiredSurface: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('follows the live terminal when an SSH respawn bound one partition before the other', async () => {
|
||||
const store = openStore(newDataFile())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hostId = toSshExecutionHostId('ssh-1')
|
||||
await seedSplitSource(store, hostId)
|
||||
const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' }
|
||||
await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' })
|
||||
await store.persistPtyBinding(
|
||||
{ ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' },
|
||||
hostId
|
||||
)
|
||||
|
||||
await expect(detach(store, 'pty-respawn', [hostId, 'local'])).resolves.toEqual({
|
||||
ok: true,
|
||||
result: { tabId: TARGET, ptyId: 'pty-respawn' }
|
||||
})
|
||||
|
||||
for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) {
|
||||
expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET])
|
||||
expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({
|
||||
[MOVED]: 'pty-respawn'
|
||||
})
|
||||
}
|
||||
expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2')
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// STA-9259 through layout.apply: move, reattach, a stale pre-move renderer save, restart.
|
||||
describe('STA-9259 sequence through layout.apply', () => {
|
||||
it('keeps one owner through reattach, a stale renderer save and a restart', async () => {
|
||||
const dataFile = newDataFile()
|
||||
const store = openStore(dataFile)
|
||||
store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' }))
|
||||
await seedSplitSource(store)
|
||||
const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession())
|
||||
|
||||
await detach(store, 'pty-agent')
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WT,
|
||||
tabId: TARGET,
|
||||
leafId: MOVED,
|
||||
ptyId: 'pty-agent',
|
||||
incarnationId: 'inc-1',
|
||||
expectedBinding: { ptyId: 'pty-agent', incarnationId: 'inc-1' },
|
||||
origin: 'reattach'
|
||||
})
|
||||
).toBe(true)
|
||||
store.setWorkspaceSession(preMoveRendererSnapshot)
|
||||
expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO])
|
||||
expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET])
|
||||
|
||||
store.flush()
|
||||
const restarted = openStore(dataFile)
|
||||
expect(ownersOf(restarted.getWorkspaceSession(), 'pty-agent')).toEqual([TO])
|
||||
expect(tabsHoldingLeaf(restarted.getWorkspaceSession(), MOVED)).toEqual([TARGET])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// A window's drag-out through `layout.apply`, checked across partitions as the desktop's move
|
||||
// (planTerminalLeafMove) is, before any partition is written.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import { findTerminal } from '../../../shared/workspace-layout/workspace-layout-command-steps'
|
||||
import type {
|
||||
CommandOf,
|
||||
LayoutRefusalCode,
|
||||
WindowPaneDetach
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { leafHeldByOtherTab } from './terminal-leaf-move'
|
||||
import {
|
||||
isTerminalOwnerPartition,
|
||||
type TerminalSessionPartition
|
||||
} from './terminal-topology-membership'
|
||||
|
||||
export type WindowPaneDetachCommand = CommandOf<'movePaneToNewTab'> & {
|
||||
windowDetach: WindowPaneDetach
|
||||
}
|
||||
|
||||
export type ResolvedWindowPaneDetach =
|
||||
| {
|
||||
/** The command every holder applies: one new tab id and one terminal for the pane. */
|
||||
command: WindowPaneDetachCommand & { windowDetach: { tabId: string } }
|
||||
/** The source tab's terminal id (the tab half of the moved pane's old key). */
|
||||
sourceEntityId: string
|
||||
}
|
||||
| { code: LayoutRefusalCode }
|
||||
/** No partition holds the pane; the routed apply refuses it. */
|
||||
| null
|
||||
|
||||
export function resolveWindowPaneDetach(
|
||||
partitions: readonly TerminalSessionPartition[],
|
||||
holderIds: readonly ExecutionHostId[],
|
||||
command: WindowPaneDetachCommand
|
||||
): ResolvedWindowPaneDetach {
|
||||
const { leafId } = command
|
||||
const holders = partitions.flatMap(({ hostId, session }) => {
|
||||
if (!holderIds.includes(hostId)) {
|
||||
return []
|
||||
}
|
||||
const workspace = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()).layout
|
||||
.workspaces[command.workspace]
|
||||
const tab = workspace && findTerminal(workspace, command.tabId)
|
||||
return tab && layoutContainsLeafId(tab.panes.root, leafId)
|
||||
? [{ entityId: tab.entityId, ptyId: workspace.leaves?.[leafId]?.ptyId }]
|
||||
: []
|
||||
})
|
||||
if (holders.length === 0) {
|
||||
return null
|
||||
}
|
||||
const sourceEntityId = holders[0]!.entityId
|
||||
if (
|
||||
partitions.some(
|
||||
({ hostId, session }) =>
|
||||
isTerminalOwnerPartition(hostId) && leafHeldByOtherTab(session, sourceEntityId, leafId)
|
||||
)
|
||||
) {
|
||||
return { code: 'pane_in_other_tab' }
|
||||
}
|
||||
const bound = new Set(holders.flatMap(({ ptyId }) => (ptyId ? [ptyId] : [])))
|
||||
// The window's live terminal wins: after an SSH respawn one copy can still name the old one.
|
||||
const ptyId = command.windowDetach.ptyId ?? (bound.size === 1 ? [...bound][0]! : null)
|
||||
if (bound.size > 0 && !(ptyId && bound.has(ptyId))) {
|
||||
return { code: 'pty_mismatch' }
|
||||
}
|
||||
return {
|
||||
command: {
|
||||
...command,
|
||||
windowDetach: { ptyId, tabId: command.windowDetach.tabId ?? randomUUID() }
|
||||
},
|
||||
sourceEntityId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment'
|
||||
import {
|
||||
terminalLeafMovePaneKeys,
|
||||
type TerminalLeafMoveRequest
|
||||
} from '../../shared/terminal-leaf-move'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */
|
||||
export function rekeyMovedPaneOwners(
|
||||
runtime: Pick<OrcaRuntimeService, 'getExistingOrchestrationDb'> | undefined,
|
||||
/** `ptyId` is the terminal the committed move bound the pane to. */
|
||||
request: TerminalLeafMoveRequest
|
||||
): void {
|
||||
const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request)
|
||||
forgetFailedAgentLaunches(request.worktreeId, {
|
||||
kind: 'pane',
|
||||
tabId: request.sourceTabId,
|
||||
leafId: request.leafId
|
||||
})
|
||||
try {
|
||||
// The process keeps the pane key baked into its env, so status must alias old to new.
|
||||
agentHookServer.transferPaneAuthority(
|
||||
fromPaneKey,
|
||||
toPaneKey,
|
||||
request.ptyId ?? undefined,
|
||||
Date.now(),
|
||||
{
|
||||
authorityVerified: true
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[pty] moved pane kept its old agent-status key:', error)
|
||||
}
|
||||
try {
|
||||
runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({
|
||||
fromPaneKey,
|
||||
toPaneKey
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[pty] moved pane kept its old orchestration resource key:', error)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-r
|
||||
import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey'
|
||||
import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
|
||||
@@ -180,7 +181,8 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog {
|
||||
const routed = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
return routed ? [routed] : []
|
||||
},
|
||||
command
|
||||
command,
|
||||
(move) => rekeyMovedPaneOwners(this, move)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,27 @@ describe('layout.apply', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a drag-out with the window’s terminal, but no tab id from the window', async () => {
|
||||
const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply')
|
||||
const move = {
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WORKTREE,
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
groupId: 'g1',
|
||||
index: 2
|
||||
}
|
||||
|
||||
expect(
|
||||
apply?.params?.parse({ command: { ...move, windowDetach: { ptyId: 'pty-1', tabId: 'x' } } })
|
||||
).toEqual({ command: { ...move, windowDetach: { ptyId: 'pty-1' } } })
|
||||
expect(apply?.params?.safeParse({ command: { ...move, windowDetach: {} } }).success).toBe(false)
|
||||
expect(
|
||||
apply?.params?.safeParse({ command: { ...move, windowDetach: { ptyId: null }, index: -1 } })
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('applies group sizes to the workspace’s routed partition', async () => {
|
||||
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
|
||||
const ratios = {
|
||||
|
||||
@@ -122,6 +122,58 @@ describe('applyWindowLayoutCommand', () => {
|
||||
expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves a dragged-out pane in every holder, and its pane-keyed owners follow', async () => {
|
||||
const { store, applyLayoutCommand } = storeWith()
|
||||
applyLayoutCommand.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tabId: 'tab-new', ptyId: 'pty-1' }
|
||||
})
|
||||
const session = { unifiedTabs: { 'wt-1': [{ id: 'entry-1', entityId: 'term-1' }] } }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only unifiedTabs.
|
||||
const withSession = { ...store, getWorkspaceSession: () => session } as unknown as RuntimeStore
|
||||
const onPaneMoved = vi.fn()
|
||||
const move: WindowLayoutCommand = {
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: 'wt-1',
|
||||
tabId: 'entry-1',
|
||||
leafId: 'leaf-1',
|
||||
groupId: 'g1',
|
||||
// The window cannot name the new tab.
|
||||
windowDetach: { ptyId: 'pty-1', tabId: 'tab-window' }
|
||||
}
|
||||
|
||||
await applyWindowLayoutCommand(
|
||||
withSession,
|
||||
() => ['ssh:t', 'runtime:env-1', 'local'],
|
||||
move,
|
||||
onPaneMoved
|
||||
)
|
||||
|
||||
expect(applyLayoutCommand).toHaveBeenCalledWith({ ...move, windowDetach: { ptyId: 'pty-1' } }, [
|
||||
'ssh:t',
|
||||
'local'
|
||||
])
|
||||
expect(onPaneMoved).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
sourceTabId: 'term-1',
|
||||
targetTabId: 'tab-new',
|
||||
leafId: 'leaf-1',
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
applyLayoutCommand.mockResolvedValueOnce({ ok: false, code: 'pty_mismatch' })
|
||||
await applyWindowLayoutCommand(withSession, () => ['local'], move, onPaneMoved)
|
||||
expect(onPaneMoved).toHaveBeenCalledTimes(1)
|
||||
// A committed move still answers ok when re-keying throws.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
applyLayoutCommand.mockResolvedValueOnce({ ok: true, result: { tabId: 'tab-2', ptyId: null } })
|
||||
onPaneMoved.mockImplementationOnce(() => {
|
||||
throw new Error('rekey failed')
|
||||
})
|
||||
await expect(
|
||||
applyWindowLayoutCommand(withSession, () => ['local'], move, onPaneMoved)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('refuses a tab a paired server routes, touching nothing', async () => {
|
||||
const { store, applyLayoutCommand } = storeWith()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
LayoutApplyReply,
|
||||
LayoutCommand
|
||||
} from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { TerminalLeafMoveRequest } from '../../shared/terminal-leaf-move'
|
||||
import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
@@ -13,12 +14,14 @@ export type WindowLayoutCommand = LayoutCommand &
|
||||
/**
|
||||
* `layout.apply`: the window's command on the partitions holding its tab, routed partition first;
|
||||
* a group tree command goes to the workspace's routed partition. A pane close is the user's: main
|
||||
* marks it so, and the window cannot.
|
||||
* marks it so, and the window cannot. A pane moved to a new tab follows the desktop's move rules.
|
||||
*/
|
||||
export async function applyWindowLayoutCommand(
|
||||
store: RuntimeStore,
|
||||
hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[],
|
||||
command: WindowLayoutCommand
|
||||
command: WindowLayoutCommand,
|
||||
/** Agent status and orchestration records follow a pane moved into a new tab. */
|
||||
onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {}
|
||||
): Promise<LayoutApplyReply> {
|
||||
const routedTabId =
|
||||
'tabId' in command ? command.tabId : command.type === 'closeTabs' ? command.tabIds[0] : null
|
||||
@@ -36,12 +39,7 @@ export async function applyWindowLayoutCommand(
|
||||
// The window already confirmed, removed the tab and stopped its terminals: main forces the
|
||||
// close, records it, and stops nothing (a terminal another tab shares must live on).
|
||||
if (command.recordClose === 'user') {
|
||||
// Launches go by the terminal's own id, which a legacy tab-bar entry's can differ from.
|
||||
const tabId =
|
||||
store
|
||||
.getWorkspaceSession?.(hostIds[0])
|
||||
?.unifiedTabs?.[command.workspace]?.find((entry) => entry.id === command.tabIds[0])
|
||||
?.entityId ?? command.tabIds[0]
|
||||
const tabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabIds[0])
|
||||
markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId })
|
||||
}
|
||||
const reply = await store.applyLayoutCommand({ ...command, force: true }, holders)
|
||||
@@ -50,6 +48,30 @@ export async function applyWindowLayoutCommand(
|
||||
? { ok: false, code: 'workspace_not_found' }
|
||||
: reply
|
||||
}
|
||||
if (command.type === 'movePaneToNewTab') {
|
||||
// Read before the move, which re-homes the pane.
|
||||
const sourceTabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabId)
|
||||
const reply = await store.applyLayoutCommand(
|
||||
{ ...command, windowDetach: { ptyId: command.windowDetach?.ptyId ?? null } },
|
||||
holders
|
||||
)
|
||||
if (reply.ok && reply.result.tabId) {
|
||||
const move = {
|
||||
worktreeId: command.workspace,
|
||||
sourceTabId,
|
||||
targetTabId: reply.result.tabId,
|
||||
leafId: command.leafId,
|
||||
ptyId: reply.result.ptyId ?? null
|
||||
}
|
||||
// Why: the move is committed; a throw here would send the window to today's move as well.
|
||||
try {
|
||||
onPaneMoved(move)
|
||||
} catch (error) {
|
||||
console.warn('[layout] moved pane kept some of its old keys:', error)
|
||||
}
|
||||
}
|
||||
return reply
|
||||
}
|
||||
if (command.type !== 'closePane') {
|
||||
return store.applyLayoutCommand(command, [hostIds[0]])
|
||||
}
|
||||
@@ -58,3 +80,17 @@ export async function applyWindowLayoutCommand(
|
||||
markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId })
|
||||
return store.applyLayoutCommand({ ...command, userPaneClose: true }, holders)
|
||||
}
|
||||
|
||||
/** A tab-bar id's terminal id, which a legacy entry's can differ from. */
|
||||
function terminalIdOf(
|
||||
store: RuntimeStore,
|
||||
hostId: ExecutionHostId,
|
||||
workspace: string,
|
||||
tabId: string
|
||||
): string {
|
||||
return (
|
||||
store
|
||||
.getWorkspaceSession?.(hostId)
|
||||
?.unifiedTabs?.[workspace]?.find((entry) => entry.id === tabId)?.entityId ?? tabId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import type {
|
||||
TerminalLeafMoveRequest,
|
||||
TerminalLeafMoveResult
|
||||
} from '../../../../shared/terminal-leaf-move'
|
||||
import { moveLeafThroughLayoutOwner } from './terminal-pane-detach-layout-owner'
|
||||
import { detachTerminalPaneToTab } from './terminal-pane-tab-detach'
|
||||
import {
|
||||
createStore,
|
||||
LEAF_1,
|
||||
LEAF_2,
|
||||
SOURCE_TAB_ID,
|
||||
TARGET_GROUP_ID,
|
||||
WORKTREE_ID
|
||||
} from './terminal-pane-tab-detach-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
send: vi.fn<typeof sendLayoutCommandToOwner>(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('@/runtime/layout-owner-command', () => ({ sendLayoutCommandToOwner: mocks.send }))
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
|
||||
const legacyMove = vi.fn<(request: TerminalLeafMoveRequest) => Promise<TerminalLeafMoveResult>>()
|
||||
const request: TerminalLeafMoveRequest = {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetTabId: 'tab-window',
|
||||
leafId: LEAF_2,
|
||||
ptyId: 'pty-right'
|
||||
}
|
||||
const lastHandlers = () => mocks.send.mock.calls.at(-1)![2]
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.send.mockReset()
|
||||
mocks.send.mockReturnValue(true)
|
||||
mocks.toastError.mockClear()
|
||||
legacyMove.mockReset()
|
||||
legacyMove.mockResolvedValue({ status: 'not_held' })
|
||||
vi.stubGlobal('window', {
|
||||
api: { pty: { moveLeafToNewTab: legacyMove }, session: { closeTerminalSurface: vi.fn() } }
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('moveLeafThroughLayoutOwner', () => {
|
||||
it('sends the drag-out with the window’s terminal and answers with main’s tab', async () => {
|
||||
const moving = moveLeafThroughLayoutOwner(request, { groupId: TARGET_GROUP_ID, index: 1 })
|
||||
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
tabId: SOURCE_TAB_ID,
|
||||
leafId: LEAF_2,
|
||||
groupId: TARGET_GROUP_ID,
|
||||
index: 1,
|
||||
windowDetach: { ptyId: 'pty-right' }
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
lastHandlers().apply({ tabId: 'tab-main', ptyId: 'pty-right' })
|
||||
await expect(moving).resolves.toEqual({
|
||||
status: 'moved',
|
||||
ptyId: 'pty-right',
|
||||
tabId: 'tab-main'
|
||||
})
|
||||
expect(legacyMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves as today when main lacks the pane, or a paired server owns the workspace', async () => {
|
||||
const lacking = moveLeafThroughLayoutOwner(request, {
|
||||
groupId: TARGET_GROUP_ID,
|
||||
index: undefined
|
||||
})
|
||||
lastHandlers().asToday()
|
||||
await expect(lacking).resolves.toEqual({ status: 'not_held' })
|
||||
|
||||
mocks.send.mockReturnValue(false)
|
||||
await moveLeafThroughLayoutOwner(request, { groupId: TARGET_GROUP_ID, index: undefined })
|
||||
expect(legacyMove).toHaveBeenCalledTimes(2)
|
||||
expect(legacyMove).toHaveBeenCalledWith(request)
|
||||
})
|
||||
|
||||
it('reports main’s refusal as the move’s', async () => {
|
||||
const moving = moveLeafThroughLayoutOwner(request, {
|
||||
groupId: TARGET_GROUP_ID,
|
||||
index: undefined
|
||||
})
|
||||
lastHandlers().refused!('pty_mismatch')
|
||||
await expect(moving).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detachTerminalPaneToTab in a layout-owner build', () => {
|
||||
function detach(store: ReturnType<typeof createStore>) {
|
||||
return detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager: {
|
||||
getPanes: () => [{ id: 1 }, { id: 2 }],
|
||||
getLeafId: (paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1),
|
||||
detachPaneForExternalMove: () => true
|
||||
},
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
}
|
||||
|
||||
it('opens the new tab under main’s id', async () => {
|
||||
const store = createStore()
|
||||
mocks.send.mockImplementation((_worktreeId, _command, handlers) => {
|
||||
handlers.apply({ tabId: 'tab-main', ptyId: 'pty-right' })
|
||||
return true
|
||||
})
|
||||
|
||||
await expect(detach(store)).resolves.toMatchObject({ leafId: LEAF_2, ptyId: 'pty-right' })
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
TARGET_GROUP_ID,
|
||||
'powershell.exe',
|
||||
expect.objectContaining({ id: 'tab-main', initialLeafId: LEAF_2 })
|
||||
)
|
||||
expect(legacyMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the pane and says so when main refuses', async () => {
|
||||
const store = createStore()
|
||||
mocks.send.mockImplementation((_worktreeId, _command, handlers) => {
|
||||
handlers.refused?.('pane_in_other_tab')
|
||||
return true
|
||||
})
|
||||
|
||||
await expect(detach(store)).resolves.toBeNull()
|
||||
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import type {
|
||||
TerminalLeafMoveRequest,
|
||||
TerminalLeafMoveResult
|
||||
} from '../../../../shared/terminal-leaf-move'
|
||||
|
||||
/**
|
||||
* A layout-owner build's drag-out: main moves the pane under the desktop's move rules and names the
|
||||
* new tab. Today's move runs instead when main cannot act on it (a paired server's workspace, or a
|
||||
* tab or pane main's copy lacks, such as a legacy tab whose tab-bar id differs from its terminal's).
|
||||
*/
|
||||
export function moveLeafThroughLayoutOwner(
|
||||
request: TerminalLeafMoveRequest,
|
||||
placement: { groupId: string; index: number | undefined }
|
||||
): Promise<TerminalLeafMoveResult> {
|
||||
const asToday = (): Promise<TerminalLeafMoveResult> => window.api.pty.moveLeafToNewTab(request)
|
||||
return new Promise((resolve, reject) => {
|
||||
const fallBack = (): void => void asToday().then(resolve, reject)
|
||||
const sent = sendLayoutCommandToOwner(
|
||||
request.worktreeId,
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
tabId: request.sourceTabId,
|
||||
leafId: request.leafId,
|
||||
groupId: placement.groupId,
|
||||
...(placement.index !== undefined ? { index: placement.index } : {}),
|
||||
windowDetach: { ptyId: request.ptyId }
|
||||
},
|
||||
{
|
||||
apply: (result) =>
|
||||
resolve(
|
||||
result.tabId
|
||||
? { status: 'moved', ptyId: result.ptyId ?? null, tabId: result.tabId }
|
||||
: { status: 'refused', reason: 'invalid_request' }
|
||||
),
|
||||
asToday: fallBack,
|
||||
refused: (code) =>
|
||||
resolve({
|
||||
status: 'refused',
|
||||
reason:
|
||||
code === 'pty_mismatch'
|
||||
? 'pty_mismatch'
|
||||
: code === 'pane_in_other_tab'
|
||||
? 'leaf_in_other_tab'
|
||||
: 'invalid_request'
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!sent) {
|
||||
fallBack()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type { TerminalLeafMoveRequest } from '../../../../shared/terminal-leaf-m
|
||||
import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent'
|
||||
import type { PaneCwdEntry } from './resolve-split-cwd'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
import { moveLeafThroughLayoutOwner } from './terminal-pane-detach-layout-owner'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
export {
|
||||
isTerminalTabStripDropTarget,
|
||||
resolveTerminalTabStripDropTarget
|
||||
@@ -139,12 +141,24 @@ async function moveLeafToNewTab(
|
||||
leafId,
|
||||
ptyId: args.livePtyId ?? persistedPtyId ?? null
|
||||
}
|
||||
const answer = await window.api.pty.moveLeafToNewTab(request).catch((error: unknown) => {
|
||||
// Switch point: main moves the pane and names the new tab.
|
||||
const answer = await (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? moveLeafThroughLayoutOwner(request, {
|
||||
groupId: args.targetGroupId,
|
||||
index: args.targetIndex
|
||||
})
|
||||
: window.api.pty.moveLeafToNewTab(request)
|
||||
).catch((error: unknown) => {
|
||||
console.warn('[terminal-pane-detach] main did not answer the move', error)
|
||||
return null
|
||||
})
|
||||
if (answer?.status === 'moved') {
|
||||
return applyMove(args, request, true, answer.ptyId)
|
||||
const moved =
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN && answer.tabId
|
||||
? { ...request, targetTabId: answer.tabId }
|
||||
: request
|
||||
return applyMove(args, moved, true, answer.ptyId)
|
||||
}
|
||||
if (answer?.status === 'not_held') {
|
||||
return applyMove(args, request, false, request.ptyId)
|
||||
|
||||
@@ -25,6 +25,14 @@ export type WindowLayoutCommand =
|
||||
| { type: 'closeTabs'; tabIds: [string]; recordClose: 'user' | 'cleanup' }
|
||||
| { type: 'renameTab'; tabId: string; kind: 'custom'; title: string | null }
|
||||
| { type: 'setTabProps'; tabId: string; color?: string | null; isPinned?: boolean }
|
||||
| {
|
||||
type: 'movePaneToNewTab'
|
||||
tabId: string
|
||||
leafId: string
|
||||
groupId: string
|
||||
index?: number
|
||||
windowDetach: { ptyId: string | null }
|
||||
}
|
||||
|
||||
export type LayoutApplyHandlers = {
|
||||
apply: (result: LayoutCommandResult) => void
|
||||
|
||||
@@ -93,6 +93,16 @@ const CloseTabsCommand = z.object({
|
||||
recordClose: z.enum(['user', 'cleanup'])
|
||||
})
|
||||
|
||||
/** A window's drag-out of one pane into a new tab; main mints the tab's id. */
|
||||
const MovePaneToNewTabCommand = z.object({
|
||||
type: z.literal('movePaneToNewTab'),
|
||||
...OnPane,
|
||||
groupId: z.string().min(1),
|
||||
index: z.number().int().nonnegative().optional(),
|
||||
/** The terminal the window's pane shows now, when it has one. */
|
||||
windowDetach: z.object({ ptyId: z.string().min(1).max(512).nullable() })
|
||||
})
|
||||
|
||||
/**
|
||||
* A terminal tab under the ids the window drew it with: its callers and the pane's terminal need
|
||||
* them at once. Main refuses an id another tab holds; the same create again changes nothing.
|
||||
@@ -152,6 +162,7 @@ export const LayoutApplyParams = z.object({
|
||||
SetGroupRatiosCommand,
|
||||
CloseTabsCommand,
|
||||
RenameTabCommand,
|
||||
SetTabPropsCommand
|
||||
SetTabPropsCommand,
|
||||
MovePaneToNewTabCommand
|
||||
])
|
||||
})
|
||||
|
||||
@@ -12,7 +12,12 @@ export type TerminalLeafMoveRequest = {
|
||||
|
||||
export type TerminalLeafMoveResult =
|
||||
/** Main moved the leaf and every pane-keyed record into the target tab in one durable write. */
|
||||
| { status: 'moved'; ptyId: string | null }
|
||||
| {
|
||||
status: 'moved'
|
||||
ptyId: string | null
|
||||
/** The new tab's id when main named it (layout-owner builds); else the request's. */
|
||||
tabId?: string
|
||||
}
|
||||
/** Main's session never held the leaf, so there is nothing to move there. */
|
||||
| { status: 'not_held' }
|
||||
| {
|
||||
|
||||
@@ -16,6 +16,16 @@ import type { LayoutEditorFile, LayoutTerminalCreation } from './workspace-layou
|
||||
|
||||
type On = { workspace: string }
|
||||
|
||||
/**
|
||||
* The desktop's drag-out rules: the terminal the window's pane shows outranks a stale binding
|
||||
* here, and a tab's last pane moves too (the window holds siblings this copy lacks).
|
||||
*/
|
||||
export type WindowPaneDetach = {
|
||||
ptyId: string | null
|
||||
/** The new tab's id, the same in every partition holding the pane; minted when absent. */
|
||||
tabId?: string
|
||||
}
|
||||
|
||||
export type LayoutCommand =
|
||||
| (On & {
|
||||
type: 'createTerminalTab'
|
||||
@@ -76,6 +86,8 @@ export type LayoutCommand =
|
||||
leafId: string
|
||||
groupId?: string
|
||||
index?: number
|
||||
/** A window's drag-out, under the desktop's move rules (WindowPaneDetach). */
|
||||
windowDetach?: WindowPaneDetach
|
||||
})
|
||||
| (On & { type: 'renameTab'; tabId: string } & TabTitle)
|
||||
| (On & { type: 'renamePane'; tabId: string; leafId: string; title: string | null })
|
||||
@@ -151,6 +163,9 @@ export type LayoutRefusalCode =
|
||||
| 'pane_already_bound'
|
||||
| 'workspace_exists'
|
||||
| 'tab_exists'
|
||||
/** A window's drag-out: another tab holds the pane, or no copy binds the window's terminal. */
|
||||
| 'pane_in_other_tab'
|
||||
| 'pty_mismatch'
|
||||
|
||||
/** What the runtime does after applying: stop or start terminals. The reply never waits on them. */
|
||||
export type LayoutEffects = { stopPtyIds: string[]; startPaneKeys: string[] }
|
||||
@@ -161,6 +176,8 @@ export type LayoutCommandResult = {
|
||||
paneKey?: string
|
||||
groupId?: string
|
||||
pageId?: string
|
||||
/** A window's drag-out: the terminal the moved pane shows, null when unbound. */
|
||||
ptyId?: string | null
|
||||
tabClosed?: boolean
|
||||
alreadyClosed?: boolean
|
||||
closed?: string[]
|
||||
|
||||
@@ -384,6 +384,80 @@ describe('layout command effects', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('a window drag-out names the tab, outranks a stale binding and moves a last pane', () => {
|
||||
const { model, tabA, tabB, leafA2, leafB, ctx } = setup()
|
||||
const workspace = model.workspaces[WS]!
|
||||
const withData: WorkspaceLayoutModel = {
|
||||
...model,
|
||||
workspaces: {
|
||||
[WS]: {
|
||||
...workspace,
|
||||
leaves: { ...workspace.leaves, [leafA2]: { ptyId: 'pty-old', incarnationId: 'inc' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
const stale = build(
|
||||
ctx,
|
||||
[
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WS,
|
||||
tabId: tabA,
|
||||
leafId: leafA2,
|
||||
windowDetach: { ptyId: 'pty-live', tabId: 'tab-main' }
|
||||
}
|
||||
],
|
||||
withData
|
||||
)
|
||||
expect(stale.results[0]).toEqual({ tabId: 'tab-main', ptyId: 'pty-live' })
|
||||
expect(stale.model.workspaces[WS]!.leaves?.[leafA2]).toEqual({ ptyId: 'pty-live' })
|
||||
// A copy that never bound the pane keeps its incarnation, as the desktop's move does.
|
||||
const unbound = applyLayoutCommand(
|
||||
{
|
||||
...model,
|
||||
workspaces: {
|
||||
[WS]: {
|
||||
...workspace,
|
||||
leaves: { ...workspace.leaves, [leafA2]: { incarnationId: 'inc' } }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WS,
|
||||
tabId: tabA,
|
||||
leafId: leafA2,
|
||||
windowDetach: { ptyId: 'pty-live', tabId: 'tab-main' }
|
||||
},
|
||||
ctx
|
||||
)
|
||||
expect(unbound.ok && unbound.model.workspaces[WS]!.leaves?.[leafA2]).toEqual({
|
||||
ptyId: 'pty-live',
|
||||
incarnationId: 'inc'
|
||||
})
|
||||
|
||||
// Main's copy lacks the window's other panes, so the tab stays for them (no rule check: to
|
||||
// main's copy alone it reads as the tab re-ided).
|
||||
const last = applyLayoutCommand(
|
||||
model,
|
||||
{
|
||||
type: 'movePaneToNewTab',
|
||||
workspace: WS,
|
||||
tabId: tabB,
|
||||
leafId: leafB,
|
||||
windowDetach: { ptyId: null }
|
||||
},
|
||||
ctx
|
||||
)
|
||||
if (!last.ok) {
|
||||
throw new Error(last.code)
|
||||
}
|
||||
const newTabId = last.result.tabId!
|
||||
expect(terminalTab(last.model, newTabId).panes.root).toEqual({ type: 'leaf', leafId: leafB })
|
||||
expect(terminalTab(last.model, tabB).panes.root).toBeNull()
|
||||
expect(last.result.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('equalizes same-axis panes into equal shares', () => {
|
||||
const { model, tabA, leafA2, ctx } = setup()
|
||||
const three = build(
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
updateTab,
|
||||
type Applied
|
||||
} from './workspace-layout-command-steps'
|
||||
import type { CommandOf, LayoutContext } from './workspace-layout-command-types'
|
||||
import type { CommandOf, LayoutContext, WindowPaneDetach } from './workspace-layout-command-types'
|
||||
import {
|
||||
paneKeyOf,
|
||||
type LayoutTerminalTab,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceLayoutModel
|
||||
} from './workspace-layout-model'
|
||||
import { retireTerminalPane, withWorkspace } from './workspace-layout-removal'
|
||||
@@ -168,12 +169,13 @@ export function movePaneToNewTab(
|
||||
if (!layoutContainsLeafId(tab.panes.root, command.leafId)) {
|
||||
return refuse('pane_not_found')
|
||||
}
|
||||
if (leafIdsOf(tab).length < 2) {
|
||||
const detach = command.windowDetach
|
||||
if (!detach && leafIdsOf(tab).length < 2) {
|
||||
return refuse('last_pane')
|
||||
}
|
||||
const { leafId } = command
|
||||
const workspace = model.workspaces[command.workspace]!
|
||||
const id = context.mintId()
|
||||
const workspace = bindDetachedLeaf(model.workspaces[command.workspace]!, leafId, detach)
|
||||
const id = detach?.tabId ?? context.mintId()
|
||||
const terminals = workspace.tabs.flatMap((entry) => (entry.kind === 'terminal' ? [entry] : []))
|
||||
const ordinal = getNextTerminalOrdinal(
|
||||
terminals.map((entry) => ({ defaultTitle: entry.terminal.defaultTitle, title: '' }))
|
||||
@@ -217,5 +219,25 @@ export function movePaneToNewTab(
|
||||
},
|
||||
context
|
||||
)
|
||||
return applied(withWorkspace(model, command.workspace, placed), { tabId: id })
|
||||
return applied(withWorkspace(model, command.workspace, placed), {
|
||||
tabId: id,
|
||||
...(detach ? { ptyId: placed.leaves?.[leafId]?.ptyId ?? null } : {})
|
||||
})
|
||||
}
|
||||
|
||||
/** The window's terminal outranks a stale binding here; a stale copy's incarnation is dropped. */
|
||||
function bindDetachedLeaf(
|
||||
workspace: WorkspaceLayout,
|
||||
leafId: string,
|
||||
detach: WindowPaneDetach | undefined
|
||||
): WorkspaceLayout {
|
||||
const leaf = workspace.leaves?.[leafId]
|
||||
if (!detach?.ptyId || leaf?.ptyId === detach.ptyId) {
|
||||
return workspace
|
||||
}
|
||||
if (!leaf?.ptyId) {
|
||||
return setLeaf(workspace, leafId, { ...leaf, ptyId: detach.ptyId })
|
||||
}
|
||||
const { incarnationId: _stale, ...rest } = leaf
|
||||
return setLeaf(workspace, leafId, { ...rest, ptyId: detach.ptyId })
|
||||
}
|
||||
|
||||
@@ -93,7 +93,17 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] {
|
||||
title: 'Pane'
|
||||
})
|
||||
}
|
||||
if (first) {
|
||||
commands.push({
|
||||
type: 'movePaneToNewTab',
|
||||
workspace,
|
||||
tabId: tab.id,
|
||||
leafId: first,
|
||||
windowDetach: { ptyId: 'pty-detached', tabId: 'tab-detached' }
|
||||
})
|
||||
}
|
||||
if (second) {
|
||||
commands.push({ type: 'movePaneToNewTab', workspace, tabId: tab.id, leafId: second })
|
||||
commands.push({ type: 'closePane', workspace, tabId: tab.id, leafId: second })
|
||||
commands.push({
|
||||
type: 'closePane',
|
||||
|
||||
Reference in New Issue
Block a user