Add the dark layout.apply channel and route pane splits through main (build switch, off by default) (#29185)

* 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

* 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
This commit is contained in:
Jinwoo Hong
2026-10-15 16:07:55 -04:00
committed by GitHub
parent b1db1031a4
commit 06ead8dad0
22 changed files with 1078 additions and 31 deletions
@@ -96,7 +96,9 @@ function loadRegistryAndSchemas(modules) {
format: 'cjs',
outfile,
logLevel: 'error',
packages: 'external'
packages: 'external',
// Why: the catalog types every method a build can register, layout-owner-only ones included.
define: { ORCA_LAYOUT_OWNER: '"main"' }
})
const loaded = createRequire(import.meta.url)(outfile)
return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES }
+10 -2
View File
@@ -66,6 +66,12 @@ const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL =
? JSON.stringify(orcaDiagnosticsTokenUrl)
: 'null'
// Why: the dark layout-owner path (src/shared/layout-owner-switch.ts) is compiled in only for a
// build that asks for it; every other build substitutes 'window', so its branches fold away.
const ORCA_LAYOUT_OWNER_LITERAL = JSON.stringify(
process.env.ORCA_LAYOUT_OWNER === 'main' ? 'main' : 'window'
)
// Why: official skill installs pin to the commit the build came from, so they match the
// bundled skills on every channel. Main runs the installer; the renderer prints the command.
const ORCA_BUILD_COMMIT_LITERAL = JSON.stringify(resolveBuildSourceCommit())
@@ -253,7 +259,8 @@ export const electronViteConfig: UserConfig = {
ORCA_BUILD_IDENTITY: ORCA_BUILD_IDENTITY_LITERAL,
ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL,
ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL,
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
},
// Why: @xterm/headless declares "exports": null in package.json, which
// prevents Vite's default resolver from finding the CJS entry. Point
@@ -276,7 +283,8 @@ export const electronViteConfig: UserConfig = {
},
renderer: {
define: {
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
},
resolve: {
alias: {
@@ -24,7 +24,9 @@ import type {
} from '../../../shared/terminal-leaf-move'
import {
admitPane,
applyLayout,
moveLeaf,
topologyCommitContext,
withdrawPane,
type TerminalTopologyCommitContext
} from '../terminal-topology/terminal-topology-commit'
@@ -34,6 +36,8 @@ import type {
} from '../terminal-topology/terminal-pane-admission'
import type {
CommandOf,
LayoutApplyOutcome,
LayoutCommand,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'
@@ -229,7 +233,7 @@ export class PtyBindingPersistenceOperations {
*/
moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
moveLeaf(request, topologyCommitContext(this))
moveLeaf(request, this.topology())
)
}
@@ -239,7 +243,7 @@ export class PtyBindingPersistenceOperations {
hostId?: string | null
): Promise<TerminalPaneAdmissionOutcome> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
admitPane(resolveHostId(hostId), admission, topologyCommitContext(this))
admitPane(resolveHostId(hostId), admission, this.topology())
)
}
@@ -248,20 +252,19 @@ export class PtyBindingPersistenceOperations {
hostId?: string | null
): Promise<LayoutRefusalCode | null> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this))
withdrawPane(resolveHostId(hostId), pane, this.topology())
)
}
}
function topologyCommitContext(
owner: PtyBindingPersistenceOperations
): TerminalTopologyCommitContext {
const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext]
return {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
private topology(): TerminalTopologyCommitContext {
return topologyCommitContext(this[ptyBindingPersistenceOperationsContext])
}
/** A client's layout command (`layout.apply`); the commit module owns the write. */
applyLayoutCommand(command: LayoutCommand, hostId?: string | null): Promise<LayoutApplyOutcome> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
applyLayout(resolveHostId(hostId), command, this.topology())
)
}
}
@@ -0,0 +1,136 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { Store } from '../loading-store/store'
import { leafIds, WindowSession } from './terminal-topology-window-session-fixture'
import {
emptyTerminalSessionProfile,
FIXTURE_FOLDER_WORKTREE_ID,
FIXTURE_GIT_WORKTREE_ID,
openTopologyStore,
reopenTopologyStore
} from './terminal-topology-profile-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
const WORKTREE = FIXTURE_GIT_WORKTREE_ID
const TAB = 'tab-1'
const LEAF = '11111111-1111-4111-8111-111111111111'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
async function storeWithOneTab(
hostId?: string,
worktreeId = WORKTREE
): Promise<{ store: Store; directory: string }> {
const directory = mkdtempSync(join(tmpdir(), 'orca-layout-apply-'))
directories.push(directory)
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
const window = new WindowSession(getDefaultWorkspaceSession())
window.addTab(worktreeId, TAB, LEAF)
window.bind(TAB, LEAF, 'pty-1')
store.setWorkspaceSession(window.snapshot(), hostId)
await store.flushPendingOrThrowAsync()
return { store, directory }
}
const split = {
type: 'splitPane',
workspace: WORKTREE,
tabId: TAB,
leafId: LEAF,
direction: 'vertical'
} as const
describe('Store.applyLayoutCommand (layout.apply)', () => {
it('writes a split durably, with a pane id main minted', async () => {
const { store, directory } = await storeWithOneTab()
const reply = await store.applyLayoutCommand(split)
expect(reply.ok).toBe(true)
const leafId = reply.ok ? reply.result.leafId : undefined
expect(leafId).toMatch(UUID)
expect(reply.ok && reply.result.paneKey).toBe(`${TAB}:${leafId}`)
const reopened = await reopenTopologyStore(store, directory)
const layout = reopened.getWorkspaceSession().terminalLayoutsByTabId[TAB]
expect(layout?.root).toEqual({
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF },
second: { type: 'leaf', leafId }
})
// The new pane has no terminal yet: the window starts it when it mounts the pane.
expect(layout?.ptyIdsByLeafId).toEqual({ [LEAF]: 'pty-1' })
})
it('writes a folder workspace’s split', async () => {
const { store } = await storeWithOneTab(undefined, FIXTURE_FOLDER_WORKTREE_ID)
const reply = await store.applyLayoutCommand({
...split,
workspace: FIXTURE_FOLDER_WORKTREE_ID
})
expect(reply.ok).toBe(true)
const root = store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root
expect(leafIds(root)).toEqual([LEAF, reply.ok ? reply.result.leafId : undefined])
})
it('keeps records the split did not touch exactly as stored', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-layout-apply-'))
directories.push(directory)
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
const window = new WindowSession(getDefaultWorkspaceSession())
window.addTab(WORKTREE, TAB, LEAF)
window.bind(TAB, LEAF, 'pty-1')
// Leftovers a full load-and-save rewrite would drop or renumber.
window.session.terminalPtyIncarnationsByPaneKey = { [`${TAB}:${LEAF}`]: 'incarnation-1' }
window.session.tabsByWorktree[WORKTREE]![0]!.sortOrder = 7
store.setWorkspaceSession(window.snapshot())
const stored = store.getWorkspaceSession()
await store.applyLayoutCommand(split)
const written = store.getWorkspaceSession()
expect(written.terminalPtyIncarnationsByPaneKey).toEqual(
stored.terminalPtyIncarnationsByPaneKey
)
expect(written.tabsByWorktree).toEqual(stored.tabsByWorktree)
expect(written.unifiedTabs).toEqual(stored.unifiedTabs)
})
it('refuses a pane main does not hold and writes nothing', async () => {
const { store } = await storeWithOneTab()
const before = store.getWorkspaceSession()
const reply = await store.applyLayoutCommand({ ...split, leafId: 'missing-leaf' })
expect(reply).toEqual({ ok: false, code: 'pane_not_found' })
expect(store.getWorkspaceSession()).toEqual(before)
})
it('writes an SSH workspace’s split into that host’s partition only', async () => {
const { store } = await storeWithOneTab('ssh:target-1')
const local = store.getWorkspaceSession()
const reply = await store.applyLayoutCommand(split, 'ssh:target-1')
expect(reply.ok).toBe(true)
const root = store.getWorkspaceSession('ssh:target-1').terminalLayoutsByTabId[TAB]?.root
expect(leafIds(root)).toHaveLength(2)
expect(store.getWorkspaceSession()).toEqual(local)
})
})
@@ -7,6 +7,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
import { locatePane } from '../../../shared/workspace-layout/workspace-layout-command-steps'
import type {
CommandOf,
LayoutApplyOutcome,
LayoutCommand,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
@@ -16,6 +17,7 @@ import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/wor
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import { paneKeyOf } 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'
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
export type TerminalPaneAdmission =
@@ -39,7 +41,8 @@ export function admitPaneToSession(
if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) {
return { value: 'exists', session: null }
}
return applyToSession(loaded, admission, 'admitted')
const change = applyToSession(loaded, admission)
return { ...change, value: change.value.ok ? 'admitted' : change.value.code }
}
export function withdrawPaneFromSession(
@@ -47,20 +50,39 @@ export function withdrawPaneFromSession(
session: WorkspaceSessionState,
pane: CommandOf<'closePane'>
): PaneLayoutChange<LayoutRefusalCode | null> {
return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null)
const change = applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane)
return { ...change, value: change.value.ok ? null : change.value.code }
}
function applyToSession<T>(
/**
* A client's layout command (`layout.apply`), with main minting every new id. Only the records the
* command changed are written; the rest of the partition stays as stored, normalized or not.
*/
export function applyCommandToSession(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
command: LayoutCommand
): PaneLayoutChange<LayoutApplyOutcome> {
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
return applyToSession(loaded, command, (after) =>
patchStoredSession(session, saveWorkspaceLayout(loaded), after)
)
}
function applyToSession(
loaded: LoadedWorkspaceLayout,
command: LayoutCommand,
committed: T
): PaneLayoutChange<T | LayoutRefusalCode> {
write: (after: WorkspaceSessionState) => WorkspaceSessionState = (after) => after
): PaneLayoutChange<LayoutApplyOutcome> {
const applied = applyLayoutCommand(loaded.layout, command, {
mintId: randomUUID,
mintLeafId: randomUUID,
now: Date.now
})
return applied.ok
? { value: committed, session: saveWorkspaceLayout({ ...loaded, layout: applied.model }) }
: { value: applied.code, session: null }
? {
value: { ok: true, result: applied.result },
session: write(saveWorkspaceLayout({ ...loaded, layout: applied.model }))
}
: { value: { ok: false, code: applied.code }, session: null }
}
@@ -7,10 +7,13 @@ import type {
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type {
CommandOf,
LayoutApplyOutcome,
LayoutCommand,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import {
admitPaneToSession,
applyCommandToSession,
withdrawPaneFromSession,
type PaneLayoutChange,
type TerminalPaneAdmission,
@@ -21,7 +24,11 @@ import {
terminalSurfaceCloseMutation,
type TerminalSurfaceCloseCommit
} from '../../runtime/terminal-surface-close'
import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state'
import type {
DurableProfileStateMutation,
StoreRuntimeState
} from '../loading-store/store-runtime-state'
import type { SessionHostPartitionOperations } from '../loading-store/session-host-partitions'
import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move'
import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
@@ -36,6 +43,7 @@ type TerminalTopologyCommitKind =
| 'move_leaf'
| 'admit_pane'
| 'withdraw_pane'
| 'apply_layout'
export function closeLeafOrTab(
commit: TerminalSurfaceCloseCommit
@@ -99,6 +107,24 @@ export function withdrawPane(
)
}
/** A client's layout command (`layout.apply`), applied to the stored partition. */
export function applyLayout(
hostId: ExecutionHostId,
command: LayoutCommand,
context: TerminalTopologyCommitContext
): () => DurableProfileStateMutation<LayoutApplyOutcome> {
return traced(
'apply_layout',
() =>
commitPaneChange(
hostId,
applyCommandToSession(hostId, context.getSession(hostId), command),
context
),
(outcome) => (outcome.ok ? undefined : outcome.code)
)
}
/**
* One `persistence.terminal-topology` span per commit, from admission to the in-memory write.
* Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code.
@@ -147,6 +173,25 @@ export type TerminalTopologyCommitContext = {
) => void
}
/** The commit context over a Store's state and its session partitions. */
export function topologyCommitContext({
runtime,
sessions
}: {
runtime: Pick<StoreRuntimeState, 'state' | 'dirtyProfileStateDomains'>
sessions: Pick<
SessionHostPartitionOperations,
'getWorkspaceSessionHostIds' | 'getWorkspaceSession'
>
}): TerminalTopologyCommitContext {
return {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
}
}
/** 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(
+19 -1
View File
@@ -26,7 +26,11 @@ import {
import { findWorkspaceReferences } from './runtime-reference-find'
import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-reference-agents'
import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import type {
LayoutApplyReply,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface.
@@ -167,6 +171,20 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog {
return this.workspaceLayoutStream.subscribe(listener)
}
/** A window's layout command (`layout.apply`), applied to the partition that holds the tab. */
async applyLayoutCommand(command: LayoutCommand & { tabId: string }): Promise<LayoutApplyReply> {
const hostId = this.getWorkspaceSessionHostIdForTab(command.workspace, command.tabId)
// Why: a paired server's workspaces keep today's mirrored layout until that server owns it.
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
return { ok: false, code: 'host_not_owned' }
}
const store = this.requireStore()
if (!store.applyLayoutCommand) {
throw new Error('layout_store_unavailable')
}
return store.applyLayoutCommand(command, hostId)
}
/** This desktop's display-only copy of each paired server's layout (`layout.getRemoteSnapshots`). */
getRemoteLayoutSnapshots(): Record<string, RemoteLayoutSnapshot> {
return this.store?.getRemoteLayoutSnapshots?.() ?? {}
+4 -1
View File
@@ -60,7 +60,8 @@ import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-sess
import { ARTIFACT_METHODS } from './artifacts'
import { AGENT_HOOK_METHODS } from './agent-hooks'
import { AGENT_LAUNCH_METHODS } from './agent-launch'
import { WORKSPACE_LAYOUT_METHODS } from './workspace-layout'
import { LAYOUT_APPLY_METHODS, WORKSPACE_LAYOUT_METHODS } from './workspace-layout'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -102,6 +103,8 @@ export const ALL_RPC_METHODS = [
...COMPUTER_METHODS,
...SESSION_TAB_METHODS,
...WORKSPACE_LAYOUT_METHODS,
// Switch point: a window-owned build registers no layout command.
...(ORCA_LAYOUT_OWNER_IS_MAIN ? LAYOUT_APPLY_METHODS : []),
...NATIVE_CHAT_METHODS,
...FILE_METHODS,
...GIT_METHODS,
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist'
import { OrcaRuntimeService } from '../../orca-runtime'
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
const WORKTREE = 'repo-1::/work/one'
const SPLIT = {
type: 'splitPane',
workspace: WORKTREE,
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
} as const
async function registeredMethods(layoutOwnerIsMain: boolean) {
vi.resetModules()
vi.doMock('../../../../shared/layout-owner-switch', () => ({
ORCA_LAYOUT_OWNER_IS_MAIN: layoutOwnerIsMain
}))
const { ALL_RPC_METHODS } = await import('./index')
return eraseRpcMethods(ALL_RPC_METHODS)
}
function sessionListing(tabId: string): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: WORKTREE,
activeTabId: tabId,
tabsByWorktree: {
[WORKTREE]: [
{
id: tabId,
ptyId: null,
worktreeId: WORKTREE,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {}
}
}
function runtimeHolding(hostId: string) {
const applyLayoutCommand = vi.fn(async () => ({ ok: true, result: { leafId: 'minted' } }))
const empty: WorkspaceSessionState = { ...sessionListing('other'), tabsByWorktree: {} }
const store = {
getRepo: () => undefined,
getWorkspaceSessionHostIds: () => ['local', hostId],
getWorkspaceSession: (host: string) => (host === hostId ? sessionListing('tab-1') : empty),
applyLayoutCommand
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the apply path reads only these store methods.
return { runtime: new OrcaRuntimeService(store as never), applyLayoutCommand }
}
afterEach(() => {
vi.doUnmock('../../../../shared/layout-owner-switch')
vi.resetModules()
})
describe('layout.apply', () => {
it('is not registered in a window-owned build, and never advertised or open to phones', async () => {
const methods = await registeredMethods(false)
expect(methods.some((method) => method.name === 'layout.apply')).toBe(false)
expect(RUNTIME_CAPABILITIES.some((capability) => capability.startsWith('layout'))).toBe(false)
expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.apply')).toBe(false)
})
it('is registered in a layout-owner build and accepts no id main should mint', async () => {
const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply')
if (!apply || isStreamingMethod(apply)) {
throw new Error('layout.apply is not a request method')
}
const applyLayoutCommand = vi.fn(async () => ({ ok: true, result: {} }))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler calls only applyLayoutCommand.
const runtime = { applyLayoutCommand } as unknown as OrcaRuntimeService
const params = apply.params?.parse({ command: { ...SPLIT, newLeafId: 'window-minted' } })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context.
await apply.handler(params, { runtime } as RpcContext)
expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT)
})
it('applies to the partition that holds the tab, such as an SSH host’s', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
await expect(runtime.applyLayoutCommand(SPLIT)).resolves.toEqual({
ok: true,
result: { leafId: 'minted' }
})
expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT, 'ssh:target-1')
})
it('refuses a paired server’s workspace, which keeps its mirrored layout', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('runtime:env-1')
await expect(runtime.applyLayoutCommand(SPLIT)).resolves.toEqual({
ok: false,
code: 'host_not_owned'
})
expect(applyLayoutCommand).not.toHaveBeenCalled()
})
})
@@ -3,6 +3,7 @@
// switch, so they are inert.
import {
LayoutApplyParams,
LayoutSubscribeParams,
LayoutUnsubscribeParams
} from '../../../../shared/rpc-contract/workspace-layout-params'
@@ -72,3 +73,13 @@ export const WORKSPACE_LAYOUT_METHODS = [
handler: async (_params, { runtime }) => ({ snapshots: runtime.getRemoteLayoutSnapshots() })
})
]
/** The window's layout commands (C1). Registered only in a layout-owner build; see methods/index.ts. */
export const LAYOUT_APPLY_METHODS = [
defineMethod({
name: 'layout.apply',
permission: 'workspace',
params: LayoutApplyParams,
handler: async (params, { runtime }) => runtime.applyLayoutCommand(params.command)
})
]
@@ -70,6 +70,7 @@ export type RuntimeStore = {
persistPtyBinding?: Store['persistPtyBinding']
admitTerminalPane?: Store['admitTerminalPane']
withdrawTerminalPane?: Store['withdrawTerminalPane']
applyLayoutCommand?: Store['applyLayoutCommand']
getSshRemotePtyLeases?: Store['getSshRemotePtyLeases']
getUI?: Store['getUI']
onUIChanged?: Store['onUIChanged']
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner'
const mocks = vi.hoisted(
(): { callRuntimeRpc: ReturnType<typeof vi.fn>; ownerEnvironmentId: string | null } => ({
callRuntimeRpc: vi.fn(),
ownerEnvironmentId: null
})
)
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId
}))
const SPLIT = {
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
} as const
describe('splitThroughLayoutOwner', () => {
beforeEach(() => {
mocks.callRuntimeRpc.mockReset()
mocks.ownerEnvironmentId = null
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('asks local main for the split and mounts the pane id it minted', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } })
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', {
command: {
type: 'splitPane',
workspace: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
}
})
await vi.waitFor(() => expect(mount).toHaveBeenCalledWith('leaf-minted'))
})
it.each([
[
'a refusal',
() => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' })
],
['a failed call', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))]
])('mounts nothing after %s', async (_case, arrange) => {
arrange()
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
await vi.waitFor(() => expect(console.warn).toHaveBeenCalled())
expect(mount).not.toHaveBeenCalled()
})
it('splits as today when main says a paired server holds the tab', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' })
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
await vi.waitFor(() => expect(mount).toHaveBeenCalledWith(undefined))
})
it('leaves a paired server’s workspace to today’s split', () => {
mocks.ownerEnvironmentId = 'env-1'
expect(splitThroughLayoutOwner(SPLIT, vi.fn())).toBe(false)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,43 @@
import { useAppStore } from '@/store'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
/**
* A split in a layout-owner build: main writes it and mints the new pane's id, then `mount` shows
* that pane (no id: split as today). False when a paired server owns the workspace's layout; the
* caller splits as today.
*/
export function splitThroughLayoutOwner(
split: {
worktreeId: string
tabId: string
leafId: string
direction: 'vertical' | 'horizontal'
},
mount: (leafId: string | undefined) => void
): boolean {
// Why: the same owner test that mirrors a workspace's tabs from a paired server; a focused
// server must not claim a workspace whose row names no owner.
if (getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), split.worktreeId)) {
return false
}
const { worktreeId: workspace, ...pane } = split
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
command: { type: 'splitPane', workspace, ...pane }
})
.then((reply) => {
if (reply.ok && reply.result.leafId) {
mount(reply.result.leafId)
} else if (!reply.ok && reply.code === 'host_not_owned') {
// A tab a paired server holds inside a local workspace (#18202): split it as today.
mount(undefined)
} else {
console.warn('[layout-owner] split refused:', reply.ok ? 'no_leaf' : reply.code)
}
})
.catch((error: unknown) => {
console.warn('[layout-owner] split failed:', error instanceof Error ? error.message : error)
})
return true
}
@@ -6,11 +6,24 @@ import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-in
import { createDeferred } from './pty-connection-test-async'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: false,
recordCreatedTerminalPaneSplit: vi.fn(),
resolveSplitCwd: vi.fn(),
splitThroughLayoutOwner:
vi.fn<(split: unknown, mount: (leafId: string | undefined) => void) => boolean>(),
splitWebRuntimeTerminal: vi.fn()
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('./terminal-pane-split-through-layout-owner', () => ({
splitThroughLayoutOwner: mocks.splitThroughLayoutOwner
}))
vi.mock('@/runtime/web-runtime-terminal-actions', () => ({
splitWebRuntimeTerminal: mocks.splitWebRuntimeTerminal
}))
@@ -29,6 +42,8 @@ function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
describe('splitTerminalPaneWithInheritedCwd', () => {
beforeEach(() => {
mocks.layoutOwnerIsMain = false
mocks.splitThroughLayoutOwner.mockReset()
mocks.recordCreatedTerminalPaneSplit.mockReset()
mocks.resolveSplitCwd.mockReset()
mocks.splitWebRuntimeTerminal.mockReset()
@@ -209,4 +224,79 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
expect(mocks.resolveSplitCwd).not.toHaveBeenCalled()
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
})
it('never asks main in a window-owned build', () => {
const splitPane = vi.fn(() => ({ id: 2 }))
splitTerminalPaneWithInheritedCwd({
worktreeId: 'worktree-1',
tabId: 'tab-1',
manager: makeManager(splitPane),
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
direction: 'vertical',
source: 'keyboard'
})
expect(mocks.splitThroughLayoutOwner).not.toHaveBeenCalled()
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
})
it('in a layout-owner build, mounts the pane main minted only once main answers', () => {
mocks.layoutOwnerIsMain = true
mocks.splitThroughLayoutOwner.mockReturnValue(true)
const createdPane = { id: 2 }
const splitPane = vi.fn(() => createdPane)
splitTerminalPaneWithInheritedCwd({
worktreeId: 'worktree-1',
tabId: 'tab-1',
manager: makeManager(splitPane),
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
direction: 'vertical',
source: 'keyboard'
})
expect(mocks.splitThroughLayoutOwner).toHaveBeenCalledWith(
{ worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' },
expect.any(Function)
)
expect(splitPane).not.toHaveBeenCalled()
mocks.splitThroughLayoutOwner.mock.calls[0]?.[1]('leaf-minted')
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', {
leafId: 'leaf-minted',
cwd: '/cached'
})
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(createdPane, {
source: 'keyboard',
direction: 'vertical'
})
})
it('in a layout-owner build, splits as today where a paired server owns the layout', () => {
mocks.layoutOwnerIsMain = true
mocks.splitThroughLayoutOwner.mockReturnValue(false)
const splitPane = vi.fn(() => ({ id: 2 }))
splitTerminalPaneWithInheritedCwd({
worktreeId: 'worktree-1',
tabId: 'tab-1',
manager: makeManager(splitPane),
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
direction: 'horizontal',
source: 'keyboard'
})
expect(splitPane).toHaveBeenCalledWith(1, 'horizontal', { cwd: '/cached' })
})
})
@@ -4,8 +4,10 @@ import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-terminal-actions'
import type { PtyTransport } from './pty-transport-types'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner'
export function splitTerminalPaneWithInheritedCwd(args: {
type SplitArgs = {
worktreeId: string
tabId: string
manager: PaneManager
@@ -16,7 +18,9 @@ export function splitTerminalPaneWithInheritedCwd(args: {
pane: ManagedPane
direction: 'vertical' | 'horizontal'
source: TerminalPaneSplitSource
}): void {
}
export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void {
const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null
if (
splitWebRuntimeTerminal(ptyId, args.direction, args.source, {
@@ -27,13 +31,36 @@ export function splitTerminalPaneWithInheritedCwd(args: {
) {
return
}
// Switch point: main writes the split and mints the new pane's id; the window then mounts it.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
splitThroughLayoutOwner(
{
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.pane.leafId,
direction: args.direction
},
(leafId) => splitLocally(args, ptyId, leafId)
)
) {
return
}
splitLocally(args, ptyId)
}
function splitLocally(args: SplitArgs, ptyId: string | null, leafId?: string): void {
const manager = args.getManager ? args.getManager() : args.manager
if (!manager) {
return
}
const placement = leafId ? { leafId } : {}
const cached = args.paneCwdMap.get(args.pane.id)
if (cached?.confirmed && cached.cwd) {
const createdPane = manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd })
const createdPane = manager.splitPane(args.pane.id, args.direction, {
...placement,
cwd: cached.cwd
})
recordCreatedTerminalPaneSplit(createdPane, {
source: args.source,
direction: args.direction
@@ -49,7 +76,7 @@ export function splitTerminalPaneWithInheritedCwd(args: {
sourcePtyId: ptyId,
fallbackCwd: args.fallbackCwd
})
const createdPane = manager.splitPane(paneId, args.direction, { cwdPromise })
const createdPane = manager.splitPane(paneId, args.direction, { ...placement, cwdPromise })
recordCreatedTerminalPaneSplit(createdPane, {
source: args.source,
direction: args.direction
+10
View File
@@ -0,0 +1,10 @@
// The one read of the ORCA_LAYOUT_OWNER build switch: 'main' turns on the dark path where main
// decides every layout change. Off in every shipped build; deleted with the old path (phase F).
// Every switch point branches on ORCA_LAYOUT_OWNER_IS_MAIN, so grep finds them all.
/** Substituted by electron.vite.config.ts; absent (off) in tests, the CLI and the web client. */
declare const ORCA_LAYOUT_OWNER: 'window' | 'main' | undefined
export const ORCA_LAYOUT_OWNER_IS_MAIN =
// oxlint-disable-next-line unicorn/no-typeof-undefined -- undeclared outside electron-vite builds; a bare read throws.
typeof ORCA_LAYOUT_OWNER !== 'undefined' && ORCA_LAYOUT_OWNER === 'main'
+6 -1
View File
@@ -582,7 +582,11 @@ import {
TerminalUpdateViewport
} from './terminal-viewport-schemas-params'
import { UpdaterCheckParams } from './updater-params'
import { LayoutSubscribeParams, LayoutUnsubscribeParams } from './workspace-layout-params'
import {
LayoutApplyParams,
LayoutSubscribeParams,
LayoutUnsubscribeParams
} from './workspace-layout-params'
import {
WorkspacePortKillHostParams,
WorkspacePortKillParams,
@@ -1024,6 +1028,7 @@ export const RPC_PARAMS_BY_METHOD = {
'jira.status': null,
'jira.testConnection': SiteSelection,
'jira.updateIssue': IssueUpdate,
'layout.apply': LayoutApplyParams,
'layout.getRemoteSnapshots': null,
'layout.subscribe': LayoutSubscribeParams,
'layout.unsubscribe': LayoutUnsubscribeParams,
@@ -10,3 +10,19 @@ export const LayoutSubscribeParams = z
export const LayoutUnsubscribeParams = z.object({
subscriptionId: z.string().min(1, 'Missing subscriptionId')
})
const SplitPaneCommand = z.object({
type: z.literal('splitPane'),
workspace: z.string().min(1),
tabId: z.string().min(1),
leafId: z.string().min(1),
direction: z.enum(['horizontal', 'vertical'])
})
/**
* A window's layout command (`layout.apply`, registered only in a layout-owner build). The window
* names what it acts on; main mints every new id, so no new id is accepted here.
*/
export const LayoutApplyParams = z.object({
command: z.discriminatedUnion('type', [SplitPaneCommand])
})
@@ -156,6 +156,14 @@ export type LayoutCommandResult = {
renamedTabIds?: Record<string, string>
}
/** A command applied to a stored layout: its result, or why it changed nothing. */
export type LayoutApplyOutcome =
| { ok: true; result: LayoutCommandResult }
| { ok: false; code: LayoutRefusalCode }
/** `layout.apply`'s reply. `host_not_owned`: a paired server owns that workspace's layout. */
export type LayoutApplyReply = LayoutApplyOutcome | { ok: false; code: 'host_not_owned' }
export type LayoutContext = {
mintId: () => string
/** Pane ids must be UUIDs (pane keys and PTY env carry them). */
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree'
import { applyLayoutCommand } from './workspace-layout-commands'
import type { LayoutCommand } from './workspace-layout-command-types'
import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from './workspace-layout-legacy-profile.test-fixture'
import { loadWorkspaceLayout } from './workspace-layout-load'
import { diffStoredSession, stableJson } from './workspace-layout-load-report'
import { nameBasedLoadContext } from './workspace-layout-minted-ids'
import type { WorkspaceLayoutModel } from './workspace-layout-model'
import { WORKSPACE_LAYOUT_PROFILES } from './workspace-layout-profile.test-fixture'
import { patchStoredSession } from './workspace-layout-record-patch'
import { saveWorkspaceLayout } from './workspace-layout-save'
const CONTEXT = {
mintId: () => 'minted-id',
mintLeafId: () => '99999999-9999-4999-8999-999999999999',
now: () => 1
}
/** Commands over every workspace and tab a profile holds, covering adds, drops, edits and moves. */
function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] {
const commands: LayoutCommand[] = []
for (const [workspace, layout] of Object.entries(model.workspaces)) {
commands.push({ type: 'createTerminalTab', workspace })
commands.push({ type: 'openEditorTab', workspace, fileId: '/new.md', contentType: 'editor' })
const lastGroup = layout.groups.at(-1)
for (const tab of layout.tabs) {
commands.push({ type: 'closeTabs', workspace, tabIds: [tab.id], force: true })
commands.push({
type: 'renameTab',
workspace,
tabId: tab.id,
kind: 'custom',
title: 'Renamed'
})
if (lastGroup) {
commands.push({
type: 'moveTab',
workspace,
tabId: tab.id,
toGroupId: lastGroup.id,
index: 0
})
}
if (tab.kind !== 'terminal') {
continue
}
const [first, second] = collectLayoutLeafIdsInOrder(tab.panes.root)
if (first) {
commands.push({
type: 'splitPane',
workspace,
tabId: tab.id,
leafId: first,
direction: 'vertical'
})
}
if (second) {
commands.push({ type: 'closePane', workspace, tabId: tab.id, leafId: second })
commands.push({ type: 'equalizePanes', workspace, tabId: tab.id })
}
}
}
return commands
}
const changedFields = (changes: ReturnType<typeof diffStoredSession>) =>
changes.map(({ table, record, field }) => `${table}/${record}/${field}`).sort()
// Session fields are separate records here: one session field must not carry another along.
const changedRecords = (changes: ReturnType<typeof diffStoredSession>) => [
...new Set(
changes.map(({ table, record, field }) =>
table === 'session' ? `session/${field}` : `${table}/${record}`
)
)
]
const LIST_TABLES: Record<string, { order: string; field: string }> = {
row: { order: 'row', field: 'tabsByWorktree' },
terminalEntry: { order: 'entry', field: 'unifiedTabs' },
entry: { order: 'entry', field: 'unifiedTabs' },
group: { order: 'group', field: 'tabGroups' },
file: { order: 'file', field: 'openFilesByWorktree' },
browser: { order: 'browser', field: 'browserTabsByWorktree' }
}
/** A record written into a list the stored partition lacks also changes that list's order and keys. */
function withListConsequences(records: string[]): string[] {
const allowed = new Set(records)
for (const record of records) {
const table = record.slice(0, record.indexOf('/'))
const list = LIST_TABLES[table]
if (list) {
const key = record.slice(table.length + 1, record.lastIndexOf('|'))
allowed.add(`${list.order}/${key}|$order`)
allowed.add(`session/${list.field}`)
}
}
return [...allowed]
}
const FOLDED_LEGACY_ROW_GAPS = new Map<string, string[]>([
['legacy row repeating a tab-bar tab’s terminal', ['closeTabs']],
['legacy row repeating a tab-bar tab’s terminal, selected', ['closeTabs']],
['legacy split row sharing one pane’s terminal with a tab-bar tab', ['closeTabs', 'moveTab']]
])
describe('patchStoredSession', () => {
const profiles = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES]
it.each(profiles.map((profile) => [profile.name, profile] as const))(
'%s: writes only the records each command touched',
(_name, profile) => {
const stored = profile.build()
const loaded = loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext())
const before = saveWorkspaceLayout(loaded)
const normalizedAtLoad = diffStoredSession(stored, before).length > 0
let applied = 0
const roundTripGaps = new Set<string>()
for (const command of commandsFor(loaded.layout)) {
const result = applyLayoutCommand(loaded.layout, command, CONTEXT)
if (!result.ok) {
continue
}
applied++
const after = saveWorkspaceLayout({ ...loaded, layout: result.model })
const patched = patchStoredSession(stored, before, after)
const label = `${command.type} ${JSON.stringify(command)}`
if (!normalizedAtLoad) {
// A partition the load leaves alone: the patch is the full save, field for field.
expect(stableJson(patched), label).toBe(stableJson(after))
}
// The written partition loads to what the command produced. The revision is exempt: the
// load's tombstone retirement bumps it again on every load.
const reloaded = (session: typeof stored) => ({
...saveWorkspaceLayout(
loadWorkspaceLayout(profile.hostId, session, nameBasedLoadContext())
),
terminalTopologyRevisionByRepoId: undefined
})
if (stableJson(reloaded(patched)) !== stableJson(reloaded(after))) {
roundTripGaps.add(command.type)
}
const written = changedRecords(diffStoredSession(stored, patched))
const commanded = withListConsequences(changedRecords(diffStoredSession(before, after)))
expect(
written.filter((record) => !commanded.includes(record)),
`${label} commanded=${commanded.join(',')}`
).toEqual([])
}
expect(applied > 0 || Object.keys(loaded.layout.workspaces).length === 0).toBe(true)
// Known gap, closed before layout.apply accepts more than splitPane: a legacy row the load
// folds into a tab-bar tab stays stored, so closing or moving that tab brings the row back.
expect([...roundTripGaps]).toEqual(FOLDED_LEGACY_ROW_GAPS.get(profile.name) ?? [])
}
)
it('keeps a legacy partition’s unrelated records as stored when a pane splits', () => {
const profile = LEGACY_WORKSPACE_LAYOUT_PROFILES.find(
(candidate) => candidate.name === 'session from before the tab bar, with an open file'
)!
const stored = profile.build()
const loaded = loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext())
const [workspace, tabs] = Object.entries(stored.tabsByWorktree)[0]!
const tabId = tabs[0]!.id
const leafId = collectLayoutLeafIdsInOrder(stored.terminalLayoutsByTabId[tabId]?.root)[0]!
const result = applyLayoutCommand(
loaded.layout,
{ type: 'splitPane', workspace, tabId, leafId, direction: 'vertical' },
CONTEXT
)
if (!result.ok) {
throw new Error(result.code)
}
const full = saveWorkspaceLayout({ ...loaded, layout: result.model })
const patched = patchStoredSession(stored, saveWorkspaceLayout(loaded), full)
expect(changedFields(diffStoredSession(stored, full)).length).toBeGreaterThan(2)
expect(changedFields(diffStoredSession(stored, patched))).toEqual([
`layout/${tabId}/root`,
'session/session/terminalTopologyRevisionByRepoId'
])
// Stored fields keep their order; the command's new field follows them.
expect(Object.keys(patched).slice(0, Object.keys(stored).length)).toEqual(Object.keys(stored))
})
it('writes a layout the load supplied whole, so the split keeps the tab’s terminal', () => {
const profile = WORKSPACE_LAYOUT_PROFILES[0]!
const stored = profile.build()
const [workspace, rows] = Object.entries(stored.tabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.ptyId)
)!
const row = rows.find((tab) => tab.ptyId)!
const { [row.id]: _dropped, ...layouts } = stored.terminalLayoutsByTabId
stored.terminalLayoutsByTabId = layouts
const loaded = loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext())
const tab = loaded.layout.workspaces[workspace]!.tabs.find((entry) => entry.id === row.id)
const leafId = tab?.kind === 'terminal' ? collectLayoutLeafIdsInOrder(tab.panes.root)[0] : null
const result = applyLayoutCommand(
loaded.layout,
{ type: 'splitPane', workspace, tabId: row.id, leafId: leafId!, direction: 'vertical' },
CONTEXT
)
if (!result.ok) {
throw new Error(result.code)
}
const after = saveWorkspaceLayout({ ...loaded, layout: result.model })
const patched = patchStoredSession(stored, saveWorkspaceLayout(loaded), after)
expect(patched.terminalLayoutsByTabId[row.id]).toEqual(after.terminalLayoutsByTabId[row.id])
expect(patched.terminalLayoutsByTabId[row.id]?.ptyIdsByLeafId?.[leafId!]).toBe(row.ptyId)
})
})
@@ -0,0 +1,191 @@
// Writes a command's effect onto the stored partition without the rest of a Serializer rewrite.
// Load then save also normalizes records the command never touched (legacy rows, sort orders,
// key order, carried leftovers); diffing the save before the command against the save after it
// isolates the command, and only those records and fields are written.
import type { WorkspaceSessionState } from '../workspace-session-state-types'
type Json = Record<string, unknown>
/** Per-workspace lists of records, matched by id rather than replaced whole. */
const LIST_FIELDS: Record<string, (record: Json) => unknown> = {
tabsByWorktree: (record) => record.id,
unifiedTabs: (record) => record.id,
tabGroups: (record) => record.id,
openFilesByWorktree: (record) => record.filePath,
browserTabsByWorktree: (record) => record.id
}
// Why 2: a session map (layouts by tab, sleeping records by pane) holds records; a changed record
// field is replaced whole, as diffStoredSession reports it.
const MAP_RECORD_DEPTH = 2
/**
* `stored` with the records the command touched (those differing between `before` and `after`)
* written as `after` has them. A touched record the load supplied is written whole; every
* untouched record stays as stored.
*/
export function patchStoredSession(
stored: WorkspaceSessionState,
before: WorkspaceSessionState,
after: WorkspaceSessionState
): WorkspaceSessionState {
const storedFields = toJson(stored)
const beforeFields = toJson(before)
const afterFields = toJson(after)
const patched: Json = { ...storedFields }
for (const field of changedKeys(beforeFields, afterFields)) {
const idOf = LIST_FIELDS[field]
// The command dropped the field: stored records the load never read go with it.
if (afterFields[field] === undefined) {
delete patched[field]
} else if (idOf) {
patched[field] = patchLists(
asJson(storedFields[field]),
asJson(beforeFields[field]),
asJson(afterFields[field]),
idOf
)
} else {
patched[field] = patchValue(
storedFields[field],
beforeFields[field],
afterFields[field],
MAP_RECORD_DEPTH
)
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is the stored session's own or copied from `after`, a WorkspaceSessionState.
return patched as unknown as WorkspaceSessionState
}
function patchLists(
stored: Json,
before: Json,
after: Json,
idOf: (record: Json) => unknown
): Json {
const patched: Json = { ...stored }
for (const key of changedKeys(before, after)) {
const list = patchList(asList(stored[key]), asList(before[key]), asList(after[key]), idOf)
if (list.length === 0 && (after[key] === undefined || stored[key] === undefined)) {
delete patched[key]
} else {
patched[key] = list
}
}
return patched
}
function patchList(stored: Json[], before: Json[], after: Json[], idOf: (record: Json) => unknown) {
const find = (list: Json[], id: unknown) => list.find((record) => idOf(record) === id)
const result = [...stored]
const ids = new Set([...before.map(idOf), ...after.map(idOf)])
for (const id of ids) {
const old = find(before, id)
const next = find(after, id)
if (sameJson(old, next)) {
continue
}
const index = result.findIndex((record) => idOf(record) === id)
if (!next) {
if (index !== -1) {
result.splice(index, 1)
}
} else if (index !== -1) {
result[index] = asJson(patchValue(result[index], old, next, 1))
} else {
result.splice(insertionIndex(result, after, after.indexOf(next), idOf), 0, next)
}
}
const order = (list: Json[]) => list.map(idOf).filter((id) => ids.has(id))
if (sameJson(order(before), order(after))) {
return result
}
// The command reordered its own records: they take `after`'s order in the slots they hold.
const placed = new Set(after.map(idOf))
const queue = after
.map((record) => result.find((candidate) => idOf(candidate) === idOf(record)))
.filter((record): record is Json => record !== undefined)
return result.map((record) => (placed.has(idOf(record)) ? queue.shift()! : record))
}
/** After the nearest record that precedes it in `after` and is already in the list. */
function insertionIndex(
result: Json[],
after: Json[],
position: number,
idOf: (record: Json) => unknown
): number {
for (let previous = position - 1; previous >= 0; previous--) {
const index = result.findIndex((record) => idOf(record) === idOf(after[previous]!))
if (index !== -1) {
return index + 1
}
}
return position === 0 ? 0 : result.length
}
function patchValue(stored: unknown, before: unknown, after: unknown, depth: number): unknown {
// A record the stored partition lacks is written whole; a missing map is patched key by key.
if (
depth === 0 ||
!isRecord(before) ||
!isRecord(after) ||
(!isRecord(stored) && depth !== MAP_RECORD_DEPTH)
) {
return after
}
const base = asJson(stored)
const patched: Json = { ...base }
for (const key of changedKeys(before, after)) {
if (after[key] === undefined) {
delete patched[key]
} else {
patched[key] = patchValue(base[key], before[key], after[key], depth - 1)
}
}
return patched
}
function changedKeys(before: Json, after: Json): string[] {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
(key) => !sameJson(before[key], after[key])
)
}
/** Deep equality where key order and undefined-valued keys do not count, as stableJson. */
function sameJson(left: unknown, right: unknown): boolean {
if (left === right) {
return true
}
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => sameJson(value, right[index]))
)
}
if (!isRecord(left) || !isRecord(right)) {
return false
}
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
return [...keys].every((key) => sameJson(left[key], right[key]))
}
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function asJson(value: unknown): Json {
return isRecord(value) ? value : {}
}
function asList(value: unknown): Json[] {
return Array.isArray(value) ? value.filter(isRecord) : []
}
function toJson(session: WorkspaceSessionState): Json {
return { ...session }
}
+2
View File
@@ -23,3 +23,5 @@ declare const ORCA_DIAGNOSTICS_TOKEN_URL: string | null
// Full commit the build was made from (`git rev-parse HEAD`); official skill installs pin to it.
declare const ORCA_BUILD_COMMIT: string
// ORCA_LAYOUT_OWNER is declared and read in one place: src/shared/layout-owner-switch.ts.