/**
* 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 (
{ 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.
*/