From 39455ff18e158d5612275b5e29e11c3306a0c0b6 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 11:25:37 +0800 Subject: [PATCH] =?UTF-8?q?docs+fix:=20exports=20=E5=8A=A0=20./client/api?= =?UTF-8?q?=20=E5=88=AB=E5=90=8D=EF=BC=9Bcatch-all+detect=20=E7=BA=AF?= =?UTF-8?q?=E5=97=85=E6=8E=A2=E8=A7=84=E5=88=99=EF=BC=9B=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=81=8F=E5=B7=AE=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,标注全注册表驱动 --- AGENTS.md | 54 +++++++++++------- README.md | 2 +- .../2026-08-11-service-registry-design.md | 55 +++++++++++++++++++ package.json | 4 ++ src/client/service.ts | 16 +++++- tests/service.spec.ts | 20 +++++++ 6 files changed, 127 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e740899..9ca4918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: , order: 50, - dedupeKey: () => 'my-plugin:notes', // 单实例 + single: true, // ≡ dedupeKey: () => 'my-plugin:notes' component: ({ scope }) => , }) ) @@ -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 的真实用法。 diff --git a/README.md b/README.md index af21455..653802b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/plans/2026-08-11-service-registry-design.md b/docs/plans/2026-08-11-service-registry-design.md index 14e811a..58de763 100644 --- a/docs/plans/2026-08-11-service-registry-design.md +++ b/docs/plans/2026-08-11-service-registry-design.md @@ -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 语义重写。 diff --git a/package.json b/package.json index c4e76db..f31927c 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/client/service.ts b/src/client/service.ts index bd755a4..24554c0 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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 } diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 649cd05..49999e1 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -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)