* 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 及更早无法解析依赖)
433 lines
22 KiB
Markdown
433 lines
22 KiB
Markdown
# 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<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
|
||
|
||
```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 | `<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`(选预览器)
|
||
|
||
```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 <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`
|
||
|
||
```jsonc
|
||
{
|
||
"peerDependencies": {
|
||
"cordis": "^4.0.0-rc.8",
|
||
"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: <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` 而非新建独立文件。
|