mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
Add inert layout commands and runtime transitions (#26749)
* feat(layout): add the inert workspace layout model, Loader and Serializer One model of a workspace's tab layout (all tab kinds, groups, split trees, pane bindings, titles/colors/pin, sleeping records) in src/shared/workspace-layout, with a Loader from today's stored session partition and a Serializer back to it. The structural rules check and the pure pane/group tree helpers move into the same folder so the module can run them; their callers import the new paths. Nothing in the app calls the new module yet. * feat(layout): add inert layout commands and runtime transitions applyLayoutCommand applies every layout command a client can send (create, split, close, move, reorder, rename, recolor, pin, open editor/browser/agent tabs, start/restart, sleep/wake) to the layout model, and applyLayoutTransition applies the facts the runtime sees (process started or exited, SSH lease ended, orphan adopted, owner removed or renamed, agent launch verdict, sleeping records). Pure tab-order, group-move and pane-split helpers move into the shared module so the renderer, today's headless host and the new commands share one implementation. Nothing in the app calls the new commands. * fix(layout): avoid toSorted in shared code and drop an unused helper The mobile bundle includes src/shared and Hermes lacks Array#toSorted. * refactor(layout): name tree comparisons for what they ignore * fix(layout): one stored home per layout fact; Loader reports every rule it applies The row's terminal id, preview flag, worktree id and execution host are now derived on save instead of kept a second time. Groups never stay empty. The Loader reports row/tab-bar disagreements, re-minted ids, worktree id and host disagreements, keeps a row stored in two workspaces where the tab bar names it, and gives a pane id repeated in a second tab a new unbound pane. The rules check runs on what the Serializer writes. Fixtures become test fixtures, and a test lists every model field with the one fact it holds. * fix(layout): commands keep one home per fact and refuse instead of throwing createGroup is gone (a group only exists while it holds a tab); a lone tab split beside another group moves its group leaf and keeps the id; moveTab refuses when the move cannot be built; renaming onto an existing workspace is refused; worktree and execution host ids come from the workspace. * fix(layout): derive each tab's host from its owner and keep one preview fact per editor tab An editor tab names its file's owner (an SSH target or paired runtime), every other tab its partition. Where the stored tab and file preview flags disagree the tab loads permanent, so a draft can never be replaced. A row terminal that differs from the one derived from its focused pane is now reported. * fix(layout): one binding kind, one title, one host rule; records follow a reassigned pane A legacy row with no pane layout now loads as a one-pane tab bound to its terminal (reusing the leaf its records name), so the null-panes branches and legacyPtyId are gone. A pane given a new id because two tabs shared it takes its sleeping and incarnation records, scrollback and focus with it. A terminal's live title is one fact (the tab-bar label wins on load). Every tab-bar entry names its execution host by one rule. The fact-location test is now keyed by on-disk field and checked by mutating every source. * fix(layout): commands drop the legacy binding branches; rename carries closed-tab records Every terminal tab has panes now, so commands lose their null-panes branches and a close always releases the tab's terminals. The pane-record rekey is shared with the Loader. Renaming a workspace renames its closed-tab records' worktree id, so a later removal or partition move still selects them. * fix(layout): one change report by construction; records without copies The Loader now picks values by fixed precedence only and reports one field-by-field diff of the stored session against what the next save writes, over the disk-field tables (now in workspace-layout-disk-fields.ts, with session fields derived from SESSION_FIELD_OWNERS). Sleeping and closed-tab records live in their workspace without the pane key, tab and worktree id their key and workspace already give; the Serializer fills them. Tab-bar entries are written in the one tab order. * fix(layout): drop transitions with no producer yet; group moves hold no selection orphanAdopted, legacyWorkerRecovered, clientHostedPageAnnounced, agentLaunchVerdict, sleepingRecordsCaptured and moveWorkspaceToPartition ship with the PRs that produce them. The shared group moves take and return LayoutGroup only; the headless host keeps its selected tab beside them. Commands keep sleeping and closed-tab records in their workspace, so rename and removal need no record patching. * fix(layout): name the change report's key-list parameter's type * refactor(layout): key pane data by leaf, check the model itself, one legacy record - Each pane's data (terminal, incarnation, title, sleeping agent) is one leaf record keyed by leaf id, and scrollback beside it is keyed the same way. Pane keys are derived only when saving. The Loader gives a repeated leaf id a new id before attaching records, so rekeyPaneRecords is gone; records naming no pane are carried as stored. - The rules check runs on the model directly (ungrouped tab, empty group, group tree that disagrees with the groups, pane data without a pane); the disk-format rules stay separate. - The row-ownership marker and topology revision move into one legacy record outside WorkspaceLayout, updated by updateLegacyPersistence after each apply. - Fixtures include a terminal tab whose tab-bar id and terminal id differ. * refactor(layout): commands on leaf-keyed pane data and the one legacy update - Commands and transitions read and write each pane's one leaf record; drag-out moves only the tree, so the pane's data and scrollback follow by structure. No command advances the topology revision itself: applyLayoutCommand and applyLayoutTransition run updateLegacyPersistence after each apply, which also marks a workspace as owning its rows once it holds a terminal. - Preview replacement closes the preview through closeTabs, so an unsaved draft refuses it as any close does. - Tests: create then close then save with and without a reload, commands on a tab whose tab-bar and terminal ids differ, drag-out scrollback, the preview refusal. * fix(layout): join the group ids the tree-mismatch detail names * fix(layout): leave the stored revision map as is when no repo advances
This commit is contained in:
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -227,19 +227,6 @@ export function dedupeEditorTabsWithinGroups(tabs: Tab[]): {
|
||||
return { tabs: dedupedTabs, tabIdAliasesByGroup }
|
||||
}
|
||||
|
||||
export function dedupeTabOrder(tabIds: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
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
|
||||
|
||||
@@ -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<string, Tab[]>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number>()
|
||||
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 {
|
||||
|
||||
@@ -44,3 +44,13 @@ export function childRecord<T>(
|
||||
record[key] ??= {}
|
||||
return record[key]
|
||||
}
|
||||
|
||||
/** The entries `keep` accepts; undefined stays undefined. */
|
||||
export function filterRecord<T>(
|
||||
record: Record<string, T> | undefined,
|
||||
keep: (key: string, value: T) => boolean
|
||||
): Record<string, T> | undefined {
|
||||
return (
|
||||
record && Object.fromEntries(Object.entries(record).filter(([key, value]) => keep(key, value)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-20
@@ -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', () => {
|
||||
+18
-28
@@ -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))
|
||||
+18
-4
@@ -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<Tab, 'id' | 'isPinned'>
|
||||
|
||||
export function dedupeTabOrder(tabIds: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TerminalTab, 'defaultTitle' | 'title'>[]
|
||||
): number {
|
||||
const usedOrdinals = new Set<number>()
|
||||
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
|
||||
}
|
||||
@@ -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=<n> FUZZ_ITERATIONS=1 pnpm test <this file>.
|
||||
|
||||
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<string, Map<string, Map<string, string | undefined>>>
|
||||
|
||||
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<typeof applyLayoutCommand> {
|
||||
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<Harness['panes']>[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 = <T>(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<string>()
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceLayoutModel>
|
||||
|
||||
export const refuse = (code: LayoutRefusalCode): Applied => ({ ok: false, code })
|
||||
|
||||
export function applied(
|
||||
model: WorkspaceLayoutModel,
|
||||
result: LayoutCommandResult = {},
|
||||
effects: Partial<LayoutEffects> = {}
|
||||
): 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))
|
||||
}
|
||||
}
|
||||
@@ -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<LayoutTerminalCreation>
|
||||
})
|
||||
| (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<TabContentType, 'terminal' | 'browser' | 'agent-session' | 'simulator'>
|
||||
/** 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<T extends LayoutCommand['type']> = Extract<LayoutCommand, { type: T }>
|
||||
|
||||
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<Model> =
|
||||
| { ok: true; model: Model; result: LayoutCommandResult; effects: LayoutEffects }
|
||||
| { ok: false; code: LayoutRefusalCode }
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutCommand['type']>([
|
||||
'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)
|
||||
}
|
||||
}
|
||||
@@ -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<LayoutContentTab, 'kind' | 'entityId'> & Partial<LayoutContentTab>
|
||||
): 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
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
)
|
||||
}
|
||||
@@ -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<T> = Record<string, T> | 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 = <T>(record: KeyedRecord<T>): KeyedRecord<T> =>
|
||||
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)! }
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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<LayoutCommandResult['refused']>[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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutTransition, { type: 'processStarted' }>
|
||||
): 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<LayoutTransition, { type: 'processExited' }>
|
||||
): 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
|
||||
}
|
||||
Reference in New Issue
Block a user