docs+fix: exports 加 ./client/api 别名;catch-all+detect 纯嗅探规则;设计文档偏差记录
- 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,标注全注册表驱动
This commit is contained in:
@@ -78,7 +78,7 @@ export function apply(ctx: Context): void {
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service` exports 子路径暴露。
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
|
||||
---
|
||||
|
||||
@@ -98,12 +98,17 @@ interface TabDescriptor {
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满) */
|
||||
available?: (state: SidebarState) => boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开)。
|
||||
* 内置策略:explorer/git/subagent 用 () => id(单实例);editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
@@ -122,7 +127,7 @@ interface TabDescriptor {
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce/openTab 等)
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? }
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
@@ -146,7 +151,7 @@ ctx.effect(() =>
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:notes', // 单实例
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
@@ -230,6 +235,7 @@ interface FileViewerProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
@@ -249,16 +255,17 @@ interface FileViewerProps {
|
||||
|
||||
### 4.4 匹配算法
|
||||
|
||||
`matchFileViewer(path, head?)` 按以下顺序解析:
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. **priority 降序**(稳定排序,相同 priority 按注册顺序)
|
||||
2. **detect 嗅探**:若 `head` 字节可用,遍历 priority 降序的 viewer,第一个 `detect(path, head)` 返回 true 的命中(**覆盖 exts**)
|
||||
3. **exts 匹配**:第一个 `exts` 含目标扩展名的 viewer 命中(`exts: []` 跳过此轮)
|
||||
4. **catch-all**:第一个 `exts: []` 的 viewer 命中
|
||||
5. **fallback**:返回 `undefined`,EditorView 走 CodeMirror 兜底(按扩展名选语法高亮)
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
|
||||
|
||||
> **内置 viewer**(不可重复注册):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / binary-download(-50)。
|
||||
> code/markdown **没有**注册为 viewer,是 EditorView 的兜底逻辑(matchFileViewer 返回 undefined 时走 fsRead + CodeMirror/MarkdownText)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
|
||||
@@ -324,10 +331,14 @@ interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 按 path 匹配 file viewer(priority desc → detect → exts) */
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重) */
|
||||
openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
@@ -439,9 +450,10 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins.tsx`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 6 个内置 viewer(image/pdf/docx/xlsx/pptx/binary-download)的注册代码
|
||||
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`tests/service.spec.ts`**:12 个测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -293,7 +293,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
- **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })`
|
||||
- **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })`
|
||||
|
||||
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 6 个 viewer(image/pdf/docx/xlsx/pptx/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。
|
||||
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 8 个 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。预览路由、+ 菜单、tab 图标全部由注册表驱动,无硬编码分发。
|
||||
|
||||
**消费插件最小骨架**:
|
||||
```ts
|
||||
|
||||
@@ -359,3 +359,58 @@ export function apply(ctx: Context): void {
|
||||
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 语义重写。
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
||||
+14
-2
@@ -222,8 +222,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (head !== undefined && v.detect !== undefined && v.detect(path, head)) return v
|
||||
if (v.exts.length === 0 || v.exts.includes(ext)) return v
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
|
||||
// paths it never sniffed (a magic-number viewer must not swallow
|
||||
// every file before the real viewers get their turn).
|
||||
if (v.exts.length === 0) continue
|
||||
} else if (v.exts.length === 0) {
|
||||
// Blind catch-all (no detect) claims anything; a sniff-only
|
||||
// catch-all (detect defined, no head yet) yields this round.
|
||||
if (v.detect === undefined) return v
|
||||
continue
|
||||
}
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -144,6 +144,26 @@ describe('matchFileViewer', () => {
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext')
|
||||
})
|
||||
|
||||
it('a catch-all with detect is sniff-only: it never blind-claims without head', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'magic-sniffer',
|
||||
exts: [],
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// No head: the sniff-only catch-all yields — the real png viewer wins.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
// Head with the magic: the sniffer claims it (detect at priority 100).
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer')
|
||||
// Head without the magic: the sniffer yields again, img claims it.
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img')
|
||||
})
|
||||
|
||||
it('returns undefined when no viewer matches and no catch-all', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
Reference in New Issue
Block a user