diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 1fce18186..d9f7609f8 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -16,12 +16,21 @@ import { View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone"; -import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; -import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; +import { + deriveWorkspacePaneState, + getWorkspacePaneDescriptors, +} from "@/screens/workspace/workspace-pane-state"; +import { + WorkspacePaneContent, + type WorkspacePaneContentModel, +} from "@/screens/workspace/workspace-pane-content"; +import { + WorkspaceDesktopTabsRow, + type WorkspaceDesktopTabRowItem, +} from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; interface SplitContainerProps { layout: WorkspaceLayout; @@ -44,9 +53,10 @@ interface SplitContainerProps { onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onSelectNewTabOption: (optionId: "__new_tab_agent__") => void; newTabAgentOptionId?: "__new_tab_agent__"; - onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void; - onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void; - onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void; + buildPaneContentModel: (input: { + paneId: string; + tab: WorkspaceTabDescriptor; + }) => WorkspacePaneContentModel; onFocusPane: (paneId: string) => void; onSplitPane: (input: { tabId: string; @@ -71,9 +81,9 @@ interface SplitPaneDropData { } interface SplitNodeViewProps - extends Omit { + extends Omit { node: SplitNode; - tabsById: Map; + uiTabs: WorkspaceTab[]; focusedPaneId: string; activeDragTabId: string | null; showDropZones: boolean; @@ -85,7 +95,6 @@ interface SplitPaneViewProps extends Omit< SplitNodeViewProps, | "node" - | "tabsById" | "focusedPaneId" | "activeDragTabId" | "showDropZones" @@ -96,7 +105,7 @@ interface SplitPaneViewProps | "onResizeSplit" > { pane: SplitPane; - tabsById: Map; + uiTabs: WorkspaceTab[]; focused: boolean; activeDragTabId: string | null; showDropZones: boolean; @@ -143,9 +152,7 @@ export function SplitContainer({ onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId = "__new_tab_agent__", - onOpenPaneTab, - onRetargetTab, - onOpenWorkspaceFile, + buildPaneContentModel, onFocusPane, onSplitPane, onMoveTabToPane, @@ -167,14 +174,6 @@ export function SplitContainer({ }) ); - const tabsById = useMemo(() => { - const next = new Map(); - for (const tab of uiTabs) { - next.set(tab.tabId, tab); - } - return next; - }, [uiTabs]); - const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); const handleDragStart = useCallback((event: DragStartEvent) => { @@ -215,8 +214,8 @@ export function SplitContainer({ return; } - const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById); - const targetTabs = getPaneTabDescriptors(targetPane, tabsById); + const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId); if (sourceIndex < 0 || targetIndex < 0) { @@ -259,7 +258,7 @@ export function SplitContainer({ setDropPreview(null); }, - [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById] + [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs] ); return ( @@ -272,7 +271,7 @@ export function SplitContainer({ > getPaneTabDescriptors(pane, tabsById), [pane, tabsById]); - const paneModel = useMemo( + const paneState = useMemo( () => - deriveWorkspaceTabModel({ - tabs: paneTabs.map((tab) => ({ - tabId: tab.tabId, - target: tab.target, - createdAt: 0, - })), - focusedTabId: pane.focusedTabId, + deriveWorkspacePaneState({ + pane, + tabs: uiTabs, }), - [pane.focusedTabId, paneTabs] + [pane, uiTabs] + ); + const paneTabs = useMemo( + () => paneState.tabs.map((tab) => tab.descriptor), + [paneState.tabs] + ); + const activeTabDescriptor = paneState.activeTab?.descriptor ?? null; + const desktopTabRowItems = useMemo( + () => + paneTabs.map((tab) => { + const isClosingAgent = + tab.target.kind === "agent" && + isArchivingAgent({ + serverId: normalizedServerId, + agentId: tab.target.agentId, + }); + const isClosingTerminal = + tab.target.kind === "terminal" && + killTerminalPending && + killTerminalId === tab.target.terminalId; + + return { + tab, + isActive: tab.key === activeTabDescriptor?.key, + isCloseHovered: hoveredCloseTabKey === tab.key, + isClosingTab: isClosingAgent || isClosingTerminal, + }; + }), + [ + activeTabDescriptor?.key, + hoveredCloseTabKey, + isArchivingAgent, + killTerminalId, + killTerminalPending, + normalizedServerId, + paneTabs, + ] + ); + const paneContent = useMemo( + () => + activeTabDescriptor + ? buildPaneContentModel({ + paneId: pane.id, + tab: activeTabDescriptor, + }) + : null, + [ + activeTabDescriptor, + buildPaneContentModel, + pane.id, + ] ); - const activeTabDescriptor = paneModel.activeTab - ? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null - : null; return ( - {activeTabDescriptor ? ( - { - onOpenPaneTab({ - paneId: pane.id, - target, - }); - }} - onCloseCurrentTab={() => { - void onCloseTab(activeTabDescriptor.tabId); - }} - onRetargetCurrentTab={(target) => { - onRetargetTab(activeTabDescriptor.tabId, target); - }} - onOpenWorkspaceFile={(filePath) => { - onOpenWorkspaceFile({ - paneId: pane.id, - filePath, - }); - }} - /> + {paneContent ? ( + ) : ( renderPaneEmptyState?.() ?? null )} @@ -598,26 +602,6 @@ function collectPanesById(node: SplitNode): Map { return next; } -function getPaneTabDescriptors( - pane: SplitPane, - tabsById: Map -): WorkspaceTabDescriptor[] { - const next: WorkspaceTabDescriptor[] = []; - for (const tabId of pane.tabIds) { - const tab = tabsById.get(tabId); - if (!tab) { - continue; - } - next.push({ - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - }); - } - return next; -} - function getNodeKey(node: SplitNode): string { if (node.kind === "pane") { return node.pane.id; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 03fae048f..27e7b7eb5 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -18,26 +18,30 @@ import { WorkspaceTabIcon, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; -import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; +import { + buildWorkspaceDesktopTabActions, + type WorkspaceDesktopTabActions, +} from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { encodeFilePathForPathSegment } from "@/utils/host-routes"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; type NewTabOptionId = "__new_tab_agent__"; +export interface WorkspaceDesktopTabRowItem { + tab: WorkspaceTabDescriptor; + isActive: boolean; + isCloseHovered: boolean; + isClosingTab: boolean; +} + type WorkspaceDesktopTabsRowProps = { paneId?: string; - tabs: WorkspaceTabDescriptor[]; - activeTabKey: string; + tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; - hoveredCloseTabKey: string | null; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; - isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean; - killTerminalPending: boolean; - killTerminalId: string | null; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; @@ -65,87 +69,41 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { return "Agent"; } -function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { - if (tab.target.kind === "agent") { - return `workspace-agent-close-${tab.target.agentId}`; - } - if (tab.target.kind === "terminal") { - return `workspace-terminal-close-${tab.target.terminalId}`; - } - if (tab.target.kind === "draft") { - return `workspace-draft-close-${tab.target.draftId}`; - } - return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; -} - function TabChip({ tab, - index, - tabCount, isActive, resolvedTabWidth, showLabel, showCloseButton, isCloseHovered, isClosingTab, - normalizedServerId, - normalizedWorkspaceId, + presentation, + tooltipLabel, + resolvedTab, setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, - onCopyResumeCommand, - onCopyAgentId, - onCloseTabsToLeft, - onCloseTabsToRight, - onCloseOtherTabs, dragHandleProps, }: { tab: WorkspaceTabDescriptor; - index: number; - tabCount: number; isActive: boolean; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; isCloseHovered: boolean; isClosingTab: boolean; - normalizedServerId: string; - normalizedWorkspaceId: string; + presentation: WorkspaceTabPresentation; + tooltipLabel: string; + resolvedTab: WorkspaceDesktopTabActions; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCloseTabsToLeft: (tabId: string) => Promise | void; - onCloseTabsToRight: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; dragHandleProps: any; }) { - const presentation = useWorkspaceTabPresentation({ - tab, - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - }); const { theme } = useUnistyles(); - - const tooltipLabel = - presentation.titleState === "loading" ? "Loading agent title" : presentation.label; - const contextMenuTestId = `workspace-tab-context-${tab.key}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "desktop", - tab, - index, - tabCount, - menuTestIDBase: contextMenuTestId, - onCopyResumeCommand, - onCopyAgentId, - onCloseTab, - onCloseTabsBefore: onCloseTabsToLeft, - onCloseTabsAfter: onCloseTabsToRight, - onCloseOtherTabs, - }); + const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; return ( @@ -214,7 +172,7 @@ function TabChip({ {showCloseButton ? ( { setHoveredTabKey(tab.key); @@ -280,15 +238,10 @@ function TabChip({ export function WorkspaceDesktopTabsRow({ paneId, tabs, - activeTabKey, normalizedServerId, normalizedWorkspaceId, - hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, - isArchivingAgent, - killTerminalPending, - killTerminalId, onNavigateTab, onCloseTab, onCopyResumeCommand, @@ -334,7 +287,7 @@ export function WorkspaceDesktopTabsRow({ const tabLabelLengths = useMemo( () => tabs.map((tab) => { - const label = getFallbackTabLabel(tab); + const label = getFallbackTabLabel(tab.tab); return label.length; }), [tabs] @@ -365,10 +318,10 @@ export function WorkspaceDesktopTabsRow({ > tab.key} + keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`} useDragHandle disabled={tabs.length < 2} - onDragEnd={onReorderTabs} + onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))} externalDndContext={externalDndContext} activeId={activeDragTabId} getItemData={ @@ -376,52 +329,36 @@ export function WorkspaceDesktopTabsRow({ ? (tab) => ({ kind: "workspace-tab", paneId, - tabId: tab.tabId, + tabId: tab.tab.tabId, }) : undefined } - renderItem={({ item: tab, index, dragHandleProps }) => { - const isActive = tab.key === activeTabKey; - const isCloseHovered = hoveredCloseTabKey === tab.key; - const isClosingAgent = - tab.target.kind === "agent" && - isArchivingAgent({ - serverId: normalizedServerId, - agentId: tab.target.agentId, - }); - const isClosingTerminal = - tab.target.kind === "terminal" && - killTerminalPending && - killTerminalId === tab.target.terminalId; - const isClosingTab = isClosingAgent || isClosingTerminal; + renderItem={({ item, index, dragHandleProps }) => { const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; return ( - ); @@ -454,6 +391,99 @@ export function WorkspaceDesktopTabsRow({ ); } +function ResolvedDesktopTabChip({ + item, + index, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + onCopyResumeCommand, + onCopyAgentId, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + resolvedTabWidth, + showLabel, + showCloseButton, + setHoveredTabKey, + setHoveredCloseTabKey, + onNavigateTab, + onCloseTab, + dragHandleProps, +}: { + item: WorkspaceDesktopTabRowItem; + index: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTabsToLeft: (tabId: string) => Promise | void; + onCloseTabsToRight: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; + resolvedTabWidth: number; + showLabel: boolean; + showCloseButton: boolean; + setHoveredTabKey: Dispatch>; + setHoveredCloseTabKey: Dispatch>; + onNavigateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => Promise | void; + dragHandleProps: any; +}) { + const presentation = useWorkspaceTabPresentation({ + tab: item.tab, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + const tooltipLabel = + presentation.titleState === "loading" ? "Loading agent title" : presentation.label; + const resolvedTab = useMemo( + () => + buildWorkspaceDesktopTabActions({ + tab: item.tab, + index, + tabCount, + onCopyResumeCommand, + onCopyAgentId, + onCloseTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + }), + [ + index, + item.tab, + onCloseOtherTabs, + onCloseTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCopyAgentId, + onCopyResumeCommand, + tabCount, + ] + ); + + return ( + + ); +} + const styles = StyleSheet.create((theme) => ({ tabsContainer: { borderBottomWidth: 1, diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 41fbadbe4..0e0c99f60 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -1,10 +1,17 @@ +import type { ComponentType } from "react"; import invariant from "tiny-invariant"; -import { PaneProvider } from "@/panels/pane-context"; +import { PaneProvider, type PaneContextValue } from "@/panels/pane-context"; import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -export interface WorkspacePaneContentProps { +export interface WorkspacePaneContentModel { + key: string; + Component: ComponentType; + paneContextValue: PaneContextValue; +} + +export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; normalizedServerId: string; normalizedWorkspaceId: string; @@ -14,7 +21,7 @@ export interface WorkspacePaneContentProps { onOpenWorkspaceFile: (filePath: string) => void; } -export function WorkspacePaneContent({ +export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, @@ -22,26 +29,36 @@ export function WorkspacePaneContent({ onCloseCurrentTab, onRetargetCurrentTab, onOpenWorkspaceFile, -}: WorkspacePaneContentProps) { +}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel { ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); - const Component = registration.component; + return { + key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`, + Component: registration.component, + paneContextValue: { + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: tab.tabId, + target: tab.target, + openTab: onOpenTab, + closeCurrentTab: onCloseCurrentTab, + retargetCurrentTab: onRetargetCurrentTab, + openFileInWorkspace: onOpenWorkspaceFile, + }, + }; +} + +export interface WorkspacePaneContentProps { + content: WorkspacePaneContentModel; +} + +export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) { + const { Component, key, paneContextValue } = content; return ( - - + + ); } diff --git a/packages/app/src/screens/workspace/workspace-pane-state.test.ts b/packages/app/src/screens/workspace/workspace-pane-state.test.ts new file mode 100644 index 000000000..f883afc77 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-pane-state.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { deriveWorkspacePaneState, getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state"; +import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; +import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; + +function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab { + return { + tabId, + target, + createdAt: 1, + }; +} + +describe("workspace-pane-state", () => { + it("selects the focused pane and keeps its tab order", () => { + const tabs: WorkspaceTab[] = [ + createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }), + createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }), + createTab("terminal_term-1", { kind: "terminal", terminalId: "term-1" }), + ]; + const layout: WorkspaceLayout = { + root: { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + { + kind: "pane", + pane: { + id: "left", + tabIds: ["file_/repo/README.md", "agent_agent-a"], + focusedTabId: "agent_agent-a", + }, + }, + { + kind: "pane", + pane: { + id: "right", + tabIds: ["terminal_term-1"], + focusedTabId: "terminal_term-1", + }, + }, + ], + }, + }, + focusedPaneId: "left", + }; + + const state = deriveWorkspacePaneState({ layout, tabs }); + + expect(state.pane?.id).toBe("left"); + expect(state.tabs.map((tab) => tab.descriptor.tabId)).toEqual([ + "file_/repo/README.md", + "agent_agent-a", + ]); + expect(state.activeTabId).toBe("agent_agent-a"); + }); + + it("falls back to the first ordered pane tab when focusedTabId is empty", () => { + const pane = { + id: "main", + tabIds: ["draft_1", "draft_2"], + focusedTabId: " ", + }; + const tabs: WorkspaceTab[] = [ + createTab("draft_2", { kind: "draft", draftId: "draft_2" }), + createTab("draft_1", { kind: "draft", draftId: "draft_1" }), + ]; + + const state = deriveWorkspacePaneState({ pane, tabs }); + + expect(state.activeTabId).toBe("draft_1"); + expect(getWorkspacePaneDescriptors({ pane, tabs }).map((tab) => tab.tabId)).toEqual([ + "draft_1", + "draft_2", + ]); + }); + + it("prefers a matching target over stale focused tab state", () => { + const pane = { + id: "main", + tabIds: ["agent_agent-a", "file_/repo/README.md"], + focusedTabId: "agent_agent-a", + }; + const tabs: WorkspaceTab[] = [ + createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }), + createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }), + ]; + + const state = deriveWorkspacePaneState({ + pane, + tabs, + preferredTarget: { kind: "file", path: "\\repo\\README.md" }, + }); + + expect(state.activeTabId).toBe("file_/repo/README.md"); + expect(state.activeTab?.descriptor.target).toEqual({ + kind: "file", + path: "/repo/README.md", + }); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts new file mode 100644 index 000000000..b376c4593 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -0,0 +1,199 @@ +import { + findPaneById, + type SplitPane, + type WorkspaceLayout, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "@/utils/workspace-tab-identity"; + +export interface WorkspaceDerivedTab { + descriptor: WorkspaceTabDescriptor; +} + +export interface WorkspacePaneState { + pane: SplitPane | null; + tabs: WorkspaceDerivedTab[]; + focusedTabId: string | null; + activeTabId: string | null; + activeTab: WorkspaceDerivedTab | null; +} + +interface NormalizeWorkspacePaneTabsResult { + tabs: WorkspaceDerivedTab[]; + openTabIds: Set; +} + +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 normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null { + if (!tab || typeof tab !== "object") { + return null; + } + const tabId = trimNonEmpty(tab.tabId); + if (!tabId) { + return null; + } + const target = normalizeWorkspaceTabTarget(tab.target); + if (!target) { + return null; + } + return { + tabId, + target, + createdAt: tab.createdAt, + }; +} + +function orderPaneTabs(input: { pane: SplitPane | null; tabs: WorkspaceTab[] }): WorkspaceTab[] { + if (!input.pane) { + return input.tabs; + } + + const tabsById = new Map(); + for (const tab of input.tabs) { + tabsById.set(tab.tabId, tab); + } + + const orderedTabs: WorkspaceTab[] = []; + for (const tabId of input.pane.tabIds) { + const tab = tabsById.get(tabId); + if (tab) { + orderedTabs.push(tab); + } + } + return orderedTabs; +} + +function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePaneTabsResult { + const nextTabs: WorkspaceDerivedTab[] = []; + const openTabIds = new Set(); + + for (const tab of tabs) { + const normalizedTab = normalizeWorkspaceTab(tab); + if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) { + continue; + } + + openTabIds.add(normalizedTab.tabId); + nextTabs.push({ + descriptor: { + key: normalizedTab.tabId, + tabId: normalizedTab.tabId, + kind: normalizedTab.target.kind, + target: normalizedTab.target, + }, + }); + } + + return { + tabs: nextTabs, + openTabIds, + }; +} + +function getActiveTabId(input: { + tabs: WorkspaceDerivedTab[]; + openTabIds: Set; + focusedTabId?: string | null; + preferredTarget?: WorkspaceTabTarget | null; +}): string | null { + const focusedTabId = trimNonEmpty(input.focusedTabId); + const preferredTarget = normalizeWorkspaceTabTarget(input.preferredTarget ?? null); + const preferredTabId = (() => { + if (!preferredTarget) { + return null; + } + const matchingTab = + input.tabs.find((tab) => workspaceTabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? + null; + return matchingTab?.descriptor.tabId ?? buildDeterministicWorkspaceTabId(preferredTarget); + })(); + + if (preferredTabId && input.openTabIds.has(preferredTabId)) { + return preferredTabId; + } + if (focusedTabId && input.openTabIds.has(focusedTabId)) { + return focusedTabId; + } + return input.tabs[0]?.descriptor.tabId ?? null; +} + +function getPane(input: { + layout: WorkspaceLayout | null; + pane?: SplitPane | null; + paneId?: string | null; +}): SplitPane | null { + if (input.pane) { + return input.pane; + } + + const layout = input.layout; + if (!layout) { + return null; + } + + const resolvedPaneId = trimNonEmpty(input.paneId) ?? layout.focusedPaneId; + if (!resolvedPaneId) { + return null; + } + + return findPaneById(layout.root, resolvedPaneId); +} + +export function deriveWorkspacePaneState(input: { + layout?: WorkspaceLayout | null; + pane?: SplitPane | null; + paneId?: string | null; + tabs: WorkspaceTab[]; + focusedTabId?: string | null; + preferredTarget?: WorkspaceTabTarget | null; +}): WorkspacePaneState { + const pane = getPane({ + layout: input.layout ?? null, + pane: input.pane ?? null, + paneId: input.paneId, + }); + const orderedTabs = orderPaneTabs({ + pane, + tabs: input.tabs, + }); + const normalizedTabs = normalizeWorkspacePaneTabs(orderedTabs); + const focusedTabId = pane?.focusedTabId ?? trimNonEmpty(input.focusedTabId) ?? null; + const activeTabId = getActiveTabId({ + tabs: normalizedTabs.tabs, + openTabIds: normalizedTabs.openTabIds, + focusedTabId, + preferredTarget: input.preferredTarget, + }); + + return { + pane, + tabs: normalizedTabs.tabs, + focusedTabId, + activeTabId, + activeTab: + activeTabId + ? normalizedTabs.tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null + : null, + }; +} + +export function getWorkspacePaneDescriptors(input: { + layout?: WorkspaceLayout | null; + pane?: SplitPane | null; + paneId?: string | null; + tabs: WorkspaceTab[]; +}): WorkspaceTabDescriptor[] { + return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 80d292ca5..6e2dee66a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -54,10 +54,7 @@ import { import { buildWorkspaceTabPersistenceKey, collectAllTabs, - findPaneById, useWorkspaceLayoutStore, - type SplitPane, - type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -69,6 +66,10 @@ import { decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; +import { + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "@/utils/workspace-tab-identity"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { @@ -95,10 +96,11 @@ import { import { deriveWorkspaceAgentVisibility, } from "@/screens/workspace/workspace-agent-visibility"; +import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import { - deriveWorkspaceTabModel, -} from "@/screens/workspace/workspace-tab-model"; -import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; + buildWorkspacePaneContentModel, + WorkspacePaneContent, +} from "@/screens/workspace/workspace-pane-content"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, @@ -109,11 +111,6 @@ const TERMINALS_QUERY_STALE_TIME = 5_000; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const EMPTY_UI_TABS: WorkspaceTab[] = []; -interface FocusedPaneTabState { - tabs: WorkspaceTab[]; - focusedTabId: string | null; -} - type WorkspaceScreenProps = { serverId: string; workspaceId: string; @@ -177,82 +174,6 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { return tab.target.path; } -function workspaceTabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { - if (left.kind !== right.kind) { - return false; - } - if (left.kind === "draft" && right.kind === "draft") { - return left.draftId === right.draftId; - } - if (left.kind === "agent" && right.kind === "agent") { - return left.agentId === right.agentId; - } - if (left.kind === "terminal" && right.kind === "terminal") { - return left.terminalId === right.terminalId; - } - if (left.kind === "file" && right.kind === "file") { - return left.path === right.path; - } - return false; -} - -function getFocusedPaneTabState(input: { - layout: WorkspaceLayout | null; - tabs: WorkspaceTab[]; -}): FocusedPaneTabState { - const { layout, tabs } = input; - if (!layout) { - return { - tabs, - focusedTabId: null, - }; - } - - const focusedPane = findPaneById(layout.root, layout.focusedPaneId); - if (!focusedPane) { - return { - tabs, - focusedTabId: null, - }; - } - - const tabsById = new Map(); - for (const tab of tabs) { - tabsById.set(tab.tabId, tab); - } - - const orderedTabs: WorkspaceTab[] = []; - for (const tabId of focusedPane.tabIds) { - const tab = tabsById.get(tabId); - if (tab) { - orderedTabs.push(tab); - } - } - - return { - tabs: orderedTabs, - focusedTabId: focusedPane.focusedTabId, - }; -} - -function getEffectiveFocusedTabId(input: FocusedPaneTabState): string | null { - return trimNonEmpty(input.focusedTabId) ?? input.tabs[0]?.tabId ?? null; -} - -function getFocusedPane(layout: WorkspaceLayout | null): SplitPane | null { - if (!layout) { - return null; - } - return findPaneById(layout.root, layout.focusedPaneId); -} - -function getFocusedPaneActiveTabId(pane: SplitPane | null): string | null { - if (!pane) { - return null; - } - return trimNonEmpty(pane.focusedTabId) ?? pane.tabIds[0] ?? null; -} - type MobileWorkspaceTabSwitcherProps = { tabs: WorkspaceTabDescriptor[]; activeTabKey: string; @@ -873,7 +794,7 @@ function WorkspaceScreenContent({ const focusedPaneTabState = useMemo( () => - getFocusedPaneTabState({ + deriveWorkspacePaneState({ layout: workspaceLayout, tabs: uiTabs, }), @@ -886,14 +807,19 @@ function WorkspaceScreenContent({ return null; } + const normalizedTarget = normalizeWorkspaceTabTarget(target); + if (!normalizedTarget) { + return null; + } + const existingTab = - uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null; + uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null; if (existingTab) { return existingTab.tabId; } - const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState); - const tabId = openWorkspaceTab(persistenceKey, target); + const previousFocusedTabId = focusedPaneTabState.activeTabId; + const tabId = openWorkspaceTab(persistenceKey, normalizedTarget); if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) { focusWorkspaceTab(persistenceKey, previousFocusedTabId); } @@ -908,10 +834,11 @@ function WorkspaceScreenContent({ return null; } - const target: WorkspaceTabTarget = { + const target = normalizeWorkspaceTabTarget({ kind: "draft", draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(), - }; + }); + invariant(target?.kind === "draft", "Draft tab target must be valid"); if (input?.focus === false) { return ensureWorkspaceTab(target); } @@ -989,6 +916,24 @@ function WorkspaceScreenContent({ const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey ? openIntent : null; + const resolvedPaneTabState = useMemo( + () => + deriveWorkspacePaneState({ + layout: workspaceLayout, + tabs: uiTabs, + preferredTarget: + unresolvedOpenIntent?.kind === "agent" + ? { kind: "agent", agentId: unresolvedOpenIntent.agentId } + : unresolvedOpenIntent?.kind === "terminal" + ? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId } + : unresolvedOpenIntent?.kind === "draft" + ? { kind: "draft", draftId: unresolvedOpenIntent.draftId } + : unresolvedOpenIntent?.kind === "file" + ? { kind: "file", path: unresolvedOpenIntent.path } + : null, + }), + [uiTabs, unresolvedOpenIntent, workspaceLayout] + ); useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) { @@ -1051,25 +996,7 @@ function WorkspaceScreenContent({ workspaceAgents, ]); - const tabModel = useMemo( - () => - deriveWorkspaceTabModel({ - tabs: focusedPaneTabState.tabs, - focusedTabId: focusedPaneTabState.focusedTabId, - preferredTarget: - unresolvedOpenIntent?.kind === "agent" - ? { kind: "agent", agentId: unresolvedOpenIntent.agentId } - : unresolvedOpenIntent?.kind === "terminal" - ? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId } - : unresolvedOpenIntent?.kind === "draft" - ? { kind: "draft", draftId: unresolvedOpenIntent.draftId } - : unresolvedOpenIntent?.kind === "file" - ? { kind: "file", path: unresolvedOpenIntent.path } - : null, - }), - [focusedPaneTabState, unresolvedOpenIntent] - ); - const activeTabId = tabModel.activeTabId; + const activeTabId = resolvedPaneTabState.activeTabId; useEffect(() => { if (!activeTabId || !persistenceKey) { @@ -1078,11 +1005,11 @@ function WorkspaceScreenContent({ focusWorkspaceTab(persistenceKey, activeTabId); }, [activeTabId, focusWorkspaceTab, persistenceKey]); - const activeTab = tabModel.activeTab; + const activeTab = resolvedPaneTabState.activeTab; const tabs = useMemo( - () => tabModel.tabs.map((tab) => tab.descriptor), - [tabModel.tabs] + () => resolvedPaneTabState.tabs.map((tab) => tab.descriptor), + [resolvedPaneTabState.tabs] ); const navigateToTabId = useCallback( @@ -1625,13 +1552,13 @@ function WorkspaceScreenContent({ return true; } - const focusedPane = getFocusedPane(workspaceLayout); + const focusedPane = focusedPaneTabState.pane; if (!focusedPane) { return true; } if (action.id === "workspace.pane.split.right") { - const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + const activePaneTabId = focusedPaneTabState.activeTabId; if (activePaneTabId) { splitWorkspacePane(persistenceKey, { tabId: activePaneTabId, @@ -1643,7 +1570,7 @@ function WorkspaceScreenContent({ } if (action.id === "workspace.pane.split.down") { - const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + const activePaneTabId = focusedPaneTabState.activeTabId; if (activePaneTabId) { splitWorkspacePane(persistenceKey, { tabId: activePaneTabId, @@ -1688,7 +1615,7 @@ function WorkspaceScreenContent({ direction === "up" || direction === "down" ) { - const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + const activePaneTabId = focusedPaneTabState.activeTabId; const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction); if (activePaneTabId && adjacentPaneId) { moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId); @@ -1712,6 +1639,8 @@ function WorkspaceScreenContent({ moveWorkspaceTabToPane, persistenceKey, splitWorkspacePane, + focusedPaneTabState.activeTabId, + focusedPaneTabState.pane, workspaceLayout, ] ); @@ -1752,6 +1681,67 @@ function WorkspaceScreenContent({ }); const activeTabDescriptor = activeTab?.descriptor ?? null; + const buildPaneContentModel = useCallback( + (input: { + tab: WorkspaceTabDescriptor; + paneId?: string | null; + focusPaneBeforeOpen?: boolean; + }) => + buildWorkspacePaneContentModel({ + tab: input.tab, + normalizedServerId, + normalizedWorkspaceId, + onOpenTab: (target) => { + if (!persistenceKey) { + return; + } + if (input.focusPaneBeforeOpen && input.paneId) { + focusWorkspacePane(persistenceKey, input.paneId); + } + const tabId = openWorkspaceTab(persistenceKey, target); + if (tabId) { + navigateToTabId(tabId); + } + }, + onCloseCurrentTab: () => { + void handleCloseTabById(input.tab.tabId); + }, + onRetargetCurrentTab: (target) => { + if (!persistenceKey) { + return; + } + retargetWorkspaceTab(persistenceKey, input.tab.tabId, target); + }, + onOpenWorkspaceFile: (filePath) => { + if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) { + focusWorkspacePane(persistenceKey, input.paneId); + } + handleOpenFileFromChat({ filePath }); + }, + }), + [ + handleCloseTabById, + handleOpenFileFromChat, + focusWorkspacePane, + navigateToTabId, + normalizedServerId, + normalizedWorkspaceId, + openWorkspaceTab, + persistenceKey, + retargetWorkspaceTab, + ] + ); + const activePaneContent = useMemo( + () => + activeTabDescriptor + ? buildPaneContentModel({ + tab: activeTabDescriptor, + paneId: focusedPaneTabState.pane?.id ?? null, + focusPaneBeforeOpen: false, + }) + : null, + [activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id] + ); const content = shouldRenderMissingWorkspaceDescriptor({ workspace: workspaceDescriptor, hasHydratedWorkspaces, @@ -1772,34 +1762,7 @@ function WorkspaceScreenContent({ ) ) : ( - { - if (!persistenceKey) { - return; - } - const tabId = openWorkspaceTab(persistenceKey, target); - if (tabId) { - navigateToTabId(tabId); - } - }} - onCloseCurrentTab={() => { - if (activeTabDescriptor.tabId) { - void handleCloseTabById(activeTabDescriptor.tabId); - } - }} - onRetargetCurrentTab={(target) => { - if (!persistenceKey) { - return; - } - retargetWorkspaceTab(persistenceKey, activeTabDescriptor.tabId, target); - }} - onOpenWorkspaceFile={(filePath) => { - handleOpenFileFromChat({ filePath }); - }} - /> + ); return ( @@ -2034,23 +1997,13 @@ function WorkspaceScreenContent({ onCloseOtherTabs={handleCloseOtherTabsInPane} onSelectNewTabOption={handleSelectNewTabOption} newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID} - onOpenPaneTab={({ paneId, target }) => { - focusWorkspacePane(persistenceKey, paneId); - const tabId = openWorkspaceTab(persistenceKey, target); - if (tabId) { - navigateToTabId(tabId); - } - }} - onRetargetTab={(tabId, target) => { - retargetWorkspaceTab(persistenceKey, tabId, target); - }} - onOpenWorkspaceFile={({ paneId, filePath }) => { - focusWorkspacePane(persistenceKey, paneId); - const tabId = openWorkspaceTab(persistenceKey, { kind: "file", path: filePath }); - if (tabId) { - navigateToTabId(tabId); - } - }} + buildPaneContentModel={({ paneId, tab }) => + buildPaneContentModel({ + tab, + paneId, + focusPaneBeforeOpen: true, + }) + } onFocusPane={(paneId) => { focusWorkspacePane(persistenceKey, paneId); }} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index ffcf3e67b..d643e0901 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -1,4 +1,5 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { encodeFilePathForPathSegment } from "@/utils/host-routes"; export type WorkspaceTabMenuSurface = "desktop" | "mobile"; @@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput { onCloseOtherTabs: (tabId: string) => Promise | void; } +interface BuildWorkspaceDesktopTabActionsInput { + tab: WorkspaceTabDescriptor; + index: number; + tabCount: number; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsToLeft: (tabId: string) => Promise | void; + onCloseTabsToRight: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +} + +export interface WorkspaceDesktopTabActions { + contextMenuTestId: string; + menuEntries: WorkspaceTabMenuEntry[]; + closeButtonTestId: string; +} + function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string { return surface === "mobile" ? "Close tabs above" : "Close to the left"; } @@ -47,6 +66,19 @@ function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string { return surface === "mobile" ? "close-below" : "close-right"; } +function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "agent") { + return `workspace-agent-close-${tab.target.agentId}`; + } + if (tab.target.kind === "terminal") { + return `workspace-terminal-close-${tab.target.terminalId}`; + } + if (tab.target.kind === "draft") { + return `workspace-draft-close-${tab.target.draftId}`; + } + return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; +} + export function buildWorkspaceTabMenuEntries( input: BuildWorkspaceTabMenuEntriesInput ): WorkspaceTabMenuEntry[] { @@ -136,3 +168,26 @@ export function buildWorkspaceTabMenuEntries( return entries; } + +export function buildWorkspaceDesktopTabActions( + input: BuildWorkspaceDesktopTabActionsInput +): WorkspaceDesktopTabActions { + const contextMenuTestId = `workspace-tab-context-${input.tab.key}`; + return { + contextMenuTestId, + menuEntries: buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: input.tab, + index: input.index, + tabCount: input.tabCount, + menuTestIDBase: contextMenuTestId, + onCopyResumeCommand: input.onCopyResumeCommand, + onCopyAgentId: input.onCopyAgentId, + onCloseTab: input.onCloseTab, + onCloseTabsBefore: input.onCloseTabsToLeft, + onCloseTabsAfter: input.onCloseTabsToRight, + onCloseOtherTabs: input.onCloseOtherTabs, + }), + closeButtonTestId: getCloseButtonTestId(input.tab), + }; +} diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index febf38bf1..e40d5416d 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -87,6 +87,31 @@ describe("deriveWorkspaceTabModel", () => { expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" }); }); + it("normalizes preferredTarget before overriding focused tab selection", () => { + const model = deriveWorkspaceTabModel({ + tabs: [ + { + tabId: "file_/repo/worktree/README.md", + target: { kind: "file", path: "/repo/worktree/README.md" }, + createdAt: 1, + }, + { + tabId: "agent_agent-a", + target: { kind: "agent", agentId: "agent-a" }, + createdAt: 2, + }, + ], + focusedTabId: "agent_agent-a", + preferredTarget: { kind: "file", path: "\\repo\\worktree\\README.md" }, + }); + + expect(model.activeTabId).toBe("file_/repo/worktree/README.md"); + expect(model.activeTab?.descriptor.target).toEqual({ + kind: "file", + path: "/repo/worktree/README.md", + }); + }); + it("keeps retargeted tab ids stable while matching upgraded targets", () => { const model = deriveWorkspaceTabModel({ tabs: [ diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index e13b2a808..93831fde8 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -1,9 +1,11 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; - -export interface WorkspaceDerivedTab { - descriptor: WorkspaceTabDescriptor; -} +import { + deriveWorkspacePaneState, + type WorkspaceDerivedTab, +} from "@/screens/workspace/workspace-pane-state"; +import { + buildDeterministicWorkspaceTabId, +} from "@/utils/workspace-tab-identity"; export interface WorkspaceTabModel { tabs: WorkspaceDerivedTab[]; @@ -11,102 +13,8 @@ export interface WorkspaceTabModel { activeTab: WorkspaceDerivedTab | null; } -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 tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { - if (left.kind !== right.kind) { - return false; - } - if (left.kind === "draft" && right.kind === "draft") { - return left.draftId === right.draftId; - } - if (left.kind === "agent" && right.kind === "agent") { - return left.agentId === right.agentId; - } - if (left.kind === "terminal" && right.kind === "terminal") { - return left.terminalId === right.terminalId; - } - if (left.kind === "file" && right.kind === "file") { - return left.path === right.path; - } - return false; -} - -function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null { - if (!tab || typeof tab !== "object") { - return null; - } - const tabId = trimNonEmpty(tab.tabId); - if (!tabId) { - return null; - } - if (!tab.target || typeof tab.target !== "object") { - return null; - } - if (tab.target.kind === "draft") { - const draftId = trimNonEmpty(tab.target.draftId); - if (!draftId) { - return null; - } - return { - tabId, - target: { kind: "draft", draftId }, - createdAt: tab.createdAt, - }; - } - if (tab.target.kind === "agent") { - const agentId = trimNonEmpty(tab.target.agentId); - if (!agentId) { - return null; - } - return { - tabId, - target: { kind: "agent", agentId }, - createdAt: tab.createdAt, - }; - } - if (tab.target.kind === "terminal") { - const terminalId = trimNonEmpty(tab.target.terminalId); - if (!terminalId) { - return null; - } - return { - tabId, - target: { kind: "terminal", terminalId }, - createdAt: tab.createdAt, - }; - } - if (tab.target.kind === "file") { - const path = trimNonEmpty(tab.target.path); - if (!path) { - return null; - } - return { - tabId, - target: { kind: "file", path: path.replace(/\\/g, "/") }, - createdAt: tab.createdAt, - }; - } - return null; -} - export function buildWorkspaceTabId(target: WorkspaceTabTarget): string { - if (target.kind === "draft") { - return target.draftId; - } - if (target.kind === "agent") { - return `agent_${target.agentId}`; - } - if (target.kind === "terminal") { - return `terminal_${target.terminalId}`; - } - return `file_${target.path}`; + return buildDeterministicWorkspaceTabId(target); } export function deriveWorkspaceTabModel(input: { @@ -114,51 +22,14 @@ export function deriveWorkspaceTabModel(input: { focusedTabId?: string | null; preferredTarget?: WorkspaceTabTarget | null; }): WorkspaceTabModel { - const tabs: WorkspaceDerivedTab[] = []; - const openTabIds = new Set(); - - for (const tab of input.tabs) { - const normalizedTab = normalizeWorkspaceTab(tab); - if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) { - continue; - } - - openTabIds.add(normalizedTab.tabId); - tabs.push({ - descriptor: { - key: normalizedTab.tabId, - tabId: normalizedTab.tabId, - kind: normalizedTab.target.kind, - target: normalizedTab.target, - }, - }); - } - - const focusedTabId = trimNonEmpty(input.focusedTabId); - const preferredTarget = input.preferredTarget ?? null; - const preferredTabId = (() => { - if (!preferredTarget) { - return null; - } - const matchingTab = - tabs.find((tab) => tabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? null; - return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget); - })(); - - const activeTabId = - preferredTabId && openTabIds.has(preferredTabId) - ? preferredTabId - : focusedTabId && openTabIds.has(focusedTabId) - ? focusedTabId - : tabs[0]?.descriptor.tabId ?? null; - - const activeTab = activeTabId - ? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null - : null; - + const paneState = deriveWorkspacePaneState({ + tabs: input.tabs, + focusedTabId: input.focusedTabId, + preferredTarget: input.preferredTarget, + }); return { - tabs, - activeTabId, - activeTab, + tabs: paneState.tabs, + activeTabId: paneState.activeTabId, + activeTab: paneState.activeTab, }; } diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts new file mode 100644 index 000000000..5a034fd08 --- /dev/null +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -0,0 +1,1128 @@ +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 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); +} + +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; + } + + 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 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; + } + 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, + }; +} diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 50f0b53cf..dc30504d4 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -121,7 +121,7 @@ describe("workspace-layout-store tree transforms", () => { vi.restoreAllMocks(); }); - it("insertSplit reuses a matching parent direction instead of nesting", () => { + it("insertSplit wraps root-level same-direction splits in a nested group", () => { vi.spyOn(globalThis.crypto, "randomUUID") .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); @@ -141,10 +141,14 @@ describe("workspace-layout-store tree transforms", () => { const nextRoot = insertSplit(root, "right", "tab-c", "right"); const nextGroup = expectGroup(nextRoot); + const nestedGroup = expectGroup(nextGroup.group.children[1]!); expect(nextGroup.group.direction).toBe("horizontal"); - expect(nextGroup.group.children).toHaveLength(3); - expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]); + expect(nextGroup.group.children).toHaveLength(2); + expect(nextGroup.group.sizes).toEqual([0.25, 0.75]); + expect(nestedGroup.group.id).toBe("group_22222222-2222-2222-2222-222222222222"); + expect(nestedGroup.group.direction).toBe("horizontal"); + expect(nestedGroup.group.sizes).toEqual([0.5, 0.5]); expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([ "left", "right", @@ -519,12 +523,33 @@ describe("workspace-layout-store actions", () => { const rootGroup = expectGroup(layout.root); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); - expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989"); + expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a"); expect(layout.focusedPaneId).toBe(paneCId); expect(rootGroup.group.direction).toBe("horizontal"); - expect(rootGroup.group.children).toEqual([ - createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }), - createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }), + expect(rootGroup.group.children).toHaveLength(2); + expect( + rootGroup.group.children.map((child) => { + expect(child.kind).toBe("pane"); + if (child.kind !== "pane") { + throw new Error("Expected pane child"); + } + return { + id: child.pane.id, + tabIds: child.pane.tabIds, + focusedTabId: child.pane.focusedTabId, + }; + }) + ).toEqual([ + { + id: "main", + tabIds: ["file_/repo/worktree/a.ts"], + focusedTabId: "file_/repo/worktree/a.ts", + }, + { + id: paneCId!, + tabIds: ["file_/repo/worktree/c.ts"], + focusedTabId: "file_/repo/worktree/c.ts", + }, ]); expect(rootGroup.group.sizes).toEqual([0.5, 0.5]); }); @@ -560,7 +585,8 @@ describe("workspace-layout-store actions", () => { it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { vi.spyOn(globalThis.crypto, "randomUUID") .mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee") - .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff"); + .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff") + .mockReturnValueOnce("11111111-1111-1111-1111-111111111111"); const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); @@ -583,17 +609,18 @@ describe("workspace-layout-store actions", () => { const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root; const splitGroup = expectGroup(splitRoot); - store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]); + const nestedGroup = expectGroup(splitGroup.group.children[1]!); + store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root; const resizedGroup = expectGroup(resizedRoot); - const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0); + const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]!); + const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); - expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff"); - expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1); - expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1); - expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1); + expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111"); + expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1); + expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1); expect(total).toBeCloseTo(1, 10); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 07dae7033..87a4e7f5d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import invariant from "tiny-invariant"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { @@ -7,40 +6,48 @@ import { type WorkspaceTab, type WorkspaceTabTarget, } from "@/stores/workspace-tabs-store"; +import { + closeTabInLayout, + collectAllPanes, + collectAllTabs, + createDefaultLayout, + findPaneById, + findPaneContainingTab, + focusPaneInLayout, + focusTabInLayout, + getTreeDepth, + insertSplit, + moveTabToPaneInLayout, + normalizeLayout, + openTabInLayout, + removePaneFromTree, + removeTabFromTree, + reorderFocusedPaneTabsInLayout, + reorderPaneTabsInLayout, + resizeSplitInLayout, + retargetTabInLayout, + splitPaneInLayout, + type SplitGroup, + type SplitNode, + type SplitPane, + type WorkspaceLayout, +} from "@/stores/workspace-layout-actions"; +import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity"; export { buildWorkspaceTabPersistenceKey }; - -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 }; +export { + collectAllPanes, + collectAllTabs, + createDefaultLayout, + findPaneById, + findPaneContainingTab, + getTreeDepth, + insertSplit, + normalizeLayout, + removePaneFromTree, + removeTabFromTree, +}; +export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout }; interface WorkspaceLayoutStore { layoutByWorkspace: Record; @@ -64,8 +71,6 @@ interface WorkspaceLayoutStore { reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; } -const DEFAULT_PANE_ID = "main"; -const MIN_SPLIT_SIZE = 0.1; const MAX_TREE_DEPTH = 4; function trimNonEmpty(value: string | null | undefined): string | null { @@ -76,851 +81,35 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): WorkspaceTabTarget | null { - if (!value || typeof value !== "object" || typeof value.kind !== "string") { - return null; - } - if (value.kind === "draft") { - const draftId = trimNonEmpty(value.draftId); - return draftId ? { kind: "draft", draftId } : null; - } - if (value.kind === "agent") { - const agentId = trimNonEmpty(value.agentId); - return agentId ? { kind: "agent", agentId } : null; - } - if (value.kind === "terminal") { - const terminalId = trimNonEmpty(value.terminalId); - return terminalId ? { kind: "terminal", terminalId } : null; - } - if (value.kind === "file") { - const path = trimNonEmpty(value.path); - return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; - } - return null; -} - -function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { - if (left.kind !== right.kind) { - return false; - } - if (left.kind === "draft" && right.kind === "draft") { - return left.draftId === right.draftId; - } - if (left.kind === "agent" && right.kind === "agent") { - return left.agentId === right.agentId; - } - if (left.kind === "terminal" && right.kind === "terminal") { - return left.terminalId === right.terminalId; - } - if (left.kind === "file" && right.kind === "file") { - return left.path === right.path; - } - return false; -} - -function buildDeterministicTabId(target: WorkspaceTabTarget): string { - if (target.kind === "draft") { - return target.draftId; - } - if (target.kind === "agent") { - return `agent_${target.agentId}`; - } - if (target.kind === "terminal") { - return `terminal_${target.terminalId}`; - } - return `file_${target.path}`; -} - -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 = normalizeTabTarget(tab.target); - const tabId = trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicTabId(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: { sizes: number[]; count: number }): 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); -} - -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: { index: number; node: SplitNodeInternal; sizes: number[] } -): 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 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, - }; -} - function getWorkspaceLayout(state: Record, workspaceKey: string): WorkspaceLayout { return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); } -function reorderTabsForPane(input: { pane: SplitPaneInternal; tabIds: string[] }): 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: { tabId: string; preserveEmptyPaneId?: string | null } -): { root: SplitNodeInternal; tab: WorkspaceTab | null; sourcePaneId: string | null } { - 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: { paneId: string; tab: WorkspaceTab; focusTabId?: string | null } -): 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: { tabId: string; target: WorkspaceTabTarget } -): 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: { groupId: string; sizes: number[] } -): 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: { paneId: string; updater: (pane: SplitPaneInternal) => SplitPaneInternal } -): 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( - root: SplitNodeInternal, - targetPaneId: string, - tabId: string, - position: "left" | "right" | "top" | "bottom" -): { root: SplitNodeInternal; newPaneId: string } { - const direction = position === "left" || position === "right" ? "horizontal" : "vertical"; - const insertAfter = position === "right" || position === "bottom"; - - const targetPathBeforeDetach = findPanePathById(root, targetPaneId); - invariant(targetPathBeforeDetach, `Target pane not found: ${targetPaneId}`); - - const detached = detachTabFromTree(root, { tabId, preserveEmptyPaneId: targetPaneId }); - invariant(detached.tab, `Tab not found: ${tabId}`); - - const targetPath = findPanePathById(detached.root, targetPaneId); - invariant(targetPath, `Target pane not found after detach: ${targetPaneId}`); - const targetNode = getNodeAtPath(detached.root, targetPath); - invariant(targetNode.kind === "pane", "Expected target pane after detach"); - - const newPaneId = generateNodeId("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: generateNodeId("group"), - direction, - children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], - sizes: [0.5, 0.5], - }); - - return { - root: replaceNodeAtPath(detached.root, targetPath, () => newGroup), - newPaneId, - }; -} - -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(asInternalNode(root), targetPaneId, tabId, position).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 const useWorkspaceLayoutStore = create()( persist( (set, get) => ({ layoutByWorkspace: {}, openTab: (workspaceKey, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTarget = normalizeTabTarget(target); + const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTarget) { return null; } - const layout = asInternalLayout(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey)); - const existingTab = collectAllTabs(layout.root).find((tab) => tabTargetsEqual(tab.target, normalizedTarget)); - if (existingTab) { - get().focusTab(normalizedWorkspaceKey, existingTab.tabId); - return existingTab.tabId; - } - - 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 = buildDeterministicTabId(normalizedTarget); - const nextTab: WorkspaceTab = { - tabId, + const result = openTabInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), target: normalizedTarget, - createdAt: Date.now(), - }; - - const nextRoot = insertTabIntoPane(layout.root, { - paneId: focusedPane.id, - tab: nextTab, - focusTabId: tabId, + now: Date.now(), }); set((state) => ({ layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: nextRoot, - focusedPaneId: focusedPane.id, - }, + [normalizedWorkspaceKey]: result.layout, }, })); - return tabId; + return result.tabId; }, closeTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); @@ -930,27 +119,18 @@ export const useWorkspaceLayoutStore = create()( } set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - const pane = findPaneContainingTab(layout.root, normalizedTabId); - if (!pane) { + const nextLayout = closeTabInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + }); + if (!nextLayout) { return state; } - const fallbackPaneId = findNearestSiblingPaneId(layout.root, pane.id); - const nextRoot = removeTabFromTree(layout.root, normalizedTabId) as SplitNodeInternal; - const nextFocusedPaneId = - findPaneById(nextRoot, layout.focusedPaneId)?.id ?? - (fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ?? - collectAllPanes(nextRoot)[0]?.id ?? - DEFAULT_PANE_ID; - return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: nextRoot, - focusedPaneId: nextFocusedPaneId, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); @@ -963,19 +143,18 @@ export const useWorkspaceLayoutStore = create()( } set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - const pane = findPaneContainingTab(layout.root, normalizedTabId); - if (!pane) { + const nextLayout = focusTabInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + }); + if (!nextLayout) { return state; } - const nextRoot = focusTabInPane(layout.root, pane.id, normalizedTabId); + return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: nextRoot, - focusedPaneId: pane.id, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); @@ -983,39 +162,28 @@ export const useWorkspaceLayoutStore = create()( retargetTab: (workspaceKey, tabId, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); - const normalizedTarget = normalizeTabTarget(target); + const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) { return null; } - let resolvedTabId: string | null = null; - set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - const pane = findPaneContainingTab(layout.root, normalizedTabId); - if (!pane) { - return state; - } - const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === normalizedTabId) ?? null; - if (currentTab && tabTargetsEqual(currentTab.target, normalizedTarget)) { - resolvedTabId = normalizedTabId; - return state; - } - resolvedTabId = normalizedTabId; - return { - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: updateTabInTree(layout.root, { - tabId: normalizedTabId, - target: normalizedTarget, - }), - focusedPaneId: layout.focusedPaneId, - }, - }, - }; + const result = retargetTabInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + target: normalizedTarget, }); + if (!result) { + return null; + } - return resolvedTabId; + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); + + return result.tabId; }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); @@ -1024,20 +192,18 @@ export const useWorkspaceLayoutStore = create()( } set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - if (!findPaneById(layout.root, layout.focusedPaneId)) { + const nextLayout = reorderFocusedPaneTabsInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabIds, + }); + if (!nextLayout) { return state; } + return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: updatePaneInTree(layout.root, { - paneId: layout.focusedPaneId, - updater: (pane) => reorderTabsForPane({ pane, tabIds }), - }), - focusedPaneId: layout.focusedPaneId, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); @@ -1047,8 +213,7 @@ export const useWorkspaceLayoutStore = create()( if (!normalizedWorkspaceKey) { return []; } - const layout = asInternalLayout(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey)); - return collectAllTabs(layout.root); + return collectAllTabs(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root); }, splitPane: (workspaceKey, input) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); @@ -1058,39 +223,32 @@ export const useWorkspaceLayoutStore = create()( return null; } - let newPaneId: string | null = null; - set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - if (!findPaneById(layout.root, normalizedTargetPaneId)) { - return state; - } - if (!findPaneContainingTab(layout.root, normalizedTabId)) { - return state; - } - - const result = insertSplitInternal( - layout.root, - normalizedTargetPaneId, - normalizedTabId, - input.position - ); - if (getTreeDepth(result.root) > MAX_TREE_DEPTH) { - return state; - } - - newPaneId = result.newPaneId; - return { - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: result.root, - focusedPaneId: result.newPaneId, - }, - }, - }; + const result = splitPaneInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + targetPaneId: normalizedTargetPaneId, + position: input.position, + maxTreeDepth: MAX_TREE_DEPTH, + createNodeId: (prefix) => { + const randomValue = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}_${randomValue}`; + }, }); + if (!result) { + return null; + } - return newPaneId; + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); + + return result.paneId; }, moveTabToPane: (workspaceKey, tabId, toPaneId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); @@ -1101,33 +259,19 @@ export const useWorkspaceLayoutStore = create()( } set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - const sourcePane = findPaneContainingTab(layout.root, normalizedTabId); - if (!sourcePane || !findPaneById(layout.root, normalizedToPaneId)) { - return state; - } - - const detached = detachTabFromTree(layout.root, { + const nextLayout = moveTabToPaneInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, - preserveEmptyPaneId: sourcePane.id === normalizedToPaneId ? normalizedToPaneId : null, + toPaneId: normalizedToPaneId, }); - if (!detached.tab) { + if (!nextLayout) { return state; } - const nextRoot = insertTabIntoPane(detached.root, { - paneId: normalizedToPaneId, - tab: detached.tab, - focusTabId: normalizedTabId, - }); - return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: nextRoot, - focusedPaneId: normalizedToPaneId, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); @@ -1138,18 +282,20 @@ export const useWorkspaceLayoutStore = create()( if (!normalizedWorkspaceKey || !normalizedPaneId) { return; } + set((state) => { - const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); - if (!findPaneById(layout.root, normalizedPaneId)) { + const nextLayout = focusPaneInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + paneId: normalizedPaneId, + }); + if (!nextLayout) { return state; } + return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: layout.root, - focusedPaneId: normalizedPaneId, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); @@ -1161,22 +307,16 @@ export const useWorkspaceLayoutStore = create()( return; } - set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - const nextRoot = updateGroupSizesInTree(layout.root, { - groupId: normalizedGroupId, - sizes, - }); - return { - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: nextRoot, - focusedPaneId: layout.focusedPaneId, - }, - }, - }; - }); + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: resizeSplitInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + groupId: normalizedGroupId, + sizes, + }), + }, + })); }, reorderTabsInPane: (workspaceKey, paneId, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); @@ -1186,20 +326,19 @@ export const useWorkspaceLayoutStore = create()( } set((state) => { - const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); - if (!findPaneById(layout.root, normalizedPaneId)) { + const nextLayout = reorderPaneTabsInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + paneId: normalizedPaneId, + tabIds, + }); + if (!nextLayout) { return state; } + return { layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - root: updatePaneInTree(layout.root, { - paneId: normalizedPaneId, - updater: (pane) => reorderTabsForPane({ pane, tabIds }), - }), - focusedPaneId: layout.focusedPaneId, - }, + [normalizedWorkspaceKey]: nextLayout, }, }; }); diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts new file mode 100644 index 000000000..b738a06da --- /dev/null +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -0,0 +1,69 @@ +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +export function normalizeWorkspaceTabTarget( + value: WorkspaceTabTarget | null | undefined +): WorkspaceTabTarget | null { + if (!value || typeof value !== "object" || typeof value.kind !== "string") { + return null; + } + if (value.kind === "draft") { + const draftId = trimNonEmpty(value.draftId); + return draftId ? { kind: "draft", draftId } : null; + } + if (value.kind === "agent") { + const agentId = trimNonEmpty(value.agentId); + return agentId ? { kind: "agent", agentId } : null; + } + if (value.kind === "terminal") { + const terminalId = trimNonEmpty(value.terminalId); + return terminalId ? { kind: "terminal", terminalId } : null; + } + if (value.kind === "file") { + const path = trimNonEmpty(value.path); + return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; + } + return null; +} + +export function workspaceTabTargetsEqual( + left: WorkspaceTabTarget, + right: WorkspaceTabTarget +): boolean { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "draft" && right.kind === "draft") { + return left.draftId === right.draftId; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + if (left.kind === "file" && right.kind === "file") { + return left.path === right.path; + } + return false; +} + +export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string { + if (target.kind === "draft") { + return target.draftId; + } + if (target.kind === "agent") { + return `agent_${target.agentId}`; + } + if (target.kind === "terminal") { + return `terminal_${target.terminalId}`; + } + return `file_${target.path}`; +} + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 6580a9773..3bb446400 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -1,8 +1,15 @@ import { defineConfig, configDefaults } from "vitest/config"; import path from "path"; +import fs from "fs"; const appNodeModules = path.resolve(__dirname, "node_modules"); const rootNodeModules = path.resolve(__dirname, "../../node_modules"); +const resolvePackageEntry = (packageName: string) => { + const appPackagePath = path.resolve(appNodeModules, packageName); + return fs.existsSync(appPackagePath) + ? appPackagePath + : path.resolve(rootNodeModules, packageName); +}; export default defineConfig({ test: { @@ -45,10 +52,10 @@ export default defineConfig({ find: "react-native", replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"), }, - { find: "react", replacement: path.resolve(appNodeModules, "react") }, + { find: "react", replacement: resolvePackageEntry("react") }, { find: "react-dom", - replacement: path.resolve(appNodeModules, "react-dom"), + replacement: resolvePackageEntry("react-dom"), }, ], },