- state.openTab 简化为 '添加到 pane + id dedupe',移除 isSingle/editor-path/diff-id 三分支 (dedupe 策略归 service.openTab 通过 descriptor.dedupeKey 统一) - intercept.tsx openSidebarFile 改走 service.openTab(editor descriptor 的 dedupeKey 接管 per-path 去重) - Sidebar.tsx 两处 subagent 触发(auto-activation + jump-back)改走 service.openTab - service.openTab seed 加可选 id 字段(editor 需要 path-derived id) - TabBar.tsx 加 getTabIcon 回调,tab 标签图标查 descriptor.icon 替代 tabTypeIcon - split-pane.tsx 移除 tabTypeIcon 兜底(buildNewTabOptions 已从 descriptor 填 icon) - Workbench/NodeView/LeafView 透传 getTabIcon - tests/unit.spec.ts 更新 openTab 测试(id dedupe 替代 type dedupe)
189 lines
6.2 KiB
TypeScript
189 lines
6.2 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 { useEffect, 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 { 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
|
|
}
|
|
}
|
|
|
|
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
|
function setTabDragging(active: boolean): void {
|
|
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
|
else document.body.removeAttribute('data-dsh-tab-dragging')
|
|
}
|
|
|
|
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
|
|
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
|
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
}) {
|
|
const {
|
|
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
|
} = props
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [dragOver, setDragOver] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
|
window.addEventListener('dragend', clear, true)
|
|
window.addEventListener('drop', clear, true)
|
|
window.addEventListener('blur', clear)
|
|
return () => {
|
|
window.removeEventListener('dragend', clear, true)
|
|
window.removeEventListener('drop', clear, true)
|
|
window.removeEventListener('blur', clear)
|
|
}
|
|
}, [])
|
|
|
|
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)
|
|
setTabDragging(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) => {
|
|
setTabDragging(true)
|
|
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
}}
|
|
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
|
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
setTabDragging(false)
|
|
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)
|
|
}
|
|
}}
|
|
>
|
|
{getTabIcon?.(tab) ?? null}
|
|
<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>
|
|
)
|
|
}
|