From fbebcf51389682895dfeb59f747fc22e6a64cb7f Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 00:09:46 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9C=8D=E5=8A=A1=E5=8C=96=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E8=AE=BE=E8=AE=A1=EF=BC=88registerTab/registerFileVie?= =?UTF-8?q?wer=20+=20=E5=86=85=E7=BD=AE=E8=BF=81=E7=A7=BB=E5=88=B0=20regis?= =?UTF-8?q?try=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-11-service-registry-design.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/plans/2026-08-11-service-registry-design.md diff --git a/docs/plans/2026-08-11-service-registry-design.md b/docs/plans/2026-08-11-service-registry-design.md new file mode 100644 index 0000000..14e811a --- /dev/null +++ b/docs/plans/2026-08-11-service-registry-design.md @@ -0,0 +1,361 @@ +# DSH-better-sidebar 服务化改造设计 + +**日期**:2026-08-11 +**状态**:已批准,待实施 +**作者**:opencode + 用户 +**当前版本**:v0.3.0(main `069c754`) +**目标版本**:v0.4.0 + +## 1. 目标 + +将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者": + +1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)** +2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器** +3. 把现有 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.kind` +- `src/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 主服务接口 + +```ts +// 新文件 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 + +```ts +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 + +```ts +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 // 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 + +```ts +// 新文件 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 新增 + +```jsonc +"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()` 内: + +```ts +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 | `` | +| `pdf` | pdf | 0 | mediaUrl | `PdfView` | +| `docx` | docx | 0 | mediaUrl | `DocxView` | +| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` | +| `pptx` | pptx | 0 | mediaUrl | `PptxView` | +| `markdown` | md/markdown | 0 | fsRead | `` | +| `code` | [](兜底) | -100 | fsRead | CodeMirror | +| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 | + +`matchFileViewer(path, head?)` 算法: +1. 按 `priority` 降序遍历 +2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中 +3. 否则匹配 `exts`(小写无点) +4. 全部 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)`;未找到渲染 `` | +| `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`(选预览器) + +```ts +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`(渲染) + +```ts +const viewer = service.matchFileViewer(path) +if (!viewer) return +return +``` + +4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。 + +## 11. 持久化与降级 + +- localStorage 里 `orphaned` tab:渲染 `` + 关闭按钮 +- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用 + +## 12. Consumer 插件使用范例 + +### 12.1 Consumer `package.json` + +```jsonc +{ + "peerDependencies": { + "cordis": "^4.0.0-rc.7", + "dsh-better-sidebar": "workspace:*" + }, + "peerDependenciesMeta": { + "dsh-better-sidebar": { "optional": true } + } +} +``` + +### 12.2 Consumer client half + +```ts +// 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: , + order: 50, + component: ({ scope, store, tab }) => , + }) + ) + + 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 }) => , + }) + ) +} +``` + +## 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 → 渲染 ``,注册后恢复 + +### 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. 实施顺序(高层) + +1. 拉取 main v0.3.0 到工作分支 +2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂) +3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export +4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)` +5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册 +6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx) +7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段 +8. `package.json` 加 `./client/api` exports + `files` 字段 +9. 加 `` 组件 + sanitize 降级 +10. 写测试(service / matchFileViewer / builtins / orphaned) +11. typecheck + test + build 三件套 +12. 重建 lib/,提交,推送,更新 profile web + +详细实施计划由 writing-plans skill 生成。