From e40039ceec5b709963a815a36bd4b0a63be9024c Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 01:39:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=8C=E5=85=A8=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E5=86=85=E7=BD=AE=20tab=20=E2=80=94=20dedupe=20?= =?UTF-8?q?=E5=BD=92=20service=EF=BC=8C=E5=9B=BE=E6=A0=87=E5=BD=92=20descr?= =?UTF-8?q?iptor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/client/Sidebar.tsx | 25 ++++++++++++------------- src/client/TabBar.tsx | 7 ++++--- src/client/intercept.tsx | 11 ++++------- src/client/service.ts | 6 +++--- src/client/split-pane.tsx | 16 +++++++++++----- src/client/state.ts | 36 ++++++++++-------------------------- tests/unit.spec.ts | 13 ++++++++----- 7 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 1f824a2..39b0356 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 + }} /> diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 493f99b..116c9af 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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} {tab.title} ))} @@ -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(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 ( ) } @@ -250,6 +253,7 @@ function NodeView(props: { actions={actions} onNewTab={onNewTab} renderTab={renderTab} + getTabIcon={getTabIcon} /> @@ -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 (
) diff --git a/src/client/state.ts b/src/client/state.ts index 82eb64a..c6ce084 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 2a36ba1..c16fdc2 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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 => ({