- 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
166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
/**
|
|
* 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 (
|
|
<div
|
|
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
|
onDragOver={(event) => {
|
|
// 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)
|
|
}}
|
|
>
|
|
<div className={css.tabList}>
|
|
{tabs.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
|
title={tab.title}
|
|
draggable
|
|
onDragStart={(event) => {
|
|
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)}
|
|
<span className={css.tabTitle}>{tab.title}</span>
|
|
<button
|
|
type="button"
|
|
className={css.tabClose}
|
|
aria-label={t('close')}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onClose(tab.id)
|
|
}}
|
|
>
|
|
<IconCloseFill14 />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{/*
|
|
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).
|
|
*/}
|
|
<Menu
|
|
open={menuOpen}
|
|
onClose={() => { 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={(
|
|
<button
|
|
type="button"
|
|
className={css.tabBarPlus}
|
|
aria-label={t('newTab')}
|
|
title={t('newTab')}
|
|
onClick={() => { setMenuOpen(v => !v) }}
|
|
>
|
|
<IconPlusOutline16 />
|
|
</button>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|