- Tabs, the + menu, and empty-pane cards carry per-type icons (folder / branch / terminal / code file); a terminal glyph joins the icon set - Hovering an explorer row reveals an @文件 button at the far right that appends @<relative path> to the composer draft (session-scoped input service, space-separated append) - Right-clicking a row opens a context menu: copy relative / absolute path (writeClipboard + transient in-row "copied" label) - Rows become div role=button (Enter/Space) to host the inner button; relativeTo helper unit-tested
262 lines
9.4 KiB
TypeScript
262 lines
9.4 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 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 (
|
|
<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). */
|
|
/**
|
|
* 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) }}
|
|
>
|
|
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
|
|
<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 ? (
|
|
/*
|
|
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.
|
|
*/
|
|
<div className={css.paneContent}>
|
|
{leaf.tabs.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
|
|
>
|
|
{renderTab(tab)}
|
|
</div>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|