- package.json exports 新增 './client/api' 别名指向 service.d.ts(设计文档 §7 的公共导入路径,api.ts 已被 fetch 助手占用故不换文件名) - matchFileViewer 补充 sniff-only 规则:exts: [] + detect 的 descriptor 在 无 head 时不得盲认领(AGENTS.md 的 magic-sniffer 示例原实现会吞掉 图片/PDF 等真实 viewer 的文件);有 head 时只认领 detect 命中的。 新增测试断言三种情形(无 head / magic 命中 / magic miss) - docs/plans/2026-08-11-service-registry-design.md 追加 §17 实施偏差记录: dedupeKey+single 语法糖、available 三参、单趟匹配+嗅探规则、EditorHost/ TextEditor 拆分、fs.read head 字段、导出别名、+ 菜单 disabled 行、 保留的 store 级操作、内置清单、测试 - AGENTS.md:available 三参、single 语法糖、viewerId、单趟匹配+head 语义、 8 viewer 内置清单、openTab seed.title 可选、参考实现指向 builtins/ 目录 - README 服务化章节:6 viewer → 8 viewer,标注全注册表驱动
19 KiB
DSH-better-sidebar 服务化改造设计
日期:2026-08-11
状态:已批准,待实施
作者:opencode + 用户
当前版本:v0.3.0(main 069c754)
目标版本:v0.4.0
1. 目标
将 dsh-better-sidebar 从一个"纯消费方"插件改造为"服务提供者":
- 允许其他 DSH 插件向 better-sidebar 注册新的侧边栏页面(tab 类型)
- 允许其他 DSH 插件向 better-sidebar 注册文件类型预览器
- 把现有 5 个内置 tab 和 6 种内置文件预览器全部迁移到新注册表(吃自己的狗粮)
2. 非目标(Out of Scope)
- 不动 host 半(
src/index.ts的 HTTP 路由 / pty / tools / settings) - 不迁移到标准 bundle 形态(不加
cordis.patch.yml、不弃用dsh.plugin.json、不放弃client-registry.js)—— 作为独立工作 - 不动 server 端
ctx.provide:本次纯 client 端服务
3. 现状回顾
3.1 Tab 系统硬编码点(7 处)
| 文件:行 | 用途 |
|---|---|
src/client/state.ts:14 |
TabType 联合类型(5 字面量) |
src/client/state.ts:295-297 |
isSingle(type) 单实例判定 |
src/client/state.ts:555-561 |
sanitizeNode 白名单校验 |
src/client/Sidebar.tsx:56-83 |
TabContent switch 渲染 |
src/client/Sidebar.tsx:87-102 |
buildNewTabOptions + 菜单项 |
src/client/Sidebar.tsx:341-364 |
onNewTab 创建分支 |
src/client/icons.tsx:26-33 |
tabTypeIcon 图标 switch |
3.2 文件预览硬编码点
src/client/EditorView.tsx:58-106:顺序 if/else 选load.kindsrc/client/EditorView.tsx:251-272:if 链按load.kind渲染组件- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在
EditorView.tsx:22-26 - 3 个纯函数模块(
office-types.ts/image-types.ts/pdf-types.ts)+lang.ts已半抽象,可作为内置注册器的实现细节复用
3.3 服务暴露现状
src/index.ts/src/client/index.tsx全文搜ctx.provide/Context.service/extends Service—— 零命中package.json的exports没有暴露任何供外部 import 的注册 API- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
3.4 平台约束
- 构建期纯度门(
tsdown.config.ts:177-180):client bundle 禁止 value-import 别的插件代码;type-only import 会被擦除,不触发门禁 - 双 cordis 实例:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出
declare module 'cordis'augmentation - ModuleLoader 运行时支持跨 bundle require,但被构建门挡 → 协作只能走 cordis 服务或 slot
4. 方案选型
| 方案 | 机制 | 评估 |
|---|---|---|
A. ctx.provide + 服务对象(采纳) |
client apply() 内 ctx.provide('betterSidebar', service),service 暴露 registerTab/registerFileViewer 返回 disposer |
registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:dsh/packages/client/locale/src/client/index.ts:362 |
B. ctx.slots.inject 声明式 slot |
声明 betterSidebar.tabType 等 slot |
偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
C. extends Service 类形式 |
完整 Service 类 + Config schema | client 半不支持 extends Service;本次纯 client,没必要 |
采纳方案 A。
5. 服务接口设计
5.1 主服务接口
// 新文件 src/client/api.ts
export interface BetterSidebarService {
/** 注册一种 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册一种文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/** 工具方法:让外部 tab 也能开/关 tab */
openTab(tab: { type: string; title: string; path?: string }): void
closeTab(tabId: string): void
/** 订阅 registry 变化(注册/卸载时触发) */
subscribe(listener: () => void): () => void
}
5.2 TabDescriptor
export interface TabDescriptor {
id: string // 唯一,如 'explorer' / 'my-plugin:db'
title: string | (() => string) // i18n 友好
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
single?: boolean // 单实例(explorer/git/subagent)
hidden?: boolean // 不出现在 + 菜单(editor 用)
available?: (ctx: Context, scope: SessionScope) => boolean
component: (props: TabComponentProps) => ReactNode
}
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
}
5.3 FileViewerDescriptor
export interface FileViewerDescriptor {
id: string // 'image' / 'pdf' / 'my-plugin:csv'
exts: readonly string[] // 小写无点,如 ['png','jpg']
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
component: (props: FileViewerProps) => ReactNode
}
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
}
6. Cordis 类型合并 outlet
// 新文件 src/types.ts (纯类型)
import type { BetterSidebarService } from './client/api'
declare module 'cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
通过 src/index.ts 的 export type * from './types.ts' re-export 到主入口。Consumer 插件 import type {} from 'dsh-better-sidebar' 触发合并。
7. package.json exports 新增
"exports": {
".": { ... },
"./invariant": { ... },
"./client": { ... },
"./client/api": { // 新增,纯类型导出
"types": "./lib/types/client/api.d.ts",
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
},
"./src/*": "./src/*",
"./package.json": "./package.json"
}
files 字段相应加 lib/types/client/api.d.ts。
8. 内置 tab 自我注册(吃狗粮)
src/client/index.tsx 的 apply() 内:
const service = createBetterSidebarService(store)
ctx.provide('betterSidebar', service)
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
8.1 5 个内置 tab
| id | order | single | hidden | available | 组件 |
|---|---|---|---|---|---|
editor |
-1 | false | true | always | EditorView(由 open-file 流程触发,不在 + 菜单) |
explorer |
10 | true | false | always | ExplorerView |
git |
20 | true | false | 检查 .git 存在 |
GitView |
subagent |
30 | true | false | always | SubagentView |
terminal |
40 | false | false | 检查 terminal 配额 | TerminalView |
8.2 内置 file viewer
| id | exts | priority | fetchStrategy | 组件 |
|---|---|---|---|---|
image |
png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | <img> |
pdf |
0 | mediaUrl | PdfView |
|
docx |
docx | 0 | mediaUrl | DocxView |
xlsx |
xlsx | 0 | mediaUrl | XlsxView |
pptx |
pptx | 0 | mediaUrl | PptxView |
markdown |
md/markdown | 0 | fsRead | <MarkdownText> |
code |
[](兜底) | -100 | fsRead | CodeMirror |
binary-download |
doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
matchFileViewer(path, head?) 算法:
- 按
priority降序遍历 - 若
descriptor.detect提供且head可用 → 调detect(path, head),true 则命中 - 否则匹配
exts(小写无点) - 全部 miss → 返回
undefined(caller 走兜底binary-download)
9. 7 处硬编码 switch 改造点
| 文件:行 | 原状 | 改造后 |
|---|---|---|
src/client/state.ts:14 |
TabType 联合 5 字面量 |
type TabType = string;保留 SIDEBAR_BUILTIN_TAB_TYPES 常量数组供向后兼容 |
src/client/state.ts:295-297 isSingle |
switch 5 字面量 | (type) => service.getTabs().find(t => t.id === type)?.single ?? false;store 持有 service 引用 |
src/client/state.ts:555-561 sanitizeNode |
白名单 5 type | 未注册 type 标记 orphaned: true 不丢弃,渲染占位卡 |
src/client/Sidebar.tsx:56-83 TabContent |
switch 5 分支 | 查 service.getTabs().find(t => t.id === tab.type),调 descriptor.component(props);未找到渲染 <OrphanedTab/> |
src/client/Sidebar.tsx:87-102 buildNewTabOptions |
硬编码 4 项 | service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...) |
src/client/Sidebar.tsx:341-364 onNewTab |
if optionId === 'explorer' 等 | service.openTab({ type: optionId, title: descriptor.title() }) |
src/client/icons.tsx:26-33 tabTypeIcon |
switch 5 分支 | 废弃;调用方直接读 descriptor.icon |
10. 文件预览路由改造
10.1 src/client/EditorView.tsx:58-106(选预览器)
const viewer = service.matchFileViewer(path, head?)
if (!viewer || viewer.fetchStrategy === 'binary-download') {
setLoad({ status: 'binary' })
return
}
switch (viewer.fetchStrategy) {
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
case 'fsRead':
api.fsRead(scope, path).then(({ content, truncated }) =>
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
)
break
case 'custom':
viewer.load(path, scope).then(data =>
setLoad({ status: 'ready', kind: 'custom', customData: data })
)
break
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
}
10.2 src/client/EditorView.tsx:251-272(渲染)
const viewer = service.matchFileViewer(path)
if (!viewer) return <BinaryDownload ... />
return <viewer.component {...props} />
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 EditorView.tsx 静态 import 移到 registerBuiltins 内部 import + 注册。
11. 持久化与降级
- localStorage 里
orphanedtab:渲染<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>+ 关闭按钮 sanitizeState/sanitizeNode不再丢弃未识别 type,改为标记orphaned,保留title/path以备插件恢复时复用
12. Consumer 插件使用范例
12.1 Consumer package.json
{
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
12.2 Consumer client half
// my-plugin/src/client/index.tsx
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots']
export function apply(ctx: Context): void {
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope, store, tab }) => <DbView scope={scope} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
13. 测试策略
13.1 单元测试
tests/service.spec.ts:createBetterSidebarService的 register/dispose/subscribe 生命周期tests/match-file-viewer.spec.ts:matchFileViewer的 priority + exts + detect 三段匹配算法tests/builtins.spec.ts:5 个内置 tab + 8 个内置 viewer 都正确注册- 现有
tests/state.spec.ts/tests/office-types.spec.ts/tests/image-types.spec.ts保持通过
13.2 集成测试
tests/orphaned-tab.spec.ts:localStorage 里有未注册 type 的 tab → 渲染<OrphanedTab/>,注册后恢复
13.3 现有 manifest 守卫
tests/manifest-consistency.spec.ts的contributes.tools/skills空数组守卫保持通过CLIENT_EXTERNALS列表守卫保持通过(不新增外部 value import)
14. 风险与缓解
| 风险 | 缓解 |
|---|---|
双 cordis 实例导致 consumer 拿不到 ctx.betterSidebar 类型 |
由 dsh-better-sidebar 自己 declare module 'cordis',consumer import type {} 触发合并 |
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis inject 自带就绪等待,consumer 不会在服务就绪前激活 |
| HMR 时 disposer 漏调导致注册项残留 | ctx.effect(() => disposer) 挂到 fiber,卸载时自动调 |
localStorage 里旧版本的 TabType 字面量 tab |
sanitizeNode 改为标记 orphaned 不丢弃,向后兼容 |
EditorView 的 kind 字段类型从联合字面量变成 string |
内部 load 状态类型加 kind: string + customData?: unknown,TypeScript 收窄在渲染层做 |
兜底 code viewer priority -100 与外部 binary-download -50 冲突 |
matchFileViewer 先尝试 detect(NUL 探测为 binary),再走 exts;外部 binary-download 的 exts: ['doc','xls','ppt'] 优先于兜底 code 的 exts: [] |
15. 版本与发布
- 版本号:
v0.4.0(破坏性:TabType从联合字面量改 string) - peerDependencies 不变(不加新依赖)
dsh.plugin.json的version同步更新到0.4.0- 重建
lib/+ 推送 main + profile 更新
16. 实施顺序(高层)
- 拉取 main v0.3.0 到工作分支
- 新建
src/client/api.ts(类型 +createBetterSidebarService工厂) - 新建
src/types.ts(declare module 'cordis'augmentation)+src/index.tsre-export src/client/index.tsx改造:ctx.provide('betterSidebar', service)+registerBuiltins(service)- 抽
registerBuiltins.ts:5 个内置 tab + 8 个内置 viewer 注册 - 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
- 改造
EditorView.tsx的选预览器 + 渲染两段 package.json加./client/apiexports +files字段- 加
<OrphanedTab/>组件 + sanitize 降级 - 写测试(service / matchFileViewer / builtins / orphaned)
- typecheck + test + build 三件套
- 重建 lib/,提交,推送,更新 profile web
详细实施计划由 writing-plans skill 生成。
17. 实施偏差记录(2026-08-11,feat/modular-registry)
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
17.1 去重机制:dedupeKey 为唯一机制,single 是其语法糖
§5.2 的 single?: boolean 与实现的 dedupeKey 合并为单一机制:
dedupeKey?: (tab) => string | undefined是唯一去重规则(单实例() => id、按 path、按 id 三种旧策略统一表达);single: true≡dedupeKey: () => id(语法糖,显式 dedupeKey 优先)。
17.2 available 三参签名
§5.2 的 available?: (ctx, scope) => boolean 实现为 (ctx, scope, state) => boolean(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
17.3 匹配算法:单趟 per-descriptor,非三趟全局
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:exts: [] + detect 是纯嗅探模式——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
§10 的 EditorView 职责一分为二:
EditorHost('editor' tab 组件):标题栏 +matchFileViewer→ 按 fetchStrategy 分发(纯逻辑在editor-load.ts的planFirstMatch/planFsReadOutcome)→ 渲染 viewer.component;binary 无匹配渲染共享BinaryDownload;TextEditor(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按viewerId === 'markdown'分支。- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
17.5 fs.read 增加 head 字段(唯一 host 触碰)
§8.2/§14 的 detect 机制需要 head 字节:host fs.read 对二进制响应附加 head(base64,前 4KB,常量 READ_HEAD_LIMIT)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
17.6 导出路径:./client/service 为主,./client/api 为别名
§7 要求 ./client/api,但 api.ts 已被既有的 fetch 助手占用;package.json exports 增加 "./client/api" 别名指向 lib/types/client/service.d.ts,公共导入路径满足设计,内部文件名不变。
17.7 + 菜单:disabled 行而非 filter
§9 的 filter(!hidden && available) 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
17.8 保留的 store 级操作(非注册表职责)
openDiffTab(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的dedupeKey: (tab) => tab.id同规则(测试断言两者一致)。reconcileAgentTerminals:agent 终端列表同步(host 推送驱动),直接落 state(openTabInActivePane),不经过 service(terminal 的 createTab 语义不适用)。- 命名:state.ts 的
openTabreducer 改名openTabInActivePane(与service.openTab区分)。
17.9 内置清单
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用
single: true。 - viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
SIDEBAR_BUILTIN_TAB_TYPES常量删除(零引用);tabTypeIcon删除(图标归 descriptor.icon)。
17.10 测试
新增 tests/builtins.spec.ts(内置注册清单)、tests/orphaned-tab.spec.tsx(sanitize 保留未注册类型 + 占位渲染)、tests/editor-load.spec.ts(策略分发纯函数);tests/browser-globals.ts 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。matchFileViewer 相关断言按 17.3 语义重写。