* 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 及更早无法解析依赖)
22 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.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. 实施顺序(高层)
- 拉取 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 语义重写。
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 而非新建独立文件。