/** * The split-pane workbench: renders the recursive split tree. A split lays * children out row- or column-wise with draggable dividers (fractional * sizes); a leaf renders its tab strip plus the active tab's content. * * Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab * over a pane, a drop overlay shows five zones — four edges (left/right/up/ * down) that split the pane with the tab in a fresh leaf, and the center * that merges the tab into the pane. The tree and all operations live in * state.ts; this file is pure presentation over them. */ import { Fragment, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import type { SidebarState, SidebarTab, SplitNode } from './state.ts' import type { DropZone } from './state.ts' import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx' import { tabTypeIcon } from './icons.tsx' import css from './sidebar.module.css' /** Actions the workbench needs (bound to the store by the sidebar shell). */ export interface WorkbenchActions { closeTab: (paneId: string, tabId: string) => void activateTab: (paneId: string, tabId: string) => void /** Make a pane the target of newly opened tabs (click focus). */ focusPane: (paneId: string) => void /** VSCode drag gesture: edge → split the target pane, center → merge. */ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void /** Reorder within a pane (drop onto another tab inserts before it). */ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void resizeSplit: (splitId: string, index: number, deltaFrac: number) => void } /** One divider: pointer-capture drag translating px deltas into fractions. */ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) { const { dir, onResize } = props const start = useRef({ x: 0, y: 0, size: 0 }) const [dragging, setDragging] = useState(false) return (
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) const box = event.currentTarget.parentElement?.getBoundingClientRect() start.current = { x: event.clientX, y: event.clientY, size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height), } setDragging(true) }} onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const delta = dir === 'row' ? event.clientX - start.current.x : event.clientY - start.current.y const size = Math.max(1, start.current.size) onResize(delta / size) }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return event.currentTarget.releasePointerCapture(event.pointerId) setDragging(false) }} /> ) } /** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone { const rect = pane.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) return 'center' const x = (event.clientX - rect.left) / rect.width const y = (event.clientY - rect.top) / rect.height if (x < 0.25) return 'left' if (x > 0.75) return 'right' if (y < 0.25) return 'up' if (y > 0.75) return 'down' return 'center' } /** The icon of one openable type card (mirror of the + menu options). */ /** * An empty pane's welcome cards: the openable types as cards, clicked to * open (instead of a bare "this pane is empty" message). */ function PaneEmptyCards(props: { newTabOptions: NewTabOption[] onNewTab: (optionId: string) => void }) { const { newTabOptions, onNewTab } = props return (
{newTabOptions.map(option => ( ))}
) } /** A leaf: tab strip + active content + VSCode-style drop target for tabs. */ function LeafView(props: { leaf: { id: string; tabs: SidebarTab[]; active: string | null } newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab) => ReactNode }) { const { leaf, newTabOptions, actions, onNewTab, renderTab } = props const [dropZone, setDropZone] = useState(null) const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1] return (
{ actions.focusPane(leaf.id) }} onDragOver={(event) => { event.preventDefault() const zone = zoneAt(event, event.currentTarget) setDropZone(zone) }} onDragLeave={(event) => { // Only clear when the pointer left the pane entirely (not onto a child). if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setDropZone(null) } }} onDrop={(event) => { event.preventDefault() const zone = dropZone ?? zoneAt(event, event.currentTarget) setDropZone(null) const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab')) if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone) }} > {dropZone !== null &&
} {/* The tab strip renders even for an empty pane: the + menu must stay reachable when the pane has no tabs (fresh split, or the last tab was dragged out), so a new tab can always be created or dragged in. */} { actions.activateTab(leaf.id, tabId) }} onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }} onNewTab={onNewTab} newTabOptions={newTabOptions} onDropTab={(payload, before) => { if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center') else actions.moveTabBefore(payload, leaf.id, before) }} /> {leaf.tabs.length > 0 ? ( /* Every tab stays MOUNTED (inactive ones hidden), so switching tabs never tears down the content: a terminal keeps its pty connection and scrollback, an editor keeps its CodeMirror view and unsaved draft, explorer/git keep their loaded data. The unmount (and the terminal's close frame) happens only when a tab is truly closed. */
{leaf.tabs.map(tab => (
{renderTab(tab)}
))}
) : ( )}
) } /** Recursive node renderer. */ function NodeView(props: { node: SplitNode state: SidebarState newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab) => ReactNode }) { const { node, state, newTabOptions, actions, onNewTab, renderTab } = props if (node.kind === 'leaf') { return ( ) } const isRow = node.dir === 'row' return (
{node.children.map((child, index) => ( {index > 0 && ( { actions.resizeSplit(node.id, index - 1, deltaFrac) }} /> )}
))}
) } /** The workbench: the split tree filling the sidebar body. */ export function Workbench(props: { state: SidebarState newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab) => ReactNode }) { const { state, newTabOptions, actions, onNewTab, renderTab } = props return (
) }