/** * The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized), * overflow scrolls horizontally, a close button per tab, a four-way split * button cluster, and the + menu that opens new tabs (explorer / git / * terminal). Tabs are draggable; dropping onto another tab inserts before it, * dropping on the strip background appends to this pane. */ import { useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconCloseFill14, IconPlusOutline16, Menu, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarTab } from './state.ts' import { tabTypeIcon } from './icons.tsx' import { t } from './locales.ts' import css from './sidebar.module.css' /** One + menu option. */ export interface NewTabOption { id: string label: string disabled?: boolean /** Leading icon (Menu row). */ icon?: ReactNode } /** Drag payload for tab moves (HTML5 DnD dataTransfer). */ export const TAB_DRAG_TYPE = 'application/x-dsh-tab' export interface TabDragPayload { tabId: string paneId: string } export function serializeDrag(payload: TabDragPayload): string { return JSON.stringify(payload) } export function parseDrag(raw: string): TabDragPayload | null { try { const parsed = JSON.parse(raw) as TabDragPayload if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed return null } catch { return null } } export function TabBar(props: { paneId: string tabs: SidebarTab[] active: string | null onActivate: (tabId: string) => void onClose: (tabId: string) => void onNewTab: (optionId: string) => void newTabOptions: NewTabOption[] /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */ onDropTab: (payload: TabDragPayload, before: string | null) => void }) { const { paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, } = props const [menuOpen, setMenuOpen] = useState(false) const [dragOver, setDragOver] = useState(false) return (
{ // The strip owns drops on itself (merge into this pane); stopping // propagation keeps the pane root from also running its edge-zone // handler on the same drop. event.preventDefault() event.stopPropagation() setDragOver(true) }} onDragLeave={() => { setDragOver(false) }} onDrop={(event) => { event.preventDefault() event.stopPropagation() setDragOver(false) const raw = event.dataTransfer.getData(TAB_DRAG_TYPE) const payload = parseDrag(raw) if (payload !== null) onDropTab(payload, null) }} >
{tabs.map(tab => (
{ event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId })) event.dataTransfer.effectAllowed = 'move' }} onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }} onDrop={(event) => { event.preventDefault() event.stopPropagation() const raw = event.dataTransfer.getData(TAB_DRAG_TYPE) const payload = parseDrag(raw) if (payload !== null) onDropTab(payload, tab.id) }} onClick={() => { onActivate(tab.id) }} onAuxClick={(event) => { // Middle-click closes the tab (and suppresses autoscroll). if (event.button === 1) { event.preventDefault() onClose(tab.id) } }} > {tabTypeIcon(tab.type, 14)} {tab.title}
))} {/* The + sits immediately after the rightmost tab (sticky at the right edge of the scrollport when the tabs overflow, so it stays reachable no matter how many tabs are open). */} { setMenuOpen(false) }} items={newTabOptions.map(option => ({ id: option.id, label: option.label, ...(option.disabled === true ? { disabled: true } : {}), ...(option.icon !== undefined ? { icon: option.icon } : {}), }))} onSelect={(id) => { onNewTab(id) setMenuOpen(false) }} portal align="end" anchor={( )} />
) }