Files
apes-sidebar/src/client/split-pane.tsx
T
Menghuan1918 97e1f60e47 ui: preview/edit toggle for markdown, empty-pane open cards, + flush to the rightmost tab
- 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).
2026-08-08 15:16:09 +08:00

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>
)
}