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]