diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index bd7ddcd53f6..423086c3fd0 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -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 } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2455c093eb5..2df9e448c46 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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: { diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index bc27b7f86a3..edf621508a3 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -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 { return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( - moveLeaf(request, topologyCommitContext(this)) + moveLeaf(request, this.topology()) ) } @@ -239,7 +243,7 @@ export class PtyBindingPersistenceOperations { hostId?: string | null ): Promise { 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 { 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 { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + applyLayout(resolveHostId(hostId), command, this.topology()) + ) } } diff --git a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts new file mode 100644 index 00000000000..a2ee53f8407 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts @@ -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) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index 4e3ab537f44..02bf656cd9d 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -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 { - 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( +/** + * 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 { + 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 { + write: (after: WorkspaceSessionState) => WorkspaceSessionState = (after) => after +): PaneLayoutChange { 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 } } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index f5998264bc3..30cf447302d 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -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 { + 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 + 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( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c48b4fe2554..3a2709da850 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { + 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 { return this.store?.getRemoteLayoutSnapshots?.() ?? {} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index f0dca1d168b..a19ea198c71 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts new file mode 100644 index 00000000000..2e5272f18c6 --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout.ts index 39dfb39e102..897272ee9c8 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.ts @@ -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) + }) +] diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 0003c2eef0f..46e2cb22ba2 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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'] diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts new file mode 100644 index 00000000000..f56dca214ed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts @@ -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; 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts new file mode 100644 index 00000000000..41e9c8ea502 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts @@ -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({ 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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts index c887bf9da43..9b179450563 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -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): 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' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts index 2e087f2bd22..16c179c91ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -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 diff --git a/src/shared/layout-owner-switch.ts b/src/shared/layout-owner-switch.ts new file mode 100644 index 00000000000..252b44cbe13 --- /dev/null +++ b/src/shared/layout-owner-switch.ts @@ -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' diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index f0392b87b75..22c211df96e 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index c6cadf9e1f7..947554e6e0a 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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]) +}) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 168cb891af1..4498f319315 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -156,6 +156,14 @@ export type LayoutCommandResult = { renamedTabIds?: Record } +/** 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). */ diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts new file mode 100644 index 00000000000..036776531ac --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts @@ -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) => + 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) => [ + ...new Set( + changes.map(({ table, record, field }) => + table === 'session' ? `session/${field}` : `${table}/${record}` + ) + ) +] + +const LIST_TABLES: Record = { + 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([ + ['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() + 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) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.ts b/src/shared/workspace-layout/workspace-layout-record-patch.ts new file mode 100644 index 00000000000..c9afdce83ca --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-record-patch.ts @@ -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 + +/** Per-workspace lists of records, matched by id rather than replaced whole. */ +const LIST_FIELDS: Record 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 } +} diff --git a/src/types/build-constants.d.ts b/src/types/build-constants.d.ts index c0c86e13e84..bcb51163e9c 100644 --- a/src/types/build-constants.d.ts +++ b/src/types/build-constants.d.ts @@ -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.