import invariant from "tiny-invariant"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; export interface SplitPane { id: string; tabIds: string[]; focusedTabId: string | null; } export interface SplitGroup { id: string; direction: "horizontal" | "vertical"; children: SplitNode[]; sizes: number[]; } export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; group: SplitGroup }; export interface WorkspaceLayout { root: SplitNode; focusedPaneId: string; } interface SplitPaneInternal extends SplitPane { tabs: WorkspaceTab[]; } interface SplitGroupInternal extends Omit { children: SplitNodeInternal[]; } type SplitNodeInternal = | { kind: "pane"; pane: SplitPaneInternal } | { kind: "group"; group: SplitGroupInternal }; interface NormalizeSizesInput { sizes: number[]; count: number; } interface ReorderTabsForPaneInput { pane: SplitPaneInternal; tabIds: string[]; } interface UpdateTabInTreeInput { tabId: string; target: WorkspaceTabTarget; } interface UpdateGroupSizesInTreeInput { groupId: string; sizes: number[]; } interface UpdatePaneInTreeInput { paneId: string; updater: (pane: SplitPaneInternal) => SplitPaneInternal; } interface InsertChildIntoGroupInput { index: number; node: SplitNodeInternal; sizes: number[]; } interface DetachTabFromTreeInput { tabId: string; preserveEmptyPaneId?: string | null; } interface DetachTabFromTreeResult { root: SplitNodeInternal; tab: WorkspaceTab | null; sourcePaneId: string | null; } interface InsertTabIntoPaneInput { paneId: string; tab: WorkspaceTab; focusTabId?: string | null; } interface InsertSplitInternalInput { root: SplitNodeInternal; targetPaneId: string; tabId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: "pane" | "group") => string; } interface InsertSplitInternalResult { root: SplitNodeInternal; newPaneId: string; } interface OpenTabInLayoutInput { layout: WorkspaceLayout; target: WorkspaceTabTarget; now: number; } interface OpenTabInLayoutResult { layout: WorkspaceLayout; tabId: string; } interface RetargetTabInLayoutInput { layout: WorkspaceLayout; tabId: string; target: WorkspaceTabTarget; } interface RetargetTabInLayoutResult { layout: WorkspaceLayout; tabId: string; } interface ReorderFocusedPaneTabsInLayoutInput { layout: WorkspaceLayout; tabIds: string[]; } interface CloseTabInLayoutInput { layout: WorkspaceLayout; tabId: string; } interface SplitPaneInLayoutInput { layout: WorkspaceLayout; tabId: string; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: "pane" | "group") => string; maxTreeDepth: number; } interface SplitPaneInLayoutResult { layout: WorkspaceLayout; paneId: string; } interface SplitPaneEmptyInLayoutInput { layout: WorkspaceLayout; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: "pane" | "group") => string; maxTreeDepth: number; } interface MoveTabToPaneInLayoutInput { layout: WorkspaceLayout; tabId: string; toPaneId: string; } interface FocusTabInLayoutInput { layout: WorkspaceLayout; tabId: string; } interface FocusPaneInLayoutInput { layout: WorkspaceLayout; paneId: string; } interface ResizeSplitInLayoutInput { layout: WorkspaceLayout; groupId: string; sizes: number[]; } interface ReorderPaneTabsInLayoutInput { layout: WorkspaceLayout; paneId: string; tabIds: string[]; } const DEFAULT_PANE_ID = "main"; const MIN_SPLIT_SIZE = 0.1; function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeTabIds(list: unknown): string[] { if (!Array.isArray(list)) { return []; } const next: string[] = []; const seen = new Set(); for (const value of list) { const tabId = trimNonEmpty(typeof value === "string" ? value : null); if (!tabId || seen.has(tabId)) { continue; } seen.add(tabId); next.push(tabId); } return next; } function generateNodeId(prefix: "pane" | "group"): string { const randomValue = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; return `${prefix}_${randomValue}`; } function createPaneNode(input: { id: string; tabs?: WorkspaceTab[]; focusedTabId?: string | null; }): SplitNodeInternal { const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []); const tabIds = normalizedTabs.map((tab) => tab.tabId); const focusedTabId = tabIds.includes(input.focusedTabId ?? "") ? (input.focusedTabId ?? null) : (tabIds[tabIds.length - 1] ?? null); return { kind: "pane", pane: { id: input.id, tabs: normalizedTabs, tabIds, focusedTabId, }, }; } function createGroupNode(input: { id: string; direction: "horizontal" | "vertical"; children: SplitNodeInternal[]; sizes?: number[]; }): SplitNodeInternal { return { kind: "group", group: { id: input.id, direction: input.direction, children: input.children, sizes: normalizeSizes({ sizes: input.sizes ?? input.children.map(() => 1 / Math.max(input.children.length, 1)), count: input.children.length, }), }, }; } function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { if (!value || typeof value !== "object") { return null; } const tab = value as WorkspaceTab; const target = normalizeWorkspaceTabTarget(tab.target); const tabId = trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null); if (!target || !tabId) { return null; } return { tabId, target, createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), }; } function normalizeWorkspaceTabs(input: unknown): WorkspaceTab[] { if (!Array.isArray(input)) { return []; } const next: WorkspaceTab[] = []; const seen = new Set(); for (const value of input) { const tab = normalizeWorkspaceTab(value); if (!tab || seen.has(tab.tabId)) { continue; } seen.add(tab.tabId); next.push(tab); } return next; } function normalizeSizes(input: NormalizeSizesInput): number[] { if (input.count <= 0) { return []; } const raw = input.sizes.slice(0, input.count); while (raw.length < input.count) { raw.push(1); } const sanitized = raw.map((value) => (Number.isFinite(value) && value > 0 ? value : 1)); const total = sanitized.reduce((sum, value) => sum + value, 0); if (total <= 0) { return Array.from({ length: input.count }, () => 1 / input.count); } return sanitized.map((value) => value / total); } export function clampNormalizedSizes(sizes: number[]): number[] { if (sizes.length === 0) { return []; } const normalized = normalizeSizes({ sizes, count: sizes.length }); if (sizes.length === 1) { return [1]; } if (sizes.length * MIN_SPLIT_SIZE > 1) { return Array.from({ length: sizes.length }, () => 1 / sizes.length); } const nextSizes = Array.from({ length: sizes.length }, () => 0); const unlocked = new Set(normalized.map((_, index) => index)); let remainingTotal = 1; while (unlocked.size > 0) { let unlockedWeight = 0; for (const index of unlocked) { unlockedWeight += normalized[index] ?? 0; } if (unlockedWeight <= 0) { const evenShare = remainingTotal / unlocked.size; for (const index of unlocked) { nextSizes[index] = evenShare; } break; } const nextLocked: number[] = []; for (const index of unlocked) { const proposedSize = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; if (proposedSize < MIN_SPLIT_SIZE) { nextLocked.push(index); } } if (nextLocked.length === 0) { for (const index of unlocked) { nextSizes[index] = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; } break; } for (const index of nextLocked) { nextSizes[index] = MIN_SPLIT_SIZE; unlocked.delete(index); remainingTotal -= MIN_SPLIT_SIZE; } } return normalizeSizes({ sizes: nextSizes, count: nextSizes.length }); } function asInternalNode(node: SplitNode): SplitNodeInternal { return node as SplitNodeInternal; } function asInternalLayout(layout: WorkspaceLayout): { root: SplitNodeInternal; focusedPaneId: string; } { return layout as { root: SplitNodeInternal; focusedPaneId: string }; } function findPanePathById( node: SplitNodeInternal, paneId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return node.pane.id === paneId ? path : null; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findPanePathById(node.group.children[index], paneId, [...path, index]); if (childPath) { return childPath; } } return null; } function findPanePathContainingTab( node: SplitNodeInternal, tabId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return node.pane.tabs.some((tab) => tab.tabId === tabId) ? path : null; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findPanePathContainingTab(node.group.children[index], tabId, [ ...path, index, ]); if (childPath) { return childPath; } } return null; } function findGroupPathById( node: SplitNodeInternal, groupId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return null; } if (node.group.id === groupId) { return path; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findGroupPathById(node.group.children[index], groupId, [...path, index]); if (childPath) { return childPath; } } return null; } function getNodeAtPath(node: SplitNodeInternal, path: number[]): SplitNodeInternal { let current = node; for (const index of path) { invariant(current.kind === "group", "Expected group while traversing split tree"); current = current.group.children[index]; } return current; } function replaceNodeAtPath( node: SplitNodeInternal, path: number[], updater: (node: SplitNodeInternal) => SplitNodeInternal, ): SplitNodeInternal { if (path.length === 0) { return updater(node); } invariant(node.kind === "group", "Expected group while replacing split tree node"); const [index, ...rest] = path; const nextChildren = node.group.children.map((child, childIndex) => childIndex === index ? replaceNodeAtPath(child, rest, updater) : child, ); return createGroupNode({ id: node.group.id, direction: node.group.direction, children: nextChildren, sizes: node.group.sizes, }); } function insertChildIntoGroup( groupNode: SplitNodeInternal, input: InsertChildIntoGroupInput, ): SplitNodeInternal { invariant(groupNode.kind === "group", "Expected group for split insertion"); const nextChildren = groupNode.group.children.slice(); nextChildren.splice(input.index, 0, input.node); return createGroupNode({ id: groupNode.group.id, direction: groupNode.group.direction, children: nextChildren, sizes: input.sizes, }); } function listPaneIds(node: SplitNodeInternal): string[] { if (node.kind === "pane") { return [node.pane.id]; } const next: string[] = []; for (const child of node.group.children) { next.push(...listPaneIds(child)); } return next; } function findNearestSiblingPaneId(root: SplitNodeInternal, paneId: string): string | null { const path = findPanePathById(root, paneId); if (!path || path.length === 0) { return null; } for (let depth = path.length - 1; depth >= 0; depth -= 1) { const parentPath = path.slice(0, depth); const childIndex = path[depth]!; const parentNode = getNodeAtPath(root, parentPath); invariant(parentNode.kind === "group", "Expected parent group for pane lookup"); for (let index = childIndex - 1; index >= 0; index -= 1) { const paneIds = listPaneIds(parentNode.group.children[index]); if (paneIds.length > 0) { return paneIds[paneIds.length - 1] ?? null; } } for (let index = childIndex + 1; index < parentNode.group.children.length; index += 1) { const paneIds = listPaneIds(parentNode.group.children[index]); if (paneIds.length > 0) { return paneIds[0] ?? null; } } } return null; } function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal { const tabs = normalizeWorkspaceTabs(pane.tabs); const tabIds = tabs.map((tab) => tab.tabId); const focusedTabId = tabIds.includes(pane.focusedTabId ?? "") ? pane.focusedTabId : (tabIds[tabIds.length - 1] ?? null); return { id: pane.id, tabs, tabIds, focusedTabId, }; } function normalizeNode(node: unknown): SplitNodeInternal | null { if (!node || typeof node !== "object") { return null; } if ((node as SplitNode).kind === "pane") { const rawPane = (node as { pane?: SplitPaneInternal }).pane; const paneId = trimNonEmpty(rawPane?.id); if (!paneId) { return null; } const tabs = normalizeWorkspaceTabs(rawPane?.tabs); const tabIds = normalizeTabIds(rawPane?.tabIds); const mergedTabs = (() => { if (tabs.length > 0) { return tabs; } return tabIds.map((tabId) => ({ tabId, target: { kind: "draft", draftId: tabId } as WorkspaceTabTarget, createdAt: Date.now(), })); })(); return createPaneNode({ id: paneId, tabs: mergedTabs, focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null, }); } if ((node as SplitNode).kind === "group") { const rawGroup = (node as { group?: SplitGroupInternal }).group; if (!rawGroup) { return null; } const groupId = trimNonEmpty(rawGroup?.id); const direction = rawGroup?.direction; if (!groupId || (direction !== "horizontal" && direction !== "vertical")) { return null; } const children = Array.isArray(rawGroup.children) ? rawGroup.children .map((child) => normalizeNode(child)) .filter((child): child is SplitNodeInternal => child !== null) : []; if (children.length === 0) { return null; } if (children.length === 1) { return children[0] ?? null; } return createGroupNode({ id: groupId, direction, children, sizes: Array.isArray(rawGroup.sizes) ? rawGroup.sizes : [], }); } return null; } function reorderTabsForPane(input: ReorderTabsForPaneInput): SplitPaneInternal { const nextIds = normalizeTabIds(input.tabIds); const byId = new Map(input.pane.tabs.map((tab) => [tab.tabId, tab])); const reordered: WorkspaceTab[] = []; const seen = new Set(); for (const tabId of nextIds) { const tab = byId.get(tabId); if (!tab || seen.has(tabId)) { continue; } seen.add(tabId); reordered.push(tab); } for (const tab of input.pane.tabs) { if (seen.has(tab.tabId)) { continue; } seen.add(tab.tabId); reordered.push(tab); } return normalizePaneAfterTabChange({ ...input.pane, tabs: reordered, }); } function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInternal { if (path.length === 0) { invariant(root.kind === "pane", "Expected pane at root while removing pane"); return createPaneNode({ id: root.pane.id }); } const parentPath = path.slice(0, -1); const removeIndex = path[path.length - 1]!; const parentNode = getNodeAtPath(root, parentPath); invariant(parentNode.kind === "group", "Expected parent group while removing pane"); const nextParentChildren = parentNode.group.children.filter((_, index) => index !== removeIndex); invariant(nextParentChildren.length > 0, "Split tree cannot remove the final pane"); const nextParentNode = nextParentChildren.length === 1 ? nextParentChildren[0]! : createGroupNode({ id: parentNode.group.id, direction: parentNode.group.direction, children: nextParentChildren, sizes: parentNode.group.sizes.filter((_, index) => index !== removeIndex), }); return replaceNodeAtPath(root, parentPath, () => nextParentNode); } function detachTabFromTree( root: SplitNodeInternal, input: DetachTabFromTreeInput, ): DetachTabFromTreeResult { const panePath = findPanePathContainingTab(root, input.tabId); if (!panePath) { return { root, tab: null, sourcePaneId: null }; } const paneNode = getNodeAtPath(root, panePath); invariant(paneNode.kind === "pane", "Expected pane while detaching tab"); const tab = paneNode.pane.tabs.find((entry) => entry.tabId === input.tabId) ?? null; if (!tab) { return { root, tab: null, sourcePaneId: paneNode.pane.id }; } const nextPane = normalizePaneAfterTabChange({ ...paneNode.pane, tabs: paneNode.pane.tabs.filter((entry) => entry.tabId !== input.tabId), }); const nextRoot = replaceNodeAtPath(root, panePath, () => ({ kind: "pane", pane: nextPane })); if (nextPane.tabs.length > 0 || nextPane.id === input.preserveEmptyPaneId) { return { root: nextRoot, tab, sourcePaneId: paneNode.pane.id }; } return { root: removePaneByPath(nextRoot, panePath), tab, sourcePaneId: paneNode.pane.id, }; } function insertTabIntoPane( root: SplitNodeInternal, input: InsertTabIntoPaneInput, ): SplitNodeInternal { const panePath = findPanePathById(root, input.paneId); invariant(panePath, `Pane not found: ${input.paneId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while inserting tab"); const existingIndex = node.pane.tabs.findIndex((tab) => tab.tabId === input.tab.tabId); const nextTabs = existingIndex >= 0 ? node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)) : [...node.pane.tabs, input.tab]; return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, tabs: nextTabs, focusedTabId: input.focusTabId ?? input.tab.tabId, }), }; }); } function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string): SplitNodeInternal { const panePath = findPanePathById(root, paneId); invariant(panePath, `Pane not found: ${paneId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while focusing tab"); return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, focusedTabId: tabId, }), }; }); } function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput): SplitNodeInternal { const panePath = findPanePathContainingTab(root, input.tabId); invariant(panePath, `Tab not found: ${input.tabId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while retargeting tab"); return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, tabs: node.pane.tabs.map((tab) => tab.tabId === input.tabId ? { ...tab, target: input.target } : tab, ), }), }; }); } function updateGroupSizesInTree( root: SplitNodeInternal, input: UpdateGroupSizesInTreeInput, ): SplitNodeInternal { const groupPath = findGroupPathById(root, input.groupId); if (!groupPath) { return root; } return replaceNodeAtPath(root, groupPath, (node) => { invariant(node.kind === "group", "Expected group while resizing split"); if (input.sizes.length !== node.group.children.length) { return node; } return createGroupNode({ id: node.group.id, direction: node.group.direction, children: node.group.children, sizes: clampNormalizedSizes(input.sizes), }); }); } function updatePaneInTree( root: SplitNodeInternal, input: UpdatePaneInTreeInput, ): SplitNodeInternal { const panePath = findPanePathById(root, input.paneId); if (!panePath) { return root; } return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while updating pane"); return { kind: "pane", pane: normalizePaneAfterTabChange(input.updater(node.pane)), }; }); } function insertSplitInternal(input: InsertSplitInternalInput): InsertSplitInternalResult { const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical"; const insertAfter = input.position === "right" || input.position === "bottom"; const targetPathBeforeDetach = findPanePathById(input.root, input.targetPaneId); invariant(targetPathBeforeDetach, `Target pane not found: ${input.targetPaneId}`); const detached = detachTabFromTree(input.root, { tabId: input.tabId, preserveEmptyPaneId: input.targetPaneId, }); invariant(detached.tab, `Tab not found: ${input.tabId}`); const targetPath = findPanePathById(detached.root, input.targetPaneId); invariant(targetPath, `Target pane not found after detach: ${input.targetPaneId}`); const targetNode = getNodeAtPath(detached.root, targetPath); invariant(targetNode.kind === "pane", "Expected target pane after detach"); const newPaneId = input.createNodeId("pane"); const newPaneNode = createPaneNode({ id: newPaneId, tabs: [detached.tab], focusedTabId: detached.tab.tabId, }); const parentPath = targetPath.slice(0, -1); const targetIndex = targetPath[targetPath.length - 1] ?? 0; const parentNode = parentPath.length > 0 ? getNodeAtPath(detached.root, parentPath) : null; if (parentNode?.kind === "group" && parentNode.group.direction === direction) { const targetSize = parentNode.group.sizes[targetIndex] ?? 0; const nextSizes = parentNode.group.sizes.slice(); const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; nextSizes.splice(insertIndex, 0, targetSize / 2); nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; return { root: replaceNodeAtPath(detached.root, parentPath, () => insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes, }), ), newPaneId, }; } const newGroup = createGroupNode({ id: input.createNodeId("group"), direction, children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], sizes: [0.5, 0.5], }); return { root: replaceNodeAtPath(detached.root, targetPath, () => newGroup), newPaneId, }; } export function normalizeLayout(layout: unknown): WorkspaceLayout { if (!layout || typeof layout !== "object") { return createDefaultLayout(); } const rawLayout = layout as WorkspaceLayout; const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root); const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId); const resolvedFocusedPaneId = (focusedPaneId && findPaneById(root, focusedPaneId)?.id) ?? collectAllPanes(root)[0]?.id ?? DEFAULT_PANE_ID; return { root, focusedPaneId: resolvedFocusedPaneId, }; } export function findPaneById(root: SplitNode, paneId: string): SplitPane | null { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.id === paneId ? internalRoot.pane : null; } for (const child of internalRoot.group.children) { const pane = findPaneById(child, paneId); if (pane) { return pane; } } return null; } export function findPaneContainingTab(root: SplitNode, tabId: string): SplitPane | null { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.tabs.some((tab) => tab.tabId === tabId) ? internalRoot.pane : null; } for (const child of internalRoot.group.children) { const pane = findPaneContainingTab(child, tabId); if (pane) { return pane; } } return null; } export function getTreeDepth(node: SplitNode): number { const internalNode = asInternalNode(node); if (internalNode.kind === "pane") { return 1; } return 1 + Math.max(...internalNode.group.children.map((child) => getTreeDepth(child))); } export function collectAllTabs(root: SplitNode): WorkspaceTab[] { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.tabs.slice(); } return internalRoot.group.children.flatMap((child) => collectAllTabs(child)); } export function collectAllPanes(root: SplitNode): SplitPane[] { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return [internalRoot.pane]; } return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); } export function createDefaultLayout(): WorkspaceLayout { return { root: createPaneNode({ id: DEFAULT_PANE_ID }), focusedPaneId: DEFAULT_PANE_ID, }; } export function insertSplit( root: SplitNode, targetPaneId: string, tabId: string, position: "left" | "right" | "top" | "bottom", ): SplitNode { return insertSplitInternal({ root: asInternalNode(root), targetPaneId, tabId, position, createNodeId: generateNodeId, }).root; } export function removePaneFromTree(root: SplitNode, paneId: string): SplitNode { const internalRoot = asInternalNode(root); const panePath = findPanePathById(internalRoot, paneId); if (!panePath) { return root; } return removePaneByPath(internalRoot, panePath); } export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { return detachTabFromTree(asInternalNode(root), { tabId }).root; } export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); const existingTab = collectAllTabs(layout.root).find((tab) => workspaceTabTargetsEqual(tab.target, input.target), ); if (existingTab) { return { tabId: existingTab.tabId, layout: focusTabInLayout({ layout, tabId: existingTab.tabId, }) ?? input.layout, }; } const focusedPane = findPaneById(layout.root, layout.focusedPaneId) ?? collectAllPanes(layout.root)[0] ?? findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID); invariant(focusedPane, "Workspace layout must always have a pane"); const tabId = buildDeterministicWorkspaceTabId(input.target); const nextTab: WorkspaceTab = { tabId, target: input.target, createdAt: input.now, }; return { tabId, layout: { root: insertTabIntoPane(layout.root, { paneId: focusedPane.id, tab: nextTab, focusTabId: tabId, }), focusedPaneId: focusedPane.id, }, }; } export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null { const internalLayout = asInternalLayout(input.layout); const pane = findPaneContainingTab(internalLayout.root, input.tabId); if (!pane) { return null; } const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id); const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal; const nextFocusedPaneId = findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ?? (fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ?? collectAllPanes(nextRoot)[0]?.id ?? DEFAULT_PANE_ID; return { root: nextRoot, focusedPaneId: nextFocusedPaneId, }; } export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const pane = findPaneContainingTab(layout.root, input.tabId); if (!pane) { return null; } if (pane.focusedTabId === input.tabId && layout.focusedPaneId === pane.id) { return null; } return { root: focusTabInPane(layout.root, pane.id, input.tabId), focusedPaneId: pane.id, }; } export function retargetTabInLayout( input: RetargetTabInLayoutInput, ): RetargetTabInLayoutResult | null { const layout = asInternalLayout(input.layout); const pane = findPaneContainingTab(layout.root, input.tabId); if (!pane) { return null; } const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null; if (currentTab && workspaceTabTargetsEqual(currentTab.target, input.target)) { return { layout: input.layout, tabId: input.tabId, }; } return { tabId: input.tabId, layout: { root: updateTabInTree(layout.root, { tabId: input.tabId, target: input.target, }), focusedPaneId: layout.focusedPaneId, }, }; } export function reorderFocusedPaneTabsInLayout( input: ReorderFocusedPaneTabsInLayoutInput, ): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, layout.focusedPaneId)) { return null; } return { root: updatePaneInTree(layout.root, { paneId: layout.focusedPaneId, updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }), }), focusedPaneId: layout.focusedPaneId, }; } export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.targetPaneId)) { return null; } if (!findPaneContainingTab(layout.root, input.tabId)) { return null; } const result = insertSplitInternal({ root: layout.root, targetPaneId: input.targetPaneId, tabId: input.tabId, position: input.position, createNodeId: input.createNodeId, }); if (getTreeDepth(result.root) > input.maxTreeDepth) { return null; } return { paneId: result.newPaneId, layout: { root: result.root, focusedPaneId: result.newPaneId, }, }; } export function splitPaneEmptyInLayout( input: SplitPaneEmptyInLayoutInput, ): SplitPaneInLayoutResult | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.targetPaneId)) { return null; } const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical"; const insertAfter = input.position === "right" || input.position === "bottom"; const targetPath = findPanePathById(layout.root, input.targetPaneId); invariant(targetPath, `Target pane not found: ${input.targetPaneId}`); const targetNode = getNodeAtPath(layout.root, targetPath); invariant(targetNode.kind === "pane", "Expected target pane"); const newPaneId = input.createNodeId("pane"); const newPaneNode = createPaneNode({ id: newPaneId }); const parentPath = targetPath.slice(0, -1); const targetIndex = targetPath[targetPath.length - 1] ?? 0; const parentNode = parentPath.length > 0 ? getNodeAtPath(layout.root, parentPath) : null; let nextRoot: SplitNodeInternal; if (parentNode?.kind === "group" && parentNode.group.direction === direction) { const targetSize = parentNode.group.sizes[targetIndex] ?? 0; const nextSizes = parentNode.group.sizes.slice(); const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; nextSizes.splice(insertIndex, 0, targetSize / 2); nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; nextRoot = replaceNodeAtPath(layout.root, parentPath, () => insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes }), ); } else { const newGroup = createGroupNode({ id: input.createNodeId("group"), direction, children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], sizes: [0.5, 0.5], }); nextRoot = replaceNodeAtPath(layout.root, targetPath, () => newGroup); } if (getTreeDepth(nextRoot) > input.maxTreeDepth) { return null; } return { paneId: newPaneId, layout: { root: nextRoot, focusedPaneId: newPaneId, }, }; } export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const sourcePane = findPaneContainingTab(layout.root, input.tabId); if (!sourcePane || !findPaneById(layout.root, input.toPaneId)) { return null; } const detached = detachTabFromTree(layout.root, { tabId: input.tabId, preserveEmptyPaneId: sourcePane.id === input.toPaneId ? input.toPaneId : null, }); if (!detached.tab) { return null; } return { root: insertTabIntoPane(detached.root, { paneId: input.toPaneId, tab: detached.tab, focusTabId: input.tabId, }), focusedPaneId: input.toPaneId, }; } export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null { if (!findPaneById(input.layout.root, input.paneId)) { return null; } if (input.layout.focusedPaneId === input.paneId) { return null; } return { root: input.layout.root, focusedPaneId: input.paneId, }; } export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout { const layout = asInternalLayout(input.layout); return { root: updateGroupSizesInTree(layout.root, { groupId: input.groupId, sizes: input.sizes, }), focusedPaneId: layout.focusedPaneId, }; } export function reorderPaneTabsInLayout( input: ReorderPaneTabsInLayoutInput, ): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.paneId)) { return null; } return { root: updatePaneInTree(layout.root, { paneId: input.paneId, updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }), }), focusedPaneId: layout.focusedPaneId, }; }