Files
apes-sidebar/docs/plans/2026-08-11-service-registry-design.md
T
Atlas Zeng 6c891514b5 feat(injection): 统一面板宿主注入重构——含块层+几何写路径+chunk缓存+锚点弱化+桌面/移动适配 (#232)
* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫

- Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none)
- 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持
- 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步
- MutationObserver(body childList)守卫锚点被宿主清空时重挂

* perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离

- 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height)
- .panel/.bottomPanel contain: layout style——内部重排不外泄
- 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform

* perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证

- 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag,
  未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取
- 测试夹具与孤儿缓存条目不跨激活存活
- index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate()

* fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出

- nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素)
- #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出),
  Desktop 壳(#208)width:100% 的加性溢出根因消除

* feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配

- 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记
- win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖
- visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移
- 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0)
- 单测 7 例(含 URLSearchParams 前导 ? 剥除修复)

* docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言

- 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录
- AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号
- mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景

* fix(cr): 响应 #232 CR 三条 findings

- P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环——
  修正后矩形不再被误判为无需修正(自清除缺陷)
- P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports;
  测试夹具加载不再被误记为生产加载
- P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化)
- 新增屏障单测 1 例;设计文档偏差记录更新

* fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation

- revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带
  AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取),
  屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口)
- resetChunks 同步清 revalidation:重验证挂起期间重置后,
  loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op)
- 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、
  resetChunks 清屏障后 loadChunk 立即放行

* fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8

- peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至
  ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form
  (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用);
  cordis peer/devDep 同步升 ^4.0.0-rc.8
- 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json /
  SIDEBAR_SERVICE_VERSION 三处同步)
- CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像
  同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留)
- CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8
- pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留
  清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml
  minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表
- 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档
  cordis 示例、issue 模板版本示例

* feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab

- SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认
  true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即
  纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响)
- 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec
  默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split
  用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged
- e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式
  (新开 hello.txt tab、Files home tab 保留),文件头注释同步
- 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、
  README/README_EN v0.13.0 特性描述 + v0.14.0 changelog

* fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享

rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务
提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部
"client module system unavailable")。修复:

- src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫,
  此前直接读 ctx.modules 抛 "cannot get property modules without
  inject");apply() 激活时 setChunkModuleSystem(ctx.modules)
- package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules
- chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__——
  懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作,
  rc.8 全局消失后副本注入变量恒空);moduleSystem() 按
  注入变量 → 插件全局 → rc.7 全局回退解析
- context-types: Context 镜像补 modules 服务声明(client half)
- e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正
  (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉
  重复的 [data-dsh-better-sidebar] 嵌套)

验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3
通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号)

* feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理)

- SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持
  yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析)
- 终端卡齿轮二级页面新增两行 text 配置(与字体行同列)
- host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取
  设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选
  shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时
  影响之后打开的终端(既有 pty 保持原 shell)
- locales zh/en 文案;parsePrefs 校验新字段;测试同步

* feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标

- 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例
  版本,与 package.json 由 tests/service.spec.ts 保持同步)
- token 驱动样式(--dsw-alias-*),名称/版本 pill 布局

* fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底

快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能
完全无 move)时的回退问题:
- pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置
  (事件合并时可能等于按下位置)
- pointercancel / lostpointercapture 中断时同样提交最后已知位置;
  无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正
  未产生位移的拖动才回滚
- commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后
  React 用旧 centerRect 覆盖底栏 right 的中间帧)

HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复):
- 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建
  复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0}
  → 底栏 visibility:hidden 永久空白
- 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-*
  时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底
- 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何
  门控,jsdom 测试不回归)

e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退

* feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景)

- PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示)
- PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组
  渲染(未分类条目保持平铺);数量徽标跟随过滤结果
- 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限
  增长;渲染数量由搜索天然收敛
- locales zh/en(搜索占位、无匹配);测试文件固定英文环境
  (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案
  断言漂移)

* chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配)

pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验,
且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目
匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方
可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配
(保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。

* docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配)

新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、
添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复;
版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
2026-08-20 02:09:36 +08:00

22 KiB
Raw Blame History

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 主服务接口

// 新文件 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 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?) 算法:

  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);未找到渲染 <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 里 orphaned tab:渲染 <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.8",
    "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. 实施顺序(高层)

  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. 加 <OrphanedTab/> 组件 + sanitize 降级
  10. 写测试(service / matchFileViewer / builtins / orphaned)
  11. typecheck + test + build 三件套
  12. 重建 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 的 openTab reducer 改名 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 语义重写。


18. v0.12.0 基座化扩展记录(2026-08-14,feat/plugin-api-v012)

§5 的服务接口在 v0.12.0 做了纯增量扩展(无任何签名删除 / 语义变更,向上兼容),全部记录于此,文档以本节为准:

  • 类型导出补齐:./client/service 入口 re-export 公共状态词汇——SidebarTab / SidebarState / SidebarStore / SidebarSnapshot / SidebarDiffRef / TabType / SessionScope / SidebarPrefs / OpenTabSeed / SidebarSettingsRenderProps。此前消费者无法命名这些类型(TS2459),只能靠推断。
  • client 声明图零 Node 依赖:context-types.ts 的 SidebarWebRoute / SidebarWebUpgradeRoute 改用结构镜像类型(SidebarHttpRequest / SidebarHttpResponse / SidebarUpgradeSocket / SidebarUpgradeHead),宿主在 ws 边界显式转型(src/index.ts 两处)。外部消费插件无需 @types/node、skipLibCheck: false 也能编译(scripts/check-consumer-types.sh 双层校验守护)。
  • 新服务方法:version / features(单调能力清单,只增不删);getSnapshot / subscribeState(状态订阅,委托 store);updateTab(tabId, patch)(title/path/meta);activateTab(tabId)(跨树激活,tab 栏点击路径);openFile(scope, path, title?)(editor 打开,id 按路径派生与内置拦截一致);openTab(seed, scope?)(定向到指定 session:SidebarStore.reduceFor 加载/变更/持久化目标 session 而不切换 snapshot,UI 不扰动;scope 指向当前 session 时走原 reduce 路径保证 notify)。
  • 新 descriptor 字段:badge(tab 角标:number 计数 99+ 封顶 / string 文本 / null 不显示,每次 tab 栏渲染调用,抛错吞掉);onOpen / onActivate / onClose(生命周期回调,service 路径派发——dedupe/id 安全网聚焦触发 onActivate 而非 onOpen;内置 diff 拆分与 agent 终端 reconcile 直接改 state 不触发,但只作用于内置类型;回调抛错只 console.error);SidebarTab.meta(JSON 可序列化自定义状态,sanitizeNode 宽松保留,跨刷新原样恢复)。
  • 设置 seam 开放:PrefsSchema 增加 pluginSettings: z.dict(z.dict(z.any())).default({}) 开放嵌套 map;settings.pluginToggles(插件自有声明式行)与 settings.render(自定义面板,props 见 §5)替代「自定义 key 被 seam 丢弃」的限制;viewer 卡片 v0.12.0 起同样有齿轮设置弹窗。
  • FileViewerDescriptor.load 第三参 signal?: AbortSignal:EditorHost 在卸载/重匹配时 abort(忽略 signal 的 load 照常工作)。
  • 测试:tests/consumer-types.ts(消费面编译门)、tests/api-surface.spec.ts(client 图零 node 依赖守护 + 能力常量)、tests/service.spec.ts 新增生命周期 / 定向打开 / updateTab / activateTab / openFile / meta 持久化用例、tests/unit.spec.ts 新增 reduceFor 与 sanitize meta 用例。

实现偏差:§5.1 的 openTab(seed) 单参签名保留为 openTab(seed, scope?) 的可选参数(非破坏);SidebarSnapshot 类型 re-export 自 ./client/service 而非新建独立文件。