fix(service): 注册表核心修正 — seed.title 优先、single 语法糖、available 三参、单趟匹配、dedupe 委托

- service.openTab 的 seed.title 优先于 descriptor.title(修复编辑器 tab 标题
  从文件名变成 t('editor') 的回归);title 变为可选,descriptor.title 兜底
- TabDescriptor 新增 single?: boolean 语法糖(展开为 dedupeKey: () => id),
  dedupeKey 显式给出时优先
- available 签名升级为 (ctx, scope, state) 三参(设计文档 §5.2 的超集),
  Sidebar.buildNewTabOptions 与 terminal 配额检查适配
- matchFileViewer 从三趟全局遍历改为设计文档 §8.2 的单趟 per-descriptor 算法:
  priority 降序,每个 descriptor 先 detect(head 可用时)再 exts,[] 为 catch-all
- applyDedupe 的落位逻辑委托给 state.openTabInActivePane(消除重复实现),
  同时 id 安全网现在也覆盖 service.openTab 路径(同 id 打开聚焦既有 tab)
- state.ts:删除零引用的 SIDEBAR_BUILTIN_TAB_TYPES;TERMINAL_LIMIT 移入
  builtins.tsx(state 不再关心 terminal);openTab reducer 改名 openTabInActivePane
- Sidebar.tsx 删除 TERMINAL_LIMIT/allLeaves 死 import;openDiffTab 补充
  dedupeKey 一致性注释(其按 id 去重与 diff descriptor 的 dedupeKey 同规则)
- tests/service.spec.ts 重写匹配/去重断言并新增 title/single/available 用例
This commit is contained in:
Menghuan1918
2026-08-11 11:12:29 +08:00
parent 7687204b43
commit db0ba36610
7 changed files with 231 additions and 107 deletions
+1
View File
@@ -261,6 +261,7 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se
)}
{load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
+6 -6
View File
@@ -15,10 +15,10 @@ 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,
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
@@ -27,7 +27,7 @@ import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { t } from './locales.ts'
import { api } from './api.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
@@ -66,7 +66,7 @@ function TabContent(props: {
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
* a disabled row (e.g. terminal at capacity) instead of hiding the option. */
function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] {
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
const service = ctx.betterSidebar
if (service === undefined) return []
return service.getTabs()
@@ -75,7 +75,7 @@ function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] {
.map(d => ({
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(state) ?? true),
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
}))
}
@@ -433,7 +433,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx)}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
+8 -5
View File
@@ -11,7 +11,7 @@ import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { TERMINAL_LIMIT, allLeaves, isAgentTabId, type SidebarState } from './state.ts'
import { allLeaves, isAgentTabId, type SidebarState } from './state.ts'
import { downloadUrl, mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { openSidebarFile } from './intercept.tsx'
@@ -28,6 +28,9 @@ import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx'
import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts'
import css from './sidebar.module.css'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
const TERMINAL_LIMIT = 3
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
@@ -54,7 +57,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
dedupeKey: () => 'explorer',
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
@@ -71,7 +74,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
dedupeKey: () => 'git',
single: true,
component: ({ ctx, store, scope, onOpenDiff }) => (
<GitView
scope={scope}
@@ -85,7 +88,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
dedupeKey: () => 'subagent',
single: true,
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
@@ -100,7 +103,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (state) => uiTerminalCount(state) < TERMINAL_LIMIT,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
+55 -48
View File
@@ -2,7 +2,7 @@
* The BetterSidebar client service: a registry that external plugins use
* to contribute sidebar tab types and file previewers. The service is
* published to the cordis context as `ctx.betterSidebar` (see
* {@link ../types.ts}); consumers declare it in `inject` and call
* {@link ../context-types.ts}); consumers declare it in `inject` and call
* `registerTab` / `registerFileViewer`, both returning a disposer that
* cordis auto-invokes on fiber disposal (HMR-safe).
*
@@ -12,15 +12,17 @@
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
* `single: true` is sugar for `dedupeKey: () => id`.
* - `createTab` lets a descriptor own tab instantiation (the terminal
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
* - `matchFileViewer` resolves viewers by priority (desc), then `detect`
* (content sniff), then `exts` (lowercase, no dot).
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
* per descriptor it tries `detect` first (when `head` bytes are given),
* then `exts`; `exts: []` is a catch-all that matches any path.
*/
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf,
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import type { SessionScope } from './api.ts'
@@ -52,14 +54,24 @@ export interface TabDescriptor {
order?: number
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
hidden?: boolean
/** + menu disabled predicate (e.g. terminal at capacity). */
available?: (state: SidebarState) => boolean
/**
* + menu disabled predicate (e.g. terminal at capacity). Receives the
* session scope and the live sidebar state (counts, expansions).
*/
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
* (opening the tab focuses an existing one of the same type instead of
* creating a duplicate). An explicit `dedupeKey` always wins when both
* are given. Builtins: explorer/git/subagent use `single: true`.
*/
single?: boolean
/**
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
* tab's key focuses the existing one instead of creating a new one.
* Returning `undefined` means "no dedup — always open a new tab".
* Builtins: explorer/git/subagent use `() => id`; editor uses `tab => tab.path`;
* diff uses `tab => tab.id` (openDiffTab mints change-derived ids).
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
* (openDiffTab mints change-derived ids).
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
@@ -87,6 +99,8 @@ export interface FileViewerProps {
scope: SessionScope
path: string
title: string
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
viewerId: string
/** fsRead text content (fetchStrategy='fsRead'). */
content?: string
truncated?: boolean
@@ -106,8 +120,8 @@ export interface FileViewerDescriptor {
priority?: number
fetchStrategy: FileFetchStrategy
/**
* Content sniff (overrides exts when `head` bytes are available).
* The first descriptor (priority desc) whose `detect` returns true wins.
* Content sniff: when `head` bytes are available the descriptor's `detect`
* is consulted before its `exts` (per-descriptor, in priority order).
*/
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' loader. */
@@ -123,10 +137,15 @@ export interface BetterSidebarService {
getFileViewers(): readonly FileViewerDescriptor[]
/** Find a tab descriptor by id (undefined if not registered). */
getTab(id: string): TabDescriptor | undefined
/** Find a file viewer for a path (priority desc → detect → exts). */
/** Find a file viewer for a path (priority desc; detect first, then 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']; id?: string }): void
/**
* Open a tab (used by external tabs and the + menu). `title` overrides
* the descriptor's title when given (the editor tab shows the file name);
* when the descriptor provides `createTab` it mints the tab itself and
* `title`/`path`/`id` are ignored.
*/
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). */
@@ -194,29 +213,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
const ext = extOfPath(path)
// Priority descending; stable order for equal priorities (insertion order).
const sorted = Array.from(viewers.values()).sort(
// Single pass in priority order (descending; stable for equal
// priorities — insertion order). Each descriptor gets first refusal in
// its own turn: `detect` (when head bytes are available) beats its own
// `exts`, and `exts: []` is a catch-all matching any path — so the
// catch-all `code` viewer (-100) only sees paths no higher-priority
// descriptor claimed.
for (const v of Array.from(viewers.values()).sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)
// Pass 1: content sniff (detect overrides exts when head bytes are available).
if (head !== undefined) {
for (const v of sorted) {
if (v.detect !== undefined && v.detect(path, head)) return v
}
}
// Pass 2: extension match ([] is catch-all, skipped here).
for (const v of sorted) {
if (v.exts.length === 0) continue
if (v.exts.includes(ext)) return v
}
// Pass 3: catch-all (exts === []).
for (const v of sorted) {
if (v.exts.length === 0) return v
)) {
if (head !== undefined && v.detect !== undefined && v.detect(path, head)) return v
if (v.exts.length === 0 || v.exts.includes(ext)) return v
}
return undefined
}
const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): 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
@@ -230,7 +242,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const tab: SidebarTab = {
id: seed.id ?? seed.type,
type: seed.type,
title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
}
@@ -260,29 +274,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
}
/**
* Apply dedup: if a tab with the same dedupeKey exists, focus it instead.
* Mirrors the three branches the old `openTab` hardcoded (single/editor/diff).
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
* same type exists, focus it; otherwise land the tab through
* `openTabInActivePane` (the id safety net + active-pane landing are that
* reducer's job — not re-implemented here).
* `single: true` resolves to the id-key sugar when no explicit key is given.
*/
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
const key = descriptor.dedupeKey?.(tab)
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key)
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
let targetId = state.activePane ?? firstLeaf(state.splits).id
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
return openTabInActivePane(state, tab)
}
/** Find which pane hosts a tab id ('' if none). */
+21 -20
View File
@@ -12,18 +12,13 @@
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
/**
* Tab type identifier. Builtins use the strings below; external plugins
* register their own ids (e.g. `'my-plugin:db'`) through the sidebar
* service. Kept as `string` so the registry is open; {@link SIDEBAR_BUILTIN_TAB_TYPES}
* lists the builtins for back-compat checks.
* Tab type identifier. Builtins register their ids (explorer / git / editor
* / terminal / subagent / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open.
*/
export type TabType = string
/** The tab type ids the plugin itself registers (see {@link ./builtins.ts}). */
export const SIDEBAR_BUILTIN_TAB_TYPES = [
'explorer', 'git', 'editor', 'terminal', 'subagent', 'diff',
] as readonly string[]
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
@@ -75,7 +70,6 @@ export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
export const TERMINAL_LIMIT = 3
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -310,14 +304,15 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
/**
* 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).
* Land a tab in the active pane (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 and
* openDiffTab already check existence before calling) and the landing
* itself — the service's dedupe path delegates here after its dedupeKey
* check misses.
*/
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
// A stale activePane (its pane was closed since) must not swallow the
// open: fall back to the first pane instead of dropping the tab.
@@ -373,6 +368,12 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
* already holds diff tabs (diff panes are sticky — repeated clicks stack
* there); on the FIRST diff of a layout the source pane splits vertically so
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
*
* This is split-tree placement surgery, not registry dispatch: the diff tab
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
* check below is exactly that rule — the two agree by construction (asserted
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
* the id check is the per-change dedupe.
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
@@ -393,7 +394,7 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
return openTab(state, tab)
return openTabInActivePane(state, tab)
}
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
return { ...state, splits: result.node, activePane: result.leafId }
@@ -488,7 +489,7 @@ export function reconcileAgentTerminals(
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTab; the next reconcile is a no-op for them.
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
@@ -496,7 +497,7 @@ export function reconcileAgentTerminals(
type: 'terminal',
title: terminal.title,
}
next = openTab(next, tab)
next = openTabInActivePane(next, tab)
}
return next
}