Fix remote client connection and terminal split parity (#2295)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f8db257a17
commit
0b29bfd9d3
|
|
@ -109,7 +109,7 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
|
|||
return
|
||||
}
|
||||
if (!timer) {
|
||||
// Why: Paseo coalesces terminal stream output before crossing the
|
||||
// Why: terminal stream output should be coalesced before crossing the
|
||||
// network. Desktop runtime subscribers need the same burst boundary.
|
||||
timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS)
|
||||
if (typeof timer.unref === 'function') {
|
||||
|
|
@ -673,9 +673,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
}
|
||||
}),
|
||||
// Why: desktop remote sessions can have dozens of panes. One streaming RPC
|
||||
// owns the binary socket and routes terminal slots by streamId, mirroring
|
||||
// Paseo's slot-based terminal data plane while keeping legacy subscribe as
|
||||
// the compatibility fallback.
|
||||
// owns the binary socket and routes terminal slots by streamId while keeping
|
||||
// legacy subscribe as the compatibility fallback.
|
||||
defineStreamingMethod({
|
||||
name: 'terminal.multiplex',
|
||||
params: TerminalMultiplex,
|
||||
|
|
|
|||
|
|
@ -292,6 +292,173 @@ describe('terminal multiplex RPC', () => {
|
|||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('preserves LF input frames before writing to the multiplexed PTY', async () => {
|
||||
const messages: string[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-byte-preserving',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 9,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() => expect(handlers.has(9)).toBe(true))
|
||||
|
||||
handlers.get(9)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 9,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('echo one\necho two\r\n')
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'echo one\necho two\r\n',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('preserves LF input frames before writing to the subscribed PTY', async () => {
|
||||
const messages: string[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-subscribe-byte-preserving',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
const streamId = JSON.parse(
|
||||
messages.find((msg) => JSON.parse(msg).result?.type === 'subscribed')!
|
||||
).result.streamId as number
|
||||
handlers.get(streamId)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamText('printf a\nprintf b\r\n')
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'printf a\nprintf b\r\n',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('bounds live output queued while a multiplex snapshot is loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '../ui/button'
|
|||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
|
||||
|
||||
const LOOPBACK_ADDRESS = '127.0.0.1'
|
||||
|
|
@ -105,9 +106,11 @@ export function RuntimePairingUrlGenerator({
|
|||
)
|
||||
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
|
||||
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
|
||||
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
|
||||
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(null)
|
||||
const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
|
||||
const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
|
||||
const networkInterfaceLoadIdRef = useRef(0)
|
||||
const accessGrantLoadIdRef = useRef(0)
|
||||
|
||||
const loadRuntimeAccessGrants = useCallback(
|
||||
|
|
@ -135,26 +138,35 @@ export function RuntimePairingUrlGenerator({
|
|||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
|
||||
const loadNetworkInterfaces = async (): Promise<void> => {
|
||||
const loadNetworkInterfaces = useCallback(
|
||||
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
|
||||
const loadId = networkInterfaceLoadIdRef.current + 1
|
||||
networkInterfaceLoadIdRef.current = loadId
|
||||
setRefreshingNetworkInterfaces(true)
|
||||
try {
|
||||
const result = await window.api.mobile.listNetworkInterfaces()
|
||||
if (!stale) {
|
||||
if (loadId === networkInterfaceLoadIdRef.current) {
|
||||
setNetworkInterfaces(result.interfaces)
|
||||
}
|
||||
} catch {
|
||||
// Keep the loopback option available even if interface enumeration fails.
|
||||
if (loadId === networkInterfaceLoadIdRef.current && options.showToastOnError) {
|
||||
toast.error('Failed to refresh network interfaces.')
|
||||
}
|
||||
} finally {
|
||||
if (loadId === networkInterfaceLoadIdRef.current) {
|
||||
setRefreshingNetworkInterfaces(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadNetworkInterfaces()
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
networkInterfaceLoadIdRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
}, [loadNetworkInterfaces])
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuntimeAccessGrants()
|
||||
|
|
@ -269,29 +281,51 @@ export function RuntimePairingUrlGenerator({
|
|||
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
|
||||
Connection address
|
||||
</Label>
|
||||
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
|
||||
<SelectTrigger
|
||||
id="runtime-pairing-address"
|
||||
size="sm"
|
||||
className="min-w-[220px]"
|
||||
aria-labelledby="runtime-pairing-address-label"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LOOPBACK_ADDRESS}>
|
||||
This computer ({LOOPBACK_ADDRESS})
|
||||
</SelectItem>
|
||||
{networkInterfaces.map((networkInterface, index) => (
|
||||
<SelectItem
|
||||
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
|
||||
value={networkInterface.address}
|
||||
>
|
||||
{networkInterface.name} ({networkInterface.address})
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
|
||||
<SelectTrigger
|
||||
id="runtime-pairing-address"
|
||||
size="sm"
|
||||
className="min-w-[220px]"
|
||||
aria-labelledby="runtime-pairing-address-label"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LOOPBACK_ADDRESS}>
|
||||
This computer ({LOOPBACK_ADDRESS})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{networkInterfaces.map((networkInterface, index) => (
|
||||
<SelectItem
|
||||
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
|
||||
value={networkInterface.address}
|
||||
>
|
||||
{networkInterface.name} ({networkInterface.address})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Why: server sharing uses the same interface list as Mobile,
|
||||
and VPN/tailnet addresses can appear after Settings opens. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void loadNetworkInterfaces({ showToastOnError: true })}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label="Refresh connection addresses"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh connection addresses
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label htmlFor="runtime-pairing-custom-address">Custom address</Label>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
getRemoteRuntimePtyEnvironmentId,
|
||||
getRemoteRuntimeTerminalHandle
|
||||
} from '@/runtime/runtime-terminal-stream'
|
||||
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
|
||||
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
isSyntheticSinglePaneTitle,
|
||||
sanitizeTerminalLayoutPaneTitles
|
||||
} from '@/lib/terminal-pane-title-sanitization'
|
||||
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
|
||||
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
|
|
@ -587,6 +589,8 @@ export default function TerminalPane({
|
|||
// so the sidebar doesn't show a stale countdown for a pane that no
|
||||
// longer exists. The closeTab path handles bulk cleanup, but closing
|
||||
// a single split pane doesn't go through closeTab.
|
||||
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
|
||||
closeWebRuntimeTerminal(ptyId)
|
||||
const leafId = manager.getLeafId(paneId)
|
||||
if (leafId) {
|
||||
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
|
||||
|
|
@ -699,6 +703,69 @@ export default function TerminalPane({
|
|||
setPaneCount
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) {
|
||||
return
|
||||
}
|
||||
const manager = managerRef.current
|
||||
if (!manager || !restoredLayout.root) {
|
||||
return
|
||||
}
|
||||
const insertions = planTerminalLiveLayoutInsertions(
|
||||
restoredLayout.root,
|
||||
manager.getPanes().map((pane) => pane.leafId)
|
||||
)
|
||||
if (insertions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let appliedInsertion = false
|
||||
for (const insertion of insertions) {
|
||||
const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId]
|
||||
const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId)
|
||||
if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) {
|
||||
continue
|
||||
}
|
||||
// Why: paired web terminals receive host split-pane snapshots after the
|
||||
// pane manager is already mounted. Adopt the host leaf + PTY instead of
|
||||
// spawning a local-only web pane.
|
||||
// Before-placement swaps [source, new] after splitPane, so invert the
|
||||
// host first-child ratio before applying it to the temporary order.
|
||||
const splitRatio =
|
||||
insertion.ratio === undefined
|
||||
? undefined
|
||||
: insertion.placement === 'before'
|
||||
? 1 - insertion.ratio
|
||||
: insertion.ratio
|
||||
const createdPane = manager.splitPaneAroundLeafIds(
|
||||
insertion.sourceLeafIds,
|
||||
sourcePaneId,
|
||||
insertion.direction,
|
||||
{
|
||||
...(splitRatio !== undefined && { ratio: splitRatio }),
|
||||
leafId: insertion.newLeafId,
|
||||
ptyId,
|
||||
placement: insertion.placement
|
||||
}
|
||||
)
|
||||
if (!createdPane) {
|
||||
continue
|
||||
}
|
||||
appliedInsertion = true
|
||||
}
|
||||
|
||||
if (appliedInsertion) {
|
||||
persistLayoutSnapshot()
|
||||
}
|
||||
|
||||
if (restoredLayout.activeLeafId) {
|
||||
const activePaneId = manager.getNumericIdForLeaf(restoredLayout.activeLeafId)
|
||||
if (activePaneId !== null) {
|
||||
manager.setActivePane(activePaneId, { focus: isActive })
|
||||
}
|
||||
}
|
||||
}, [isActive, paneCount, persistLayoutSnapshot, restoredLayout])
|
||||
|
||||
// Why (Activity-only pane isolation): when this TerminalPane is being
|
||||
// portaled into the Activity page for a specific agent pane, hide the
|
||||
// other split siblings so the user only sees that agent's pane. Uses
|
||||
|
|
|
|||
|
|
@ -376,6 +376,26 @@ describe('connectPanePty', () => {
|
|||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-pane-2')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
consumeSuppressedPtyExit: vi.fn(() => true)
|
||||
})
|
||||
|
||||
connectPanePty(createPane(2) as never, manager as never, deps as never)
|
||||
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
|
||||
expect(onPtyExit).toBeTypeOf('function')
|
||||
|
||||
onPtyExit?.('pty-pane-2')
|
||||
|
||||
expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2')
|
||||
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
|
||||
expect(manager.closePane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send startup command via sendInput for local connections', async () => {
|
||||
// Why: the local PTY provider already writes the command via
|
||||
// writeStartupCommandWhenShellReady — sending it again from the renderer
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('normalizes bare LF input to carriage returns before writing to the remote PTY', async () => {
|
||||
it('preserves literal LF input when sending remote PTY binary frames', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
|
|
@ -673,15 +673,18 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
const { streamId } = latestSubscribePayload()
|
||||
runtimeCall.mockClear()
|
||||
subscriptionSendBinary.mockClear()
|
||||
|
||||
expect(transport.sendInput('echo one\necho two\r\n')).toBe(true)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
|
||||
const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
|
||||
expect(frame?.opcode).toBe(TerminalStreamOpcode.Input)
|
||||
expect(frame?.streamId).toBe(streamId)
|
||||
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\recho two\r')
|
||||
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\necho two\r\n')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,6 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
|
|||
const HOST_SESSION_ATTACH_POLL_MS = 150
|
||||
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
|
||||
|
||||
function normalizeRemoteTerminalInput(data: string): string {
|
||||
// Why: PTYs expect the Enter key as carriage return. Some browser/mobile
|
||||
// input paths can emit bare LF, which zsh renders with PROMPT_SP `%` marks.
|
||||
return data.replace(/\r\n/g, '\r').replace(/\n/g, '\r')
|
||||
}
|
||||
|
||||
function isRemoteTerminalGoneMessage(message: string): boolean {
|
||||
return (
|
||||
message.includes('terminal_handle_stale') ||
|
||||
|
|
@ -455,13 +449,12 @@ export function createRemoteRuntimePtyTransport(
|
|||
if (!connected || !handle) {
|
||||
return false
|
||||
}
|
||||
const normalized = normalizeRemoteTerminalInput(data)
|
||||
if (!normalized) {
|
||||
if (!data) {
|
||||
return true
|
||||
}
|
||||
// Why: remote terminal input currently crosses the runtime RPC boundary;
|
||||
// coalescing same-frame key bursts avoids a per-keystroke remote round-trip.
|
||||
inputBatcher.push(normalized)
|
||||
// Why: callers use \r or terminal.send's enter flag for semantic Enter;
|
||||
// literal LF bytes from paste/programmatic input must survive the stream.
|
||||
inputBatcher.push(data)
|
||||
return true
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
|
||||
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
|
||||
|
||||
describe('planTerminalLiveLayoutInsertions', () => {
|
||||
it('plans a host-added split leaf from an already-mounted source leaf', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-a',
|
||||
sourceLeafIds: ['leaf-a'],
|
||||
newLeafId: 'leaf-b',
|
||||
direction: 'vertical',
|
||||
placement: 'after'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('plans nested missing leaves in the order splitPane can apply them', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-b' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
}
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-a',
|
||||
sourceLeafIds: ['leaf-a'],
|
||||
newLeafId: 'leaf-b',
|
||||
direction: 'vertical',
|
||||
placement: 'after'
|
||||
},
|
||||
{
|
||||
sourceLeafId: 'leaf-b',
|
||||
sourceLeafIds: ['leaf-b'],
|
||||
newLeafId: 'leaf-c',
|
||||
direction: 'horizontal',
|
||||
placement: 'after'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('bridges a missing parent second subtree before filling the first subtree', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
},
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-a',
|
||||
sourceLeafIds: ['leaf-a'],
|
||||
newLeafId: 'leaf-c',
|
||||
direction: 'vertical',
|
||||
placement: 'after'
|
||||
},
|
||||
{
|
||||
sourceLeafId: 'leaf-a',
|
||||
sourceLeafIds: ['leaf-a'],
|
||||
newLeafId: 'leaf-b',
|
||||
direction: 'horizontal',
|
||||
placement: 'after'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('plans a parent sibling after an already-mounted first-side split with host ratio', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.35,
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
},
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a', 'leaf-b'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-b',
|
||||
sourceLeafIds: ['leaf-a', 'leaf-b'],
|
||||
newLeafId: 'leaf-c',
|
||||
direction: 'vertical',
|
||||
placement: 'after',
|
||||
ratio: 0.35
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('plans a missing first subtree before an already-mounted second leaf', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-b',
|
||||
sourceLeafIds: ['leaf-b'],
|
||||
newLeafId: 'leaf-a',
|
||||
direction: 'vertical',
|
||||
placement: 'before'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('plans nested missing first subtrees from an anchor in the second subtree', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-b' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
}
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-c'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-c',
|
||||
sourceLeafIds: ['leaf-c'],
|
||||
newLeafId: 'leaf-a',
|
||||
direction: 'vertical',
|
||||
placement: 'before'
|
||||
},
|
||||
{
|
||||
sourceLeafId: 'leaf-c',
|
||||
sourceLeafIds: ['leaf-c'],
|
||||
newLeafId: 'leaf-b',
|
||||
direction: 'horizontal',
|
||||
placement: 'before'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('plans a parent sibling before an already-mounted second-side split with host ratio', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.25,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-b' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
}
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b', 'leaf-c'])).toEqual([
|
||||
{
|
||||
sourceLeafId: 'leaf-b',
|
||||
sourceLeafIds: ['leaf-b', 'leaf-c'],
|
||||
newLeafId: 'leaf-a',
|
||||
direction: 'vertical',
|
||||
placement: 'before',
|
||||
ratio: 0.25
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not plan insertions when the layout has no mounted anchor leaf', () => {
|
||||
const layout: TerminalPaneLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
|
||||
expect(planTerminalLiveLayoutInsertions(layout, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types'
|
||||
|
||||
export type TerminalLiveLayoutInsertion = {
|
||||
sourceLeafId: string
|
||||
sourceLeafIds: string[]
|
||||
newLeafId: string
|
||||
direction: TerminalPaneSplitDirection
|
||||
placement: 'before' | 'after'
|
||||
ratio?: number
|
||||
}
|
||||
|
||||
function leftmostLeafId(node: TerminalPaneLayoutNode): string {
|
||||
return node.type === 'leaf' ? node.leafId : leftmostLeafId(node.first)
|
||||
}
|
||||
|
||||
function rightmostMountedLeafId(
|
||||
node: TerminalPaneLayoutNode,
|
||||
mountedLeafIds: ReadonlySet<string>
|
||||
): string | null {
|
||||
if (node.type === 'leaf') {
|
||||
return mountedLeafIds.has(node.leafId) ? node.leafId : null
|
||||
}
|
||||
return (
|
||||
rightmostMountedLeafId(node.second, mountedLeafIds) ??
|
||||
rightmostMountedLeafId(node.first, mountedLeafIds)
|
||||
)
|
||||
}
|
||||
|
||||
function leftmostMountedLeafId(
|
||||
node: TerminalPaneLayoutNode,
|
||||
mountedLeafIds: ReadonlySet<string>
|
||||
): string | null {
|
||||
if (node.type === 'leaf') {
|
||||
return mountedLeafIds.has(node.leafId) ? node.leafId : null
|
||||
}
|
||||
return (
|
||||
leftmostMountedLeafId(node.first, mountedLeafIds) ??
|
||||
leftmostMountedLeafId(node.second, mountedLeafIds)
|
||||
)
|
||||
}
|
||||
|
||||
function hasMountedLeaf(
|
||||
node: TerminalPaneLayoutNode,
|
||||
mountedLeafIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (node.type === 'leaf') {
|
||||
return mountedLeafIds.has(node.leafId)
|
||||
}
|
||||
return hasMountedLeaf(node.first, mountedLeafIds) || hasMountedLeaf(node.second, mountedLeafIds)
|
||||
}
|
||||
|
||||
function mountedLeafIdsIn(
|
||||
node: TerminalPaneLayoutNode,
|
||||
mountedLeafIds: ReadonlySet<string>
|
||||
): string[] {
|
||||
if (node.type === 'leaf') {
|
||||
return mountedLeafIds.has(node.leafId) ? [node.leafId] : []
|
||||
}
|
||||
return [
|
||||
...mountedLeafIdsIn(node.first, mountedLeafIds),
|
||||
...mountedLeafIdsIn(node.second, mountedLeafIds)
|
||||
]
|
||||
}
|
||||
|
||||
export function planTerminalLiveLayoutInsertions(
|
||||
root: TerminalPaneLayoutNode | null | undefined,
|
||||
currentLeafIds: Iterable<string>
|
||||
): TerminalLiveLayoutInsertion[] {
|
||||
if (!root) {
|
||||
return []
|
||||
}
|
||||
|
||||
const mountedLeafIds = new Set(currentLeafIds)
|
||||
const insertions: TerminalLiveLayoutInsertion[] = []
|
||||
|
||||
const ensureSubtree = (node: TerminalPaneLayoutNode): boolean => {
|
||||
if (node.type === 'leaf') {
|
||||
return mountedLeafIds.has(node.leafId)
|
||||
}
|
||||
|
||||
const firstHasMounted = hasMountedLeaf(node.first, mountedLeafIds)
|
||||
const secondHasMounted = hasMountedLeaf(node.second, mountedLeafIds)
|
||||
if (!firstHasMounted && !secondHasMounted) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: bridge the current split before filling nested descendants; once a
|
||||
// child subtree is split internally, PaneManager can no longer wrap it as
|
||||
// this split's sibling using a leaf-only splitPane call.
|
||||
if (firstHasMounted && !secondHasMounted) {
|
||||
const sourceLeafId = rightmostMountedLeafId(node.first, mountedLeafIds)
|
||||
const newLeafId = leftmostLeafId(node.second)
|
||||
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
|
||||
insertions.push({
|
||||
sourceLeafId,
|
||||
sourceLeafIds: mountedLeafIdsIn(node.first, mountedLeafIds),
|
||||
newLeafId,
|
||||
direction: node.direction,
|
||||
placement: 'after',
|
||||
ratio: node.ratio
|
||||
})
|
||||
mountedLeafIds.add(newLeafId)
|
||||
}
|
||||
ensureSubtree(node.second)
|
||||
ensureSubtree(node.first)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!firstHasMounted && secondHasMounted) {
|
||||
const sourceLeafId = leftmostMountedLeafId(node.second, mountedLeafIds)
|
||||
const newLeafId = leftmostLeafId(node.first)
|
||||
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
|
||||
insertions.push({
|
||||
sourceLeafId,
|
||||
sourceLeafIds: mountedLeafIdsIn(node.second, mountedLeafIds),
|
||||
newLeafId,
|
||||
direction: node.direction,
|
||||
placement: 'before',
|
||||
ratio: node.ratio
|
||||
})
|
||||
mountedLeafIds.add(newLeafId)
|
||||
}
|
||||
ensureSubtree(node.first)
|
||||
ensureSubtree(node.second)
|
||||
return true
|
||||
}
|
||||
|
||||
ensureSubtree(node.first)
|
||||
ensureSubtree(node.second)
|
||||
return true
|
||||
}
|
||||
|
||||
ensureSubtree(root)
|
||||
return insertions
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
shouldDetachPaneTransportOnUnmount,
|
||||
splitPaneWithOneShotStartup
|
||||
splitPaneWithOneShotStartup,
|
||||
suppressIntentionalPaneCloseExit
|
||||
} from './use-terminal-pane-lifecycle'
|
||||
|
||||
describe('splitPaneWithOneShotStartup', () => {
|
||||
|
|
@ -123,3 +124,25 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suppressIntentionalPaneCloseExit', () => {
|
||||
it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => {
|
||||
const suppressPtyExit = vi.fn()
|
||||
const transport = {
|
||||
getPtyId: vi.fn(() => 'pty-pane-2')
|
||||
}
|
||||
|
||||
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBe('pty-pane-2')
|
||||
expect(suppressPtyExit).toHaveBeenCalledWith('pty-pane-2')
|
||||
})
|
||||
|
||||
it('does not suppress natural PTY exits that already cleared the transport id', () => {
|
||||
const suppressPtyExit = vi.fn()
|
||||
const transport = {
|
||||
getPtyId: vi.fn(() => null)
|
||||
}
|
||||
|
||||
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBeNull()
|
||||
expect(suppressPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -141,6 +141,17 @@ type UseTerminalPaneLifecycleDeps = {
|
|||
setPaneCount: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
|
||||
export function suppressIntentionalPaneCloseExit(
|
||||
transport: Pick<PtyTransport, 'getPtyId'> | null | undefined,
|
||||
suppressPtyExit: (ptyId: string) => void
|
||||
): string | null {
|
||||
const ptyId = transport?.getPtyId() ?? null
|
||||
if (ptyId) {
|
||||
suppressPtyExit(ptyId)
|
||||
}
|
||||
return ptyId
|
||||
}
|
||||
|
||||
function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean {
|
||||
const range = terminal.getSelectionPosition()
|
||||
if (!range) {
|
||||
|
|
@ -681,8 +692,14 @@ export function useTerminalPaneLifecycle({
|
|||
panePtyBindings.delete(paneId)
|
||||
}
|
||||
if (transport) {
|
||||
const ptyId = transport.getPtyId()
|
||||
const ptyId = suppressIntentionalPaneCloseExit(
|
||||
transport,
|
||||
useAppStore.getState().suppressPtyExit
|
||||
)
|
||||
if (ptyId) {
|
||||
// Why: user/CLI pane closes intentionally tear down this PTY after
|
||||
// PaneManager has already promoted the sibling. Suppress that exit
|
||||
// so the last-surviving pane is not mistaken for an exited tab.
|
||||
syncPanePtyLayoutBinding(paneId, null)
|
||||
clearTabPtyId(tabId, ptyId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ vi.mock('@/runtime/web-runtime-session', () => ({
|
|||
isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock
|
||||
}))
|
||||
|
||||
import { createNewTerminalTab } from './terminal-tab-actions'
|
||||
import {
|
||||
closeOtherTerminalTabs,
|
||||
closeTerminalTabsToRight,
|
||||
createNewTerminalTab
|
||||
} from './terminal-tab-actions'
|
||||
|
||||
describe('createNewTerminalTab', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -83,3 +87,82 @@ describe('createNewTerminalTab', () => {
|
|||
expect(setActiveTabType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTerminalTabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('delegates other terminal closes to the host runtime in paired web clients', () => {
|
||||
const setActiveTab = vi.fn()
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'keep' }, { id: 'close-a' }, { id: 'close-b' }]
|
||||
},
|
||||
setActiveTab,
|
||||
closeTab
|
||||
})
|
||||
|
||||
closeOtherTerminalTabs('keep', 'wt-1')
|
||||
|
||||
expect(setActiveTab).toHaveBeenCalledWith('keep')
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'close-a',
|
||||
environmentId: 'web-runtime'
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'close-b',
|
||||
environmentId: 'web-runtime'
|
||||
})
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTerminalTabsToRight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('delegates terminal tabs to the host while still closing local editor tabs to the right', () => {
|
||||
const closeTab = vi.fn()
|
||||
const closeFile = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
getStateMock
|
||||
.mockReturnValueOnce({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'term-a' }, { id: 'term-b' }, { id: 'term-c' }]
|
||||
},
|
||||
openFiles: [{ id: 'file-b', worktreeId: 'wt-1' }],
|
||||
tabBarOrderByWorktree: { 'wt-1': ['term-a', 'file-b', 'term-b', 'term-c'] },
|
||||
closeTab
|
||||
})
|
||||
.mockReturnValue({
|
||||
closeFile
|
||||
})
|
||||
|
||||
closeTerminalTabsToRight('term-a', 'wt-1')
|
||||
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'term-b',
|
||||
environmentId: 'web-runtime'
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'term-c',
|
||||
environmentId: 'web-runtime'
|
||||
})
|
||||
expect(closeFile).toHaveBeenCalledWith('file-b')
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,9 +109,21 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string |
|
|||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? []
|
||||
state.setActiveTab(tabId)
|
||||
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
for (const tab of currentTabs) {
|
||||
if (tab.id !== tabId) {
|
||||
state.closeTab(tab.id)
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: paired web tabs are host-owned; local-only bulk close leaves
|
||||
// the host to re-publish the supposedly closed terminal tabs.
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: tab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
} else {
|
||||
state.closeTab(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +136,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
|
|||
const state = useAppStore.getState()
|
||||
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
|
||||
const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
const terminalIds = currentTerminalTabs.map((t) => t.id)
|
||||
const terminalIdSet = new Set(terminalIds)
|
||||
const orderedIds = reconcileTabOrder(
|
||||
|
|
@ -139,7 +153,17 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
|
|||
const rightIds = orderedIds.slice(index + 1)
|
||||
for (const id of rightIds) {
|
||||
if (terminalIdSet.has(id)) {
|
||||
state.closeTab(id)
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: paired web tabs are host-owned; local-only bulk close leaves
|
||||
// the host to re-publish the supposedly closed terminal tabs.
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
} else {
|
||||
state.closeTab(id)
|
||||
}
|
||||
} else {
|
||||
useAppStore.getState().closeFile(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ManagedPaneInternal,
|
||||
DropZone
|
||||
} from './pane-manager-types'
|
||||
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
|
||||
import {
|
||||
createDivider,
|
||||
applyDividerStyles,
|
||||
|
|
@ -32,6 +33,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
|||
import { PaneIdentityRegistry } from './pane-identity-registry'
|
||||
import { closeManagedPane, splitManagedPane } from './pane-split-close'
|
||||
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
|
||||
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
|
||||
|
||||
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
||||
|
||||
|
|
@ -100,6 +102,33 @@ export class PaneManager {
|
|||
})
|
||||
}
|
||||
|
||||
splitPaneAroundLeafIds(
|
||||
sourceLeafIds: readonly string[],
|
||||
fallbackPaneId: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
opts?: SplitPaneAroundLeafIdsOptions
|
||||
): ManagedPane | null {
|
||||
return splitPaneAroundMountedSubtree({
|
||||
sourceLeafIds,
|
||||
fallbackPaneId,
|
||||
direction,
|
||||
opts,
|
||||
panes: this.panes,
|
||||
root: this.root,
|
||||
styleOptions: this.styleOptions,
|
||||
managerOptions: this.options,
|
||||
getNumericIdForLeaf: (leafId) => this.identities.getNumericIdForLeaf(leafId),
|
||||
createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint),
|
||||
createDivider: (isVertical) => this.createDividerWrapped(isVertical),
|
||||
publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints),
|
||||
getDragCallbacks: () => this.getDragCallbacks(),
|
||||
setActivePaneId: (id) => {
|
||||
this.activePaneId = id
|
||||
},
|
||||
isDestroyed: () => this.destroyed
|
||||
})
|
||||
}
|
||||
|
||||
closePane(paneId: number): void {
|
||||
closeManagedPane({
|
||||
paneId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
|
||||
const captureScrollState = vi.hoisted(() => vi.fn())
|
||||
const wrapInSplit = vi.hoisted(() => vi.fn())
|
||||
const openTerminal = vi.hoisted(() => vi.fn())
|
||||
const disposeWebgl = vi.hoisted(() => vi.fn())
|
||||
const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn())
|
||||
const updateMultiPaneState = vi.hoisted(() => vi.fn())
|
||||
const applyPaneOpacity = vi.hoisted(() => vi.fn())
|
||||
const applyDividerStyles = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./pane-tree-ops', () => ({
|
||||
captureScrollState,
|
||||
findPaneChildren: vi.fn(),
|
||||
promoteSibling: vi.fn(),
|
||||
removeDividers: vi.fn(),
|
||||
safeFit: vi.fn(),
|
||||
wrapInSplit
|
||||
}))
|
||||
|
||||
vi.mock('./pane-lifecycle', () => ({
|
||||
disposePane: vi.fn(),
|
||||
openTerminal
|
||||
}))
|
||||
|
||||
vi.mock('./pane-webgl-renderer', () => ({
|
||||
disposeWebgl
|
||||
}))
|
||||
|
||||
vi.mock('./pane-split-scroll', () => ({
|
||||
scheduleSplitScrollRestore
|
||||
}))
|
||||
|
||||
vi.mock('./pane-drag-reorder', () => ({
|
||||
updateMultiPaneState
|
||||
}))
|
||||
|
||||
vi.mock('./pane-divider', () => ({
|
||||
applyDividerStyles,
|
||||
applyPaneOpacity
|
||||
}))
|
||||
|
||||
import { splitManagedPane } from './pane-split-close'
|
||||
|
||||
const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
|
||||
|
||||
class MockElement {
|
||||
classList: { contains: (className: string) => boolean }
|
||||
dataset: Record<string, string> = {}
|
||||
parentElement: MockElement | null = null
|
||||
style: Record<string, string> = {}
|
||||
private descendants: MockElement[] = []
|
||||
|
||||
constructor(private readonly classNames: string[]) {
|
||||
this.classList = {
|
||||
contains: (className: string) => this.classNames.includes(className)
|
||||
}
|
||||
}
|
||||
|
||||
setQuerySelectorAllResult(descendants: MockElement[]): void {
|
||||
this.descendants = descendants
|
||||
}
|
||||
|
||||
querySelectorAll(): MockElement[] {
|
||||
return this.descendants
|
||||
}
|
||||
}
|
||||
|
||||
function createScrollState(viewportY: number): ScrollState {
|
||||
return {
|
||||
bufferType: 'normal',
|
||||
wasAtBottom: false,
|
||||
firstVisibleLineContent: `line-${viewportY}`,
|
||||
viewportY,
|
||||
totalLines: 100
|
||||
}
|
||||
}
|
||||
|
||||
function createPane(id: number, webglAddon: unknown): ManagedPaneInternal {
|
||||
const container = new MockElement(['pane'])
|
||||
container.dataset.paneId = String(id)
|
||||
container.dataset.leafId = TEST_LEAF_ID
|
||||
return {
|
||||
id,
|
||||
leafId: TEST_LEAF_ID,
|
||||
stablePaneId: TEST_LEAF_ID,
|
||||
terminal: {
|
||||
focus: vi.fn()
|
||||
} as never,
|
||||
container: container as unknown as HTMLElement,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
gpuRenderingEnabled: true,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: webglAddon as never,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
fitAddon: {} as never,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('splitManagedPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
|
||||
const fallbackPane = createPane(1, { dispose: vi.fn() })
|
||||
const siblingPane = createPane(2, { dispose: vi.fn() })
|
||||
const newPane = createPane(3, null)
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[fallbackPane.id, fallbackPane],
|
||||
[siblingPane.id, siblingPane]
|
||||
])
|
||||
const root = new MockElement(['root'])
|
||||
const sourceContainer = new MockElement(['pane-split'])
|
||||
sourceContainer.parentElement = root
|
||||
sourceContainer.setQuerySelectorAllResult([
|
||||
fallbackPane.container as unknown as MockElement,
|
||||
siblingPane.container as unknown as MockElement
|
||||
])
|
||||
const fallbackScrollState = createScrollState(11)
|
||||
const siblingScrollState = createScrollState(22)
|
||||
captureScrollState
|
||||
.mockReturnValueOnce(fallbackScrollState)
|
||||
.mockReturnValueOnce(siblingScrollState)
|
||||
|
||||
const result = splitManagedPane({
|
||||
paneId: fallbackPane.id,
|
||||
direction: 'vertical',
|
||||
sourceContainer: sourceContainer as unknown as HTMLElement,
|
||||
panes,
|
||||
root: root as unknown as HTMLElement,
|
||||
styleOptions: {},
|
||||
managerOptions: {},
|
||||
createPaneInternal: () => {
|
||||
panes.set(newPane.id, newPane)
|
||||
return newPane
|
||||
},
|
||||
createDivider: () => new MockElement(['pane-divider']) as unknown as HTMLElement,
|
||||
publishPaneCreated: vi.fn(),
|
||||
getDragCallbacks: () => ({}) as never,
|
||||
setActivePaneId: vi.fn(),
|
||||
isDestroyed: () => false
|
||||
})
|
||||
|
||||
expect(result?.id).toBe(newPane.id)
|
||||
expect(captureScrollState).toHaveBeenCalledWith(fallbackPane.terminal)
|
||||
expect(captureScrollState).toHaveBeenCalledWith(siblingPane.terminal)
|
||||
expect(fallbackPane.pendingSplitScrollState).toBe(fallbackScrollState)
|
||||
expect(siblingPane.pendingSplitScrollState).toBe(siblingScrollState)
|
||||
expect(disposeWebgl).toHaveBeenCalledWith(fallbackPane)
|
||||
expect(disposeWebgl).toHaveBeenCalledWith(siblingPane)
|
||||
expect(wrapInSplit).toHaveBeenCalledWith(
|
||||
sourceContainer,
|
||||
newPane.container,
|
||||
true,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
expect(scheduleSplitScrollRestore).toHaveBeenCalledTimes(2)
|
||||
expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(Function),
|
||||
fallbackPane.id,
|
||||
fallbackScrollState,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
siblingPane.id,
|
||||
siblingScrollState,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -21,10 +21,17 @@ import { scheduleSplitScrollRestore } from './pane-split-scroll'
|
|||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import { toPublicPane } from './pane-public-view'
|
||||
|
||||
type MovedPaneSplitState = {
|
||||
pane: ManagedPaneInternal
|
||||
scrollState: ReturnType<typeof captureScrollState>
|
||||
hadWebgl: boolean
|
||||
}
|
||||
|
||||
type SplitManagedPaneArgs = {
|
||||
paneId: number
|
||||
direction: 'vertical' | 'horizontal'
|
||||
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
|
||||
sourceContainer?: HTMLElement
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
root: HTMLElement
|
||||
styleOptions: PaneStyleOptions
|
||||
|
|
@ -45,7 +52,8 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null
|
|||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
const parent = existing.container.parentElement
|
||||
const existingContainer = args.sourceContainer ?? existing.container
|
||||
const parent = existingContainer.parentElement
|
||||
if (!parent) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -53,32 +61,78 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null
|
|||
const isVertical = args.direction === 'vertical'
|
||||
const divider = args.createDivider(isVertical)
|
||||
|
||||
// Why: wrapInSplit reparents the existing container, resetting scrollTop.
|
||||
const scrollState = captureScrollState(existing.terminal)
|
||||
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during the
|
||||
// async settle window; scheduleSplitScrollRestore owns the restore.
|
||||
existing.pendingSplitScrollState = scrollState
|
||||
const movedPaneStates = prepareMovedPanesForSplit(existingContainer, existing, args.panes)
|
||||
|
||||
// Why: DOM reparenting can silently invalidate a WebGL context without
|
||||
// firing contextlost, so dispose before the move and reattach after settle.
|
||||
const hadWebgl = !!existing.webglAddon
|
||||
disposeWebgl(existing)
|
||||
|
||||
wrapInSplit(existing.container, newPane.container, isVertical, divider, args.opts)
|
||||
wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts)
|
||||
args.setActivePaneId(newPane.id)
|
||||
openSplitPane(args, newPane, args.opts?.cwd)
|
||||
|
||||
scheduleSplitScrollRestore(
|
||||
(id) => args.panes.get(id),
|
||||
existing.id,
|
||||
scrollState,
|
||||
args.isDestroyed,
|
||||
hadWebgl ? reattachWebglIfNeeded : undefined
|
||||
)
|
||||
for (const movedPaneState of movedPaneStates) {
|
||||
scheduleSplitScrollRestore(
|
||||
(id) => args.panes.get(id),
|
||||
movedPaneState.pane.id,
|
||||
movedPaneState.scrollState,
|
||||
args.isDestroyed,
|
||||
movedPaneState.hadWebgl ? reattachWebglIfNeeded : undefined
|
||||
)
|
||||
}
|
||||
|
||||
return toPublicPane(newPane)
|
||||
}
|
||||
|
||||
function prepareMovedPanesForSplit(
|
||||
sourceContainer: HTMLElement,
|
||||
fallbackPane: ManagedPaneInternal,
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): MovedPaneSplitState[] {
|
||||
const movedPanes = findManagedPanesInContainer(sourceContainer, panes)
|
||||
if (movedPanes.length === 0) {
|
||||
movedPanes.push(fallbackPane)
|
||||
}
|
||||
|
||||
return movedPanes.map((pane) => {
|
||||
// Why: wrapInSplit reparents moved containers, resetting browser scrollTop.
|
||||
const scrollState = captureScrollState(pane.terminal)
|
||||
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during the
|
||||
// async settle window; scheduleSplitScrollRestore owns the restore.
|
||||
pane.pendingSplitScrollState = scrollState
|
||||
|
||||
// Why: DOM reparenting can silently invalidate a WebGL context without
|
||||
// firing contextlost, so dispose before the move and reattach after settle.
|
||||
const hadWebgl = !!pane.webglAddon
|
||||
disposeWebgl(pane)
|
||||
return { pane, scrollState, hadWebgl }
|
||||
})
|
||||
}
|
||||
|
||||
function findManagedPanesInContainer(
|
||||
sourceContainer: HTMLElement,
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): ManagedPaneInternal[] {
|
||||
const movedPanes: ManagedPaneInternal[] = []
|
||||
const appendPaneById = (paneIdValue: string | undefined): void => {
|
||||
if (!paneIdValue) {
|
||||
return
|
||||
}
|
||||
const paneId = Number(paneIdValue)
|
||||
if (!Number.isFinite(paneId)) {
|
||||
return
|
||||
}
|
||||
const pane = panes.get(paneId)
|
||||
if (pane && !movedPanes.includes(pane)) {
|
||||
movedPanes.push(pane)
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceContainer.classList.contains('pane')) {
|
||||
appendPaneById(sourceContainer.dataset.paneId)
|
||||
}
|
||||
for (const paneElement of sourceContainer.querySelectorAll<HTMLElement>('.pane[data-pane-id]')) {
|
||||
appendPaneById(paneElement.dataset.paneId)
|
||||
}
|
||||
return movedPanes
|
||||
}
|
||||
|
||||
function openSplitPane(
|
||||
args: SplitManagedPaneArgs,
|
||||
newPane: ManagedPaneInternal,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import type {
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneManagerOptions,
|
||||
PaneStyleOptions
|
||||
} from './pane-manager-types'
|
||||
import type { DragReorderCallbacks } from './pane-drag-reorder'
|
||||
import { splitManagedPane } from './pane-split-close'
|
||||
|
||||
export type SplitPaneAroundLeafIdsOptions = {
|
||||
ratio?: number
|
||||
cwd?: string
|
||||
leafId?: string
|
||||
ptyId?: string
|
||||
placement?: 'before' | 'after'
|
||||
}
|
||||
|
||||
type SplitPaneAroundLeafIdsArgs = {
|
||||
sourceLeafIds: readonly string[]
|
||||
fallbackPaneId: number
|
||||
direction: 'vertical' | 'horizontal'
|
||||
opts?: SplitPaneAroundLeafIdsOptions
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
root: HTMLElement
|
||||
styleOptions: PaneStyleOptions
|
||||
managerOptions: PaneManagerOptions
|
||||
getNumericIdForLeaf: (leafId: string) => number | null
|
||||
createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal
|
||||
createDivider: (isVertical: boolean) => HTMLElement
|
||||
publishPaneCreated: (
|
||||
pane: ManagedPaneInternal,
|
||||
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[1]
|
||||
) => void
|
||||
getDragCallbacks: () => DragReorderCallbacks
|
||||
setActivePaneId: (paneId: number | null) => void
|
||||
isDestroyed: () => boolean
|
||||
}
|
||||
|
||||
export function splitPaneAroundMountedSubtree(
|
||||
args: SplitPaneAroundLeafIdsArgs
|
||||
): ManagedPane | null {
|
||||
// Why: live host reconciliation may need to add a sibling to an already
|
||||
// mounted split subtree; splitting only the anchor leaf corrupts the shape.
|
||||
const sourceContainer =
|
||||
findMountedSubtreeContainer(args.sourceLeafIds, args) ??
|
||||
args.panes.get(args.fallbackPaneId)?.container
|
||||
if (!sourceContainer) {
|
||||
return null
|
||||
}
|
||||
const createdPane = splitManagedPane({
|
||||
paneId: args.fallbackPaneId,
|
||||
direction: args.direction,
|
||||
opts: args.opts,
|
||||
sourceContainer,
|
||||
panes: args.panes,
|
||||
root: args.root,
|
||||
styleOptions: args.styleOptions,
|
||||
managerOptions: args.managerOptions,
|
||||
createPaneInternal: args.createPaneInternal,
|
||||
createDivider: args.createDivider,
|
||||
publishPaneCreated: args.publishPaneCreated,
|
||||
getDragCallbacks: args.getDragCallbacks,
|
||||
setActivePaneId: args.setActivePaneId,
|
||||
isDestroyed: args.isDestroyed
|
||||
})
|
||||
if (!createdPane || args.opts?.placement !== 'before') {
|
||||
return createdPane
|
||||
}
|
||||
|
||||
const createdInternal = args.panes.get(createdPane.id)
|
||||
if (createdInternal) {
|
||||
placeCreatedPaneBeforeSource(sourceContainer, createdInternal.container)
|
||||
}
|
||||
return createdPane
|
||||
}
|
||||
|
||||
function findMountedSubtreeContainer(
|
||||
sourceLeafIds: readonly string[],
|
||||
args: Pick<SplitPaneAroundLeafIdsArgs, 'getNumericIdForLeaf' | 'panes' | 'root'>
|
||||
): HTMLElement | null {
|
||||
if (sourceLeafIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
const expectedLeafIds = new Set(sourceLeafIds)
|
||||
const firstLeafId = sourceLeafIds[0]
|
||||
if (!firstLeafId) {
|
||||
return null
|
||||
}
|
||||
const firstPaneId = args.getNumericIdForLeaf(firstLeafId)
|
||||
const firstPane = firstPaneId === null ? null : args.panes.get(firstPaneId)
|
||||
let candidate: HTMLElement | null = firstPane?.container ?? null
|
||||
while (candidate && candidate !== args.root) {
|
||||
if (
|
||||
(candidate.classList.contains('pane') || candidate.classList.contains('pane-split')) &&
|
||||
setsEqual(leafIdsInContainer(candidate), expectedLeafIds)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
candidate = candidate.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function leafIdsInContainer(container: HTMLElement): Set<string> {
|
||||
const leafIds = new Set<string>()
|
||||
if (container.classList.contains('pane') && container.dataset.leafId) {
|
||||
leafIds.add(container.dataset.leafId)
|
||||
}
|
||||
for (const pane of container.querySelectorAll<HTMLElement>('.pane[data-leaf-id]')) {
|
||||
if (pane.dataset.leafId) {
|
||||
leafIds.add(pane.dataset.leafId)
|
||||
}
|
||||
}
|
||||
return leafIds
|
||||
}
|
||||
|
||||
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function placeCreatedPaneBeforeSource(
|
||||
sourceContainer: HTMLElement,
|
||||
createdContainer: HTMLElement
|
||||
): boolean {
|
||||
const split = createdContainer.parentElement
|
||||
if (!split || sourceContainer.parentElement !== split) {
|
||||
return false
|
||||
}
|
||||
const divider = Array.from(split.children).find(
|
||||
(child): child is HTMLElement =>
|
||||
child instanceof HTMLElement && child.classList.contains('pane-divider')
|
||||
)
|
||||
if (!divider) {
|
||||
return false
|
||||
}
|
||||
|
||||
split.replaceChildren(createdContainer, divider, sourceContainer)
|
||||
return true
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ const runtimeEnvironmentSubscribe = vi.fn()
|
|||
const runtimeCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
fsReadFile.mockReset()
|
||||
fsOnChanged.mockReset()
|
||||
|
|
@ -1183,4 +1184,76 @@ describe('runtime file client', () => {
|
|||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates stopped pre-ready web shared file watch cleanup to the subscription handle', async () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
const onPayload = vi.fn()
|
||||
const unsubscribe = vi.fn()
|
||||
let onResponse: ((response: unknown) => void) | undefined
|
||||
runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => {
|
||||
onResponse = callbacks.onResponse
|
||||
return Promise.resolve({ unsubscribe, sendBinary: vi.fn() })
|
||||
})
|
||||
|
||||
const stop = await subscribeRuntimeFileChanges(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/remote/repo'
|
||||
},
|
||||
onPayload
|
||||
)
|
||||
|
||||
stop()
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.unwatch' })
|
||||
)
|
||||
|
||||
onResponse?.({
|
||||
id: 'ready',
|
||||
ok: true,
|
||||
result: { type: 'ready', subscriptionId: 'files-watch-late' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.unwatch' })
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates stopped ready web shared file watch cleanup to the subscription handle', async () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
const onPayload = vi.fn()
|
||||
const unsubscribe = vi.fn()
|
||||
let onResponse: ((response: unknown) => void) | undefined
|
||||
runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => {
|
||||
onResponse = callbacks.onResponse
|
||||
return Promise.resolve({ unsubscribe, sendBinary: vi.fn() })
|
||||
})
|
||||
|
||||
const stop = await subscribeRuntimeFileChanges(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/remote/repo'
|
||||
},
|
||||
onPayload
|
||||
)
|
||||
|
||||
onResponse?.({
|
||||
id: 'ready',
|
||||
ok: true,
|
||||
result: { type: 'ready', subscriptionId: 'files-watch-ready' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
stop()
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.unwatch' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ type SharedRuntimeFileWatch = {
|
|||
start: Promise<void>
|
||||
unsubscribe: (() => void) | null
|
||||
remoteSubscriptionId: string | null
|
||||
keepStreamUntilReady: boolean
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
|
|
@ -635,6 +636,7 @@ function createSharedRuntimeFileWatch(
|
|||
start: Promise.resolve(),
|
||||
unsubscribe: null,
|
||||
remoteSubscriptionId: null,
|
||||
keepStreamUntilReady: isWebRuntimeFileWatchSharedSocket(),
|
||||
closed: false
|
||||
}
|
||||
// Why: editor reloads and the Explorer can watch the same remote worktree.
|
||||
|
|
@ -664,12 +666,14 @@ function createSharedRuntimeFileWatch(
|
|||
}
|
||||
)
|
||||
.then((subscription) => {
|
||||
shared.unsubscribe = subscription.unsubscribe
|
||||
if (shared.closed || sharedRuntimeFileWatches.get(key) !== shared) {
|
||||
subscription.unsubscribe()
|
||||
unwatchSharedRuntimeFileWatch(shared)
|
||||
return
|
||||
shared.unsubscribe = null
|
||||
if (!shared.keepStreamUntilReady) {
|
||||
unwatchSharedRuntimeFileWatch(shared)
|
||||
}
|
||||
}
|
||||
shared.unsubscribe = subscription.unsubscribe
|
||||
})
|
||||
.catch((err) => {
|
||||
if (sharedRuntimeFileWatches.get(key) === shared) {
|
||||
|
|
@ -693,6 +697,13 @@ function handleSharedRuntimeFileWatchResponse(
|
|||
)
|
||||
if (event.type === 'ready') {
|
||||
shared.remoteSubscriptionId = event.subscriptionId
|
||||
if (shared.closed) {
|
||||
shared.unsubscribe?.()
|
||||
shared.unsubscribe = null
|
||||
if (!shared.keepStreamUntilReady) {
|
||||
unwatchSharedRuntimeFileWatch(shared)
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'changed') {
|
||||
for (const listener of Array.from(shared.listeners)) {
|
||||
listener.onPayload({ worktreePath, events: event.events })
|
||||
|
|
@ -715,11 +726,22 @@ function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch
|
|||
}
|
||||
shared.closed = true
|
||||
sharedRuntimeFileWatches.delete(key)
|
||||
if (shared.keepStreamUntilReady) {
|
||||
// Why: WebRuntimeClient owns shared-socket file-watch cleanup, including
|
||||
// pre-ready fallback timers and late-ready files.unwatch.
|
||||
shared.unsubscribe?.()
|
||||
shared.unsubscribe = null
|
||||
return
|
||||
}
|
||||
shared.unsubscribe?.()
|
||||
shared.unsubscribe = null
|
||||
unwatchSharedRuntimeFileWatch(shared)
|
||||
}
|
||||
|
||||
function isWebRuntimeFileWatchSharedSocket(): boolean {
|
||||
return Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
|
||||
}
|
||||
|
||||
function unwatchSharedRuntimeFileWatch(shared: SharedRuntimeFileWatch): void {
|
||||
if (!shared.remoteSubscriptionId) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import {
|
||||
activateWebRuntimeSessionTab,
|
||||
closeWebRuntimeTerminal,
|
||||
closeWebRuntimeSessionTab,
|
||||
createWebRuntimeSessionBrowserTab,
|
||||
createWebRuntimeSessionTerminal,
|
||||
|
|
@ -667,3 +668,64 @@ describe('web runtime session tab actions', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeWebRuntimeTerminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('delegates remote pane close to the host runtime', async () => {
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'close',
|
||||
ok: true,
|
||||
result: {
|
||||
close: {
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
ptyKilled: true
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'web-env-1',
|
||||
method: 'terminal.close',
|
||||
params: {
|
||||
terminal: 'terminal-1'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores local panes and inactive web sessions', () => {
|
||||
const runtimeCall = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(closeWebRuntimeTerminal('pty-local-1')).toBe(false)
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
|
||||
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(false)
|
||||
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalSplit
|
||||
} from '../../../shared/runtime-types'
|
||||
import type { AppState } from '../store/types'
|
||||
|
|
@ -455,3 +456,37 @@ export function splitWebRuntimeTerminal(
|
|||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export function closeWebRuntimeTerminal(ptyId: string | null | undefined): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
const remote = parseRemoteRuntimePtyId(ptyId)
|
||||
const environmentId = remote?.environmentId?.trim()
|
||||
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: host-session mirror panes are detached locally in the browser, but
|
||||
// the host owns the real pane graph. Close the host terminal first so later
|
||||
// session snapshots cannot resurrect the locally removed pane.
|
||||
void window.api.runtimeEnvironments
|
||||
.call({
|
||||
selector: environmentId,
|
||||
method: 'terminal.close',
|
||||
params: {
|
||||
terminal: remote.handle
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
.then((response) => {
|
||||
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ close: RuntimeTerminalClose }>)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[web-runtime-session] failed to close terminal pane:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: these tests share one mocked browser
|
||||
WebSocket/E2EE transport fixture, and splitting them would obscure the
|
||||
subscription lifecycle regressions they cover. */
|
||||
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'
|
||||
import WebSocket, { WebSocketServer } from 'ws'
|
||||
import { WebRuntimeClient } from './web-runtime-client'
|
||||
|
|
@ -128,6 +131,227 @@ describe('WebRuntimeClient', () => {
|
|||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps file watches on the owning WebSocket instead of opening child clients', async () => {
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: Buffer.alloc(32).toString('base64')
|
||||
})
|
||||
const handle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
const internals = client as unknown as {
|
||||
childClients: Set<WebRuntimeClient>
|
||||
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
|
||||
}
|
||||
const subscribeOnCurrentConnection = vi
|
||||
.spyOn(internals, 'subscribeOnCurrentConnection')
|
||||
.mockResolvedValue(handle)
|
||||
|
||||
const subscription = await client.subscribe(
|
||||
'files.watch',
|
||||
{ worktree: 'wt-1' },
|
||||
{ onResponse: vi.fn() }
|
||||
)
|
||||
|
||||
expect(subscribeOnCurrentConnection).toHaveBeenCalledWith(
|
||||
'files.watch',
|
||||
{ worktree: 'wt-1' },
|
||||
expect.objectContaining({ onResponse: expect.any(Function) }),
|
||||
undefined
|
||||
)
|
||||
expect(internals.childClients.size).toBe(0)
|
||||
const frame = new Uint8Array([1])
|
||||
subscription.sendBinary(frame)
|
||||
expect(handle.sendBinary).toHaveBeenCalledWith(frame)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('unwatches a direct file watch before removing the shared local subscription', async () => {
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: Buffer.alloc(32).toString('base64')
|
||||
})
|
||||
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
const internals = client as unknown as {
|
||||
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
|
||||
}
|
||||
const subscribeOnCurrentConnection = vi
|
||||
.spyOn(internals, 'subscribeOnCurrentConnection')
|
||||
.mockResolvedValue(localHandle)
|
||||
const unwatch = vi.spyOn(client, 'call').mockImplementation(() => {
|
||||
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
|
||||
return Promise.resolve({
|
||||
id: 'unwatch',
|
||||
ok: true,
|
||||
result: { unsubscribed: true },
|
||||
_meta: { runtimeId: 'runtime-web-test' }
|
||||
})
|
||||
})
|
||||
const onResponse = vi.fn()
|
||||
|
||||
const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse })
|
||||
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
|
||||
wrappedCallbacks?.onResponse({
|
||||
id: 'watch',
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId: 'files-watch-1' },
|
||||
_meta: { runtimeId: 'runtime-web-test' }
|
||||
} as RuntimeRpcResponse<unknown> & { streaming: true })
|
||||
|
||||
subscription.unsubscribe()
|
||||
|
||||
expect(onResponse).toHaveBeenCalledTimes(1)
|
||||
expect(unwatch).toHaveBeenCalledWith(
|
||||
'files.unwatch',
|
||||
{ subscriptionId: 'files-watch-1' },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('removes the shared local subscription when remote unwatch fails', async () => {
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: Buffer.alloc(32).toString('base64')
|
||||
})
|
||||
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
const internals = client as unknown as {
|
||||
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
|
||||
}
|
||||
const subscribeOnCurrentConnection = vi
|
||||
.spyOn(internals, 'subscribeOnCurrentConnection')
|
||||
.mockResolvedValue(localHandle)
|
||||
const unwatchError = new Error('remote unwatch failed')
|
||||
const unwatch = vi.spyOn(client, 'call').mockRejectedValue(unwatchError)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const subscription = await client.subscribe(
|
||||
'files.watch',
|
||||
{ worktree: 'wt-1' },
|
||||
{ onResponse: vi.fn() }
|
||||
)
|
||||
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
|
||||
wrappedCallbacks?.onResponse({
|
||||
id: 'watch',
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId: 'files-watch-failing-unwatch' },
|
||||
_meta: { runtimeId: 'runtime-web-test' }
|
||||
} as RuntimeRpcResponse<unknown> & { streaming: true })
|
||||
|
||||
subscription.unsubscribe()
|
||||
|
||||
expect(unwatch).toHaveBeenCalledWith(
|
||||
'files.unwatch',
|
||||
{ subscriptionId: 'files-watch-failing-unwatch' },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
|
||||
expect(warn).toHaveBeenCalledWith('Failed to unwatch remote file subscription:', unwatchError)
|
||||
} finally {
|
||||
client.close()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stopped direct file watch alive until ready so it can unwatch', async () => {
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: Buffer.alloc(32).toString('base64')
|
||||
})
|
||||
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
const internals = client as unknown as {
|
||||
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
|
||||
}
|
||||
const subscribeOnCurrentConnection = vi
|
||||
.spyOn(internals, 'subscribeOnCurrentConnection')
|
||||
.mockResolvedValue(localHandle)
|
||||
const unwatch = vi.spyOn(client, 'call').mockResolvedValue({
|
||||
id: 'unwatch',
|
||||
ok: true,
|
||||
result: { unsubscribed: true },
|
||||
_meta: { runtimeId: 'runtime-web-test' }
|
||||
})
|
||||
const onResponse = vi.fn()
|
||||
|
||||
const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse })
|
||||
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
|
||||
|
||||
subscription.unsubscribe()
|
||||
expect(unwatch).not.toHaveBeenCalled()
|
||||
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
wrappedCallbacks?.onResponse({
|
||||
id: 'watch',
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId: 'files-watch-late' },
|
||||
_meta: { runtimeId: 'runtime-web-test' }
|
||||
} as RuntimeRpcResponse<unknown> & { streaming: true })
|
||||
|
||||
expect(onResponse).not.toHaveBeenCalled()
|
||||
expect(unwatch).toHaveBeenCalledWith(
|
||||
'files.unwatch',
|
||||
{ subscriptionId: 'files-watch-late' },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('cleans up a stopped pre-ready shared file watch if ready never arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
const timerWindow = window as unknown as {
|
||||
setTimeout: typeof setTimeout
|
||||
clearTimeout: typeof clearTimeout
|
||||
}
|
||||
timerWindow.setTimeout = setTimeout
|
||||
timerWindow.clearTimeout = clearTimeout
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: Buffer.alloc(32).toString('base64')
|
||||
})
|
||||
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
const internals = client as unknown as {
|
||||
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
|
||||
}
|
||||
vi.spyOn(internals, 'subscribeOnCurrentConnection').mockResolvedValue(localHandle)
|
||||
const unwatch = vi.spyOn(client, 'call')
|
||||
|
||||
try {
|
||||
const subscription = await client.subscribe(
|
||||
'files.watch',
|
||||
{ worktree: 'wt-1' },
|
||||
{ onResponse: vi.fn() }
|
||||
)
|
||||
|
||||
subscription.unsubscribe()
|
||||
expect(unwatch).not.toHaveBeenCalled()
|
||||
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(unwatch).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
client.close()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('decrypts binary WebSocket frames into subscription callbacks', async () => {
|
||||
const client = new WebRuntimeClient({
|
||||
v: 2,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable max-lines -- Why: this browser runtime client owns the E2EE
|
||||
WebSocket state machine, JSON-RPC request routing, streaming callbacks, and
|
||||
binary frame forwarding as one transport boundary. */
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope'
|
||||
import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
|
||||
import type { WebPairingOffer } from './web-pairing'
|
||||
import {
|
||||
|
|
@ -50,7 +50,9 @@ export type WebRuntimeSubscriptionHandle = {
|
|||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const FILE_WATCH_READY_CLEANUP_TIMEOUT_MS = 5_000
|
||||
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 15_000]
|
||||
const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch'])
|
||||
|
||||
export class WebRuntimeClient {
|
||||
private ws: WebSocket | null = null
|
||||
|
|
@ -101,6 +103,12 @@ export class WebRuntimeClient {
|
|||
callbacks: SubscriptionCallbacks,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<WebRuntimeSubscriptionHandle> {
|
||||
if (SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method)) {
|
||||
// Why: file watches are text-only and already have an explicit
|
||||
// files.unwatch RPC, so sharing the main socket avoids exhausting the
|
||||
// server's WebSocket connection cap in large browser sessions.
|
||||
return this.subscribeSharedFileWatch(params, callbacks, options)
|
||||
}
|
||||
const client = new WebRuntimeClient(this.pairing)
|
||||
this.childClients.add(client)
|
||||
const closeChild = (notifySubscriptions = false): void => {
|
||||
|
|
@ -138,6 +146,112 @@ export class WebRuntimeClient {
|
|||
}
|
||||
}
|
||||
|
||||
private async subscribeSharedFileWatch(
|
||||
params: unknown,
|
||||
callbacks: SubscriptionCallbacks,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<WebRuntimeSubscriptionHandle> {
|
||||
let stopped = false
|
||||
let remoteSubscriptionId: string | null = null
|
||||
let unwatchStarted = false
|
||||
let handle: WebRuntimeSubscriptionHandle | null = null
|
||||
let readyCleanupTimer: number | null = null
|
||||
const clearReadyCleanupTimer = (): void => {
|
||||
if (readyCleanupTimer === null) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(readyCleanupTimer)
|
||||
readyCleanupTimer = null
|
||||
}
|
||||
const dropLocalSubscription = (): void => {
|
||||
clearReadyCleanupTimer()
|
||||
handle?.unsubscribe()
|
||||
}
|
||||
const schedulePreReadyCleanup = (): void => {
|
||||
if (readyCleanupTimer !== null) {
|
||||
return
|
||||
}
|
||||
// Why: if a stopped watch never reaches ready/error, no remote id exists
|
||||
// to unwatch; bound the lifetime of the local callback on this socket.
|
||||
readyCleanupTimer = window.setTimeout(() => {
|
||||
readyCleanupTimer = null
|
||||
handle?.unsubscribe()
|
||||
}, FILE_WATCH_READY_CLEANUP_TIMEOUT_MS)
|
||||
}
|
||||
const unwatchAndDropLocalSubscription = (): void => {
|
||||
if (unwatchStarted) {
|
||||
return
|
||||
}
|
||||
unwatchStarted = true
|
||||
if (!remoteSubscriptionId) {
|
||||
dropLocalSubscription()
|
||||
return
|
||||
}
|
||||
clearReadyCleanupTimer()
|
||||
// Why: shared files.watch streams stay on this socket, so stop the
|
||||
// server watcher before removing the local callback that receives ready.
|
||||
void this.call(
|
||||
'files.unwatch',
|
||||
{ subscriptionId: remoteSubscriptionId },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('Failed to unwatch remote file subscription:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
dropLocalSubscription()
|
||||
})
|
||||
}
|
||||
const wrappedCallbacks: SubscriptionCallbacks = {
|
||||
...callbacks,
|
||||
onResponse: (response) => {
|
||||
if (isFileWatchReadyResponse(response)) {
|
||||
remoteSubscriptionId = response.result.subscriptionId
|
||||
if (stopped) {
|
||||
unwatchAndDropLocalSubscription()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!stopped) {
|
||||
callbacks.onResponse(response)
|
||||
} else if (response.ok === false) {
|
||||
dropLocalSubscription()
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!stopped) {
|
||||
callbacks.onError?.(error)
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
if (!stopped) {
|
||||
callbacks.onClose?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
handle = await this.subscribeOnCurrentConnection(
|
||||
'files.watch',
|
||||
params,
|
||||
wrappedCallbacks,
|
||||
options
|
||||
)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
if (remoteSubscriptionId) {
|
||||
unwatchAndDropLocalSubscription()
|
||||
} else {
|
||||
schedulePreReadyCleanup()
|
||||
}
|
||||
},
|
||||
sendBinary: (bytes) => handle?.sendBinary(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribeOnCurrentConnection(
|
||||
method: string,
|
||||
params: unknown,
|
||||
|
|
@ -529,6 +643,21 @@ function isRuntimeFailureResponse(
|
|||
)
|
||||
}
|
||||
|
||||
function isFileWatchReadyResponse(
|
||||
response: RuntimeRpcResponse<unknown>
|
||||
): response is RuntimeRpcSuccess<{ type: 'ready'; subscriptionId: string }> {
|
||||
if (!response.ok) {
|
||||
return false
|
||||
}
|
||||
const result = response.result
|
||||
return (
|
||||
!!result &&
|
||||
typeof result === 'object' &&
|
||||
(result as { type?: unknown }).type === 'ready' &&
|
||||
typeof (result as { subscriptionId?: unknown }).subscriptionId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isEndResult(value: unknown): value is { type: 'end' } {
|
||||
return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue