From 991e2dda26360b71473c771bed0fad22dc321035 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:43:29 -0700 Subject: [PATCH] feat(layout): move, reorder and split tab groups through main behind the layout-owner build switch (#29313) * feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the window's layout command to the stored partition through the shared applyLayoutCommand, minting new ids. A user split then waits for main's answer and mounts the pane id main minted. Every other build substitutes 'window', so neither branch is compiled in. * fix(layout): write only the records a layout.apply command touched A load-then-save rewrite also normalizes records the command never touched. The command's effect is now the diff between the save before and after it, written onto the stored partition record by record. A split whose main reply says a paired server holds the tab splits as today. * fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result * feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER In a layout-owner build the window's pane close, title change and equalize go through layout.apply and the window shows main's answer. A window pane close keeps the legacy close's rules: the tab's last pane is refused, a pane restarted since main read the close is left alone, every partition holding the tab closes it, and the tab's remote session id follows the remaining pane. The rules sit behind a command field only layout.apply sets. * fix(layout): close the tab when a racing pane close leaves the window one pane * fix(layout): leave an unmounted tab alone when main refuses its last pane * feat(layout): move panes and save divider ratios through main behind ORCA_LAYOUT_OWNER Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): a taken pane drop whose pane closed does nothing Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): pin the window's pane move to main's saved tree, and the drops main cannot take Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * fix(layout): close a user's pane even if its terminal restarted, as today Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): pin the restarted-pane close directly, not through a made-up profile state Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * fix(layout): leave a split to today's path only when the workspace's row names a paired server The split switch point asked which host to route to with a helper that falls back to the focused server, so a focused server could claim a workspace whose row names no owner. Use the same owner test the tab mirror uses. * test(layout): type the split owner mock without a cast * test(layout): type the layout-owner command mocks with the real signature * test(layout): type the gesture command mock with the real signature * fix(layout): close the dark path's gaps: unmounted split panes, refused or failed splits and moves, ratios during a move, release guard - A pane main saved but the window could not mount (source pane or manager gone during the await) is closed again in main, so it does not come back on restart. - A split main refuses because its copy lacks the workspace, tab or pane, or a failed call, splits as today; a refused or failed move moves as today. - Divider ratios committed while a move is in flight wait for the window's move, then go to main. - The orcad bundle gets the ORCA_LAYOUT_OWNER define when the switch is on. - Packaging and the orcad template refuse ORCA_LAYOUT_OWNER=main and any output a dark build marked. - workspace-layout-record-patch.ts: no non-null assertions; plain comment for the map depth. * test(layout): count a split pane as mounted from the split mock's own results * fix(layout): act as today whenever main lacks the target or the call throws, not just for splits - sendPaneCommandToLayoutOwner runs asToday for workspace/tab/pane_not_found and a thrown call, so close, rename and equalize fall back like split and move; a handler that throws no longer runs the fallback too. - A close main refuses as its last pane, while this window still has others, closes as today. - A move whose local step throws still releases held divider ratios. - Drop the orcad output marker: nothing read it; the template build refuses the switch itself. * test(layout): plain names for the last-pane fallback test and the marker comment * docs(layout): say plainly what an indeterminate write can leave behind * fix(layout): finish the main merge with this branch's versions of the layout-owner files * test(build): copy the layout-owner build guard into the compiler-cache checkout fixture * fix(layout): write a folded legacy row in its loaded form when a layout.apply command touches its claim A legacy terminal row the load folds into a tab-bar tab stays stored as is. Closing the tab it repeats, or giving it a tab-bar entry by moving it, let the next load resolve the claim the other way: the row came back or took the other tab's terminal. Such a command now writes the row as the load had it; every other record still stays as stored. * feat(layout): accept tab moves, group splits and group sizes on layout.apply Group sizes have no tab, so they go to the workspace's routed partition. * refactor(layout): move the window's layout.apply sender out of the terminal pane folder Tab groups send through it too; it now also acts as today when main lacks the group. * feat(layout): reorder, move and split tab groups, and save group sizes, through main behind ORCA_LAYOUT_OWNER A tab drop and Move Tab to Split wait for main's answer and then show main's placement, with main's new group id; group divider sizes are drawn while dragging and saved by main when the drag ends. Closing an empty group needs no command: main never holds an empty group. * fix(layout): review fixes for tab moves through main - Read a folded row's terminals with the loader's own claim rule, so rows sharing only an SSH session are covered too, and drop the row's remote session entry with it. - The record-patch test allows a folded row only in its loaded form. - A tab drag ends at once; main's answer shows the move after. - A lone group main moved whole is placed so only when this window's group is lone too. * refactor(layout): adopt only a folded row's pane layout beside its row A row's remote session entry is carried through load and save unchanged, so adopting it from the load changed nothing (the earlier commit's message overstated it). --- config/renderer-layout-writer-baseline.txt | 1 + src/main/runtime/orca-runtime.ts | 8 +- .../methods/workspace-layout-apply.test.ts | 52 ++++ src/main/runtime/runtime-layout-apply.test.ts | 15 ++ src/main/runtime/runtime-layout-apply.ts | 13 +- .../tab-bar/tab-move-to-pane-column.ts | 14 ++ .../tab-group/TabGroupSplitNodeTree.tsx | 9 +- .../tab-group/tab-drag-drop-commit.ts | 54 +++++ .../tab-group-layout-owner-commands.test.ts | 223 ++++++++++++++++++ .../tab-group-layout-owner-commands.ts | 178 ++++++++++++++ .../layout-owner-pane-close.test.ts | 28 +-- .../terminal-layout-gesture-persist.test.tsx | 18 +- ...erminal-pane-layout-owner-commands.test.ts | 112 +-------- .../terminal-pane-layout-owner-commands.ts | 79 +------ ...erminal-pane-layout-owner-gestures.test.ts | 8 +- .../terminal-pane-layout-owner-gestures.ts | 6 +- ...inal-pane-split-with-inherited-cwd.test.ts | 28 +-- .../terminal-pane-split-with-inherited-cwd.ts | 6 +- .../use-terminal-pane-close-actions.ts | 4 +- .../use-terminal-pane-layout-persistence.ts | 4 +- .../src/runtime/layout-owner-command.test.ts | 125 ++++++++++ .../src/runtime/layout-owner-command.ts | 87 +++++++ .../store/slices/tabs/tabs-drop-actions.ts | 4 +- .../store/slices/tabs/tabs-slice-contract.ts | 2 + .../rpc-contract/workspace-layout-params.ts | 31 ++- .../legacy-terminal-row-claims.ts | 2 +- .../workspace-layout-folded-rows.ts | 103 ++++++++ .../workspace-layout-record-patch.test.ts | 154 ++++++++++-- .../workspace-layout-record-patch.ts | 18 +- 29 files changed, 1119 insertions(+), 267 deletions(-) create mode 100644 src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts create mode 100644 src/renderer/src/runtime/layout-owner-command.test.ts create mode 100644 src/renderer/src/runtime/layout-owner-command.ts create mode 100644 src/shared/workspace-layout/workspace-layout-folded-rows.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index aedbce55f1e..bf67ac6d8e2 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -15,6 +15,7 @@ 1 src/renderer/src/components/status-bar/workspace-space-decision-inputs.ts 2 src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts 3 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +1 src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts 1 src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts 1 src/renderer/src/components/terminal-pane/pty-connection-test-deps.ts 4 src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7639d6ca3d8..1287c7592aa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -173,7 +173,13 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { applyLayoutCommand(command: WindowLayoutCommand): Promise { return applyWindowLayoutCommand( this.requireStore(), - (worktreeId, tabId) => this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId), + (worktreeId, tabId) => { + if (tabId !== null) { + return this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) + } + const routed = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return routed ? [routed] : [] + }, command ) } diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 1510917c556..5b87c4a4db3 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -121,6 +121,58 @@ describe('layout.apply', () => { ).toBe(false) }) + it('accepts tab moves and group sizes, but no group id from the window', async () => { + const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply') + const tab = { workspace: WORKTREE, tabId: 'tab-1' } + const parse = (command: Record) => apply?.params?.safeParse({ command }) + + expect(parse({ type: 'moveTab', ...tab, toGroupId: 'g1', index: 0 })?.success).toBe(true) + expect(parse({ type: 'moveTab', ...tab, toGroupId: 'g1', index: -1 })?.success).toBe(false) + expect( + apply?.params?.parse({ + command: { + type: 'splitGroup', + ...tab, + besideGroupId: 'g1', + direction: 'left', + newGroupId: 'window-minted' + } + }) + ).toEqual({ + command: { type: 'splitGroup', ...tab, besideGroupId: 'g1', direction: 'left' } + }) + expect( + parse({ type: 'splitGroup', ...tab, besideGroupId: 'g1', direction: 'center' })?.success + ).toBe(false) + const split = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { type: 'leaf', groupId: 'g2' } + } + expect( + parse({ type: 'setGroupRatios', workspace: WORKTREE, groupLayout: { ...split, ratio: 0.3 } }) + ?.success + ).toBe(true) + expect( + parse({ type: 'setGroupRatios', workspace: WORKTREE, groupLayout: { ...split, ratio: 3 } }) + ?.success + ).toBe(false) + }) + + it('applies group sizes to the workspace’s routed partition', async () => { + const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') + const ratios = { + type: 'setGroupRatios', + workspace: WORKTREE, + groupLayout: { type: 'leaf', groupId: 'g1' } + } as const + + await runtime.applyLayoutCommand(ratios) + + expect(applyLayoutCommand).toHaveBeenCalledWith(ratios, ['local']) + }) + it('applies to the partition that holds the tab, such as an SSH host’s', async () => { const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index 7e649a137fd..4d727fceb24 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -45,6 +45,21 @@ describe('applyWindowLayoutCommand', () => { expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() }) + it('routes a group tree command by its workspace, with no tab to look up', async () => { + const { store, applyLayoutCommand } = storeWith() + const hostIdsFor = vi.fn(() => ['ssh:target-1' as const]) + const ratios = { + type: 'setGroupRatios', + workspace: 'wt-1', + groupLayout: { type: 'leaf', groupId: 'g1' } + } as const + + await applyWindowLayoutCommand(store, hostIdsFor, ratios) + + expect(hostIdsFor).toHaveBeenCalledWith('wt-1', null) + expect(applyLayoutCommand).toHaveBeenCalledWith(ratios, ['ssh:target-1']) + }) + it('refuses a tab a paired server routes, touching nothing', async () => { const { store, applyLayoutCommand } = storeWith() diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 8cb7db232a0..1af71db4cc3 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -6,19 +6,20 @@ import type { import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { RuntimeStore } from './runtime-store-contract' -/** A window's layout command: one on a tab, which names the partitions it applies to. */ -export type WindowLayoutCommand = LayoutCommand & { tabId: string } +/** A window's layout command: one on a tab, which names its partitions, or on the group tree. */ +export type WindowLayoutCommand = LayoutCommand & ({ tabId: string } | { type: 'setGroupRatios' }) /** - * `layout.apply`: the window's command on the partitions holding its tab, routed partition first. - * A pane close is the user's: main marks it so, and the window cannot. + * `layout.apply`: the window's command on the partitions holding its tab, routed partition first; + * a group tree command goes to the workspace's routed partition. A pane close is the user's: main + * marks it so, and the window cannot. */ export async function applyWindowLayoutCommand( store: RuntimeStore, - hostIdsForTab: (worktreeId: string, tabId: string) => ExecutionHostId[], + hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[], command: WindowLayoutCommand ): Promise { - const hostIds = hostIdsForTab(command.workspace, command.tabId) + const hostIds = hostIdsFor(command.workspace, 'tabId' in command ? command.tabId : null) // Why: a paired server's workspaces keep today's mirrored layout until that server owns it. if (!hostIds[0] || parseExecutionHostId(hostIds[0])?.kind === 'runtime') { return { ok: false, code: 'host_not_owned' } diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts index 1d3de0bb916..6dfed428e75 100644 --- a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts @@ -1,6 +1,8 @@ import { useAppStore } from '../../store' import type { TabSplitDirection } from '../../store/slices/tabs' import { mirrorWebRuntimeTabMove } from './web-runtime-tab-move-mirror' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { dropTabThroughLayoutOwner } from '../tab-group/tab-group-layout-owner-commands' type TabMovePaneColumnState = Pick< ReturnType, @@ -48,6 +50,18 @@ export function moveTabToNewPaneColumn(args: { if (!worktreeId || !canMoveTabToNewPaneColumnFromState(state, args.unifiedTabId, args.groupId)) { return false } + // Switch point: main places the tab beside its group first. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + dropTabThroughLayoutOwner(worktreeId, { + kind: 'split', + tabId: args.unifiedTabId, + groupId: args.groupId, + direction: args.direction + }) + ) { + return true + } const moved = state.dropUnifiedTab(args.unifiedTabId, { groupId: args.groupId, splitDirection: args.direction diff --git a/src/renderer/src/components/tab-group/TabGroupSplitNodeTree.tsx b/src/renderer/src/components/tab-group/TabGroupSplitNodeTree.tsx index 12713f45202..5a948d4c45d 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitNodeTree.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitNodeTree.tsx @@ -3,6 +3,8 @@ import type { TabGroupLayoutNode } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import TabGroupPanel, { type TabGroupPanelTabStrip } from './TabGroupPanel' import type { HoveredTabInsertion } from './useTabDragSplit' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { setGroupRatioThroughLayoutOwner } from './tab-group-layout-owner-commands' const MIN_RATIO = 0.15 const MAX_RATIO = 0.85 @@ -241,7 +243,12 @@ function SplitNode({ recordFeatureInteraction('terminal-panes')} - onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)} + onRatioChange={(nextRatio) => + // Switch point: main saves the group sizes once the drag ends. + ORCA_LAYOUT_OWNER_IS_MAIN + ? setGroupRatioThroughLayoutOwner(worktreeId, nodePath, nextRatio) + : setTabGroupSplitRatio(worktreeId, nodePath, nextRatio) + } />
@@ -50,6 +52,20 @@ export function commitTabDragDrop({ geometry: dragGeometryRef.current }) if (paneColumnSplit) { + // Switch point: main places the tab beside the group first; the drag ends now, the move shows + // once main answers. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + dropTabThroughLayoutOwner(worktreeId, { + kind: 'split', + tabId: activeData.unifiedTabId, + groupId: paneColumnSplit.groupId, + direction: paneColumnSplit.zone + }) + ) { + finishDrag(true) + return + } const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: paneColumnSplit.groupId, splitDirection: paneColumnSplit.zone @@ -115,6 +131,19 @@ export function commitTabDragDrop({ if (oldIndex !== -1 && oldIndex !== nextIndex) { const nextOrder = targetGroup.tabOrder.filter((id) => id !== activeData.unifiedTabId) nextOrder.splice(nextIndex, 0, activeData.unifiedTabId) + // Switch point: main reorders the group first. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + dropTabThroughLayoutOwner(worktreeId, { + kind: 'reorder', + tabId: activeData.unifiedTabId, + groupId: overData.groupId, + tabOrder: nextOrder + }) + ) { + finishDrag(true) + return + } reorderUnifiedTabs(overData.groupId, nextOrder) mirrorWebRuntimeTabMove({ kind: 'reorder', @@ -126,6 +155,19 @@ export function commitTabDragDrop({ } } else { const index = overIndex === -1 ? targetGroup.tabOrder.length : rawInsertIndex + // Switch point: main moves the tab into the group first. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + dropTabThroughLayoutOwner(worktreeId, { + kind: 'move', + tabId: activeData.unifiedTabId, + groupId: overData.groupId, + index + }) + ) { + finishDrag(true) + return + } const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: overData.groupId, index @@ -151,6 +193,18 @@ export function commitTabDragDrop({ if (isPaneDropData(overData)) { if (activeData.groupId !== overData.groupId) { + // Switch point: main moves the tab into the group first. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + dropTabThroughLayoutOwner(worktreeId, { + kind: 'move', + tabId: activeData.unifiedTabId, + groupId: overData.groupId + }) + ) { + finishDrag(true) + return + } const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: overData.groupId }) diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts new file mode 100644 index 00000000000..22caeeaa1b1 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' +import { + dropTabThroughLayoutOwner, + setGroupRatioThroughLayoutOwner +} from './tab-group-layout-owner-commands' + +const mocks = vi.hoisted( + (): { + send: ReturnType> + store: ReturnType | null + } => ({ send: vi.fn(), store: null }) +) + +// The store's split takes main's group id only in a layout-owner build. +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('@/runtime/layout-owner-command', () => ({ sendLayoutCommandToOwner: mocks.send })) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +createTabsSliceMockApi() + +const WT = 'repo1::/path/wt1' + +function store() { + return mocks.store! +} + +function seeded(): { left: string; right: string } { + mocks.store = createTestStore() + seedStore(store(), { + activeWorktreeId: WT, + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] } + }) + for (const id of ['a', 'b', 'c']) { + store().getState().createTab(WT, undefined, undefined, { id }) + } + const left = store().getState().groupsByWorktree[WT]![0]!.id + store().getState().dropUnifiedTab('c', { groupId: left, splitDirection: 'right' }) + const right = store() + .getState() + .groupsByWorktree[WT]!.find((g) => g.id !== left)!.id + store().getState().activateTab('a') + return { left, right } +} + +const groupOf = (tabId: string) => + store() + .getState() + .unifiedTabsByWorktree[WT]!.find((tab) => tab.id === tabId)?.groupId +const orderOf = (groupId: string) => + store() + .getState() + .groupsByWorktree[WT]!.find((group) => group.id === groupId)?.tabOrder +const lastHandlers = () => mocks.send.mock.calls.at(-1)![2] + +beforeEach(() => { + mocks.send.mockReset() + mocks.send.mockReturnValue(true) +}) + +describe('dropTabThroughLayoutOwner', () => { + it('moves the tab only once main has, then selects it in its new group', () => { + const { right } = seeded() + expect( + dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right, index: 0 }) + ).toBe(true) + expect(mocks.send).toHaveBeenCalledWith( + WT, + { type: 'moveTab', tabId: 'a', toGroupId: right, index: 0 }, + expect.anything() + ) + expect(groupOf('a')).not.toBe(right) + + lastHandlers().apply({}) + + expect(orderOf(right)).toEqual(['a', 'c']) + expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right) + }) + + it('only selects the tab when main’s frame already moved it', () => { + const { left, right } = seeded() + dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right }) + // Main's frame lands first: the tab is in its group, the window's selection unchanged. + store().getState().dropUnifiedTab('a', { groupId: right, index: 1 }) + store().getState().focusGroup(WT, left) + + lastHandlers().apply({}) + + expect(orderOf(right)).toEqual(['c', 'a']) + expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right) + expect( + store() + .getState() + .groupsByWorktree[WT]!.find((g) => g.id === right)?.activeTabId + ).toBe('a') + }) + + it('splits off a group under main’s id when the window makes the move itself', () => { + const { left } = seeded() + + dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'b', groupId: left, direction: 'down' }) + expect(mocks.send.mock.calls[0]![1]).toEqual({ + type: 'splitGroup', + tabId: 'b', + besideGroupId: left, + direction: 'down' + }) + lastHandlers().apply({ groupId: 'group-main' }) + + expect(groupOf('b')).toBe('group-main') + expect(store().getState().activeGroupIdByWorktree[WT]).toBe('group-main') + }) + + it('moves a lone tab’s group whole when main kept its id', () => { + const { left, right } = seeded() + + dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'c', groupId: left, direction: 'left' }) + lastHandlers().apply({ groupId: right }) + + expect( + store() + .getState() + .groupsByWorktree[WT]!.map((group) => group.id) + ).toEqual([left, right]) + expect(store().getState().layoutByWorktree[WT]).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: right }, + second: { type: 'leaf', groupId: left }, + ratio: 0.5 + }) + expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right) + }) + + it('leaves a drop the window would skip to today’s path, sending nothing', () => { + const { left, right } = seeded() + + expect( + dropTabThroughLayoutOwner(WT, { + kind: 'split', + tabId: 'c', + groupId: left, + direction: 'right' + }) + ).toBe(false) + expect(mocks.send).not.toHaveBeenCalled() + expect(groupOf('c')).toBe(right) + }) + + it('reorders within a group at the index main got', () => { + const { left } = seeded() + + dropTabThroughLayoutOwner(WT, { + kind: 'reorder', + tabId: 'a', + groupId: left, + tabOrder: ['b', 'a'] + }) + expect(mocks.send.mock.calls[0]![1]).toEqual({ + type: 'moveTab', + tabId: 'a', + toGroupId: left, + index: 1 + }) + lastHandlers().apply({}) + + expect(orderOf(left)).toEqual(['b', 'a']) + }) + + it('moves as today when main cannot act, and not at all when main refuses', () => { + const { right } = seeded() + + dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right }) + lastHandlers().refused?.('invalid_params') + expect(groupOf('a')).not.toBe(right) + + dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right }) + lastHandlers().asToday() + expect(orderOf(right)).toEqual(['c', 'a']) + }) + + it('splits as today when main moved a group whole that holds more tabs here', () => { + const { left } = seeded() + + dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'b', groupId: left, direction: 'down' }) + // Main had only `b` in that group, so it kept the group's id and moved it. + lastHandlers().apply({ groupId: left }) + + expect(orderOf(left)).toEqual(['a']) + expect(groupOf('b')).not.toBe(left) + }) +}) + +describe('setGroupRatioThroughLayoutOwner', () => { + it('sets the ratio here, then has main save the group tree', () => { + seeded() + + setGroupRatioThroughLayoutOwner(WT, '', 0.3) + + const groupLayout = store().getState().layoutByWorktree[WT] + expect(groupLayout).toMatchObject({ type: 'split', ratio: 0.3 }) + expect(mocks.send).toHaveBeenCalledWith( + WT, + { type: 'setGroupRatios', groupLayout }, + expect.anything() + ) + }) + + it('sends nothing for an unchanged ratio', () => { + seeded() + + setGroupRatioThroughLayoutOwner(WT, '', 0.5) + + expect(mocks.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts new file mode 100644 index 00000000000..83444856738 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts @@ -0,0 +1,178 @@ +// A layout-owner build's tab moves and group sizes. A move: main places the tab first, then the +// window shows main's answer. Main's frame usually shows the move before main answers; while this +// window owes main a save the frame waits, so the window then makes the same move itself, with +// main's new group id. Group sizes: drawn while dragging, main saves them when the drag ends. + +import { useAppStore } from '@/store' +import type { TabSplitDirection } from '@/store/slices/tabs' +import { sendLayoutCommandToOwner, type WindowLayoutCommand } from '@/runtime/layout-owner-command' +import { moveGroupLeafBeside } from '../../../../shared/workspace-layout/tab-group-layout-tree' +import { stableJson } from '../../../../shared/workspace-layout/workspace-layout-load-report' +import { isPaneColumnSplitDropNoOp } from '@/store/slices/pane-column-split-drop-no-op' +import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror' + +/** A tab drop as the window resolved it: within its group, into another, or beside a group. */ +export type TabDrop = + | { kind: 'reorder'; tabId: string; groupId: string; tabOrder: string[] } + | { kind: 'move'; tabId: string; groupId: string; index?: number } + | { kind: 'split'; tabId: string; groupId: string; direction: TabSplitDirection } + +/** + * Sends a tab drop to main, which the window then shows; a refused drop moves nothing. False when + * the window skips the drop or a paired server owns the workspace's layout: the caller moves the + * tab as today. + */ +export function dropTabThroughLayoutOwner(worktreeId: string, drop: TabDrop): boolean { + const command = commandFor(worktreeId, drop) + if (!command) { + return false + } + return sendLayoutCommandToOwner(worktreeId, command, { + apply: ({ groupId }) => showMainMove(worktreeId, drop, groupId), + asToday: () => moveAsToday(worktreeId, drop) + }) +} + +function commandFor(worktreeId: string, drop: TabDrop): WindowLayoutCommand | null { + const { tabId, groupId } = drop + const state = useAppStore.getState() + const groups = state.groupsByWorktree[worktreeId] ?? [] + if (drop.kind === 'split') { + const source = groups.find((candidate) => candidate.tabOrder.includes(tabId)) + // Why: a drop the window's own move skips changes nothing; main would still reset the ratio. + if ( + !source || + isPaneColumnSplitDropNoOp({ + sourceGroupId: source.id, + targetGroupId: groupId, + splitDirection: drop.direction, + sourceTabCount: source.tabOrder.length, + layout: state.layoutByWorktree[worktreeId] + }) + ) { + return null + } + return { type: 'splitGroup', tabId, besideGroupId: groupId, direction: drop.direction } + } + const group = groups.find((candidate) => candidate.id === groupId) + if (!group) { + return null + } + const index = + drop.kind === 'reorder' + ? drop.tabOrder.indexOf(tabId) + : Math.min(drop.index ?? group.tabOrder.length, group.tabOrder.length) + return { type: 'moveTab', tabId, toGroupId: groupId, index } +} + +/** Today's move, mirrored to a web client's server as today. */ +function moveAsToday(worktreeId: string, drop: TabDrop, newGroupId?: string): boolean { + const state = useAppStore.getState() + const { tabId, groupId: targetGroupId } = drop + if (drop.kind === 'reorder') { + state.reorderUnifiedTabs(targetGroupId, drop.tabOrder) + mirrorWebRuntimeTabMove({ + kind: 'reorder', + worktreeId, + tabId, + targetGroupId, + tabOrder: drop.tabOrder + }) + return true + } + if (drop.kind === 'split') { + const splitDirection = drop.direction + const moved = state.dropUnifiedTab(tabId, { + groupId: targetGroupId, + splitDirection, + newGroupId + }) + if (moved) { + mirrorWebRuntimeTabMove({ kind: 'split', worktreeId, tabId, targetGroupId, splitDirection }) + } + return moved + } + const { index } = drop + const moved = state.dropUnifiedTab(tabId, { + groupId: targetGroupId, + ...(index === undefined ? {} : { index }) + }) + if (moved) { + mirrorWebRuntimeTabMove({ kind: 'move-to-group', worktreeId, tabId, targetGroupId, index }) + } + return moved +} + +/** Shows main's placement (main's frame may already have) and selects the tab. */ +function showMainMove(worktreeId: string, drop: TabDrop, mainGroupId: string | undefined): void { + const state = useAppStore.getState() + const { tabId } = drop + const groups = state.groupsByWorktree[worktreeId] ?? [] + if (drop.kind === 'reorder') { + const order = groups.find((group) => group.id === drop.groupId)?.tabOrder + if (order?.indexOf(tabId) !== drop.tabOrder.indexOf(tabId)) { + moveAsToday(worktreeId, drop) + } + return + } + const groupId = drop.kind === 'split' ? mainGroupId : drop.groupId + const tab = state.unifiedTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + if (!tab || !groupId) { + return + } + if (tab.groupId !== groupId) { + moveAsToday(worktreeId, drop, groupId) + return + } + if (drop.kind === 'split') { + // Main moved the tab's lone group whole, keeping its id; a no-op once main's frame showed it. + // Why the window's count: main may not have this window's newer tabs in that group yet. + if (groups.find((group) => group.id === groupId)?.tabOrder.length !== 1) { + moveAsToday(worktreeId, drop) + return + } + placeGroupBeside(worktreeId, groupId, drop.groupId, drop.direction) + } + state.activateTab(tabId, { worktreeId, preservePreview: true, recordFocus: false }) + useAppStore.getState().focusGroup(worktreeId, groupId) +} + +function placeGroupBeside( + worktreeId: string, + groupId: string, + besideGroupId: string, + side: TabSplitDirection +): void { + useAppStore.setState((state) => { + const layout = state.layoutByWorktree[worktreeId] + const placed = moveGroupLeafBeside( + layout ?? { type: 'leaf', groupId: besideGroupId }, + groupId, + besideGroupId, + side + ) + return stableJson(placed) === stableJson(layout) + ? state + : { layoutByWorktree: { ...state.layoutByWorktree, [worktreeId]: placed } } + }) +} + +/** Sets a group divider's ratio here, as today, then has main save the workspace's group sizes. */ +export function setGroupRatioThroughLayoutOwner( + worktreeId: string, + nodePath: string, + ratio: number +): void { + const state = useAppStore.getState() + state.setTabGroupSplitRatio(worktreeId, nodePath, ratio) + const groupLayout = useAppStore.getState().layoutByWorktree[worktreeId] + if (groupLayout && groupLayout !== state.layoutByWorktree[worktreeId]) { + // Why keep on every answer: the window's own save still writes these sizes in this phase. + const keep = (): void => {} + sendLayoutCommandToOwner( + worktreeId, + { type: 'setGroupRatios', groupLayout }, + { apply: keep, asToday: keep } + ) + } +} diff --git a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts index 40aedc591d0..82e4f825cd8 100644 --- a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts @@ -1,11 +1,11 @@ import { beforeEach, expect, it, vi } from 'vitest' import { preparePendingSplitClose } from './pending-split-close-test-fixture' -import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: false, commitTerminalSurfaceClose: vi.fn(), - sendPaneCommandToLayoutOwner: vi.fn() + sendLayoutCommandToOwner: vi.fn() })) vi.mock('../../../../shared/layout-owner-switch', () => ({ @@ -13,8 +13,8 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({ return mocks.layoutOwnerIsMain } })) -vi.mock('./terminal-pane-layout-owner-commands', () => ({ - sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner +vi.mock('@/runtime/layout-owner-command', () => ({ + sendLayoutCommandToOwner: mocks.sendLayoutCommandToOwner })) vi.mock('@/store/terminals/terminal-surface-close-intent', () => ({ commitTerminalSurfaceClose: mocks.commitTerminalSurfaceClose @@ -25,8 +25,8 @@ const LEAF = '11111111-1111-4111-8111-111111111111' beforeEach(() => { mocks.layoutOwnerIsMain = false mocks.commitTerminalSurfaceClose.mockReset() - mocks.sendPaneCommandToLayoutOwner.mockReset() - mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) + mocks.sendLayoutCommandToOwner.mockReset() + mocks.sendLayoutCommandToOwner.mockReturnValue(true) }) it('a window-owned build closes the pane at once and reports it to main', async () => { @@ -34,7 +34,7 @@ it('a window-owned build closes the pane at once and reports it to main', async p.actions.executeClosePane(1) - expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() + expect(mocks.sendLayoutCommandToOwner).not.toHaveBeenCalled() expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledWith('workspace', { kind: 'pane', tabId: 'tab-parent', @@ -49,14 +49,14 @@ it('a layout-owner build removes the pane only once main has closed it', async ( p.actions.executeClosePane(1) - expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith( + expect(mocks.sendLayoutCommandToOwner).toHaveBeenCalledWith( 'workspace', { type: 'closePane', tabId: 'tab-parent', leafId: LEAF }, { apply: expect.any(Function), asToday: expect.any(Function), refused: expect.any(Function) } ) expect(p.transports.has(1)).toBe(true) - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({}) + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].apply({}) expect(p.transports.has(1)).toBe(false) expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled() @@ -67,7 +67,7 @@ it('a layout-owner build closes as today when a paired server holds the tab', as const p = await preparePendingSplitClose() p.actions.executeClosePane(1) - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].asToday() + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].asToday() expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledOnce() expect(p.transports.has(1)).toBe(false) @@ -78,7 +78,7 @@ it('a layout-owner build closes the tab when main refuses a second close as the const p = await preparePendingSplitClose() p.actions.executeClosePane(1) p.actions.executeClosePane(2) - const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on) + const [first, second] = mocks.sendLayoutCommandToOwner.mock.calls.map(([, , on]) => on) first?.apply({}) second?.refused?.('last_pane') @@ -91,7 +91,7 @@ it('a layout-owner build closes the tab when main’s answer finds the pane alon const p = await preparePendingSplitClose() p.actions.executeClosePane(1) p.actions.executeClosePane(2) - const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on) + const [first, second] = mocks.sendLayoutCommandToOwner.mock.calls.map(([, , on]) => on) first?.apply({}) second?.apply({}) @@ -106,7 +106,7 @@ it('a layout-owner build leaves an unmounted tab alone when main refuses its las p.actions.executeClosePane(1) p.controller.managerRef.current = null - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].refused?.('last_pane') + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].refused?.('last_pane') expect(p.controller.onCloseTab).not.toHaveBeenCalled() }) @@ -116,7 +116,7 @@ it('a layout-owner build closes as today when main calls it the last pane but th const p = await preparePendingSplitClose() p.actions.executeClosePane(1) - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].refused?.('last_pane') + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].refused?.('last_pane') expect(p.controller.onCloseTab).not.toHaveBeenCalled() expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index 6940f1297e6..0d6d385ce71 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -2,14 +2,14 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-persistence' -import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' const LEFT = '11111111-1111-4111-8111-111111111111' const RIGHT = '22222222-2222-4222-8222-222222222222' const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: false, - sendPaneCommandToLayoutOwner: vi.fn() + sendLayoutCommandToOwner: vi.fn() })) vi.mock('../../../../shared/layout-owner-switch', () => ({ @@ -17,8 +17,8 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({ return mocks.layoutOwnerIsMain } })) -vi.mock('./terminal-pane-layout-owner-commands', () => ({ - sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner +vi.mock('@/runtime/layout-owner-command', () => ({ + sendLayoutCommandToOwner: mocks.sendLayoutCommandToOwner })) vi.mock('../../store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } @@ -86,7 +86,7 @@ function renderPersistence() { afterEach(() => { cleanup() mocks.layoutOwnerIsMain = false - mocks.sendPaneCommandToLayoutOwner.mockReset() + mocks.sendLayoutCommandToOwner.mockReset() }) describe('terminal layout gesture persist', () => { @@ -134,26 +134,26 @@ describe('terminal layout gesture persist', () => { hook.result.current.changePaneTitle(2, 'server') - expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() + expect(mocks.sendLayoutCommandToOwner).not.toHaveBeenCalled() expect(pushedIntents()).toEqual(['gesture']) }) it('a layout-owner build shows a title change only once main has saved it', () => { mocks.layoutOwnerIsMain = true - mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) + mocks.sendLayoutCommandToOwner.mockReturnValue(true) const { hook, pushedIntents, push } = renderPersistence() push.mockClear() hook.result.current.changePaneTitle(2, 'server') hook.result.current.removePaneTitle(1) - expect(mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, command]) => command)).toEqual([ + expect(mocks.sendLayoutCommandToOwner.mock.calls.map(([, command]) => command)).toEqual([ { type: 'renamePane', tabId: 'tab', leafId: RIGHT, title: 'server' }, { type: 'renamePane', tabId: 'tab', leafId: LEFT, title: null } ]) expect(pushedIntents()).toEqual([]) - for (const [, , handlers] of mocks.sendPaneCommandToLayoutOwner.mock.calls) { + for (const [, , handlers] of mocks.sendLayoutCommandToOwner.mock.calls) { handlers.apply({}) } expect(pushedIntents()).toEqual(['gesture', 'gesture']) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts index 75648c6cb5d..12f796b3c37 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { - equalizeTerminalPanes, - sendPaneCommandToLayoutOwner -} from './terminal-pane-layout-owner-commands' +import { equalizeTerminalPanes } from './terminal-pane-layout-owner-commands' const mocks = vi.hoisted( (): { @@ -29,113 +26,6 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId })) -const SPLIT = { - type: 'splitPane', - tabId: 'tab-1', - leafId: 'leaf-1', - direction: 'vertical' -} as const - -function handlers() { - return { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() } -} - -describe('sendPaneCommandToLayoutOwner', () => { - beforeEach(() => { - mocks.callRuntimeRpc.mockReset() - mocks.ownerEnvironmentId = null - vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - it('asks local main first, then applies main’s result', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) - const on = handlers() - - expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) - - expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', { - command: { ...SPLIT, workspace: 'worktree-1' } - }) - expect(on.apply).not.toHaveBeenCalled() - await vi.waitFor(() => expect(on.apply).toHaveBeenCalledWith({ leafId: 'leaf-minted' })) - expect(on.asToday).not.toHaveBeenCalled() - }) - - it('changes nothing itself after a refusal', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' }) - const on = handlers() - - expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) - - await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) - expect(on.apply).not.toHaveBeenCalled() - expect(on.asToday).not.toHaveBeenCalled() - }) - - it.each([ - [ - 'main lacks the pane', - () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' }) - ], - [ - 'main lacks the tab', - () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'tab_not_found' }) - ], - [ - 'main lacks the workspace', - () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' }) - ], - ['the call fails', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))] - ])('acts as today when %s', async (_case, arrange) => { - arrange() - const on = handlers() - - sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on) - - await vi.waitFor(() => expect(on.asToday).toHaveBeenCalledOnce()) - expect(on.refused).not.toHaveBeenCalled() - }) - - it('does not run the fallback when the caller’s own handler throws', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) - const on = handlers() - on.apply.mockImplementation(() => { - throw new Error('mount failed') - }) - - sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on) - - await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) - expect(on.asToday).not.toHaveBeenCalled() - }) - - it('hands any other refusal to the caller', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' }) - const on = handlers() - - sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on) - - await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane')) - }) - - it('acts as today when main says a paired server holds the tab', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' }) - const on = handlers() - - expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) - - await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled()) - expect(on.apply).not.toHaveBeenCalled() - }) - - it('leaves a paired server’s workspace to today’s path', () => { - mocks.ownerEnvironmentId = 'env-1' - - expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, handlers())).toBe(false) - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() - }) -}) - describe('equalizeTerminalPanes', () => { beforeEach(() => { mocks.callRuntimeRpc.mockReset() diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts index 7cdf1dff933..76fa2e47df5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts @@ -1,81 +1,6 @@ -import { useAppStore } from '@/store' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' -import type { PaneSide } from '../../../../shared/workspace-layout/terminal-pane-tree' -import type { - LayoutApplyReply, - LayoutCommandResult, - LayoutRefusalCode -} from '../../../../shared/workspace-layout/workspace-layout-command-types' - -/** The pane commands a window sends `layout.apply`; main mints any new id. */ -export type PaneLayoutCommand = - | { type: 'splitPane'; tabId: string; leafId: string; direction: 'vertical' | 'horizontal' } - | { type: 'closePane'; tabId: string; leafId: string } - | { type: 'renamePane'; tabId: string; leafId: string; title: string | null } - | { type: 'equalizePanes'; tabId: string } - | { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide } - | { type: 'setPaneRatios'; tabId: string; root: TerminalPaneLayoutNode } - -/** - * A pane command in a layout-owner build: main applies it first, then `apply` shows main's result. - * `asToday` runs instead when main cannot act on it: a paired server holds the tab (#18202), main's - * copy lacks the workspace, tab or pane, or the call threw. `refused` on any other refusal. False - * when a paired server owns the workspace's layout; the caller acts as today. - */ -export function sendPaneCommandToLayoutOwner( - worktreeId: string, - command: PaneLayoutCommand, - handlers: { - apply: (result: LayoutCommandResult) => void - asToday: () => void - refused?: (code: LayoutRefusalCode) => 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(), worktreeId)) { - return false - } - void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { - command: { ...command, workspace: worktreeId } - }) - .then( - (reply) => { - if (reply.ok) { - handlers.apply(reply.result) - } else if (reply.code === 'host_not_owned' || mainLacksTarget(reply.code)) { - // Why lacking: main's copy lags the window's here (the window still saves its own). - handlers.asToday() - } else { - console.warn(`[layout-owner] ${command.type} refused:`, reply.code) - handlers.refused?.(reply.code) - } - }, - (error: unknown) => { - // Why as today: a thrown write is rolled back. An indeterminate disk write is not, and may - // leave a pane main minted; accepted until main is the only writer. - console.warn(`[layout-owner] ${command.type} failed:`, errorMessage(error)) - handlers.asToday() - } - ) - // A throwing handler is not a failed call; it must not run the fallback too. - .catch((error: unknown) => { - console.warn(`[layout-owner] ${command.type} handler threw:`, errorMessage(error)) - }) - return true -} - -function mainLacksTarget(code: LayoutRefusalCode): boolean { - return code === 'workspace_not_found' || code === 'tab_not_found' || code === 'pane_not_found' -} - -function errorMessage(error: unknown): unknown { - return error instanceof Error ? error.message : error -} /** Equalizes a tab's panes, then `then` runs (focus); in a layout-owner build main equalizes first. */ export function equalizeTerminalPanes( @@ -92,7 +17,7 @@ export function equalizeTerminalPanes( // Switch point: main equalizes the tab first; the window then redraws it. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( pane.worktreeId, { type: 'equalizePanes', tabId: pane.tabId }, { apply: equalize, asToday: equalize } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts index e6807f473a6..1823a72054c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { layoutOwnerPaneGestures } from './terminal-pane-layout-owner-gestures' -import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' const mocks = vi.hoisted(() => ({ root: { @@ -11,11 +11,11 @@ const mocks = vi.hoisted(() => ({ first: { type: 'leaf', leafId: 'leaf-1' }, second: { type: 'leaf', leafId: 'leaf-2' } }, - send: vi.fn() + send: vi.fn() })) -vi.mock('./terminal-pane-layout-owner-commands', () => ({ - sendPaneCommandToLayoutOwner: mocks.send +vi.mock('@/runtime/layout-owner-command', () => ({ + sendLayoutCommandToOwner: mocks.send })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: { 'tab-1': { root: mocks.root } } }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts index 167f751821d..8bdd641745e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts @@ -1,6 +1,6 @@ import type { PaneManager, PaneManagerOptions } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' -import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' /** * A layout-owner build's pane gestures: an in-tab drop moves the pane once main has answered, as @@ -29,7 +29,7 @@ export function layoutOwnerPaneGestures(pane: { const root = useAppStore.getState().terminalLayoutsByTabId[tabId]?.root if (root && pane.shouldPersistLayout()) { const keep = (): void => {} - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( worktreeId, { type: 'setPaneRatios', tabId, root }, { apply: keep, asToday: keep } @@ -54,7 +54,7 @@ export function layoutOwnerPaneGestures(pane: { } } } - const taken = sendPaneCommandToLayoutOwner( + const taken = sendLayoutCommandToOwner( worktreeId, { type: 'movePane', tabId, leafId, targetLeafId, side: zone }, { apply: settle, asToday: settle, refused: settle } 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 cabbf81921e..9439da3c934 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 @@ -4,13 +4,13 @@ import type { PtyTransport } from './pty-transport-types' import type { PaneCwdMap } from './resolve-split-cwd' import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { createDeferred } from './pty-connection-test-async' -import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: false, recordCreatedTerminalPaneSplit: vi.fn(), resolveSplitCwd: vi.fn(), - sendPaneCommandToLayoutOwner: vi.fn(), + sendLayoutCommandToOwner: vi.fn(), splitWebRuntimeTerminal: vi.fn() })) @@ -20,8 +20,8 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({ } })) -vi.mock('./terminal-pane-layout-owner-commands', () => ({ - sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner +vi.mock('@/runtime/layout-owner-command', () => ({ + sendLayoutCommandToOwner: mocks.sendLayoutCommandToOwner })) vi.mock('@/runtime/web-runtime-terminal-actions', () => ({ @@ -51,7 +51,7 @@ function makeManager(splitPane: ReturnType): PaneManager { describe('splitTerminalPaneWithInheritedCwd', () => { beforeEach(() => { mocks.layoutOwnerIsMain = false - mocks.sendPaneCommandToLayoutOwner.mockReset() + mocks.sendLayoutCommandToOwner.mockReset() mocks.recordCreatedTerminalPaneSplit.mockReset() mocks.resolveSplitCwd.mockReset() mocks.splitWebRuntimeTerminal.mockReset() @@ -248,13 +248,13 @@ describe('splitTerminalPaneWithInheritedCwd', () => { source: 'keyboard' }) - expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() + expect(mocks.sendLayoutCommandToOwner).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.sendPaneCommandToLayoutOwner.mockReturnValue(true) + mocks.sendLayoutCommandToOwner.mockReturnValue(true) const createdPane = { id: 2 } const splitPane = vi.fn(() => createdPane) @@ -270,7 +270,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => { source: 'keyboard' }) - expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith( + expect(mocks.sendLayoutCommandToOwner).toHaveBeenCalledWith( 'worktree-1', { type: 'splitPane', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' }, { @@ -280,7 +280,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => { ) expect(splitPane).not.toHaveBeenCalled() - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' }) + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' }) expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { leafId: 'leaf-minted', @@ -290,12 +290,12 @@ describe('splitTerminalPaneWithInheritedCwd', () => { source: 'keyboard', direction: 'vertical' }) - expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledOnce() + expect(mocks.sendLayoutCommandToOwner).toHaveBeenCalledOnce() }) it('in a layout-owner build, splits as today where a paired server owns the layout', () => { mocks.layoutOwnerIsMain = true - mocks.sendPaneCommandToLayoutOwner.mockReturnValue(false) + mocks.sendLayoutCommandToOwner.mockReturnValue(false) const splitPane = vi.fn(() => ({ id: 2 })) splitTerminalPaneWithInheritedCwd({ @@ -315,7 +315,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => { function splitInLayoutOwnerBuild(manager: PaneManager | null): void { mocks.layoutOwnerIsMain = true - mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) + mocks.sendLayoutCommandToOwner.mockReturnValue(true) splitTerminalPaneWithInheritedCwd({ worktreeId: 'worktree-1', tabId: 'tab-1', @@ -336,9 +336,9 @@ describe('splitTerminalPaneWithInheritedCwd', () => { ])('withdraws the pane main saved when %s by the time main answers', (_case, manager) => { splitInLayoutOwnerBuild(manager) - mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' }) + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' }) - expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenLastCalledWith( + expect(mocks.sendLayoutCommandToOwner).toHaveBeenLastCalledWith( 'worktree-1', { type: 'closePane', tabId: 'tab-1', leafId: 'leaf-minted' }, { apply: expect.any(Function), asToday: expect.any(Function) } 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 3939e6a108f..325111756b7 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 @@ -5,7 +5,7 @@ import type { PtyTransport } from './pty-transport-types' import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' type SplitArgs = { worktreeId: string @@ -34,7 +34,7 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void { // Switch point: main writes the split and mints the new pane's id; the window then mounts it. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( args.worktreeId, { type: 'splitPane', tabId: args.tabId, leafId: args.pane.leafId, direction: args.direction }, { @@ -54,7 +54,7 @@ function mountMainSplit(args: SplitArgs, ptyId: string | null, leafId: string): const manager = args.getManager ? args.getManager() : args.manager if (manager?.getNumericIdForLeaf(leafId) == null) { const keep = (): void => {} - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( args.worktreeId, { type: 'closePane', tabId: args.tabId, leafId }, { apply: keep, asToday: keep } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 7efdd623872..dcf02a8c3a6 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -20,7 +20,7 @@ import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import { closeAfterLastPaneRefusal } from './layout-owner-last-pane-refusal' export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { @@ -119,7 +119,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr if ( ORCA_LAYOUT_OWNER_IS_MAIN && leafId && - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( worktreeId, { type: 'closePane', tabId, leafId }, { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index 982bfe66e70..0d4adc66539 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -14,7 +14,7 @@ import { import type { TerminalPaneStartupController } from './use-terminal-pane-startup-actions' import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartupController) { const { @@ -207,7 +207,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if ( ORCA_LAYOUT_OWNER_IS_MAIN && leafId && - sendPaneCommandToLayoutOwner( + sendLayoutCommandToOwner( worktreeId, { type: 'renamePane', tabId, leafId, title }, { apply: showTitle, asToday: showTitle } diff --git a/src/renderer/src/runtime/layout-owner-command.test.ts b/src/renderer/src/runtime/layout-owner-command.test.ts new file mode 100644 index 00000000000..408c208df63 --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-command.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sendLayoutCommandToOwner } from './layout-owner-command' + +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 = { + type: 'splitPane', + tabId: 'tab-1', + leafId: 'leaf-1', + direction: 'vertical' +} as const + +function handlers() { + return { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() } +} + +describe('sendLayoutCommandToOwner', () => { + beforeEach(() => { + mocks.callRuntimeRpc.mockReset() + mocks.ownerEnvironmentId = null + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('asks local main first, then applies main’s result', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) + const on = handlers() + + expect(sendLayoutCommandToOwner('worktree-1', SPLIT, on)).toBe(true) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', { + command: { ...SPLIT, workspace: 'worktree-1' } + }) + expect(on.apply).not.toHaveBeenCalled() + await vi.waitFor(() => expect(on.apply).toHaveBeenCalledWith({ leafId: 'leaf-minted' })) + expect(on.asToday).not.toHaveBeenCalled() + }) + + it('changes nothing itself after a refusal', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' }) + const on = handlers() + + expect(sendLayoutCommandToOwner('worktree-1', SPLIT, on)).toBe(true) + + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) + expect(on.apply).not.toHaveBeenCalled() + expect(on.asToday).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'main lacks the pane', + () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' }) + ], + [ + 'main lacks the tab', + () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'tab_not_found' }) + ], + [ + 'main lacks the workspace', + () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' }) + ], + ['the call fails', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))] + ])('acts as today when %s', async (_case, arrange) => { + arrange() + const on = handlers() + + sendLayoutCommandToOwner('worktree-1', SPLIT, on) + + await vi.waitFor(() => expect(on.asToday).toHaveBeenCalledOnce()) + expect(on.refused).not.toHaveBeenCalled() + }) + + it('does not run the fallback when the caller’s own handler throws', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) + const on = handlers() + on.apply.mockImplementation(() => { + throw new Error('mount failed') + }) + + sendLayoutCommandToOwner('worktree-1', SPLIT, on) + + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) + expect(on.asToday).not.toHaveBeenCalled() + }) + + it('hands any other refusal to the caller', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' }) + const on = handlers() + + sendLayoutCommandToOwner('worktree-1', SPLIT, on) + + await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane')) + }) + + it('acts as today when main says a paired server holds the tab', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' }) + const on = handlers() + + expect(sendLayoutCommandToOwner('worktree-1', SPLIT, on)).toBe(true) + + await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled()) + expect(on.apply).not.toHaveBeenCalled() + }) + + it('leaves a paired server’s workspace to today’s path', () => { + mocks.ownerEnvironmentId = 'env-1' + + expect(sendLayoutCommandToOwner('worktree-1', SPLIT, handlers())).toBe(false) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/layout-owner-command.ts b/src/renderer/src/runtime/layout-owner-command.ts new file mode 100644 index 00000000000..e6d1972333a --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-command.ts @@ -0,0 +1,87 @@ +import { useAppStore } from '@/store' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { TabGroupLayoutNode } from '../../../shared/tab-types' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { PaneSide } from '../../../shared/workspace-layout/terminal-pane-tree' +import type { + GroupSide, + LayoutApplyReply, + LayoutCommandResult, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' + +/** The commands a window sends `layout.apply`; main mints any new id. */ +export type WindowLayoutCommand = + | { type: 'splitPane'; tabId: string; leafId: string; direction: 'vertical' | 'horizontal' } + | { type: 'closePane'; tabId: string; leafId: string } + | { type: 'renamePane'; tabId: string; leafId: string; title: string | null } + | { type: 'equalizePanes'; tabId: string } + | { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide } + | { type: 'setPaneRatios'; tabId: string; root: TerminalPaneLayoutNode } + | { type: 'moveTab'; tabId: string; toGroupId: string; index: number } + | { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide } + | { type: 'setGroupRatios'; groupLayout: TabGroupLayoutNode } + +/** + * A layout command in a layout-owner build: main applies it first, then `apply` shows main's + * result. `asToday` runs instead when main cannot act on it: a paired server holds the tab + * (#18202), main's copy lacks the workspace, tab, pane or group, or the call threw. `refused` on + * any other refusal. False when a paired server owns the workspace's layout; the caller acts as + * today. + */ +export function sendLayoutCommandToOwner( + worktreeId: string, + command: WindowLayoutCommand, + handlers: { + apply: (result: LayoutCommandResult) => void + asToday: () => void + refused?: (code: LayoutRefusalCode) => 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(), worktreeId)) { + return false + } + void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { + command: { ...command, workspace: worktreeId } + }) + .then( + (reply) => { + if (reply.ok) { + handlers.apply(reply.result) + } else if (reply.code === 'host_not_owned' || mainLacksTarget(reply.code)) { + // Why lacking: main's copy lags the window's here (the window still saves its own). + handlers.asToday() + } else { + console.warn(`[layout-owner] ${command.type} refused:`, reply.code) + handlers.refused?.(reply.code) + } + }, + (error: unknown) => { + // Why as today: a thrown write is rolled back. An indeterminate disk write is not, and may + // leave a pane main minted; accepted until main is the only writer. + console.warn(`[layout-owner] ${command.type} failed:`, errorMessage(error)) + handlers.asToday() + } + ) + // A throwing handler is not a failed call; it must not run the fallback too. + .catch((error: unknown) => { + console.warn(`[layout-owner] ${command.type} handler threw:`, errorMessage(error)) + }) + return true +} + +function mainLacksTarget(code: LayoutRefusalCode): boolean { + return ( + code === 'workspace_not_found' || + code === 'tab_not_found' || + code === 'pane_not_found' || + code === 'group_not_found' + ) +} + +function errorMessage(error: unknown): unknown { + return error instanceof Error ? error.message : error +} diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index 19d067fac5d..28cbe4a3a39 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -17,6 +17,7 @@ import { sanitizeRecentTabIds } from '../tab-group-state' import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' export function createTabsDropActions( set: TabsSliceSet, @@ -66,7 +67,8 @@ export function createTabsDropActions( let resolvedTargetGroupId = target.groupId if (target.splitDirection) { - const newGroupId = createBrowserUuid() + // Switch point: main minted the new group's id (layout-owner build). + const newGroupId = (ORCA_LAYOUT_OWNER_IS_MAIN && target.newGroupId) || createBrowserUuid() const newGroup: TabGroup = { id: newGroupId, worktreeId, diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index 825408fda76..87decf18ad4 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -146,6 +146,8 @@ export type TabsSlice = { groupId: string index?: number splitDirection?: TabSplitDirection + /** The split's new group id, as main minted it; layout-owner builds only. */ + newGroupId?: string } ) => boolean setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 89235b6541b..d2eaa9e1fb0 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params' +import { TerminalOrphanGroupLayout } from './terminal-orphan-params' export const LayoutSubscribeParams = z .object({ @@ -55,6 +56,31 @@ const EqualizePanesCommand = z.object({ tabId: OnPane.tabId }) +const OnTab = { workspace: OnPane.workspace, tabId: OnPane.tabId } + +/** Within one group too: a reorder is a move to the tab's own group. */ +const MoveTabCommand = z.object({ + type: z.literal('moveTab'), + ...OnTab, + toGroupId: z.string().min(1), + index: z.number().int().nonnegative() +}) + +/** Main mints the new group's id, or moves a lone tab's group whole. */ +const SplitGroupCommand = z.object({ + type: z.literal('splitGroup'), + ...OnTab, + besideGroupId: z.string().min(1), + direction: z.enum(['left', 'right', 'up', 'down']) +}) + +/** The workspace's group tree with its new ratios; main refuses one whose groups differ. */ +const SetGroupRatiosCommand = z.object({ + type: z.literal('setGroupRatios'), + workspace: OnPane.workspace, + groupLayout: TerminalOrphanGroupLayout +}) + /** * 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. @@ -66,6 +92,9 @@ export const LayoutApplyParams = z.object({ RenamePaneCommand, EqualizePanesCommand, MovePaneCommand, - SetPaneRatiosCommand + SetPaneRatiosCommand, + MoveTabCommand, + SplitGroupCommand, + SetGroupRatiosCommand ]) }) diff --git a/src/shared/workspace-layout/legacy-terminal-row-claims.ts b/src/shared/workspace-layout/legacy-terminal-row-claims.ts index 255a5e20099..bad5a307ab5 100644 --- a/src/shared/workspace-layout/legacy-terminal-row-claims.ts +++ b/src/shared/workspace-layout/legacy-terminal-row-claims.ts @@ -90,7 +90,7 @@ type PersistedTerminalPtyIds = { orphaned: string[] } -function readPersistedTerminalPtyIds( +export function readPersistedTerminalPtyIds( session: WorkspaceSessionState, tab: TerminalTab ): PersistedTerminalPtyIds { diff --git a/src/shared/workspace-layout/workspace-layout-folded-rows.ts b/src/shared/workspace-layout/workspace-layout-folded-rows.ts new file mode 100644 index 00000000000..33303b9d33b --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-folded-rows.ts @@ -0,0 +1,103 @@ +// Legacy terminal rows the load folds into a tab-bar tab: dropped as a stale copy of it, or with +// its terminal taken off them (legacy-terminal-row-claims). Stored, they still name that terminal, +// so once a command closes the claiming tab, moves its terminal, or gives the row a tab-bar entry, +// the next load resolves the claim another way and the row comes back. Such a command writes the +// row in the load's form. + +import type { TerminalTab } from '../terminal-tab-types' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { + readCanonicalPtyOwners, + readPersistedTerminalPtyIds, + resolveCanonicalPtyClaim +} from './legacy-terminal-row-claims' +import { stableJson } from './workspace-layout-load-report' + +/** + * `stored` with each folded row whose claim `patched` changes (its own, or a claiming tab's) + * replaced by its form in `loaded`: gone when the load dropped it, with a selection naming it + * following its twin as the load does; its row and pane layout as loaded otherwise. + */ +export function adoptTouchedFoldedRows( + stored: WorkspaceSessionState, + patched: WorkspaceSessionState, + loaded: WorkspaceSessionState +): WorkspaceSessionState { + let adopted = stored + for (const [key, rows] of Object.entries(stored.tabsByWorktree ?? {})) { + const canonical = readCanonicalPtyOwners(stored, key, rows) + for (const row of rows) { + if (canonical.tabIds.has(row.id)) { + continue + } + const claim = resolveCanonicalPtyClaim(stored, row, canonical) + if (claim.kind === 'retained' && claim.releasedPtyIds.size === 0) { + continue + } + const claimants = ptyIdsOf(stored, row).flatMap( + (ptyId) => canonical.tabIdByPtyId.get(ptyId) ?? [] + ) + const touched = [row.id, ...claimants].some( + (tabId) => claimOf(stored, key, tabId) !== claimOf(patched, key, tabId) + ) + if (touched) { + adopted = withLoadedRow(adopted, loaded, key, row.id) + } + } + } + return adopted +} + +/** The terminals a row names, as the claim rule reads them (the row's own and its panes'). */ +function ptyIdsOf(session: WorkspaceSessionState, row: TerminalTab): string[] { + const { owned, orphaned } = readPersistedTerminalPtyIds(session, row) + return [...owned, ...orphaned] +} + +/** What a tab claims with: whether it has a row and a tab-bar entry, and the terminals it names. */ +function claimOf(session: WorkspaceSessionState, key: string, tabId: string): string { + const row = session.tabsByWorktree?.[key]?.find((candidate) => candidate.id === tabId) + const entry = session.unifiedTabs?.[key]?.some((candidate) => candidate.entityId === tabId) + return stableJson({ row: Boolean(row), entry, ptyIds: row ? ptyIdsOf(session, row).sort() : [] }) +} + +function withLoadedRow( + session: WorkspaceSessionState, + loaded: WorkspaceSessionState, + key: string, + tabId: string +): WorkspaceSessionState { + const loadedRow = loaded.tabsByWorktree?.[key]?.find((row) => row.id === tabId) + const rows = (session.tabsByWorktree[key] ?? []).flatMap((row) => + row.id !== tabId ? [row] : loadedRow ? [loadedRow] : [] + ) + const next: WorkspaceSessionState = { + ...session, + tabsByWorktree: { ...session.tabsByWorktree, [key]: rows }, + ...withLoadedLayout(session, loaded, tabId) + } + if (!loadedRow && next.activeTabId === tabId) { + next.activeTabId = loaded.activeTabId + } + if (!loadedRow && next.activeTabIdByWorktree?.[key] === tabId) { + next.activeTabIdByWorktree = { + ...next.activeTabIdByWorktree, + [key]: loaded.activeTabIdByWorktree?.[key] ?? null + } + } + return next +} + +/** The pane layouts with the tab's as loaded; nothing when neither side has one. */ +function withLoadedLayout( + session: WorkspaceSessionState, + loaded: WorkspaceSessionState, + tabId: string +): Pick | Record { + const { [tabId]: stored, ...rest } = session.terminalLayoutsByTabId ?? {} + const layout = loaded.terminalLayoutsByTabId?.[tabId] + if (!stored && !layout) { + return {} + } + return { terminalLayoutsByTabId: layout ? { ...rest, [tabId]: layout } : rest } +} diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts index 74a68157966..275a789dff4 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts @@ -10,6 +10,7 @@ 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' +import type { WorkspaceSessionState } from '../workspace-session-state-types' const CONTEXT = { mintId: () => 'minted-id', @@ -24,6 +25,13 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] { commands.push({ type: 'createTerminalTab', workspace }) commands.push({ type: 'openEditorTab', workspace, fileId: '/new.md', contentType: 'editor' }) const lastGroup = layout.groups.at(-1) + if (layout.groupLayout?.type === 'split') { + commands.push({ + type: 'setGroupRatios', + workspace, + groupLayout: { ...layout.groupLayout, ratio: 0.3 } + }) + } for (const tab of layout.tabs) { commands.push({ type: 'closeTabs', workspace, tabIds: [tab.id], force: true }) commands.push({ @@ -41,6 +49,13 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] { toGroupId: lastGroup.id, index: 0 }) + commands.push({ + type: 'splitGroup', + workspace, + tabId: tab.id, + besideGroupId: lastGroup.id, + direction: 'right' + }) } if (tab.kind !== 'terminal') { continue @@ -131,12 +146,6 @@ function withListConsequences(records: string[]): string[] { 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] @@ -148,7 +157,28 @@ describe('patchStoredSession', () => { const before = saveWorkspaceLayout(loaded) const normalizedAtLoad = diffStoredSession(stored, before).length > 0 let applied = 0 - const roundTripGaps = new Set() + // Legacy rows the load folds into a tab-bar tab (dropped, or terminals taken off them). + const entryIds = new Set( + Object.values(stored.unifiedTabs ?? {}) + .flat() + .map((entry) => entry.entityId) + ) + const loadChanged = changedRecords(diffStoredSession(stored, before)) + const foldedIds = Object.values(stored.tabsByWorktree) + .flat() + .map((row) => row.id) + .filter( + (id) => + !entryIds.has(id) && + loadChanged.some((record) => record.endsWith(`|${id}`) || record === `layout/${id}`) + ) + const isFolded = (record: string) => + foldedIds.some((id) => record.endsWith(`|${id}`) || record === `layout/${id}`) + const selectionNamesFolded = foldedIds.some( + (id) => + stored.activeTabId === id || + Object.values(stored.activeTabIdByWorktree ?? {}).includes(id) + ) for (const command of commandsFor(loaded.layout)) { const result = applyLayoutCommand(loaded.layout, command, CONTEXT) if (!result.ok) { @@ -170,23 +200,119 @@ describe('patchStoredSession', () => { ), terminalTopologyRevisionByRepoId: undefined }) - if (stableJson(reloaded(patched)) !== stableJson(reloaded(after))) { - roundTripGaps.add(command.type) - } + expect(stableJson(reloaded(patched)), label).toBe(stableJson(reloaded(after))) const written = changedRecords(diffStoredSession(stored, patched)) - const commanded = withListConsequences(changedRecords(diffStoredSession(before, after))) + // A folded row may be written only in its loaded form, and a selection naming it with it. + const notLoadForm = new Set(changedRecords(diffStoredSession(before, patched))) + const loadForms = written.filter( + (record) => + !notLoadForm.has(record) && + (isFolded(record) || + (selectionNamesFolded && + ['session/activeTabId', 'session/activeTabIdByWorktree'].includes(record))) + ) + const commanded = withListConsequences([ + ...changedRecords(diffStoredSession(before, after)), + ...loadForms + ]) 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) ?? []) } ) + describe('a legacy row the load folds into a tab-bar tab', () => { + const patchedBy = ( + name: string, + command: (workspace: string) => LayoutCommand, + edit: (stored: WorkspaceSessionState) => void = () => {} + ) => { + const profile = LEGACY_WORKSPACE_LAYOUT_PROFILES.find((candidate) => candidate.name === name)! + const stored = profile.build() + edit(stored) + const loaded = loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext()) + const workspace = Object.keys(stored.tabsByWorktree)[0]! + const result = applyLayoutCommand(loaded.layout, command(workspace), CONTEXT) + if (!result.ok) { + throw new Error(result.code) + } + const after = saveWorkspaceLayout({ ...loaded, layout: result.model }) + return { stored, patched: patchStoredSession(stored, saveWorkspaceLayout(loaded), after) } + } + + it('goes with the tab it repeats, and a selection naming it follows the load', () => { + const { patched } = patchedBy( + 'legacy row repeating a tab-bar tab’s terminal, selected', + (workspace) => ({ type: 'closeTabs', workspace, tabIds: ['tab-a'], force: true }) + ) + + expect(Object.values(patched.tabsByWorktree)[0]!.map((row) => row.id)).toEqual(['tab-b']) + expect(patched.terminalLayoutsByTabId['tab-legacy']).toBeUndefined() + expect(patched.activeTabId).not.toBe('tab-legacy') + }) + + it('goes with the tab it repeats when they share only a remote session', () => { + const { patched } = patchedBy( + 'legacy row repeating a tab-bar tab’s terminal', + (workspace) => ({ type: 'closeTabs', workspace, tabIds: ['tab-a'], force: true }), + (stored) => { + for (const row of Object.values(stored.tabsByWorktree).flat()) { + if (row.id !== 'tab-b') { + row.ptyId = null + stored.terminalLayoutsByTabId[row.id]!.ptyIdsByLeafId = {} + } + } + stored.remoteSessionIdsByTabId = { 'tab-a': 'remote-1', 'tab-legacy': 'remote-1' } + } + ) + + expect(Object.values(patched.tabsByWorktree)[0]!.map((row) => row.id)).toEqual(['tab-b']) + expect(patched.terminalLayoutsByTabId['tab-legacy']).toBeUndefined() + }) + + it('keeps its loaded terminals when it gains a tab-bar entry, so the shared one stays put', () => { + const { patched } = patchedBy( + 'legacy split row sharing one pane’s terminal with a tab-bar tab', + (workspace) => ({ + type: 'moveTab', + workspace, + tabId: 'tab-legacy', + toGroupId: 'g1', + index: 0 + }) + ) + + const rows = Object.values(patched.tabsByWorktree)[0]! + expect(rows.find((row) => row.id === 'tab-legacy')?.ptyId).toBe('pty-c') + expect(rows.find((row) => row.id === 'tab-a')?.ptyId).toBe('pty-a') + expect(Object.values(patched.terminalLayoutsByTabId['tab-legacy']!.ptyIdsByLeafId!)).toEqual([ + 'pty-c' + ]) + }) + + it('stays as stored when the tab it repeats is only renamed', () => { + const { stored, patched } = patchedBy( + 'legacy row repeating a tab-bar tab’s terminal', + (workspace) => ({ + type: 'renameTab', + workspace, + tabId: 'tab-a', + kind: 'custom', + title: 'Renamed' + }) + ) + + expect(patched.tabsByWorktree).not.toBe(stored.tabsByWorktree) + expect(Object.values(patched.tabsByWorktree)[0]!.map((row) => row.id)).toContain('tab-legacy') + expect(patched.terminalLayoutsByTabId['tab-legacy']).toEqual( + stored.terminalLayoutsByTabId['tab-legacy'] + ) + }) + }) + 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' diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.ts b/src/shared/workspace-layout/workspace-layout-record-patch.ts index 1340122f2a6..2ede8f357d1 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.ts @@ -4,6 +4,7 @@ // isolates the command, and only those records and fields are written. import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { adoptTouchedFoldedRows } from './workspace-layout-folded-rows' type Json = Record @@ -22,21 +23,32 @@ 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. `stored` itself when the command changed nothing. + * written as `after` has them. A touched record the load supplied is written whole, and a folded + * legacy row whose claim the command touched takes its loaded form; every untouched record stays + * as stored. `stored` itself when the command changed nothing. */ export function patchStoredSession( stored: WorkspaceSessionState, before: WorkspaceSessionState, after: WorkspaceSessionState ): WorkspaceSessionState { - const storedFields = toJson(stored) + const patched = patchRecords(stored, before, after) + const adopted = adoptTouchedFoldedRows(stored, patched, before) + return adopted === stored ? patched : patchRecords(adopted, before, after) +} + +function patchRecords( + stored: WorkspaceSessionState, + before: WorkspaceSessionState, + after: WorkspaceSessionState +): WorkspaceSessionState { const beforeFields = toJson(before) const afterFields = toJson(after) const changed = changedKeys(beforeFields, afterFields) if (changed.length === 0) { return stored } + const storedFields = toJson(stored) const patched: Json = { ...storedFields } for (const field of changed) { const idOf = LIST_FIELDS[field]