import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { DndContext, KeyboardSensor, PointerSensor, closestCenter, pointerWithin, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; 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 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"; interface SplitContainerProps { layout: WorkspaceLayout; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; uiTabs: WorkspaceTab[]; 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; onCopyAgentId: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; 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; onFocusPane: (paneId: string) => void; onSplitPane: (input: { tabId: string; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; }) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void; onResizeSplit: (groupId: string, sizes: number[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; renderPaneEmptyState?: () => ReactNode; } interface WorkspaceTabDragData { kind: "workspace-tab"; paneId: string; tabId: string; } interface SplitPaneDropData { kind: "split-pane-drop"; paneId: string; } interface SplitNodeViewProps extends Omit { node: SplitNode; tabsById: Map; focusedPaneId: string; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; } interface SplitPaneViewProps extends Omit< SplitNodeViewProps, | "node" | "tabsById" | "focusedPaneId" | "activeDragTabId" | "showDropZones" | "dropPreview" | "onDropPreviewChange" | "onSplitPane" | "onMoveTabToPane" | "onResizeSplit" > { pane: SplitPane; tabsById: Map; focused: boolean; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; } const dropCollisionDetection: CollisionDetection = (args) => { const pointerHits = pointerWithin(args); const tabHits = pointerHits.filter( (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab" ); if (tabHits.length > 0) { return tabHits; } const paneHits = pointerHits.filter( (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop" ); if (paneHits.length > 0) { return paneHits; } return closestCenter(args); }; export function SplitContainer({ layout, normalizedServerId, normalizedWorkspaceId, uiTabs, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, isArchivingAgent, killTerminalPending, killTerminalId, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId = "__new_tab_agent__", onOpenPaneTab, onRetargetTab, onOpenWorkspaceFile, onFocusPane, onSplitPane, onMoveTabToPane, onResizeSplit, onReorderTabsInPane, renderPaneEmptyState = () => null, }: SplitContainerProps) { const [activeDragTabId, setActiveDragTabId] = useState(null); const [dropPreview, setDropPreview] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); 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) => { const data = event.active.data.current as WorkspaceTabDragData | undefined; if (data?.kind !== "workspace-tab") { setActiveDragTabId(null); setDropPreview(null); return; } setActiveDragTabId(data.tabId); }, []); const handleDragCancel = useCallback(() => { setActiveDragTabId(null); setDropPreview(null); }, []); const handleDragEnd = useCallback( (event: DragEndEvent) => { const activeData = event.active.data.current as WorkspaceTabDragData | undefined; const overData = event.over?.data.current as | WorkspaceTabDragData | SplitPaneDropData | undefined; setActiveDragTabId(null); if (activeData?.kind !== "workspace-tab" || !event.over) { setDropPreview(null); return; } if (overData?.kind === "workspace-tab") { const sourcePane = panesById.get(activeData.paneId) ?? null; const targetPane = panesById.get(overData.paneId) ?? null; if (!sourcePane || !targetPane) { setDropPreview(null); return; } const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById); const targetTabs = getPaneTabDescriptors(targetPane, tabsById); const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId); if (sourceIndex < 0 || targetIndex < 0) { setDropPreview(null); return; } if (activeData.paneId === overData.paneId) { if (sourceIndex !== targetIndex) { const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex); onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId)); } setDropPreview(null); return; } const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); nextTargetTabIds.splice(targetIndex, 0, activeData.tabId); onMoveTabToPane(activeData.tabId, overData.paneId); onReorderTabsInPane(overData.paneId, nextTargetTabIds); setDropPreview(null); return; } if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) { if (dropPreview.position === "center") { if (activeData.paneId !== overData.paneId) { onMoveTabToPane(activeData.tabId, overData.paneId); } setDropPreview(null); return; } onSplitPane({ tabId: activeData.tabId, targetPaneId: overData.paneId, position: dropPreview.position, }); } setDropPreview(null); }, [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById] ); return ( ); } function SplitNodeView({ node, tabsById, focusedPaneId, normalizedServerId, normalizedWorkspaceId, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, isArchivingAgent, killTerminalPending, killTerminalId, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId, onOpenPaneTab, onRetargetTab, onOpenWorkspaceFile, onFocusPane, onSplitPane, onMoveTabToPane, onResizeSplit, onReorderTabsInPane, renderPaneEmptyState, activeDragTabId, showDropZones, dropPreview, onDropPreviewChange, }: SplitNodeViewProps) { if (node.kind === "pane") { return ( ); } return ( {node.group.children.map((child, index) => ( {index < node.group.children.length - 1 ? ( ) : null} ))} ); } function SplitPaneView({ pane, tabsById, focused, normalizedServerId, normalizedWorkspaceId, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, isArchivingAgent, killTerminalPending, killTerminalId, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId, onOpenPaneTab, onRetargetTab, onOpenWorkspaceFile, onFocusPane, onReorderTabsInPane, renderPaneEmptyState, activeDragTabId, showDropZones, dropPreview, onDropPreviewChange, }: SplitPaneViewProps) { const { theme } = useUnistyles(); const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]); const paneModel = useMemo( () => deriveWorkspaceTabModel({ tabs: paneTabs.map((tab) => ({ tabId: tab.tabId, target: tab.target, createdAt: 0, })), focusedTabId: pane.focusedTabId, }), [pane.focusedTabId, paneTabs] ); const activeTabDescriptor = paneModel.activeTab ? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null : null; return ( { onFocusPane(pane.id); }} > onCloseTabsToLeft(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} onSelectNewTabOption={onSelectNewTabOption} newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} onReorderTabs={(nextTabs) => { onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); }} externalDndContext activeDragTabId={activeDragTabId} /> {activeTabDescriptor ? ( { onOpenPaneTab({ paneId: pane.id, target, }); }} onCloseCurrentTab={() => { void onCloseTab(activeTabDescriptor.tabId); }} onRetargetCurrentTab={(target) => { onRetargetTab(activeTabDescriptor.tabId, target); }} onOpenWorkspaceFile={(filePath) => { onOpenWorkspaceFile({ paneId: pane.id, filePath, }); }} /> ) : ( renderPaneEmptyState?.() ?? null )} ); } function collectPanesById(node: SplitNode): Map { const next = new Map(); function visit(current: SplitNode) { if (current.kind === "pane") { next.set(current.pane.id, current.pane); return; } for (const child of current.group.children) { visit(child); } } visit(node); 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; } return node.group.id; } const styles = StyleSheet.create((theme) => ({ group: { flex: 1, minWidth: 0, minHeight: 0, }, groupHorizontal: { flexDirection: "row", }, groupVertical: { flexDirection: "column", }, groupChild: { flexBasis: 0, minWidth: 0, minHeight: 0, }, pane: { position: "relative", flex: 1, minWidth: 0, minHeight: 0, backgroundColor: theme.colors.surface0, borderWidth: 1, overflow: "hidden", }, paneTabs: { borderTopWidth: 2, borderTopColor: "transparent", }, paneContent: { flex: 1, minWidth: 0, minHeight: 0, }, }));