diff --git a/src/main/runtime/headless-tab-group-selection.test.ts b/src/main/runtime/headless-tab-group-selection.test.ts new file mode 100644 index 00000000000..286c3e6fa06 --- /dev/null +++ b/src/main/runtime/headless-tab-group-selection.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { withSelectedTabs } from './headless-tab-group-selection' + +describe('withSelectedTabs', () => { + it('selects the moved tab where it lands and the survivor where it left', () => { + const before = [{ id: 'g1', activeTabId: 'b', tabOrder: ['a', 'b'], recentTabIds: ['b', 'a'] }] + const after = [ + { id: 'g1', tabOrder: ['a'] }, + { id: 'g2', tabOrder: ['b'] } + ] + expect(withSelectedTabs(before, after, 'b')).toEqual([ + { id: 'g1', activeTabId: 'a', tabOrder: ['a'], recentTabIds: ['b', 'a'] }, + { id: 'g2', activeTabId: 'b', tabOrder: ['b'] } + ]) + }) + + it('keeps a selection the move did not touch', () => { + const before = [ + { id: 'g1', activeTabId: 'a', tabOrder: ['a', 'b'] }, + { id: 'g2', activeTabId: 'c', tabOrder: ['c'] } + ] + const after = [ + { id: 'g1', tabOrder: ['a'] }, + { id: 'g2', tabOrder: ['b', 'c'] } + ] + expect(withSelectedTabs(before, after, 'b').map((group) => group.activeTabId)).toEqual([ + 'a', + 'b' + ]) + }) +}) diff --git a/src/main/runtime/headless-tab-group-selection.ts b/src/main/runtime/headless-tab-group-selection.ts new file mode 100644 index 00000000000..8d44af34a58 --- /dev/null +++ b/src/main/runtime/headless-tab-group-selection.ts @@ -0,0 +1,23 @@ +// The headless host's per-group selected tab, kept beside the shared group moves, which hold only +// order and membership: the moved tab is selected where it lands; elsewhere a group keeps its +// selection while that tab stays, else selects its first tab. + +import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types' +import type { LayoutGroup } from '../../shared/workspace-layout/workspace-layout-model' + +export function withSelectedTabs( + before: readonly RuntimeMobileSessionTabGroup[], + after: readonly LayoutGroup[], + movedTabId: string +): RuntimeMobileSessionTabGroup[] { + return after.map((group) => { + const previous = before.find((candidate) => candidate.id === group.id) + const kept = previous?.activeTabId + const activeTabId = group.tabOrder.includes(movedTabId) + ? movedTabId + : kept && group.tabOrder.includes(kept) + ? kept + : (group.tabOrder[0] ?? null) + return { ...previous, id: group.id, tabOrder: group.tabOrder, activeTabId } + }) +} diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 8e326750f19..946fd05b6c5 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -2,6 +2,11 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import { + insertLeafBeside, + removeLayoutLeaf +} from '../../shared/workspace-layout/terminal-pane-tree' + /** * Insert a newly split-off leaf into a terminal tab's persisted layout tree. * @@ -19,45 +24,22 @@ export function buildHeadlessTerminalSplitLayout( direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { - const removeProvisionalLeaf = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode | null => { - if (node.type === 'leaf') { - return node.leafId === args.leafId ? null : node - } - const first = removeProvisionalLeaf(node.first) - const second = removeProvisionalLeaf(node.second) - if (!first) { - return second - } - if (!second) { - return first - } - return { ...node, first, second } - } // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. - const currentRoot = existing?.root ? removeProvisionalLeaf(existing.root) : null + const currentRoot = existing?.root ? removeLayoutLeaf(existing.root, args.leafId) : null const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', leafId: args.splitFromLeafId } - const insertSplit = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode => { - if (node.type === 'leaf') { - if (node.leafId !== args.splitFromLeafId) { - return node - } - return { - type: 'split', - direction: args.direction, - first: node, - second: { type: 'leaf', leafId: args.leafId } - } - } - return { ...node, first: insertSplit(node.first), second: insertSplit(node.second) } - } const ptyIdsByLeafId = { ...existing?.ptyIdsByLeafId } delete ptyIdsByLeafId[args.leafId] return { ...existing, - root: insertSplit(existingRoot), + root: insertLeafBeside( + existingRoot, + args.splitFromLeafId, + args.leafId, + args.direction === 'vertical' ? 'right' : 'bottom' + ), activeLeafId: args.leafId, expandedLeafId: existing?.expandedLeafId ?? null, ptyIdsByLeafId: { diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index caaf892e65b..8caa6bbd9de 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -9,8 +9,9 @@ import type { import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit -} from './headless-tab-group-split-layout' +} from '../../shared/workspace-layout/tab-group-moves' import { randomUUID } from 'node:crypto' +import { withSelectedTabs } from './headless-tab-group-selection' import type { TabGroupLayoutNode } from '../../shared/tab-types' export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWithPersistHeadlessSessionTabProps { @@ -102,15 +103,16 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith // as a no-op; mirror that instead of churning the snapshot. return { moved: true } } + const groups = withSelectedTabs(snapshot.tabGroups ?? [], split.groups, hostTabId) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeGroupId: split.newGroupId, - tabGroups: split.groups, + tabGroups: groups, tabGroupLayout: split.layout } - this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) + this.persistHeadlessTabGroups(worktreeId, groups, split.layout) this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } @@ -138,15 +140,16 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith return { moved: true } } const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } + const groups = withSelectedTabs(snapshot.tabGroups ?? [], moved.groups, hostTabId) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeGroupId: move.targetGroupId, - tabGroups: moved.groups, + tabGroups: groups, tabGroupLayout: layout } - this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) + this.persistHeadlessTabGroups(worktreeId, groups, layout) this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } diff --git a/src/renderer/src/lib/tab-number-shortcuts.ts b/src/renderer/src/lib/tab-number-shortcuts.ts index 0f816ba592b..fe8449b1172 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.ts @@ -2,7 +2,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { dedupeTabOrder } from '@/store/slices/tab-group-state' +import { dedupeTabOrder } from '../../../shared/workspace-layout/tab-order' import type { Tab } from '../../../shared/tab-types' import { activateWebRuntimeSessionTab, diff --git a/src/renderer/src/lib/unified-tab-anchor-insertion.ts b/src/renderer/src/lib/unified-tab-anchor-insertion.ts index ab3bc2c68b7..7192bf7c165 100644 --- a/src/renderer/src/lib/unified-tab-anchor-insertion.ts +++ b/src/renderer/src/lib/unified-tab-anchor-insertion.ts @@ -1,5 +1,5 @@ import { useAppStore } from '../store' -import { insertTabIdIntoOrder } from '../store/slices/tabs/tabs-tab-order' +import { insertTabIdIntoOrder } from '../../../shared/workspace-layout/tab-order' /** Move `tabId` to sit immediately after `anchorTabId`; no-op unless both share a live group. */ export function insertUnifiedTabAfterAnchor( diff --git a/src/renderer/src/store/slices/tab-group-state.ts b/src/renderer/src/store/slices/tab-group-state.ts index f67d529652f..85fe8c5da22 100644 --- a/src/renderer/src/store/slices/tab-group-state.ts +++ b/src/renderer/src/store/slices/tab-group-state.ts @@ -227,19 +227,6 @@ export function dedupeEditorTabsWithinGroups(tabs: Tab[]): { return { tabs: dedupedTabs, tabIdAliasesByGroup } } -export function dedupeTabOrder(tabIds: string[]): string[] { - const seen = new Set() - const deduped: string[] = [] - for (const tabId of tabIds) { - if (seen.has(tabId)) { - continue - } - seen.add(tabId) - deduped.push(tabId) - } - return deduped -} - /** * Apply a partial update to a single tab, returning the new `unifiedTabsByWorktree` * map. Returns `null` if the tab is not found (callers should return `{}` to the diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index 7edfc667c07..9d391ae3d67 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -10,12 +10,12 @@ import { } from './tab-group-reference-repair' import { dedupeEditorTabsWithinGroups, - dedupeTabOrder, getPersistedEditFileIdsByWorktree, isTransientEditorContentType, sanitizeRecentTabIds, selectHydratedActiveGroupId } from './tab-group-state' +import { dedupeTabOrder } from '../../../../shared/workspace-layout/tab-order' type HydratedTabState = { unifiedTabsByWorktree: Record diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 90bb031a456..6af491e7a37 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,12 +1,12 @@ import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { - dedupeTabOrder, findGroupForTab, findTabAndWorktree, pickNextActiveTab, sanitizeRecentTabIds } from '../tab-group-state' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' import { buildActiveSurfacePatch } from './tabs-surface' import { beginStructuredAgentSessionTabClose } from '@/runtime/structured-agent-session-tab-retirement' import { diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index 2270fe79446..e88b5cd0b02 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -8,7 +8,6 @@ import { replaceLeaf } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { - dedupeTabOrder, ensureGroup, findGroupForTab, pushRecentTabId, @@ -18,8 +17,9 @@ import { import { applyTabOrderSortValues, canReplacePreviewContentType, + dedupeTabOrder, insertTabIdIntoOrder -} from './tabs-tab-order' +} from '../../../../../shared/workspace-layout/tab-order' import { resolveUnifiedTabCreatePlacement } from './tabs-create-placement' import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' 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 ed9dfd9a8b9..19d067fac5d 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -9,7 +9,6 @@ import { } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { buildActiveSurfacePatch } from './tabs-surface' import { - dedupeTabOrder, findGroupAndWorktree, findGroupForTab, findTabAndWorktree, @@ -17,6 +16,7 @@ import { pushRecentTabId, sanitizeRecentTabIds } from '../tab-group-state' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' export function createTabsDropActions( set: TabsSliceSet, diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 3ee06aa105c..df3bb17f322 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -1,8 +1,12 @@ import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' import type { TuiAgent } from '../../../../../shared/tui-agent' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' -import { findTabAndWorktree, patchTab, updateGroup, dedupeTabOrder } from '../tab-group-state' -import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-order' +import { findTabAndWorktree, patchTab, updateGroup } from '../tab-group-state' +import { + applyTabOrderSortValues, + dedupeTabOrder, + partitionPinnedTabOrder +} from '../../../../../shared/workspace-layout/tab-order' import { mirrorTabPinnedToHost, mirrorTabViewModeToHost, diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 71fe38a2617..fa28767fac4 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -2,7 +2,6 @@ import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contrac import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { buildActiveSurfacePatch } from './tabs-surface' import { - dedupeTabOrder, findGroupAndWorktree, findGroupForTab, findTabAndWorktree, @@ -10,6 +9,7 @@ import { pushRecentTabId, sanitizeRecentTabIds } from '../tab-group-state' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' export function createTabsMoveActions( set: TabsSliceSet, diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts index d5a005f537e..7cd446f76a3 100644 --- a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts @@ -1,6 +1,7 @@ import type { AppState } from '../../types' import type { Tab } from '../../../../../shared/tab-types' -import { dedupeTabOrder, ensureGroup, sanitizeRecentTabIds, updateGroup } from '../tab-group-state' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' +import { ensureGroup, sanitizeRecentTabIds, updateGroup } from '../tab-group-state' import { pruneTabGroupLayoutForGroups } from '../tabs-hydration' import { buildOrphanTerminalCleanupPatch, diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 79ee1e52233..b5d739554bd 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -2,6 +2,7 @@ import { warnIfZCodeCannotOpenSession } from '@/components/terminal-pane/zcode-m import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' +import { getNextTerminalOrdinal } from '../../../../shared/workspace-layout/terminal-tab-ordinal' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { @@ -9,13 +10,13 @@ import { getOrphanTerminalIds } from '../slices/terminal-orphan-helpers' import { - dedupeTabOrder, ensureGroup, findTabByEntityInGroup, pushRecentTabId, sanitizeRecentTabIds, updateGroup } from '../slices/tab-group-state' +import { dedupeTabOrder } from '../../../../shared/workspace-layout/tab-order' import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -25,22 +26,6 @@ import { resolveCreatedTabShellOverride, worktreeUsesWslPath } from './terminal-workspace-routing' -export function getNextTerminalOrdinal(tabs: TerminalTab[]): number { - const usedOrdinals = new Set() - for (const tab of tabs) { - const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) - if (!match) { - continue - } - usedOrdinals.add(Number(match[1])) - } - let nextOrdinal = 1 - while (usedOrdinals.has(nextOrdinal)) { - nextOrdinal += 1 - } - return nextOrdinal -} - type TabStartupCommand = TerminalSlice['pendingStartupByTabId'][string] function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { diff --git a/src/shared/workspace-layout/stored-record-fields.ts b/src/shared/workspace-layout/stored-record-fields.ts index 578398892fa..c213340f1ca 100644 --- a/src/shared/workspace-layout/stored-record-fields.ts +++ b/src/shared/workspace-layout/stored-record-fields.ts @@ -44,3 +44,13 @@ export function childRecord( record[key] ??= {} return record[key] } + +/** The entries `keep` accepts; undefined stays undefined. */ +export function filterRecord( + record: Record | undefined, + keep: (key: string, value: T) => boolean +): Record | undefined { + return ( + record && Object.fromEntries(Object.entries(record).filter(([key, value]) => keep(key, value))) + ) +} diff --git a/src/shared/workspace-layout/tab-group-layout-tree.ts b/src/shared/workspace-layout/tab-group-layout-tree.ts index 66277bea020..fe9ae87651a 100644 --- a/src/shared/workspace-layout/tab-group-layout-tree.ts +++ b/src/shared/workspace-layout/tab-group-layout-tree.ts @@ -126,3 +126,20 @@ export function collapseGroupLayout( } } } + +/** Moves an existing group's leaf to `side` of another group; the moved group keeps its id. */ +export function moveGroupLeafBeside( + root: TabGroupLayoutNode, + groupId: string, + besideGroupId: string, + side: 'left' | 'right' | 'up' | 'down' +): TabGroupLayoutNode { + const rest = removeGroupLayoutLeaf(root, groupId) ?? { type: 'leaf', groupId: besideGroupId } + const direction = side === 'left' || side === 'right' ? 'horizontal' : 'vertical' + const position = side === 'left' || side === 'up' ? 'first' : 'second' + return replaceLeaf( + rest, + besideGroupId, + buildSplitNode(besideGroupId, groupId, direction, position) + ) +} diff --git a/src/main/runtime/headless-tab-group-split-layout.test.ts b/src/shared/workspace-layout/tab-group-moves.test.ts similarity index 89% rename from src/main/runtime/headless-tab-group-split-layout.test.ts rename to src/shared/workspace-layout/tab-group-moves.test.ts index 604941bb446..58d344c195e 100644 --- a/src/main/runtime/headless-tab-group-split-layout.test.ts +++ b/src/shared/workspace-layout/tab-group-moves.test.ts @@ -4,13 +4,9 @@ import { buildHeadlessTabGroupSplit, collectTabGroupLayoutGroupIds, removeTabGroupLayoutLeaf -} from './headless-tab-group-split-layout' +} from './tab-group-moves' -const group = (id: string, tabs: string[], activeTabId = tabs[0] ?? null) => ({ - id, - activeTabId, - tabOrder: tabs -}) +const group = (id: string, tabs: string[]) => ({ id, tabOrder: tabs }) describe('buildHeadlessTabGroupSplit', () => { it('splits a tab into a new group to the right (horizontal, second)', () => { @@ -89,19 +85,6 @@ describe('buildHeadlessTabGroupSplit', () => { ).toBeNull() }) - it('moves the active tab out and reassigns source active to the survivor', () => { - const result = buildHeadlessTabGroupSplit({ - groups: [group('g1', ['a', 'b'], 'b')], - layout: { type: 'leaf', groupId: 'g1' }, - tabId: 'b', - targetGroupId: 'g1', - splitDirection: 'right', - newGroupId: 'g2' - }) - expect(result!.groups.find((g) => g.id === 'g1')!.activeTabId).toBe('a') - expect(result!.groups.find((g) => g.id === 'g2')!.activeTabId).toBe('b') - }) - it('collapses an emptied source group out of the layout when moving across groups', () => { // g2 holds only 'b'; moving 'b' into a new split off g1 empties g2. const result = buildHeadlessTabGroupSplit({ @@ -155,7 +138,6 @@ describe('buildHeadlessTabGroupMove', () => { }) expect(result!.groups.find((g) => g.id === 'g1')!.tabOrder).toEqual(['a']) expect(result!.groups.find((g) => g.id === 'g2')!.tabOrder).toEqual(['b', 'c']) - expect(result!.groups.find((g) => g.id === 'g2')!.activeTabId).toBe('b') }) it('collapses the source group out of the layout when the move empties it', () => { diff --git a/src/main/runtime/headless-tab-group-split-layout.ts b/src/shared/workspace-layout/tab-group-moves.ts similarity index 81% rename from src/main/runtime/headless-tab-group-split-layout.ts rename to src/shared/workspace-layout/tab-group-moves.ts index 5698ca47b3c..c37764eb34f 100644 --- a/src/main/runtime/headless-tab-group-split-layout.ts +++ b/src/shared/workspace-layout/tab-group-moves.ts @@ -1,6 +1,6 @@ -import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types' -import type { TabGroupLayoutNode } from '../../shared/tab-types' -import { buildSplitNode, replaceLeaf } from '../../shared/workspace-layout/tab-group-layout-tree' +import type { TabGroupLayoutNode } from '../tab-types' +import { buildSplitNode, replaceLeaf } from './tab-group-layout-tree' +import type { LayoutGroup } from './workspace-layout-model' /** * Headless ("Orca server") tab-GROUP split operations (distinct from terminal @@ -8,7 +8,8 @@ import { buildSplitNode, replaceLeaf } from '../../shared/workspace-layout/tab-g * tab into a single group, so a client drag-to-split-group was lost on the next * snapshot. These pure helpers let the host model + persist a real multi-group * layout, sharing the renderer's group tree operations so host and client - * agree on the tree. + * agree on the tree. Groups are order and membership only; a caller keeping a per-view + * selected tab adapts it. */ type SplitDirection = 'left' | 'right' | 'up' | 'down' @@ -53,7 +54,7 @@ export function removeTabGroupLayoutLeaf( } export type HeadlessTabGroupMoveResult = { - groups: RuntimeMobileSessionTabGroup[] + groups: LayoutGroup[] layout: TabGroupLayoutNode | null } @@ -65,7 +66,7 @@ export type HeadlessTabGroupMoveResult = { * same-group no-op. */ export function buildHeadlessTabGroupMove(args: { - groups: readonly RuntimeMobileSessionTabGroup[] + groups: readonly LayoutGroup[] layout: TabGroupLayoutNode | null | undefined tabId: string targetGroupId: string @@ -80,22 +81,17 @@ export function buildHeadlessTabGroupMove(args: { return null } - let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => { + let groups: LayoutGroup[] = args.groups.map((group) => { if (group.id === sourceGroup.id) { - const tabOrder = group.tabOrder.filter((id) => id !== args.tabId) - return { - ...group, - tabOrder, - activeTabId: group.activeTabId === args.tabId ? (tabOrder[0] ?? null) : group.activeTabId - } + return { id: group.id, tabOrder: group.tabOrder.filter((id) => id !== args.tabId) } } if (group.id === args.targetGroupId) { const tabOrder = group.tabOrder.filter((id) => id !== args.tabId) const at = Math.max(0, Math.min(args.index ?? tabOrder.length, tabOrder.length)) tabOrder.splice(at, 0, args.tabId) - return { ...group, tabOrder, activeTabId: args.tabId } + return { id: group.id, tabOrder } } - return group + return { id: group.id, tabOrder: group.tabOrder } }) groups = groups.filter((group) => group.tabOrder.length > 0) @@ -110,7 +106,7 @@ export function buildHeadlessTabGroupMove(args: { } export type HeadlessTabGroupSplitResult = { - groups: RuntimeMobileSessionTabGroup[] + groups: LayoutGroup[] layout: TabGroupLayoutNode newGroupId: string } @@ -124,7 +120,7 @@ export type HeadlessTabGroupSplitResult = { * the only tab off its own group — a renderer-side no-op). */ export function buildHeadlessTabGroupSplit(args: { - groups: readonly RuntimeMobileSessionTabGroup[] + groups: readonly LayoutGroup[] layout: TabGroupLayoutNode | null | undefined tabId: string targetGroupId: string @@ -157,17 +153,11 @@ export function buildHeadlessTabGroupSplit(args: { ) const sourceOrder = sourceGroup.tabOrder.filter((id) => id !== args.tabId) - let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => { - if (group.id === sourceGroup.id) { - return { - ...group, - tabOrder: sourceOrder, - activeTabId: group.activeTabId === args.tabId ? (sourceOrder[0] ?? null) : group.activeTabId - } - } - return group - }) - groups.push({ id: args.newGroupId, activeTabId: args.tabId, tabOrder: [args.tabId] }) + let groups: LayoutGroup[] = args.groups.map((group) => ({ + id: group.id, + tabOrder: group.id === sourceGroup.id ? sourceOrder : group.tabOrder + })) + groups.push({ id: args.newGroupId, tabOrder: [args.tabId] }) // Drop any group emptied by the move and collapse it out of the layout. groups = groups.filter((group) => group.tabOrder.length > 0) const liveGroupIds = new Set(groups.map((group) => group.id)) diff --git a/src/renderer/src/store/slices/tabs/tabs-tab-order.ts b/src/shared/workspace-layout/tab-order.ts similarity index 84% rename from src/renderer/src/store/slices/tabs/tabs-tab-order.ts rename to src/shared/workspace-layout/tab-order.ts index b62e85fdb08..315965c4293 100644 --- a/src/renderer/src/store/slices/tabs/tabs-tab-order.ts +++ b/src/shared/workspace-layout/tab-order.ts @@ -1,9 +1,23 @@ -import type { Tab } from '../../../../../shared/tab-types' -import { dedupeTabOrder } from '../tab-group-state' +import type { Tab } from '../tab-types' + +type PinnedTab = Pick + +export function dedupeTabOrder(tabIds: string[]): string[] { + const seen = new Set() + const deduped: string[] = [] + for (const tabId of tabIds) { + if (seen.has(tabId)) { + continue + } + seen.add(tabId) + deduped.push(tabId) + } + return deduped +} export function partitionPinnedTabOrder( tabOrder: string[], - tabs: Tab[], + tabs: readonly PinnedTab[], movingTabId: string ): string[] { const tabById = new Map(tabs.map((tab) => [tab.id, tab])) @@ -27,7 +41,7 @@ export function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] */ export function insertTabIdIntoOrder( tabOrder: readonly string[], - tabs: readonly Tab[], + tabs: readonly PinnedTab[], tabId: string, isPinned: boolean, anchorTabId?: string diff --git a/src/shared/workspace-layout/terminal-pane-tree.ts b/src/shared/workspace-layout/terminal-pane-tree.ts index 54f0bd5d573..f2a261a1a4a 100644 --- a/src/shared/workspace-layout/terminal-pane-tree.ts +++ b/src/shared/workspace-layout/terminal-pane-tree.ts @@ -47,3 +47,78 @@ export function removeLayoutLeaf( } return first === node.first && second === node.second ? node : { ...node, first, second } } + +export type PaneSide = 'left' | 'right' | 'top' | 'bottom' + +/** Replaces `targetLeafId` with a split holding it and `newLeafId` on `side` of it. */ +export function insertLeafBeside( + node: TerminalPaneLayoutNode, + targetLeafId: string, + newLeafId: string, + side: PaneSide, + ratio?: number +): TerminalPaneLayoutNode { + if (node.type === 'leaf') { + if (node.leafId !== targetLeafId) { + return node + } + const added: TerminalPaneLayoutNode = { type: 'leaf', leafId: newLeafId } + const before = side === 'left' || side === 'top' + return { + type: 'split', + direction: side === 'left' || side === 'right' ? 'vertical' : 'horizontal', + first: before ? added : node, + second: before ? node : added, + ...(ratio !== undefined ? { ratio } : {}) + } + } + return { + ...node, + first: insertLeafBeside(node.first, targetLeafId, newLeafId, side, ratio), + second: insertLeafBeside(node.second, targetLeafId, newLeafId, side, ratio) + } +} + +/** Same splits, directions and panes in the same places; only ratios may differ. */ +export function samePanesIgnoringRatios( + left: TerminalPaneLayoutNode, + right: TerminalPaneLayoutNode +): boolean { + if (left.type === 'leaf' || right.type === 'leaf') { + return ( + left.type === right.type && + left.type === 'leaf' && + right.type === 'leaf' && + left.leafId === right.leafId + ) + } + return ( + left.direction === right.direction && + samePanesIgnoringRatios(left.first, right.first) && + samePanesIgnoringRatios(left.second, right.second) + ) +} + +function equalizeWeight( + node: TerminalPaneLayoutNode, + direction: 'vertical' | 'horizontal' +): number { + return node.type === 'split' && node.direction === direction + ? equalizeWeight(node.first, direction) + equalizeWeight(node.second, direction) + : 1 +} + +/** Same-axis panes get equal shares, so three side by side become thirds, as the window does. */ +export function equalizeLayout(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode { + if (node.type === 'leaf') { + return node + } + const first = equalizeWeight(node.first, node.direction) + const second = equalizeWeight(node.second, node.direction) + return { + ...node, + ratio: first / (first + second), + first: equalizeLayout(node.first), + second: equalizeLayout(node.second) + } +} diff --git a/src/shared/workspace-layout/terminal-tab-ordinal.ts b/src/shared/workspace-layout/terminal-tab-ordinal.ts new file mode 100644 index 00000000000..03fd2c61ada --- /dev/null +++ b/src/shared/workspace-layout/terminal-tab-ordinal.ts @@ -0,0 +1,20 @@ +import type { TerminalTab } from '../terminal-tab-types' + +/** The lowest free "Terminal N", so a fresh terminal is "Terminal 1" again after older tabs close. */ +export function getNextTerminalOrdinal( + tabs: readonly Pick[] +): number { + const usedOrdinals = new Set() + for (const tab of tabs) { + const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) + if (!match) { + continue + } + usedOrdinals.add(Number(match[1])) + } + let nextOrdinal = 1 + while (usedOrdinals.has(nextOrdinal)) { + nextOrdinal += 1 + } + return nextOrdinal +} diff --git a/src/shared/workspace-layout/workspace-layout-command-model.test.ts b/src/shared/workspace-layout/workspace-layout-command-model.test.ts new file mode 100644 index 00000000000..ac0cba6c644 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-command-model.test.ts @@ -0,0 +1,406 @@ +// Extends the seeded model test of #26073 (terminal-topology-model.test.ts) to the layout module: +// the same seeds and plain reference model, now driven by layout commands from two clients and by +// process exits, with the structural rules (and id stability) checked after every step and a +// save/load round trip standing in for quit and relaunch. +// Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . + +import { describe, expect, it } from 'vitest' +import { mulberry32 } from '../agent-tui-ansi-fuzz-stream' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../constants' +import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { LayoutCommand, LayoutContext } from './workspace-layout-command-types' +import { emptyModel, testContext } from './workspace-layout-command.test-fixture' +import { loadWorkspaceLayout } from './workspace-layout-load' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { checkWorkspaceLayoutModelRules, emptyLayoutBeside } from './workspace-layout-model-rules' +import { saveWorkspaceLayout } from './workspace-layout-save' +import { applyLayoutTransition, type LayoutTransition } from './workspace-layout-transitions' + +const WORKSPACES = ['repo-1::/fixture/local', 'folder:folder-1', FLOATING_TERMINAL_WORKTREE_ID] +const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417) +const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6) +const OPS_PER_SEED = 60 + +/** What the runtime must hold: workspace → terminal tab → pane → bound terminal. */ +type Reference = Map>> + +type Step = { label: string; command?: LayoutCommand; transition?: LayoutTransition } + +function project(model: WorkspaceLayoutModel) { + return Object.fromEntries( + WORKSPACES.map((key) => { + const tabs = (model.workspaces[key]?.tabs ?? []).flatMap((tab) => + tab.kind === 'terminal' + ? [ + [ + tab.entityId, + Object.fromEntries( + collectLayoutLeafIdsInOrder(tab.panes.root).map((leafId) => [ + leafId, + model.workspaces[key]!.leaves?.[leafId]?.ptyId + ]) + ) + ] + ] + : [] + ) + return [key, Object.fromEntries(tabs)] + }) + ) +} + +function expected(reference: Reference) { + return Object.fromEntries( + WORKSPACES.map((key) => [ + key, + Object.fromEntries( + [...(reference.get(key) ?? [])].map(([tabId, leaves]) => [ + tabId, + Object.fromEntries(leaves) + ]) + ) + ]) + ) +} + +class Harness { + model = emptyModel() + reference: Reference = new Map(WORKSPACES.map((key) => [key, new Map()])) + readonly log: string[] = [] + private ptyCounter = 0 + + constructor(readonly context: LayoutContext) {} + + newPtyId(): string { + return `pty-${++this.ptyCounter}` + } + + /** Applies one step, checks the rules against the previous model, returns whether it applied. */ + apply(step: Step): ReturnType { + const result = step.command + ? applyLayoutCommand(this.model, step.command, this.context) + : applyLayoutTransition(this.model, step.transition!) + this.log.push(`${step.label} → ${result.ok ? 'ok' : result.code}`) + if (result.ok) { + expect( + checkWorkspaceLayoutModelRules([result.model], [this.model]), + this.log.join('\n') + ).toEqual([]) + this.model = result.model + } + return result + } + + /** A step the reference says must apply. */ + must(step: Step) { + const result = this.apply(step) + if (!result.ok) { + throw new Error(`expected ${step.label} to apply:\n${this.log.join('\n')}`) + } + return result + } + + panes(): { key: string; tabId: string; leafId: string; ptyId: string | undefined }[] { + return [...this.reference].flatMap(([key, tabs]) => + [...tabs].flatMap(([tabId, leaves]) => + [...leaves].map(([leafId, ptyId]) => ({ key, tabId, leafId, ptyId })) + ) + ) + } + + removePane(key: string, tabId: string, leafId: string): void { + const tabs = this.reference.get(key)! + tabs.get(tabId)?.delete(leafId) + if (tabs.get(tabId)?.size === 0) { + tabs.delete(tabId) + } + } + + reopen(): void { + const saved = saveWorkspaceLayout({ ...emptyLayoutBeside(), layout: this.model }) + let minted = 0 + const loaded = loadWorkspaceLayout(this.model.hostId, saved, { + mintId: () => `reload-${++minted}`, + mintLeafId: () => `reload-leaf-${++minted}` + }) + expect(loaded.changes, this.log.join('\n')).toEqual([]) + expect(saveWorkspaceLayout({ ...emptyLayoutBeside(), layout: loaded.layout })).toEqual(saved) + this.model = loaded.layout + this.log.push('reopen') + } +} + +/** Two clients race on one pane or tab; the runtime applies whichever it reads first. */ +type Race = 'close-tab-vs-split' | 'close-pane-vs-drag-out' | 'exit-vs-close-tab' + +function runRace( + harness: Harness, + race: Race, + pane: ReturnType[number], + closeFirst: boolean +): void { + const { key, tabId, leafId } = pane + const closeTab: Step = { + label: `A closeTabs ${tabId}`, + command: { type: 'closeTabs', workspace: key, tabIds: [tabId] } + } + const closePane: Step = { + label: `A closePane ${leafId}`, + command: { type: 'closePane', workspace: key, tabId, leafId } + } + const other: Step = + race === 'close-tab-vs-split' + ? { + label: `B splitPane ${leafId}`, + command: { type: 'splitPane', workspace: key, tabId, leafId, direction: 'horizontal' } + } + : race === 'close-pane-vs-drag-out' + ? { + label: `B movePaneToNewTab ${leafId}`, + command: { type: 'movePaneToNewTab', workspace: key, tabId, leafId } + } + : { + label: `exited ${leafId}`, + transition: { + type: 'processExited', + surface: { worktreeId: key, terminalTabId: tabId, leafId, ptyId: pane.ptyId! } + } + } + const closing = race === 'close-pane-vs-drag-out' ? closePane : closeTab + const order = closeFirst ? [closing, other] : [other, closing] + for (const step of order) { + const result = harness.apply(step) + if (!result.ok) { + continue + } + if (step === closing) { + if (race === 'close-pane-vs-drag-out') { + // The pane may already live in the dragged-out tab; then this close finds nothing. + if (harness.reference.get(key)!.get(tabId)?.has(leafId)) { + harness.removePane(key, tabId, leafId) + } + } else { + harness.reference.get(key)!.delete(tabId) + } + } else if (race === 'close-tab-vs-split' && result.result.leafId) { + harness.reference.get(key)!.get(tabId)!.set(result.result.leafId, undefined) + } else if (race === 'close-pane-vs-drag-out' && result.result.tabId) { + const ptyId = harness.reference.get(key)!.get(tabId)!.get(leafId) + harness.removePane(key, tabId, leafId) + harness.reference.get(key)!.set(result.result.tabId, new Map([[leafId, ptyId]])) + } else if ( + race === 'exit-vs-close-tab' && + harness.reference.get(key)!.get(tabId)?.has(leafId) + ) { + harness.removePane(key, tabId, leafId) + } + } +} + +function runSeed(seed: number): string[] { + const random = mulberry32(seed) + const pick = (items: readonly T[]): T | undefined => items[Math.floor(random() * items.length)] + const harness = new Harness(testContext()) + const ops = [ + 'create', + 'create', + 'split', + 'bind', + 'bind', + 'exit', + 'closePane', + 'closeTab', + 'dragOut', + 'movePane', + 'moveTab', + 'equalize', + 'sleepWake', + 'race', + 'reopen' + ] as const + for (let step = 0; step < OPS_PER_SEED; step++) { + const op = pick(ops)! + const pane = pick(harness.panes()) + const client = random() < 0.5 ? 'A' : 'B' + if (op === 'create' || !pane) { + const key = pick(WORKSPACES)! + const result = harness.must({ + label: `${client} createTerminalTab ${key}`, + command: { type: 'createTerminalTab', workspace: key } + }) + harness.reference + .get(key)! + .set(result.result.tabId!, new Map([[result.result.leafId!, undefined]])) + } else if (op === 'split') { + const result = harness.must({ + label: `${client} splitPane ${pane.leafId}`, + command: { + type: 'splitPane', + workspace: pane.key, + tabId: pane.tabId, + leafId: pane.leafId, + direction: random() < 0.5 ? 'vertical' : 'horizontal' + } + }) + harness.reference.get(pane.key)!.get(pane.tabId)!.set(result.result.leafId!, undefined) + } else if (op === 'bind' && !pane.ptyId) { + const ptyId = harness.newPtyId() + harness.must({ + label: `started ${pane.leafId} ${ptyId}`, + transition: { + type: 'processStarted', + workspace: pane.key, + paneKey: `${pane.tabId}:${pane.leafId}`, + ptyId + } + }) + harness.reference.get(pane.key)!.get(pane.tabId)!.set(pane.leafId, ptyId) + } else if (op === 'exit' && pane.ptyId) { + harness.must({ + label: `exited ${pane.leafId}`, + transition: { + type: 'processExited', + surface: { + worktreeId: pane.key, + terminalTabId: pane.tabId, + leafId: pane.leafId, + ptyId: pane.ptyId + } + } + }) + harness.removePane(pane.key, pane.tabId, pane.leafId) + } else if (op === 'closePane') { + harness.must({ + label: `${client} closePane ${pane.leafId}`, + command: { type: 'closePane', workspace: pane.key, tabId: pane.tabId, leafId: pane.leafId } + }) + harness.removePane(pane.key, pane.tabId, pane.leafId) + } else if (op === 'closeTab') { + harness.must({ + label: `${client} closeTabs ${pane.tabId}`, + command: { type: 'closeTabs', workspace: pane.key, tabIds: [pane.tabId] } + }) + harness.reference.get(pane.key)!.delete(pane.tabId) + } else if (op === 'dragOut' && harness.reference.get(pane.key)!.get(pane.tabId)!.size > 1) { + const result = harness.must({ + label: `${client} movePaneToNewTab ${pane.leafId}`, + command: { + type: 'movePaneToNewTab', + workspace: pane.key, + tabId: pane.tabId, + leafId: pane.leafId + } + }) + harness.removePane(pane.key, pane.tabId, pane.leafId) + harness.reference + .get(pane.key)! + .set(result.result.tabId!, new Map([[pane.leafId, pane.ptyId]])) + } else if (op === 'movePane') { + const target = pick( + [...harness.reference.get(pane.key)!.get(pane.tabId)!.keys()].filter( + (leafId) => leafId !== pane.leafId + ) + ) + if (target) { + harness.must({ + label: `${client} movePane ${pane.leafId}`, + command: { + type: 'movePane', + workspace: pane.key, + tabId: pane.tabId, + leafId: pane.leafId, + targetLeafId: target, + side: pick(['left', 'right', 'top', 'bottom'] as const)! + } + }) + } + } else if (op === 'moveTab') { + const groups = harness.model.workspaces[pane.key]!.groups + const command: LayoutCommand = + random() < 0.5 + ? { + type: 'splitGroup', + workspace: pane.key, + tabId: pane.tabId, + besideGroupId: pick(groups)!.id, + direction: pick(['left', 'right', 'up', 'down'] as const)! + } + : { + type: 'moveTab', + workspace: pane.key, + tabId: pane.tabId, + toGroupId: pick(groups)!.id, + index: Math.floor(random() * 4) + } + harness.apply({ label: `${client} ${command.type} ${pane.tabId}`, command }) + } else if (op === 'equalize') { + harness.must({ + label: `${client} equalizePanes ${pane.tabId}`, + command: { type: 'equalizePanes', workspace: pane.key, tabId: pane.tabId } + }) + } else if (op === 'sleepWake') { + const paneKey = `${pane.tabId}:${pane.leafId}` + const record = { + paneKey, + tabId: pane.tabId, + worktreeId: pane.key, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: `s-${pane.leafId}` }, + prompt: 'p', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1 + } + harness.must({ + label: `${client} sleep ${paneKey}`, + command: { type: 'sleep', workspace: pane.key, paneKeys: [paneKey], records: [record] } + }) + harness.must({ + label: `${client} wake ${paneKey}`, + command: { type: 'wake', workspace: pane.key, paneKeys: [paneKey] } + }) + } else if (op === 'race') { + // An exit names the terminal it ended, so only a bound pane can race one. + const races: Race[] = pane.ptyId + ? ['close-tab-vs-split', 'close-pane-vs-drag-out', 'exit-vs-close-tab'] + : ['close-tab-vs-split', 'close-pane-vs-drag-out'] + runRace(harness, pick(races)!, pane, random() < 0.5) + } else if (op === 'reopen') { + harness.reopen() + } + expect(project(harness.model), `seed ${seed}:\n${harness.log.join('\n')}`).toEqual( + expected(harness.reference) + ) + } + harness.reopen() + expect(project(harness.model), `seed ${seed}, after reopen:\n${harness.log.join('\n')}`).toEqual( + expected(harness.reference) + ) + return harness.log +} + +describe('workspace layout commands (seeded model)', () => { + it('keeps one tab per pane, one pane per terminal, stable ids and the expected layout across random commands', () => { + const ran = new Set() + for (let seed = FIRST_SEED; seed < FIRST_SEED + ITERATIONS; seed++) { + for (const entry of runSeed(seed)) { + ran.add(entry.split(' ')[1] ?? entry) + } + } + // No empty passes: every command and transition the seeds draw from actually ran. + for (const name of [ + 'createTerminalTab', + 'splitPane', + 'closePane', + 'closeTabs', + 'movePaneToNewTab', + 'movePane', + 'equalizePanes', + 'sleep', + 'wake' + ]) { + expect(ran, name).toContain(name) + } + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts b/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts new file mode 100644 index 00000000000..74740306b99 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts @@ -0,0 +1,181 @@ +// Commands against what reaches disk: the legacy row-ownership marker and revision, a terminal tab +// whose tab-bar id and terminal id differ, and the unsaved-draft refusal on preview replacement. + +import { describe, expect, it } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import { applyLayoutCommand } from './workspace-layout-commands' +import { asLoaded, build, testContext, WS } from './workspace-layout-command.test-fixture' +import { loadWorkspaceLayout } from './workspace-layout-load' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { localDesktopSession } from './workspace-layout-profile.test-fixture' +import { saveWorkspaceLayout } from './workspace-layout-save' +import { GIT_KEY, leaf } from './workspace-layout-session.test-fixture' +import type { WorkspaceSessionState } from '../workspace-session-state-types' + +function reload(session: WorkspaceSessionState) { + let next = 0 + const loaded = loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, session, { + mintId: () => `reload-${++next}`, + mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}` + }) + expect(loaded.changes).toEqual([]) + return loaded +} + +const save = (model: WorkspaceLayoutModel) => saveWorkspaceLayout(asLoaded(model)) + +describe('the legacy persistence record through commands', () => { + it('keeps an emptied row list after create then close, with or without a reload between', () => { + const context = testContext() + const created = build(context, [{ type: 'createTerminalTab', workspace: WS }]) + const tabId = created.results[0]!.tabId! + expect(created.model.legacy).toEqual({ + terminalRowOwners: { [WS]: true }, + topologyRevisionByRepoId: { 'repo-1': 1 } + }) + const close = { type: 'closeTabs' as const, workspace: WS, tabIds: [tabId] } + const direct = build(context, [close], created.model).model + const reopened = reload(save(created.model)).layout + const afterReload = build(context, [close], reopened).model + for (const closed of [direct, afterReload]) { + const saved = save(closed) + expect(saved.tabsByWorktree).toEqual({ [WS]: [] }) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 2 }) + expect(reload(saved).layout.legacy.terminalRowOwners).toEqual({ [WS]: true }) + } + }) + + it('advances the revision only for commands that change terminal panes', () => { + const context = testContext() + const { model, results } = build(context, [ + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS } + ]) + const revision = (next: WorkspaceLayoutModel) => next.legacy.topologyRevisionByRepoId + expect(revision(model)).toEqual({ 'repo-1': 2 }) + const group = model.workspaces[WS]!.groups[0]!.id + const tabId = results[0]!.tabId! + const moved = build( + context, + [{ type: 'moveTab', workspace: WS, tabId, toGroupId: group, index: 1 }], + model + ) + expect(revision(moved.model)).toEqual({ 'repo-1': 2 }) + const leafId = results[0]!.leafId! + const split = build( + context, + [{ type: 'splitPane', workspace: WS, tabId, leafId, direction: 'vertical' }], + model + ) + expect(revision(split.model)).toEqual({ 'repo-1': 3 }) + }) +}) + +describe('a terminal tab whose tab-bar id and terminal id differ', () => { + it('takes commands by its tab-bar id and saves pane records under its terminal id', () => { + const context = testContext() + const loaded = reload(localDesktopSession()) + const model = loaded.layout + const split = build( + context, + [ + { + type: 'splitPane', + workspace: GIT_KEY, + tabId: 'tab-agent', + leafId: leaf(3), + direction: 'vertical' + } + ], + model + ) + const newLeaf = split.results[0]!.leafId! + expect(split.results[0]!.paneKey).toBe(`term-agent:${newLeaf}`) + const record = loaded.layout.workspaces[GIT_KEY]!.leaves![leaf(3)]!.sleeping! + const slept = build( + context, + [ + { type: 'wake', workspace: GIT_KEY, paneKeys: [`term-agent:${leaf(3)}`] }, + { + type: 'sleep', + workspace: GIT_KEY, + paneKeys: [`term-agent:${newLeaf}`], + records: [ + { + ...record, + paneKey: `term-agent:${newLeaf}`, + tabId: 'term-agent', + worktreeId: GIT_KEY + } + ] + }, + { + type: 'renamePane', + workspace: GIT_KEY, + tabId: 'tab-agent', + leafId: newLeaf, + title: 'logs' + } + ], + split.model + ) + const saved = saveWorkspaceLayout({ ...loaded, layout: slept.model }) + expect(Object.keys(saved.sleepingAgentSessionsByPaneKey!)).toEqual([`term-agent:${newLeaf}`]) + expect(saved.sleepingAgentSessionsByPaneKey![`term-agent:${newLeaf}`]).toMatchObject({ + paneKey: `term-agent:${newLeaf}`, + tabId: 'term-agent' + }) + expect(saved.terminalLayoutsByTabId['term-agent']!.titlesByLeafId).toEqual({ + [newLeaf]: 'logs' + }) + expect(saved.terminalPtyIncarnationsByPaneKey![`term-agent:${leaf(3)}`]).toBe('inc-3') + expect(saved.unifiedTabs![GIT_KEY]!.find((tab) => tab.entityId === 'term-agent')!.id).toBe( + 'tab-agent' + ) + const dragged = build( + context, + [{ type: 'movePaneToNewTab', workspace: GIT_KEY, tabId: 'tab-agent', leafId: newLeaf }], + slept.model + ) + const draggedTab = dragged.results[0]!.tabId! + const afterDrag = reload(saveWorkspaceLayout({ ...loaded, layout: dragged.model })) + const resaved = saveWorkspaceLayout(afterDrag) + expect(Object.keys(resaved.sleepingAgentSessionsByPaneKey!)).toEqual([ + `${draggedTab}:${newLeaf}` + ]) + const closed = build( + context, + [{ type: 'closeTabs', workspace: GIT_KEY, tabIds: ['tab-agent'] }], + afterDrag.layout + ) + const final = saveWorkspaceLayout({ ...afterDrag, layout: closed.model }) + expect(final.terminalLayoutsByTabId['term-agent']).toBeUndefined() + expect(final.terminalPtyIncarnationsByPaneKey![`term-agent:${leaf(3)}`]).toBeUndefined() + }) +}) + +describe('preview replacement', () => { + it('keeps a preview with an unsaved draft and opens beside it, refused as closeTabs refuses', () => { + const context = testContext() + const open = (fileId: string, dirtyTabIds?: string[]) => ({ + type: 'openEditorTab' as const, + workspace: WS, + fileId, + contentType: 'editor' as const, + preview: true, + ...(dirtyTabIds ? { dirtyTabIds } : {}) + }) + const first = build(context, [open('/w/a.ts')]) + const previewId = first.results[0]!.tabId! + const refused = applyLayoutCommand(first.model, open('/w/b.ts', [previewId]), context) + expect(refused.ok && refused.result.refused).toEqual([ + { tabId: previewId, code: 'editor_tab_has_unsaved_draft' } + ]) + expect(refused.ok && refused.model.workspaces[WS]!.tabs.map((tab) => tab.entityId)).toEqual([ + '/w/a.ts', + '/w/b.ts' + ]) + const clean = build(context, [open('/w/b.ts')], first.model) + expect(clean.model.workspaces[WS]!.tabs.map((tab) => tab.entityId)).toEqual(['/w/b.ts']) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-command-steps.ts b/src/shared/workspace-layout/workspace-layout-command-steps.ts new file mode 100644 index 00000000000..64ebf7b77e0 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-command-steps.ts @@ -0,0 +1,154 @@ +// Steps every command shares: lookups, placing a tab in the one tab order, and result shapes. + +import { insertTabIdIntoOrder } from './tab-order' +import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' +import type { + LayoutApplyResult, + LayoutCommandResult, + LayoutContext, + LayoutEffects, + LayoutRefusalCode +} from './workspace-layout-command-types' +import { + paneKeyOf, + type LayoutLeaf, + type LayoutTab, + type LayoutTerminalTab, + type WorkspaceLayout, + type WorkspaceLayoutModel +} from './workspace-layout-model' +import { withWorkspace } from './workspace-layout-removal' + +export type Applied = LayoutApplyResult + +export const refuse = (code: LayoutRefusalCode): Applied => ({ ok: false, code }) + +export function applied( + model: WorkspaceLayoutModel, + result: LayoutCommandResult = {}, + effects: Partial = {} +): Applied { + return { + ok: true, + model, + result, + effects: { stopPtyIds: effects.stopPtyIds ?? [], startPaneKeys: effects.startPaneKeys ?? [] } + } +} + +/** A workspace's first tab creates its entry; its worktree id is its key unless the runtime opened it first. */ +export function workspaceOrEmpty(model: WorkspaceLayoutModel, key: string): WorkspaceLayout { + return model.workspaces[key] ?? { worktreeId: key, tabs: [], groups: [] } +} + +export function findTab(workspace: WorkspaceLayout, tabId: string): LayoutTab | undefined { + return workspace.tabs.find((tab) => tab.id === tabId) +} + +export function findTerminal( + workspace: WorkspaceLayout, + tabId: string +): LayoutTerminalTab | undefined { + const tab = findTab(workspace, tabId) + return tab?.kind === 'terminal' ? tab : undefined +} + +export function replaceTab(workspace: WorkspaceLayout, tab: LayoutTab): WorkspaceLayout { + return { ...workspace, tabs: workspace.tabs.map((entry) => (entry.id === tab.id ? tab : entry)) } +} + +export function updateTab( + model: WorkspaceLayoutModel, + key: string, + tab: LayoutTab, + result: LayoutCommandResult = {} +): Applied { + return applied(withWorkspace(model, key, replaceTab(model.workspaces[key]!, tab)), result) +} + +export function leafIdsOf(tab: LayoutTerminalTab): string[] { + return collectLayoutLeafIdsInOrder(tab.panes.root) +} + +export function boundPtyIds(workspace: WorkspaceLayout, tab: LayoutTab): string[] { + return tab.kind === 'terminal' + ? leafIdsOf(tab).flatMap((leafId) => workspace.leaves?.[leafId]?.ptyId ?? []) + : [] +} + +/** The workspace with one pane's data replaced; a pane with no data keeps no record. */ +export function setLeaf( + workspace: WorkspaceLayout, + leafId: string, + leaf: LayoutLeaf +): WorkspaceLayout { + const leaves = { ...workspace.leaves, [leafId]: leaf } + if (Object.keys(leaf).length === 0) { + delete leaves[leafId] + } + return { ...workspace, leaves } +} + +export function paneKeysOf(tab: LayoutTerminalTab): string[] { + return leafIdsOf(tab).map((leafId) => paneKeyOf(tab.entityId, leafId)) +} + +/** The terminal tab and pane a pane key names in this workspace. */ +export function locatePane( + workspace: WorkspaceLayout, + paneKey: string +): { tab: LayoutTerminalTab; leafId: string } | null { + for (const tab of workspace.tabs) { + if (tab.kind !== 'terminal') { + continue + } + const leafId = leafIdsOf(tab).find( + (candidate) => paneKeyOf(tab.entityId, candidate) === paneKey + ) + if (leafId) { + return { tab, leafId } + } + } + return null +} + +export type TabPlacement = { groupId?: string; afterTabId?: string; index?: number } + +/** + * Puts a new tab in the one tab order: the named group, else the anchor's, else the first; a + * workspace with no group gets one. Pinned tabs stay ahead of unpinned ones. + */ +export function placeNewTab( + workspace: WorkspaceLayout, + tab: LayoutTab, + placement: TabPlacement, + context: LayoutContext +): WorkspaceLayout { + const tabs = [...workspace.tabs, tab] + const anchorGroup = placement.afterTabId + ? workspace.groups.find((group) => group.tabOrder.includes(placement.afterTabId!)) + : undefined + let group = + workspace.groups.find((entry) => entry.id === placement.groupId) ?? + anchorGroup ?? + workspace.groups[0] + const groups = [...workspace.groups] + if (!group) { + group = { id: context.mintId(), tabOrder: [] } + groups.push(group) + } + let tabOrder: string[] + if (placement.index !== undefined) { + tabOrder = group.tabOrder.filter((id) => id !== tab.id) + tabOrder.splice(Math.max(0, Math.min(placement.index, tabOrder.length)), 0, tab.id) + } else { + const anchor = anchorGroup === group ? placement.afterTabId : undefined + tabOrder = insertTabIdIntoOrder(group.tabOrder, tabs, tab.id, tab.isPinned === true, anchor) + } + const placed = { ...group, tabOrder } + return { + ...workspace, + tabs, + groups: groups.map((entry) => (entry.id === placed.id ? placed : entry)) + } +} diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts new file mode 100644 index 00000000000..c9899da303b --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -0,0 +1,136 @@ +// Commands a client sends to change the layout (design 2.1). The pure module applies only their +// layout effect. Refusals that need facts it does not hold stay with the runtime: an unknown +// workspace or unresolved folder home (catalog), `shell_override_refused` and +// `startup_agent_conflict` (host support), `file_outside_workspace` (paths) and +// `terminal_liveness_unavailable` (PTY host). + +import type { SleepingAgentSessionRecord } from '../agent-session-resume' +import type { AgentType } from '../agent-status-types' +import type { AiVaultSessionTitle } from '../ai-vault-session-title' +import type { TabContentType, TabGroupLayoutNode } from '../tab-types' +import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../terminal-tab-types' +import type { PaneSide } from './terminal-pane-tree' +import type { LayoutEditorFile, LayoutTerminalCreation } from './workspace-layout-model' + +type On = { workspace: string } + +export type LayoutCommand = + | (On & { + type: 'createTerminalTab' + groupId?: string + afterTabId?: string + index?: number + title?: string + color?: string + viewMode?: 'terminal' | 'chat' + creation?: Partial + }) + | (On & { + type: 'splitPane' + tabId: string + leafId: string + direction: TerminalPaneSplitDirection + ratio?: number + }) + | (On & { type: 'closePane'; tabId: string; leafId: string }) + | (On & { + type: 'closeTabs' + tabIds: string[] + force?: boolean + /** The sender confirms pinned closes itself (new phones, the CLI without --force). */ + refusePinned?: boolean + /** Editor tabs whose view reported an unsaved draft (rule 10). */ + dirtyTabIds?: string[] + }) + | (On & { type: 'moveTab'; tabId: string; toGroupId: string; index: number }) + | (On & { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide }) + | (On & { type: 'setGroupRatios'; groupLayout: TabGroupLayoutNode }) + | (On & { type: 'setPaneRatios'; tabId: string; root: TerminalPaneLayoutNode }) + | (On & { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide }) + | (On & { type: 'equalizePanes'; tabId: string }) + | (On & { + type: 'movePaneToNewTab' + tabId: string + leafId: string + groupId?: string + index?: number + }) + | (On & { type: 'renameTab'; tabId: string } & TabTitle) + | (On & { type: 'renamePane'; tabId: string; leafId: string; title: string | null }) + | (On & { + type: 'setTabProps' + tabId: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + }) + | (On & { type: 'setChatPane'; tabId: string; leafId: string | null }) + | (On & { + type: 'openEditorTab' + /** The editor's file id: the tab's entity. */ + fileId: string + contentType: Exclude + /** Present for edit-mode files, which are restored after restart. */ + file?: LayoutEditorFile + groupId?: string + preview?: boolean + /** Editor tabs whose view reported an unsaved draft: a preview among them is not replaced. */ + dirtyTabIds?: string[] + }) + | (On & { type: 'promotePreviewTab'; tabId: string }) + | (On & { type: 'openBrowserTab'; profileId?: string | null; groupId?: string }) + | (On & { type: 'openAgentSessionTab'; sessionId: string; agent: AgentType; groupId?: string }) + | (On & { type: 'startPane'; paneKey: string }) + | (On & { type: 'restartPane'; paneKey: string }) + | (On & { type: 'sleep'; paneKeys?: string[]; records: SleepingAgentSessionRecord[] }) + | (On & { type: 'wake'; paneKeys?: string[] }) + +export type CommandOf = Extract + +export type GroupSide = 'left' | 'right' | 'up' | 'down' + +export type TabTitle = + | { kind: 'custom' | 'generated'; title: string | null } + | { kind: 'aiVault'; title: AiVaultSessionTitle | null } + +export type LayoutRefusalCode = + | 'workspace_not_found' + | 'tab_not_found' + | 'pane_not_found' + | 'group_not_found' + | 'invalid_params' + | 'group_set_changed' + | 'pane_structure_changed' + | 'same_pane' + | 'last_pane' + | 'pane_sleeping' + | 'pane_already_bound' + | 'workspace_exists' + +/** What the runtime does after applying: stop or start terminals. The reply never waits on them. */ +export type LayoutEffects = { stopPtyIds: string[]; startPaneKeys: string[] } + +export type LayoutCommandResult = { + tabId?: string + leafId?: string + paneKey?: string + groupId?: string + pageId?: string + tabClosed?: boolean + alreadyClosed?: boolean + closed?: string[] + refused?: { tabId: string; code: 'tab_pinned' | 'editor_tab_has_unsaved_draft' }[] + slept?: string[] + woken?: string[] +} + +export type LayoutContext = { + mintId: () => string + /** Pane ids must be UUIDs (pane keys and PTY env carry them). */ + mintLeafId: () => string + now: () => number +} + +export type LayoutApplyResult = + | { ok: true; model: Model; result: LayoutCommandResult; effects: LayoutEffects } + | { ok: false; code: LayoutRefusalCode } diff --git a/src/shared/workspace-layout/workspace-layout-command.test-fixture.ts b/src/shared/workspace-layout/workspace-layout-command.test-fixture.ts new file mode 100644 index 00000000000..3d0ab11397c --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-command.test-fixture.ts @@ -0,0 +1,61 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { LayoutCommand, LayoutContext } from './workspace-layout-command-types' +import type { LayoutTerminalTab, WorkspaceLayoutModel } from './workspace-layout-model' +import { checkWorkspaceLayoutModelRules, emptyLayoutBeside } from './workspace-layout-model-rules' + +export const WS = 'repo-1::/Users/dev/orca' + +/** Deterministic ids; pane ids are UUIDs as pane keys require. */ +export function testContext(): LayoutContext { + let id = 0 + let leaf = 0 + let clock = 1_700_000_000_000 + return { + mintId: () => `id-${++id}`, + mintLeafId: () => `00000000-0000-4000-8000-${String(++leaf).padStart(12, '0')}`, + now: () => (clock += 1000) + } +} + +export function emptyModel(): WorkspaceLayoutModel { + return { + hostId: LOCAL_EXECUTION_HOST_ID, + workspaces: {}, + records: {}, + legacy: { terminalRowOwners: {} } + } +} + +/** Applies commands that must succeed and keep every structural rule. */ +export function build(context: LayoutContext, commands: LayoutCommand[], model = emptyModel()) { + let next = model + const results = commands.map((command) => { + const applied = applyLayoutCommand(next, command, context) + if (!applied.ok) { + throw new Error(`${command.type} refused: ${applied.code}`) + } + const violations = checkWorkspaceLayoutModelRules([applied.model], [next]) + if (violations.length > 0) { + throw new Error( + `${command.type} broke ${violations.map((violation) => violation.rule).join(', ')}` + ) + } + next = applied.model + return applied.result + }) + return { model: next, results } +} + +export function terminalTab(model: WorkspaceLayoutModel, tabId: string): LayoutTerminalTab { + const tab = model.workspaces[WS]!.tabs.find((entry) => entry.id === tabId) + if (tab?.kind !== 'terminal') { + throw new Error(`no terminal tab ${tabId}`) + } + return tab +} + +/** A model with nothing beside it, as the Serializer and the rules check take it. */ +export function asLoaded(layout: WorkspaceLayoutModel) { + return { ...emptyLayoutBeside(), layout } +} diff --git a/src/shared/workspace-layout/workspace-layout-commands.test.ts b/src/shared/workspace-layout/workspace-layout-commands.test.ts new file mode 100644 index 00000000000..0b14702a547 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-commands.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from 'vitest' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { LayoutCommand } from './workspace-layout-command-types' +import { + asLoaded, + build, + emptyModel, + terminalTab, + testContext, + WS +} from './workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { saveWorkspaceLayout } from './workspace-layout-save' + +const context = testContext() + +/** Two terminal tabs (the first split in two) and an editor tab, in one group. */ +function setup() { + const ctx = testContext() + const { model, results } = build(ctx, [ + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS }, + { + type: 'openEditorTab', + workspace: WS, + fileId: '/w/a.ts', + contentType: 'editor', + file: { filePath: '/w/a.ts', relativePath: 'a.ts', language: 'typescript' } + } + ]) + const [first, second, editor] = results + const split = build( + ctx, + [ + { + type: 'splitPane', + workspace: WS, + tabId: first!.tabId!, + leafId: first!.leafId!, + direction: 'vertical' + } + ], + model + ) + return { + ctx, + model: split.model, + tabA: first!.tabId!, + leafA: first!.leafId!, + leafA2: split.results[0]!.leafId!, + tabB: second!.tabId!, + leafB: second!.leafId!, + editor: editor!.tabId! + } +} + +function refusal(model: WorkspaceLayoutModel, command: LayoutCommand) { + const result = applyLayoutCommand(model, command, context) + return result.ok ? 'applied' : result.code +} + +const groupOrder = (model: WorkspaceLayoutModel) => + model.workspaces[WS]!.groups.map((group) => group.tabOrder) + +describe('layout command refusals', () => { + it('refuses commands on a workspace the model has never seen, and treats closes there as done', () => { + const model = emptyModel() + expect( + refusal(model, { + type: 'splitPane', + workspace: WS, + tabId: 't', + leafId: 'l', + direction: 'vertical' + }) + ).toBe('workspace_not_found') + expect( + applyLayoutCommand(model, { type: 'closeTabs', workspace: WS, tabIds: ['t'] }, context) + ).toMatchObject({ ok: true, result: { alreadyClosed: true } }) + expect( + applyLayoutCommand( + model, + { type: 'closePane', workspace: WS, tabId: 't', leafId: 'l' }, + context + ) + ).toMatchObject({ ok: true, result: { alreadyClosed: true } }) + }) + + it('pane commands refuse a missing tab or pane', () => { + const { model, tabA, leafA } = setup() + expect( + refusal(model, { + type: 'splitPane', + workspace: WS, + tabId: 'nope', + leafId: leafA, + direction: 'vertical' + }) + ).toBe('tab_not_found') + expect( + refusal(model, { + type: 'splitPane', + workspace: WS, + tabId: tabA, + leafId: 'nope', + direction: 'vertical' + }) + ).toBe('pane_not_found') + expect( + refusal(model, { type: 'renamePane', workspace: WS, tabId: tabA, leafId: 'nope', title: 'x' }) + ).toBe('pane_not_found') + expect( + refusal(model, { type: 'setChatPane', workspace: WS, tabId: tabA, leafId: 'nope' }) + ).toBe('pane_not_found') + expect( + refusal(model, { + type: 'movePane', + workspace: WS, + tabId: tabA, + leafId: leafA, + targetLeafId: 'nope', + side: 'left' + }) + ).toBe('pane_not_found') + expect(refusal(model, { type: 'equalizePanes', workspace: WS, tabId: 'nope' })).toBe( + 'tab_not_found' + ) + expect( + refusal(model, { + type: 'renameTab', + workspace: WS, + tabId: 'nope', + kind: 'custom', + title: 'x' + }) + ).toBe('tab_not_found') + expect( + refusal(model, { type: 'setTabProps', workspace: WS, tabId: 'nope', color: 'red' }) + ).toBe('tab_not_found') + expect(refusal(model, { type: 'promotePreviewTab', workspace: WS, tabId: 'nope' })).toBe( + 'tab_not_found' + ) + }) + + it('movePane refuses moving a pane onto itself', () => { + const { model, tabA, leafA } = setup() + expect( + refusal(model, { + type: 'movePane', + workspace: WS, + tabId: tabA, + leafId: leafA, + targetLeafId: leafA, + side: 'left' + }) + ).toBe('same_pane') + }) + + it('movePaneToNewTab refuses the last pane, which moves as a tab instead', () => { + const { model, tabB, leafB } = setup() + expect( + refusal(model, { type: 'movePaneToNewTab', workspace: WS, tabId: tabB, leafId: leafB }) + ).toBe('last_pane') + }) + + it('setPaneRatios accepts divider sizes only', () => { + const { model, tabA, leafA, leafA2 } = setup() + const root = terminalTab(model, tabA).panes!.root! + if (root.type !== 'split') { + throw new Error('expected a split') + } + expect( + refusal(model, { + type: 'setPaneRatios', + workspace: WS, + tabId: tabA, + root: { ...root, ratio: 0.3 } + }) + ).toBe('applied') + const swapped = { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: leafA2 }, + second: { type: 'leaf' as const, leafId: leafA } + } + expect( + refusal(model, { type: 'setPaneRatios', workspace: WS, tabId: tabA, root: swapped }) + ).toBe('pane_structure_changed') + expect( + refusal(model, { + type: 'setPaneRatios', + workspace: WS, + tabId: tabA, + root: { type: 'leaf', leafId: leafA } + }) + ).toBe('pane_structure_changed') + }) + + it('group commands refuse a missing group, a changed group set, and splitting a lone tab off itself', () => { + const { model, tabA } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + expect( + refusal(model, { type: 'moveTab', workspace: WS, tabId: tabA, toGroupId: 'nope', index: 0 }) + ).toBe('group_not_found') + expect( + refusal(model, { + type: 'moveTab', + workspace: WS, + tabId: 'nope', + toGroupId: groupId, + index: 0 + }) + ).toBe('tab_not_found') + expect( + refusal(model, { + type: 'splitGroup', + workspace: WS, + tabId: tabA, + besideGroupId: 'nope', + direction: 'right' + }) + ).toBe('group_not_found') + expect( + refusal(model, { + type: 'setGroupRatios', + workspace: WS, + groupLayout: { type: 'leaf', groupId: 'other' } + }) + ).toBe('group_set_changed') + expect( + refusal(model, { + type: 'setGroupRatios', + workspace: WS, + groupLayout: { type: 'leaf', groupId } + }) + ).toBe('applied') + const single = build(testContext(), [{ type: 'createTerminalTab', workspace: WS }]).model + expect( + refusal(single, { + type: 'splitGroup', + workspace: WS, + tabId: single.workspaces[WS]!.tabs[0]!.id, + besideGroupId: single.workspaces[WS]!.groups[0]!.id, + direction: 'right' + }) + ).toBe('invalid_params') + }) + + it('startPane refuses a missing or sleeping pane; restartPane a missing one', () => { + const { model, tabB, leafB } = setup() + const paneKey = `${tabB}:${leafB}` + expect(refusal(model, { type: 'startPane', workspace: WS, paneKey: 'nope:x' })).toBe( + 'pane_not_found' + ) + expect(refusal(model, { type: 'restartPane', workspace: WS, paneKey: 'nope:x' })).toBe( + 'pane_not_found' + ) + const record = { + paneKey, + tabId: tabB, + worktreeId: WS, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 's' }, + prompt: 'p', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1 + } + const slept = build( + testContext(), + [{ type: 'sleep', workspace: WS, records: [record] }], + model + ).model + expect(refusal(slept, { type: 'startPane', workspace: WS, paneKey })).toBe('pane_sleeping') + const woken = build(testContext(), [{ type: 'wake', workspace: WS }], slept) + expect(woken.results[0]!.woken).toEqual([paneKey]) + expect(refusal(woken.model, { type: 'startPane', workspace: WS, paneKey })).toBe('applied') + }) +}) + +describe('closeTabs', () => { + it('refuses a pinned tab only for senders that confirm pinned closes, and never with force', () => { + const { model, tabB, ctx } = setup() + const pinned = build( + ctx, + [{ type: 'setTabProps', workspace: WS, tabId: tabB, isPinned: true }], + model + ).model + const asPhone = applyLayoutCommand( + pinned, + { type: 'closeTabs', workspace: WS, tabIds: [tabB], refusePinned: true }, + ctx + ) + expect(asPhone).toMatchObject({ + ok: true, + result: { closed: [], refused: [{ tabId: tabB, code: 'tab_pinned' }] } + }) + const forced = applyLayoutCommand( + pinned, + { type: 'closeTabs', workspace: WS, tabIds: [tabB], refusePinned: true, force: true }, + ctx + ) + expect(forced).toMatchObject({ ok: true, result: { closed: [tabB] } }) + const asWindow = applyLayoutCommand( + pinned, + { type: 'closeTabs', workspace: WS, tabIds: [tabB] }, + ctx + ) + expect(asWindow).toMatchObject({ ok: true, result: { closed: [tabB] } }) + }) + + it('refuses an editor tab with an unsaved draft unless forced, and closes the rest in one apply', () => { + const { model, editor, tabA, ctx } = setup() + const result = applyLayoutCommand( + model, + { type: 'closeTabs', workspace: WS, tabIds: [editor, tabA, 'gone'], dirtyTabIds: [editor] }, + ctx + ) + expect(result).toMatchObject({ + ok: true, + result: { + closed: [tabA, 'gone'], + refused: [{ tabId: editor, code: 'editor_tab_has_unsaved_draft' }] + } + }) + if (result.ok) { + expect(result.effects.stopPtyIds).toEqual([]) + expect(groupOrder(result.model)).toEqual([[expect.any(String), editor]]) + } + }) +}) + +describe('layout command effects', () => { + it('places a new terminal after its anchor, keeps pinned tabs first, and reuses the lowest free ordinal', () => { + const { model, tabA, tabB, editor, ctx } = setup() + const pinned = build( + ctx, + [{ type: 'setTabProps', workspace: WS, tabId: editor, isPinned: true }], + model + ).model + expect(groupOrder(pinned)).toEqual([[editor, tabA, tabB]]) + const closed = build(ctx, [{ type: 'closeTabs', workspace: WS, tabIds: [tabA] }], pinned).model + const created = build( + ctx, + [{ type: 'createTerminalTab', workspace: WS, afterTabId: editor }], + closed + ) + const tabId = created.results[0]!.tabId! + expect(groupOrder(created.model)).toEqual([[editor, tabId, tabB]]) + expect(terminalTab(created.model, tabId).terminal.defaultTitle).toBe('Terminal 1') + }) + + it('moves a pane to a new tab keeping its id, so its data and scrollback move by structure', () => { + const { model, tabA, leafA2, ctx } = setup() + const workspace = model.workspaces[WS]! + const paneData = { ptyId: 'pty-2', incarnationId: 'inc', title: 'logs' } + const withData: WorkspaceLayoutModel = { + ...model, + workspaces: { + [WS]: { ...workspace, leaves: { ...workspace.leaves, [leafA2]: paneData } } + } + } + const moved = build( + ctx, + [{ type: 'movePaneToNewTab', workspace: WS, tabId: tabA, leafId: leafA2 }], + withData + ) + const newTabId = moved.results[0]!.tabId! + expect(terminalTab(moved.model, newTabId).panes.root).toEqual({ type: 'leaf', leafId: leafA2 }) + expect(moved.model.workspaces[WS]!.leaves).toEqual(withData.workspaces[WS]!.leaves) + const loaded = asLoaded(moved.model) + loaded.facts.scrollback[leafA2] = { buffer: 'scrollback' } + const saved = saveWorkspaceLayout(loaded) + expect(saved.terminalLayoutsByTabId[newTabId]).toMatchObject({ + ptyIdsByLeafId: { [leafA2]: 'pty-2' }, + titlesByLeafId: { [leafA2]: 'logs' }, + buffersByLeafId: { [leafA2]: 'scrollback' } + }) + expect(saved.terminalLayoutsByTabId[tabA]!.buffersByLeafId).toBeUndefined() + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ [`${newTabId}:${leafA2}`]: 'inc' }) + expect(groupOrder(moved.model)[0]!.indexOf(newTabId)).toBe( + groupOrder(moved.model)[0]!.indexOf(tabA) + 1 + ) + }) + + it('equalizes same-axis panes into equal shares', () => { + const { model, tabA, leafA2, ctx } = setup() + const three = build( + ctx, + [ + { type: 'splitPane', workspace: WS, tabId: tabA, leafId: leafA2, direction: 'vertical' }, + { type: 'equalizePanes', workspace: WS, tabId: tabA } + ], + model + ).model + const root = terminalTab(three, tabA).panes!.root! + expect(root.type === 'split' && root.ratio).toBeCloseTo(1 / 3) + }) + + it('reuses the open tab for a file and replaces a preview tab of the same kind', () => { + const { model, editor, ctx } = setup() + const again = applyLayoutCommand( + model, + { type: 'openEditorTab', workspace: WS, fileId: '/w/a.ts', contentType: 'editor' }, + ctx + ) + expect(again).toMatchObject({ ok: true, result: { tabId: editor } }) + const previews = build( + ctx, + [ + { + type: 'openEditorTab', + workspace: WS, + fileId: '/w/b.ts', + contentType: 'editor', + preview: true + }, + { + type: 'openEditorTab', + workspace: WS, + fileId: '/w/c.ts', + contentType: 'editor', + preview: true + } + ], + model + ) + const entities = previews.model.workspaces[WS]!.tabs.map((tab) => tab.entityId) + expect(entities).toContain('/w/c.ts') + expect(entities).not.toContain('/w/b.ts') + }) + + it('opens browser and agent-session tabs with their records', () => { + const { model, ctx } = setup() + const opened = build( + ctx, + [ + { type: 'openBrowserTab', workspace: WS, profileId: 'p1' }, + { type: 'openAgentSessionTab', workspace: WS, sessionId: 'sess-1', agent: 'codex' } + ], + model + ) + const [browser, session] = opened.results + expect(opened.model.workspaces[WS]!.browserTabs).toEqual([ + { + id: browser!.tabId, + sessionProfileId: 'p1', + pageIds: [browser!.pageId], + createdAt: expect.any(Number) + } + ]) + expect( + opened.model.workspaces[WS]!.tabs.find((tab) => tab.id === session!.tabId) + ).toMatchObject({ kind: 'agent-session', entityId: 'sess-1', agentSessionAgent: 'codex' }) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-commands.ts b/src/shared/workspace-layout/workspace-layout-commands.ts new file mode 100644 index 00000000000..b6ca55d5745 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-commands.ts @@ -0,0 +1,111 @@ +// applyLayoutCommand: every layout change a client can ask for, applied to the model in one step. +// Pure and synchronous, so the runtime applies commands in arrival order on one thread. + +import { applied, refuse, type Applied } from './workspace-layout-command-steps' +import type { LayoutCommand, LayoutContext } from './workspace-layout-command-types' +import { + openAgentSessionTab, + openBrowserTab, + openEditorTab +} from './workspace-layout-content-commands' +import { moveTab, setGroupRatios, splitGroup } from './workspace-layout-group-commands' +import { updateLegacyPersistence } from './workspace-layout-legacy-persistence' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { + closePane, + equalizePanes, + movePane, + movePaneToNewTab, + renamePane, + setPaneRatios, + splitPane +} from './workspace-layout-pane-commands' +import { restartPane, sleep, startPane, wake } from './workspace-layout-process-commands' +import { + closeTabs, + createTerminalTab, + promotePreviewTab, + renameTab, + setChatPane, + setTabProps +} from './workspace-layout-tab-commands' + +const CREATES_WORKSPACE = new Set([ + 'createTerminalTab', + 'openEditorTab', + 'openBrowserTab', + 'openAgentSessionTab' +]) + +export function applyLayoutCommand( + model: WorkspaceLayoutModel, + command: LayoutCommand, + context: LayoutContext +): Applied { + const result = applyCommand(model, command, context) + return result.ok ? { ...result, model: updateLegacyPersistence(model, result.model) } : result +} + +function applyCommand( + model: WorkspaceLayoutModel, + command: LayoutCommand, + context: LayoutContext +): Applied { + if (!model.workspaces[command.workspace] && !CREATES_WORKSPACE.has(command.type)) { + // Closing what is already gone is not an error (#10747). + if (command.type === 'closeTabs') { + return applied(model, { alreadyClosed: true, closed: command.tabIds, refused: [] }) + } + return command.type === 'closePane' + ? applied(model, { alreadyClosed: true }) + : refuse('workspace_not_found') + } + switch (command.type) { + case 'createTerminalTab': + return createTerminalTab(model, command, context) + case 'splitPane': + return splitPane(model, command, context) + case 'closePane': + return closePane(model, command) + case 'closeTabs': + return closeTabs(model, command) + case 'moveTab': + return moveTab(model, command) + case 'splitGroup': + return splitGroup(model, command, context) + case 'setGroupRatios': + return setGroupRatios(model, command) + case 'setPaneRatios': + return setPaneRatios(model, command) + case 'movePane': + return movePane(model, command) + case 'equalizePanes': + return equalizePanes(model, command) + case 'movePaneToNewTab': + return movePaneToNewTab(model, command, context) + case 'renameTab': + return renameTab(model, command) + case 'renamePane': + return renamePane(model, command) + case 'setTabProps': + return setTabProps(model, command) + case 'setChatPane': + return setChatPane(model, command) + case 'openEditorTab': + return openEditorTab(model, command, context) + case 'promotePreviewTab': + return promotePreviewTab(model, command) + case 'openBrowserTab': + return openBrowserTab(model, command, context) + case 'openAgentSessionTab': + return openAgentSessionTab(model, command, context) + case 'startPane': + return startPane(model, command) + case 'restartPane': + return restartPane(model, command) + case 'sleep': + return sleep(model, command) + case 'wake': + return wake(model, command) + } +} diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts new file mode 100644 index 00000000000..a82dbc668b4 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -0,0 +1,95 @@ +// Opening editor, browser and agent-session tabs: the tab and its content record in one apply. + +import { applied, type Applied } from './workspace-layout-command-steps' +import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import type { LayoutContentTab, WorkspaceLayoutModel } from './workspace-layout-model' +import { withWorkspace } from './workspace-layout-removal' +import { placeContentTab } from './workspace-layout-tab-commands' + +function contentTab( + context: LayoutContext, + fields: Pick & Partial +): LayoutContentTab { + return { + id: context.mintId(), + createdAt: context.now(), + customTitle: null, + color: null, + ...fields + } +} + +/** An open tab for the same file is returned instead of a second one. */ +export function openEditorTab( + model: WorkspaceLayoutModel, + command: CommandOf<'openEditorTab'>, + context: LayoutContext +): Applied { + const key = command.workspace + const existing = model.workspaces[key]?.tabs.find( + (tab) => tab.entityId === command.fileId && tab.kind === command.contentType + ) + if (existing) { + return applied(model, { tabId: existing.id }) + } + const tab = contentTab(context, { + kind: command.contentType, + entityId: command.fileId, + ...(command.preview ? { isPreview: true } : {}) + }) + const placed = placeContentTab(model, key, tab, command, context) + let next = placed.model + const file = command.file + if (file) { + const workspace = next.workspaces[key]! + const files = (workspace.editorFiles ?? []).filter((entry) => entry.filePath !== file.filePath) + next = withWorkspace(next, key, { ...workspace, editorFiles: [...files, file] }) + } + return applied(next, { tabId: tab.id, refused: placed.refused }) +} + +export function openBrowserTab( + model: WorkspaceLayoutModel, + command: CommandOf<'openBrowserTab'>, + context: LayoutContext +): Applied { + const key = command.workspace + // The tab-bar id and the browser tab record share one id, as the window mints them. + const browserTabId = context.mintId() + const pageId = context.mintId() + const tab = contentTab(context, { + kind: 'browser', + id: browserTabId, + entityId: browserTabId + }) + const next = placeContentTab(model, key, tab, command, context).model + const workspace = next.workspaces[key]! + const browserTab = { + id: browserTabId, + sessionProfileId: command.profileId ?? null, + pageIds: [pageId], + createdAt: tab.createdAt + } + return applied( + withWorkspace(next, key, { + ...workspace, + browserTabs: [...(workspace.browserTabs ?? []), browserTab] + }), + { tabId: tab.id, pageId } + ) +} + +export function openAgentSessionTab( + model: WorkspaceLayoutModel, + command: CommandOf<'openAgentSessionTab'>, + context: LayoutContext +): Applied { + const tab = contentTab(context, { + kind: 'agent-session', + entityId: command.sessionId, + agentSessionAgent: command.agent + }) + return applied(placeContentTab(model, command.workspace, tab, command, context).model, { + tabId: tab.id + }) +} diff --git a/src/shared/workspace-layout/workspace-layout-group-commands.ts b/src/shared/workspace-layout/workspace-layout-group-commands.ts new file mode 100644 index 00000000000..f95325e676c --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-group-commands.ts @@ -0,0 +1,128 @@ +// Commands on tab groups and the group split tree, built on the headless host's group moves so +// the runtime and today's headless host agree on the tree. There is no empty-group command: a +// group exists only while it holds a tab, so memory and disk never differ. + +import type { TabGroupLayoutNode } from '../tab-types' +import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit } from './tab-group-moves' +import { moveGroupLeafBeside } from './tab-group-layout-tree' +import { applied, findTab, refuse, type Applied } from './workspace-layout-command-steps' +import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import type { WorkspaceLayout, WorkspaceLayoutModel } from './workspace-layout-model' +import { withWorkspace } from './workspace-layout-removal' + +function sourceGroupOf(workspace: WorkspaceLayout, tabId: string) { + return findTab(workspace, tabId) + ? workspace.groups.find((group) => group.tabOrder.includes(tabId)) + : undefined +} + +export function moveTab(model: WorkspaceLayoutModel, command: CommandOf<'moveTab'>): Applied { + const workspace = model.workspaces[command.workspace]! + const source = sourceGroupOf(workspace, command.tabId) + if (!source) { + return refuse('tab_not_found') + } + if (!workspace.groups.some((group) => group.id === command.toGroupId)) { + return refuse('group_not_found') + } + if (source.id === command.toGroupId) { + const tabOrder = source.tabOrder.filter((id) => id !== command.tabId) + tabOrder.splice(Math.max(0, Math.min(command.index, tabOrder.length)), 0, command.tabId) + const groups = workspace.groups.map((group) => + group.id === source.id ? { ...group, tabOrder } : group + ) + return applied(withWorkspace(model, command.workspace, { ...workspace, groups })) + } + const moved = buildHeadlessTabGroupMove({ + groups: workspace.groups, + layout: workspace.groupLayout, + tabId: command.tabId, + targetGroupId: command.toGroupId, + index: command.index + }) + if (!moved) { + return refuse('invalid_params') + } + return applied( + withWorkspace(model, command.workspace, { + ...workspace, + groups: moved.groups, + groupLayout: moved.layout ?? undefined + }) + ) +} + +export function splitGroup( + model: WorkspaceLayoutModel, + command: CommandOf<'splitGroup'>, + context: LayoutContext +): Applied { + const workspace = model.workspaces[command.workspace]! + const source = sourceGroupOf(workspace, command.tabId) + if (!source) { + return refuse('tab_not_found') + } + if (!workspace.groups.some((group) => group.id === command.besideGroupId)) { + return refuse('group_not_found') + } + if (source.tabOrder.length === 1 && source.id !== command.besideGroupId) { + // The group's only tab moves with its group, so the group keeps its id (ids never change). + const groupLayout = moveGroupLeafBeside( + workspace.groupLayout ?? { type: 'leaf', groupId: source.id }, + source.id, + command.besideGroupId, + command.direction + ) + return applied(withWorkspace(model, command.workspace, { ...workspace, groupLayout }), { + groupId: source.id + }) + } + const split = buildHeadlessTabGroupSplit({ + groups: workspace.groups, + layout: workspace.groupLayout, + tabId: command.tabId, + targetGroupId: command.besideGroupId, + splitDirection: command.direction, + newGroupId: context.mintId() + }) + if (!split) { + // Splitting a group's only tab off itself would leave nothing behind. + return refuse('invalid_params') + } + return applied( + withWorkspace(model, command.workspace, { + ...workspace, + groups: split.groups, + groupLayout: split.layout + }), + { + groupId: split.newGroupId + } + ) +} + +function sameGroupsIgnoringRatios(left: TabGroupLayoutNode, right: TabGroupLayoutNode): boolean { + if (left.type === 'leaf' || right.type === 'leaf') { + return left.type === 'leaf' && right.type === 'leaf' && left.groupId === right.groupId + } + return ( + left.direction === right.direction && + sameGroupsIgnoringRatios(left.first, right.first) && + sameGroupsIgnoringRatios(left.second, right.second) + ) +} + +export function setGroupRatios( + model: WorkspaceLayoutModel, + command: CommandOf<'setGroupRatios'> +): Applied { + const workspace = model.workspaces[command.workspace]! + const first = workspace.groups[0] + const current = workspace.groupLayout ?? (first && { type: 'leaf' as const, groupId: first.id }) + if (!current || !sameGroupsIgnoringRatios(current, command.groupLayout)) { + return refuse('group_set_changed') + } + return applied( + withWorkspace(model, command.workspace, { ...workspace, groupLayout: command.groupLayout }) + ) +} diff --git a/src/shared/workspace-layout/workspace-layout-owner-transitions.ts b/src/shared/workspace-layout/workspace-layout-owner-transitions.ts new file mode 100644 index 00000000000..fd7de667d46 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-owner-transitions.ts @@ -0,0 +1,74 @@ +// Transitions that remove or rename a whole workspace with every record that names it. Pane data +// and closed-tab records live in their workspace, so they follow without being touched. + +import { filterRecord } from './stored-record-fields' +import type { WorkspaceLayoutModel } from './workspace-layout-model' + +type KeyedRecord = Record | undefined + +/** A deleted worktree, repo, folder or project group takes its layout and records with it. */ +export function removeWorkspaces( + model: WorkspaceLayoutModel, + keys: readonly string[] +): WorkspaceLayoutModel { + const workspaces = { ...model.workspaces } + for (const key of keys) { + delete workspaces[key] + } + const { records } = model + return { + ...model, + workspaces, + records: { + ...records, + defaultTabsAppliedByWorkspace: filterRecord( + records.defaultTabsAppliedByWorkspace, + (key) => !keys.includes(key) + ), + clientHostedBrowserPagesByWorkspace: filterRecord( + records.clientHostedBrowserPagesByWorkspace, + (key) => !keys.includes(key) + ) + } + } +} + +const renamed = (value: string, from: string, to: string) => (value === from ? to : value) + +/** + * A worktree's identity changed: its key, its worktree id and the records keyed by them follow. + * Null when `to` already holds a workspace: two layouts are never merged. + */ +export function renameWorkspace( + model: WorkspaceLayoutModel, + from: string, + to: string +): WorkspaceLayoutModel | null { + const workspace = model.workspaces[from] + if (!workspace || from === to) { + return model + } + if (model.workspaces[to]) { + return null + } + const workspaces = { + ...model.workspaces, + [to]: { ...workspace, worktreeId: renamed(workspace.worktreeId, from, to) } + } + delete workspaces[from] + const rekey = (record: KeyedRecord): KeyedRecord => + record && + Object.fromEntries( + Object.entries(record).map(([key, value]) => [renamed(key, from, to), value]) + ) + return { + ...model, + workspaces, + records: { + ...model.records, + defaultTabsAppliedByWorkspace: rekey(model.records.defaultTabsAppliedByWorkspace), + clientHostedBrowserPagesByWorkspace: rekey(model.records.clientHostedBrowserPagesByWorkspace) + }, + legacy: { ...model.legacy, terminalRowOwners: rekey(model.legacy.terminalRowOwners)! } + } +} diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts new file mode 100644 index 00000000000..1c57d32752d --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -0,0 +1,217 @@ +// Commands on the panes of one terminal tab. Pane ids never change; a moved pane keeps its id, +// and so its data and scrollback, which are keyed by it. + +import { getNextTerminalOrdinal } from './terminal-tab-ordinal' +import { + equalizeLayout, + insertLeafBeside, + layoutContainsLeafId, + removeLayoutLeaf, + samePanesIgnoringRatios +} from './terminal-pane-tree' +import { + applied, + findTerminal, + leafIdsOf, + placeNewTab, + refuse, + setLeaf, + updateTab, + type Applied +} from './workspace-layout-command-steps' +import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import { + paneKeyOf, + type LayoutTerminalTab, + type WorkspaceLayoutModel +} from './workspace-layout-model' +import { retireTerminalPane, withWorkspace } from './workspace-layout-removal' + +type PaneCommand = { workspace: string; tabId: string } + +function paneTab(model: WorkspaceLayoutModel, command: PaneCommand): LayoutTerminalTab | null { + const tab = findTerminal(model.workspaces[command.workspace]!, command.tabId) + return tab?.panes.root ? tab : null +} + +export function splitPane( + model: WorkspaceLayoutModel, + command: CommandOf<'splitPane'>, + context: LayoutContext +): Applied { + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + if (!layoutContainsLeafId(tab.panes.root, command.leafId)) { + return refuse('pane_not_found') + } + const leafId = context.mintLeafId() + const side = command.direction === 'vertical' ? 'right' : 'bottom' + const root = insertLeafBeside(tab.panes.root!, command.leafId, leafId, side, command.ratio) + const next = updateTab(model, command.workspace, { ...tab, panes: { ...tab.panes, root } }) + if (!next.ok) { + return next + } + const paneKey = paneKeyOf(tab.entityId, leafId) + return applied(next.model, { leafId, paneKey }, { startPaneKeys: [paneKey] }) +} + +/** Closing a pane that is already gone succeeds; the last pane closes its tab. */ +export function closePane(model: WorkspaceLayoutModel, command: CommandOf<'closePane'>): Applied { + const tab = findTerminal(model.workspaces[command.workspace]!, command.tabId) + if (!tab || !layoutContainsLeafId(tab.panes.root, command.leafId)) { + return applied(model, { alreadyClosed: true }) + } + const ptyId = model.workspaces[command.workspace]!.leaves?.[command.leafId]?.ptyId + const retired = retireTerminalPane( + model, + { workspaceKey: command.workspace, tab }, + command.leafId + ) + const tabClosed = !retired.workspaces[command.workspace]!.tabs.some( + (entry) => entry.id === tab.id + ) + return applied(retired, { tabClosed }, { stopPtyIds: ptyId ? [ptyId] : [] }) +} + +export function movePane(model: WorkspaceLayoutModel, command: CommandOf<'movePane'>): Applied { + if (command.leafId === command.targetLeafId) { + return refuse('same_pane') + } + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + const { root } = tab.panes + if ( + !layoutContainsLeafId(root, command.leafId) || + !layoutContainsLeafId(root, command.targetLeafId) + ) { + return refuse('pane_not_found') + } + const without = removeLayoutLeaf(root, command.leafId)! + const moved = insertLeafBeside(without, command.targetLeafId, command.leafId, command.side) + return updateTab(model, command.workspace, { ...tab, panes: { ...tab.panes, root: moved } }) +} + +/** Divider sizes only: a different tree or different panes is refused (rule 11). */ +export function setPaneRatios( + model: WorkspaceLayoutModel, + command: CommandOf<'setPaneRatios'> +): Applied { + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + if (!samePanesIgnoringRatios(tab.panes.root!, command.root)) { + return refuse('pane_structure_changed') + } + return updateTab(model, command.workspace, { + ...tab, + panes: { ...tab.panes, root: command.root } + }) +} + +export function equalizePanes( + model: WorkspaceLayoutModel, + command: CommandOf<'equalizePanes'> +): Applied { + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + return updateTab(model, command.workspace, { + ...tab, + panes: { ...tab.panes, root: equalizeLayout(tab.panes.root!) } + }) +} + +export function renamePane(model: WorkspaceLayoutModel, command: CommandOf<'renamePane'>): Applied { + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + if (!layoutContainsLeafId(tab.panes.root, command.leafId)) { + return refuse('pane_not_found') + } + const workspace = model.workspaces[command.workspace]! + const leaf = { ...workspace.leaves?.[command.leafId] } + delete leaf.title + return applied( + withWorkspace( + model, + command.workspace, + setLeaf( + workspace, + command.leafId, + command.title === null ? leaf : { ...leaf, title: command.title } + ) + ) + ) +} + +/** Drag-out (#25380): only the tree moves; the pane's data and scrollback are keyed by its id. */ +export function movePaneToNewTab( + model: WorkspaceLayoutModel, + command: CommandOf<'movePaneToNewTab'>, + context: LayoutContext +): Applied { + const tab = paneTab(model, command) + if (!tab) { + return refuse('tab_not_found') + } + if (!layoutContainsLeafId(tab.panes.root, command.leafId)) { + return refuse('pane_not_found') + } + if (leafIdsOf(tab).length < 2) { + return refuse('last_pane') + } + const { leafId } = command + const workspace = model.workspaces[command.workspace]! + const id = context.mintId() + const terminals = workspace.tabs.flatMap((entry) => (entry.kind === 'terminal' ? [entry] : [])) + const ordinal = getNextTerminalOrdinal( + terminals.map((entry) => ({ defaultTitle: entry.terminal.defaultTitle, title: '' })) + ) + const moved: LayoutTerminalTab = { + id, + entityId: id, + createdAt: context.now(), + customTitle: null, + color: null, + kind: 'terminal', + terminal: { + defaultTitle: `Terminal ${ordinal}`, + ...(tab.terminal.startupCwd ? { startupCwd: tab.terminal.startupCwd } : {}), + ...(tab.terminal.shellOverride ? { shellOverride: tab.terminal.shellOverride } : {}) + }, + panes: { + root: { type: 'leaf', leafId }, + ...(tab.panes.chatLeafId === leafId ? { chatLeafId: leafId } : {}) + } + } + const source: LayoutTerminalTab = { + ...tab, + panes: { ...tab.panes, root: removeLayoutLeaf(tab.panes.root, leafId) } + } + if (source.panes.chatLeafId === leafId) { + delete source.panes.chatLeafId + } + const sourceGroup = workspace.groups.find((group) => group.tabOrder.includes(tab.id)) + const withSource = { + ...workspace, + tabs: workspace.tabs.map((entry) => (entry.id === tab.id ? source : entry)) + } + const placed = placeNewTab( + withSource, + moved, + { + groupId: command.groupId ?? sourceGroup?.id, + index: command.index, + afterTabId: command.index === undefined ? tab.id : undefined + }, + context + ) + return applied(withWorkspace(model, command.workspace, placed), { tabId: id }) +} diff --git a/src/shared/workspace-layout/workspace-layout-process-commands.ts b/src/shared/workspace-layout/workspace-layout-process-commands.ts new file mode 100644 index 00000000000..507471fef82 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-process-commands.ts @@ -0,0 +1,107 @@ +// Commands that start, restart, sleep or wake terminals. Their layout effect is small (bindings +// and sleeping records); the runtime runs the returned effects after replying. + +import { omitStoredFields } from './stored-record-fields' +import { + applied, + leafIdsOf, + locatePane, + refuse, + setLeaf, + type Applied +} from './workspace-layout-command-steps' +import type { CommandOf } from './workspace-layout-command-types' +import { + paneKeyOf, + type WorkspaceLayout, + type WorkspaceLayoutModel +} from './workspace-layout-model' +import { withWorkspace } from './workspace-layout-removal' + +/** Every pane of the workspace: its key (what commands name) and its leaf (what keys its data). */ +function workspacePanes(workspace: WorkspaceLayout): { paneKey: string; leafId: string }[] { + return workspace.tabs.flatMap((tab) => + tab.kind === 'terminal' + ? leafIdsOf(tab).map((leafId) => ({ paneKey: paneKeyOf(tab.entityId, leafId), leafId })) + : [] + ) +} + +/** Idempotent: starts or restores a pane that has no live terminal. */ +export function startPane(model: WorkspaceLayoutModel, command: CommandOf<'startPane'>): Applied { + const workspace = model.workspaces[command.workspace]! + const pane = locatePane(workspace, command.paneKey) + if (!pane) { + return refuse('pane_not_found') + } + if (workspace.leaves?.[pane.leafId]?.sleeping) { + return refuse('pane_sleeping') + } + return applied(model, {}, { startPaneKeys: [command.paneKey] }) +} + +/** Stops then starts in place: the pane keeps its id, its terminal binding is cleared until the new one starts. */ +export function restartPane( + model: WorkspaceLayoutModel, + command: CommandOf<'restartPane'> +): Applied { + const workspace = model.workspaces[command.workspace]! + const pane = locatePane(workspace, command.paneKey) + if (!pane) { + return refuse('pane_not_found') + } + const leaf = workspace.leaves?.[pane.leafId] ?? {} + return applied( + withWorkspace( + model, + command.workspace, + setLeaf(workspace, pane.leafId, omitStoredFields(leaf, ['ptyId', 'incarnationId'])) + ), + {}, + { stopPtyIds: leaf.ptyId ? [leaf.ptyId] : [], startPaneKeys: [command.paneKey] } + ) +} + +/** Stops the terminals and records how to resume each agent; panes and bindings stay. */ +export function sleep(model: WorkspaceLayoutModel, command: CommandOf<'sleep'>): Applied { + let workspace = model.workspaces[command.workspace]! + const panes = workspacePanes(workspace) + const targets = command.paneKeys + ? panes.filter((pane) => command.paneKeys!.includes(pane.paneKey)) + : panes + const stopPtyIds = targets.flatMap((pane) => workspace.leaves?.[pane.leafId]?.ptyId ?? []) + for (const { paneKey, leafId } of targets) { + const record = command.records.find((entry) => entry.paneKey === paneKey) + if (record) { + const sleeping = omitStoredFields(record, ['paneKey', 'tabId', 'worktreeId']) + workspace = setLeaf(workspace, leafId, { ...workspace.leaves?.[leafId], sleeping }) + } + } + return applied( + withWorkspace(model, command.workspace, workspace), + { slept: targets.map((pane) => pane.paneKey) }, + { stopPtyIds } + ) +} + +export function wake(model: WorkspaceLayoutModel, command: CommandOf<'wake'>): Applied { + let workspace = model.workspaces[command.workspace]! + const woken = workspacePanes(workspace).filter( + (pane) => + (!command.paneKeys || command.paneKeys.includes(pane.paneKey)) && + workspace.leaves?.[pane.leafId]?.sleeping + ) + for (const { leafId } of woken) { + workspace = setLeaf( + workspace, + leafId, + omitStoredFields(workspace.leaves![leafId]!, ['sleeping']) + ) + } + const paneKeys = woken.map((pane) => pane.paneKey) + return applied( + withWorkspace(model, command.workspace, workspace), + { woken: paneKeys }, + { startPaneKeys: paneKeys } + ) +} diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts new file mode 100644 index 00000000000..2d96a35a1d1 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -0,0 +1,240 @@ +import { filterRecord, omitStoredFields } from './stored-record-fields' +import { canReplacePreviewContentType, partitionPinnedTabOrder } from './tab-order' +import { getNextTerminalOrdinal } from './terminal-tab-ordinal' +import { layoutContainsLeafId } from './terminal-pane-tree' +import { + applied, + boundPtyIds, + workspaceOrEmpty, + findTab, + findTerminal, + leafIdsOf, + placeNewTab, + refuse, + updateTab, + type Applied +} from './workspace-layout-command-steps' +import type { + CommandOf, + LayoutCommandResult, + LayoutContext +} from './workspace-layout-command-types' +import { + paneKeyOf, + type LayoutContentTab, + type LayoutTerminalTab, + type WorkspaceLayout, + type WorkspaceLayoutModel +} from './workspace-layout-model' +import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal' + +type RefusedClose = NonNullable[number] + +export function createTerminalTab( + model: WorkspaceLayoutModel, + command: CommandOf<'createTerminalTab'>, + context: LayoutContext +): Applied { + const workspace = workspaceOrEmpty(model, command.workspace) + const terminals = workspace.tabs.flatMap((tab) => (tab.kind === 'terminal' ? [tab] : [])) + const ordinal = getNextTerminalOrdinal( + terminals.map((tab) => ({ defaultTitle: tab.terminal.defaultTitle, title: '' })) + ) + const id = context.mintId() + const leafId = context.mintLeafId() + const tab: LayoutTerminalTab = { + id, + entityId: id, + createdAt: context.now(), + customTitle: command.title ?? null, + color: command.color ?? null, + ...(command.viewMode ? { viewMode: command.viewMode } : {}), + kind: 'terminal', + terminal: { defaultTitle: `Terminal ${ordinal}`, ...command.creation }, + panes: { root: { type: 'leaf', leafId } } + } + const placed = placeNewTab(workspace, tab, command, context) + const paneKey = paneKeyOf(id, leafId) + return applied( + withWorkspace(model, command.workspace, placed), + { tabId: id, leafId, paneKey }, + { startPaneKeys: [paneKey] } + ) +} + +/** Removes a tab and its content record; a terminal tab's pane data goes with it. */ +export function closeTab( + model: WorkspaceLayoutModel, + key: string, + tabId: string +): WorkspaceLayoutModel { + const workspace = model.workspaces[key]! + const tab = findTab(workspace, tabId)! + let next: WorkspaceLayout = removeTabFromWorkspace(workspace, tabId) + if (tab.kind === 'terminal' && next.leaves) { + const leafIds = leafIdsOf(tab) + next = { ...next, leaves: filterRecord(next.leaves, (leafId) => !leafIds.includes(leafId)) } + } + if ( + tab.kind === 'editor' && + next.editorFiles && + !next.tabs.some((entry) => entry.entityId === tab.entityId) + ) { + next = { + ...next, + editorFiles: next.editorFiles.filter((file) => file.filePath !== tab.entityId) + } + } + if (tab.kind === 'browser' && next.browserTabs) { + next = { ...next, browserTabs: next.browserTabs.filter((entry) => entry.id !== tab.entityId) } + } + return withWorkspace(model, key, next) +} + +export function closeTabs(model: WorkspaceLayoutModel, command: CommandOf<'closeTabs'>): Applied { + let next = model + const closed: string[] = [] + const refused: RefusedClose[] = [] + const stopPtyIds: string[] = [] + for (const tabId of command.tabIds) { + const tab = findTab(next.workspaces[command.workspace]!, tabId) + if (!tab) { + closed.push(tabId) + continue + } + if (!command.force && command.refusePinned && tab.isPinned) { + refused.push({ tabId, code: 'tab_pinned' }) + continue + } + if (!command.force && command.dirtyTabIds?.includes(tabId)) { + refused.push({ tabId, code: 'editor_tab_has_unsaved_draft' }) + continue + } + stopPtyIds.push(...boundPtyIds(next.workspaces[command.workspace]!, tab)) + next = closeTab(next, command.workspace, tabId) + closed.push(tabId) + } + return applied(next, { closed, refused }, { stopPtyIds }) +} + +export function renameTab(model: WorkspaceLayoutModel, command: CommandOf<'renameTab'>): Applied { + const tab = findTab(model.workspaces[command.workspace]!, command.tabId) + if (!tab) { + return refuse('tab_not_found') + } + if (command.kind === 'aiVault') { + return updateTab(model, command.workspace, { ...tab, aiVaultTitle: command.title }) + } + return updateTab( + model, + command.workspace, + command.kind === 'custom' + ? { ...tab, customTitle: command.title } + : { ...tab, generatedTitle: command.title } + ) +} + +export function setTabProps( + model: WorkspaceLayoutModel, + command: CommandOf<'setTabProps'> +): Applied { + const workspace = model.workspaces[command.workspace]! + const tab = findTab(workspace, command.tabId) + if (!tab) { + return refuse('tab_not_found') + } + const next = { + ...tab, + ...(command.color !== undefined ? { color: command.color } : {}), + ...(command.isPinned !== undefined ? { isPinned: command.isPinned } : {}), + ...(command.viewMode !== undefined ? { viewMode: command.viewMode } : {}) + } + let updated = { + ...workspace, + tabs: workspace.tabs.map((entry) => (entry.id === tab.id ? next : entry)) + } + if (command.isPinned !== undefined && command.isPinned !== (tab.isPinned === true)) { + // Pinning moves the tab to the edge of the pinned run, as the tab bar does. + updated = { + ...updated, + groups: updated.groups.map((group) => + group.tabOrder.includes(tab.id) + ? { ...group, tabOrder: partitionPinnedTabOrder(group.tabOrder, updated.tabs, tab.id) } + : group + ) + } + } + return applied(withWorkspace(model, command.workspace, updated)) +} + +export function setChatPane( + model: WorkspaceLayoutModel, + command: CommandOf<'setChatPane'> +): Applied { + const tab = findTerminal(model.workspaces[command.workspace]!, command.tabId) + if (!tab) { + return refuse('tab_not_found') + } + if (command.leafId !== null && !layoutContainsLeafId(tab.panes.root, command.leafId)) { + return refuse('pane_not_found') + } + const panes = omitStoredFields(tab.panes, ['chatLeafId']) + return updateTab(model, command.workspace, { + ...tab, + panes: command.leafId === null ? panes : { ...panes, chatLeafId: command.leafId } + }) +} + +export function promotePreviewTab( + model: WorkspaceLayoutModel, + command: CommandOf<'promotePreviewTab'> +): Applied { + const tab = findTab(model.workspaces[command.workspace]!, command.tabId) + if (!tab) { + return refuse('tab_not_found') + } + const promoted = { ...tab } + delete promoted.isPreview + return updateTab(model, command.workspace, promoted) +} + +/** + * A new tab replaces the group's preview tab of a compatible kind, as a single click does today, + * unless closing it is refused like any close (an unsaved draft): then both stay. + */ +export function placeContentTab( + model: WorkspaceLayoutModel, + key: string, + tab: LayoutContentTab, + placement: { groupId?: string; dirtyTabIds?: string[] }, + context: LayoutContext +): { model: WorkspaceLayoutModel; refused: RefusedClose[] } { + let workspace = workspaceOrEmpty(model, key) + const group = + workspace.groups.find((entry) => entry.id === placement.groupId) ?? workspace.groups[0] + const preview = tab.isPreview + ? workspace.tabs.find( + (entry) => + entry.isPreview && + group?.tabOrder.includes(entry.id) && + canReplacePreviewContentType(tab.kind, entry.kind) + ) + : undefined + let refused: RefusedClose[] = [] + if (preview) { + const closing = closeTabs(withWorkspace(model, key, workspace), { + type: 'closeTabs', + workspace: key, + tabIds: [preview.id], + dirtyTabIds: placement.dirtyTabIds + }) + if (closing.ok) { + workspace = closing.model.workspaces[key]! + refused = closing.result.refused ?? [] + } + } + return { + model: withWorkspace(model, key, placeNewTab(workspace, tab, { groupId: group?.id }, context)), + refused + } +} diff --git a/src/shared/workspace-layout/workspace-layout-transitions.test.ts b/src/shared/workspace-layout/workspace-layout-transitions.test.ts new file mode 100644 index 00000000000..7c5c1f9201f --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-transitions.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { LayoutCommand, LayoutContext } from './workspace-layout-command-types' +import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' +import { build, terminalTab, testContext, WS } from './workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { checkWorkspaceLayoutModelRules } from './workspace-layout-model-rules' +import { applyLayoutTransition, type LayoutTransition } from './workspace-layout-transitions' + +/** One terminal tab split in two, the first pane running `pty-1`. */ +function setup() { + const context = testContext() + const created = build(context, [{ type: 'createTerminalTab', workspace: WS }]) + const { tabId, leafId, paneKey } = created.results[0]! + const split = build( + context, + [{ type: 'splitPane', workspace: WS, tabId: tabId!, leafId: leafId!, direction: 'vertical' }], + created.model + ) + const started = applyLayoutTransition(split.model, { + type: 'processStarted', + workspace: WS, + paneKey: paneKey!, + ptyId: 'pty-1', + incarnationId: 'inc-1' + }) + if (!started.ok) { + throw new Error(started.code) + } + return { + context, + model: started.model, + tabId: tabId!, + leafId: leafId!, + leaf2: split.results[0]!.leafId! + } +} + +/** Each pane of the tab and the terminal it shows. */ +const leavesOf = (model: WorkspaceLayoutModel, tabId: string) => { + if (!model.workspaces[WS]!.tabs.some((tab) => tab.id === tabId)) { + return 'closed' + } + const leaves = model.workspaces[WS]!.leaves ?? {} + return Object.fromEntries( + collectLayoutLeafIdsInOrder(terminalTab(model, tabId).panes.root).flatMap((leafId) => + leaves[leafId]?.ptyId ? [[leafId, leaves[leafId].ptyId]] : [] + ) + ) +} + +const incarnationOf = (model: WorkspaceLayoutModel, leafId: string) => + model.workspaces[WS]?.leaves?.[leafId]?.incarnationId + +describe('layout transitions', () => { + it('binds a started terminal once and refuses binding it to a second pane', () => { + const { model, tabId, leafId, leaf2 } = setup() + expect(leavesOf(model, tabId)).toEqual({ [leafId]: 'pty-1' }) + expect(incarnationOf(model, leafId)).toBe('inc-1') + const twice = applyLayoutTransition(model, { + type: 'processStarted', + workspace: WS, + paneKey: `${tabId}:${leaf2}`, + ptyId: 'pty-1', + incarnationId: 'inc-1' + }) + expect(twice).toEqual({ ok: false, code: 'pane_already_bound' }) + const missing = applyLayoutTransition(model, { + type: 'processStarted', + workspace: WS, + paneKey: `${tabId}:nope`, + ptyId: 'pty-9' + }) + expect(missing).toEqual({ ok: false, code: 'pane_not_found' }) + }) + + it('retires an exited pane only while it still shows that terminal and incarnation', () => { + const { model, tabId, leafId } = setup() + const surface = { worktreeId: WS, terminalTabId: tabId, leafId, ptyId: 'pty-1' } + const stale: LayoutTransition = { + type: 'processExited', + surface: { ...surface, incarnationId: 'inc-old' } + } + const other: LayoutTransition = { + type: 'processExited', + surface: { ...surface, ptyId: 'pty-other' } + } + for (const transition of [stale, other]) { + const result = applyLayoutTransition(model, transition) + expect(result.ok && leavesOf(result.model, tabId)).toEqual({ [leafId]: 'pty-1' }) + } + const exited = applyLayoutTransition(model, { + type: 'processExited', + surface: { ...surface, incarnationId: 'inc-1' } + }) + expect(exited.ok && incarnationOf(exited.model, leafId)).toBeUndefined() + expect(exited.ok && terminalTab(exited.model, tabId).panes!.root).toEqual({ + type: 'leaf', + leafId: expect.any(String) + }) + }) + + it('unbinds panes whose SSH lease ended without closing them', () => { + const { model, tabId } = setup() + const result = applyLayoutTransition(model, { type: 'sshLeaseTerminated', ptyIds: ['pty-1'] }) + expect(result.ok && leavesOf(result.model, tabId)).toEqual({}) + }) + + it('removes and renames a workspace with the records that name it', () => { + const { model, context } = setup() + const renamed = applyLayoutTransition(model, { + type: 'identityRenamed', + from: WS, + to: 'repo-1::/renamed' + }) + expect(renamed.ok && Object.keys(renamed.model.workspaces)).toEqual(['repo-1::/renamed']) + expect(renamed.ok && renamed.model.workspaces['repo-1::/renamed']!.worktreeId).toBe( + 'repo-1::/renamed' + ) + // Closed-tab records live in their workspace, so they follow a rename and go with a removal. + const withClosed = { + ...model, + workspaces: { + [WS]: { + ...model.workspaces[WS]!, + closedTerminalTabs: { closed: { closedAt: 1, reason: 'user' as const } } + } + } + } + const renamedWithClosed = applyLayoutTransition(withClosed, { + type: 'identityRenamed', + from: WS, + to: 'repo-1::/renamed' + }) + expect( + renamedWithClosed.ok && + renamedWithClosed.model.workspaces['repo-1::/renamed']!.closedTerminalTabs + ).toEqual({ closed: { closedAt: 1, reason: 'user' } }) + const occupied = build( + context, + [{ type: 'createTerminalTab', workspace: 'repo-1::/other' }], + model + ).model + expect( + applyLayoutTransition(occupied, { type: 'identityRenamed', from: WS, to: 'repo-1::/other' }) + ).toEqual({ ok: false, code: 'workspace_exists' }) + const removed = applyLayoutTransition(model, { type: 'ownerRemoved', workspaces: [WS] }) + expect(removed.ok && removed.model.workspaces).toEqual({}) + expect(removed.ok && removed.model.legacy.terminalRowOwners).toEqual({}) + }) +}) + +/** Two clients' commands for the same tab or pane: both arrival orders end in a valid layout. */ +describe('concurrent commands, both orders', () => { + type Step = { command: LayoutCommand } | { transition: LayoutTransition } + type Run = { model: WorkspaceLayoutModel; codes: string[] } + function run(model: WorkspaceLayoutModel, steps: Step[], context: LayoutContext): Run { + let next = model + const codes: string[] = [] + for (const step of steps) { + const result = + 'command' in step + ? applyLayoutCommand(next, step.command, context) + : applyLayoutTransition(next, step.transition) + codes.push(result.ok ? 'ok' : result.code) + if (result.ok) { + expect(checkWorkspaceLayoutModelRules([result.model], [next])).toEqual([]) + next = result.model + } + } + return { model: next, codes } + } + + it('close tab vs split pane', () => { + const { model, tabId, leafId, context } = setup() + const close: Step = { command: { type: 'closeTabs', workspace: WS, tabIds: [tabId] } } + const split: Step = { + command: { type: 'splitPane', workspace: WS, tabId, leafId, direction: 'horizontal' } + } + expect(run(model, [close, split], context).codes).toEqual(['ok', 'tab_not_found']) + const splitFirst = run(model, [split, close], context) + expect(splitFirst.codes).toEqual(['ok', 'ok']) + expect(leavesOf(splitFirst.model, tabId)).toBe('closed') + }) + + it('close pane vs drag the pane out', () => { + const { model, tabId, leafId, context } = setup() + const close: Step = { command: { type: 'closePane', workspace: WS, tabId, leafId } } + const drag: Step = { command: { type: 'movePaneToNewTab', workspace: WS, tabId, leafId } } + expect(run(model, [close, drag], context).codes).toEqual(['ok', 'pane_not_found']) + const dragFirst = run(model, [drag, close], context) + expect(dragFirst.codes).toEqual(['ok', 'ok']) + // The close found nothing in the old tab: the dragged pane keeps running in its new tab. + expect( + dragFirst.model.workspaces[WS]!.tabs.some( + (tab) => tab.kind === 'terminal' && leavesOf(dragFirst.model, tab.id)[leafId] === 'pty-1' + ) + ).toBe(true) + }) + + it('process exit vs close tab', () => { + const { model, tabId, leafId, context } = setup() + const exit: Step = { + transition: { + type: 'processExited', + surface: { worktreeId: WS, terminalTabId: tabId, leafId, ptyId: 'pty-1' } + } + } + const close: Step = { command: { type: 'closeTabs', workspace: WS, tabIds: [tabId] } } + for (const order of [ + [exit, close], + [close, exit] + ]) { + const result = run(model, order, context) + expect(result.codes).toEqual(['ok', 'ok']) + expect(leavesOf(result.model, tabId)).toBe('closed') + } + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-transitions.ts b/src/shared/workspace-layout/workspace-layout-transitions.ts new file mode 100644 index 00000000000..3634a40ab23 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-transitions.ts @@ -0,0 +1,113 @@ +// Runtime-internal transitions (design 2.3): facts the runtime sees about processes, hosts and +// owners, applied through the same module as commands. No client sends these. Transitions whose +// producer arrives later (orphan adoption, legacy worker recovery, client-hosted pages, agent +// launch verdicts, sleep capture, partition moves) ship with that producer's PR. + +import { isSameTerminal } from './terminal-owner-invariants' +import { omitStoredFields } from './stored-record-fields' +import { + applied, + locatePane, + refuse, + setLeaf, + type Applied +} from './workspace-layout-command-steps' +import { updateLegacyPersistence } from './workspace-layout-legacy-persistence' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { retireExitedSurface, withWorkspace, type ExitedSurface } from './workspace-layout-removal' +import { removeWorkspaces, renameWorkspace } from './workspace-layout-owner-transitions' + +export type LayoutTransition = + | { + type: 'processStarted' + workspace: string + paneKey: string + ptyId: string + incarnationId?: string + } + | { type: 'processExited'; surface: ExitedSurface } + | { type: 'sshLeaseTerminated'; ptyIds: string[] } + | { type: 'ownerRemoved'; workspaces: string[] } + | { type: 'identityRenamed'; from: string; to: string } + +function boundElsewhere( + model: WorkspaceLayoutModel, + leafId: string, + binding: { ptyId: string; incarnationId?: string } +): boolean { + return Object.values(model.workspaces).some((workspace) => + Object.entries(workspace.leaves ?? {}).some( + ([otherId, leaf]) => + otherId !== leafId && + isSameTerminal({ ptyId: leaf.ptyId, incarnationId: leaf.incarnationId }, binding) + ) + ) +} + +/** Binds the started terminal to the pane that was starting; one terminal never shows in two panes. */ +function processStarted( + model: WorkspaceLayoutModel, + transition: Extract +): Applied { + const workspace = model.workspaces[transition.workspace] + const pane = workspace && locatePane(workspace, transition.paneKey) + if (!pane) { + return refuse('pane_not_found') + } + if (boundElsewhere(model, pane.leafId, transition)) { + return refuse('pane_already_bound') + } + const leaf = { ...workspace.leaves?.[pane.leafId], ptyId: transition.ptyId } + if (transition.incarnationId !== undefined) { + leaf.incarnationId = transition.incarnationId + } + return applied(withWorkspace(model, transition.workspace, setLeaf(workspace, pane.leafId, leaf))) +} + +/** Loss of an SSH lease unbinds its panes; it is not evidence the remote process exited. */ +function sshLeaseTerminated(model: WorkspaceLayoutModel, ptyIds: readonly string[]): Applied { + let next = model + for (const [key, workspace] of Object.entries(model.workspaces)) { + let updated = workspace + for (const [leafId, leaf] of Object.entries(workspace.leaves ?? {})) { + if (leaf.ptyId !== undefined && ptyIds.includes(leaf.ptyId)) { + updated = setLeaf(updated, leafId, omitStoredFields(leaf, ['ptyId'])) + } + } + next = updated === workspace ? next : withWorkspace(next, key, updated) + } + return applied(next) +} + +function applyTransition( + model: WorkspaceLayoutModel, + transition: Exclude +): Applied { + switch (transition.type) { + case 'processStarted': + return processStarted(model, transition) + case 'sshLeaseTerminated': + return sshLeaseTerminated(model, transition.ptyIds) + case 'ownerRemoved': + return applied(removeWorkspaces(model, transition.workspaces)) + case 'identityRenamed': { + const renamedModel = renameWorkspace(model, transition.from, transition.to) + return renamedModel ? applied(renamedModel) : refuse('workspace_exists') + } + } +} + +export function applyLayoutTransition( + model: WorkspaceLayoutModel, + transition: LayoutTransition +): Applied { + if (transition.type === 'processExited') { + const retired = retireExitedSurface(model, transition.surface) + // An accepted exit advances the revision even when its pane was already gone, as today. + return applied( + retired ? updateLegacyPersistence(model, retired, [transition.surface.worktreeId]) : model + ) + } + const result = applyTransition(model, transition) + return result.ok ? { ...result, model: updateLegacyPersistence(model, result.model) } : result +}