refactor: 完全解耦内置 tab — dedupe 归 service,图标归 descriptor
- 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)
This commit is contained in:
+12
-13
@@ -15,7 +15,7 @@ import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import {
|
||||
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab,
|
||||
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
@@ -185,12 +185,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionList, sessionId, store])
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
@@ -208,12 +205,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionId, store])
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// Panel width drag (left edge strip).
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
@@ -443,6 +437,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={(tab) => {
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) return null
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ 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'
|
||||
|
||||
@@ -62,9 +61,11 @@ export function TabBar(props: {
|
||||
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,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
@@ -134,7 +135,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tabTypeIcon(tab.type, 14)}
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { openTab, type SidebarStore } from './state.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -19,12 +19,9 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
|
||||
const absolute = resolveSidebarPath(summary?.cwd, path)
|
||||
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||
store.reduce(state => openTab(state, {
|
||||
id: `editor:${absolute}`,
|
||||
type: 'editor',
|
||||
title,
|
||||
path: absolute,
|
||||
}))
|
||||
// Route through the sidebar service so the editor descriptor's dedupeKey
|
||||
// (per-path) applies; the id is path-derived so multiple editors coexist.
|
||||
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
}
|
||||
|
||||
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface BetterSidebarService {
|
||||
/** Find a file viewer for a path (priority desc → detect → exts). */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** Open a tab (used by external tabs and the + menu). */
|
||||
openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void
|
||||
openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
@@ -216,7 +216,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void => {
|
||||
const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
@@ -228,7 +228,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return result.patch !== undefined ? { ...next, ...result.patch } : next
|
||||
}
|
||||
const tab: SidebarTab = {
|
||||
id: seed.type,
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title,
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
|
||||
@@ -15,7 +15,6 @@ 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). */
|
||||
@@ -108,7 +107,7 @@ function PaneEmptyCards(props: {
|
||||
title={option.label}
|
||||
onClick={() => { onNewTab(option.id) }}
|
||||
>
|
||||
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
|
||||
{option.icon ?? null}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -123,8 +122,9 @@ function LeafView(props: {
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
@@ -177,6 +177,7 @@ function LeafView(props: {
|
||||
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
||||
onNewTab={onNewTab}
|
||||
newTabOptions={newTabOptions}
|
||||
getTabIcon={getTabIcon}
|
||||
onDropTab={(payload, before) => {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
@@ -215,8 +216,9 @@ function NodeView(props: {
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
@@ -225,6 +227,7 @@ function NodeView(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -250,6 +253,7 @@ function NodeView(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
@@ -265,8 +269,9 @@ export function Workbench(props: {
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
@@ -276,6 +281,7 @@ export function Workbench(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+10
-26
@@ -309,15 +309,13 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
|
||||
function isSingle(type: TabType): boolean {
|
||||
return type === 'explorer' || type === 'git' || type === 'subagent'
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab (or focus its existing instance). Single-instance types focus
|
||||
* the existing tab wherever it lives; editors dedupe by path. The tab lands
|
||||
* in the active pane (or the first pane when the active one is gone).
|
||||
* Open a tab (or focus its existing instance by id). Dedup strategies
|
||||
* (single-instance, per-path, per-change) are owned by the tab descriptor
|
||||
* through {@link BetterSidebarService.openTab} / `dedupeKey`; this reducer
|
||||
* only handles the id-based safety net (reconcile/openDiffTab already
|
||||
* check existence before calling) and lands the tab in the active pane
|
||||
* (or the first pane when the active one is gone).
|
||||
*/
|
||||
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
@@ -326,24 +324,10 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
if (isSingle(tab.type)) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'editor' && tab.path !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'diff') {
|
||||
// One tab per change: the same path+side (or commit) focuses its instance.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
// Id-based safety net: if a tab with the same id exists, focus it.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
|
||||
+8
-5
@@ -214,23 +214,26 @@ describe('git parsing', () => {
|
||||
describe('sidebar state', () => {
|
||||
const state = (): SidebarState => makeDefaultState()
|
||||
|
||||
it('opens tabs into the active pane and dedupes single-instance types', () => {
|
||||
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
|
||||
let s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
s = openTab(s, gitTab)
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening git focuses the existing tab instead of duplicating.
|
||||
const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
// Reopening with the SAME id focuses the existing tab instead of duplicating.
|
||||
const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// A different id opens a new tab (type-level dedupe is the service's job).
|
||||
const after2 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('dedupes editors by path', () => {
|
||||
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
|
||||
let s = state()
|
||||
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
|
||||
})
|
||||
|
||||
const diffTab = (id: string): SidebarTab => ({
|
||||
|
||||
Reference in New Issue
Block a user