- markdown tabs open in rendered preview with a 预览/编辑 segmented toggle; edit mode uses the CodeMirror source editor (markdown highlighting), the preview renders the live unsaved draft, and the editor stays mounted while previewing so edits survive the toggle. - an empty pane shows openable-type cards (explorer / git / terminal, the same options as the + menu, terminal dimmed at the cap) instead of the bare 'this pane is empty' message; clicking a card opens the tab. - the + button sits immediately right of the last tab (sticky at the scrollport's right edge when the tab row overflows, so it stays reachable).
257 lines
9.1 KiB
TypeScript
257 lines
9.1 KiB
TypeScript
/**
|
|
* 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 {
|
|
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
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 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 (
|
|
<div
|
|
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
|
|
onPointerDown={(event) => {
|
|
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). */
|
|
function optionIcon(optionId: string): ReactNode {
|
|
switch (optionId) {
|
|
case 'explorer': return <IconFolderOpen16 size={16} />
|
|
case 'git': return <IconBranchOutline16 size={16} />
|
|
case 'terminal': return <IconCodeOutline16 size={16} />
|
|
default: return <IconCodeOutline16 size={16} />
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className={css.paneEmptyCards}>
|
|
{newTabOptions.map(option => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
className={css.paneCard}
|
|
disabled={option.disabled === true}
|
|
title={option.label}
|
|
onClick={() => { onNewTab(option.id) }}
|
|
>
|
|
{optionIcon(option.id)}
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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<DropZone | null>(null)
|
|
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
|
return (
|
|
<div
|
|
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
|
onPointerDown={() => { 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 && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
|
|
{/*
|
|
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.
|
|
*/}
|
|
<TabBar
|
|
paneId={leaf.id}
|
|
tabs={leaf.tabs}
|
|
active={leaf.active}
|
|
onActivate={(tabId) => { 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 ? (
|
|
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
|
|
) : (
|
|
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<LeafView
|
|
leaf={node}
|
|
newTabOptions={newTabOptions}
|
|
actions={actions}
|
|
onNewTab={onNewTab}
|
|
renderTab={renderTab}
|
|
/>
|
|
)
|
|
}
|
|
const isRow = node.dir === 'row'
|
|
return (
|
|
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
|
|
{node.children.map((child, index) => (
|
|
<Fragment key={child.id}>
|
|
{index > 0 && (
|
|
<Divider
|
|
dir={node.dir}
|
|
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
|
|
/>
|
|
)}
|
|
<div
|
|
className={css.splitChild}
|
|
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
|
>
|
|
<NodeView
|
|
node={child}
|
|
state={state}
|
|
newTabOptions={newTabOptions}
|
|
actions={actions}
|
|
onNewTab={onNewTab}
|
|
renderTab={renderTab}
|
|
/>
|
|
</div>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<div className={css.workbench}>
|
|
<NodeView
|
|
node={state.splits}
|
|
state={state}
|
|
newTabOptions={newTabOptions}
|
|
actions={actions}
|
|
onNewTab={onNewTab}
|
|
renderTab={renderTab}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|