From 82a69546d2045f49c3be6b00f0fc005eda0f0fca Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 14 Aug 2026 20:44:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=85=B1=20API=20=E5=9F=BA?= =?UTF-8?q?=E5=BA=A7=E5=8C=96=EF=BC=88v0.12.0=EF=BC=8C=E7=BA=AF=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=EF=BC=89=E2=80=94=20=E7=B1=BB=E5=9E=8B/=E8=83=BD?= =?UTF-8?q?=E5=8A=9B/=E8=AE=BE=E7=BD=AE=20seam=20=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型生态:service 模块 re-export SidebarTab/SidebarState/SidebarStore/ SidebarSnapshot/SidebarDiffRef/TabType/SessionScope/SidebarPrefs 等公共 状态词汇(此前 TS2459 无法命名);context-types 去 Node 依赖(结构镜像 SidebarHttpRequest/Response/UpgradeSocket/Head),client 可达声明图零 node:* 引用,外部消费者无 @types/node + skipLibCheck:false 可编译 (scripts/check-consumer-types.sh 守护) - 服务能力:version/features 版本与能力探测(单调只增)、getSnapshot/ subscribeState 状态订阅、updateTab(title/path/meta)、activateTab、 openFile(路径派生 id 与内置拦截一致)、openTab(seed, scope?) 定向到 指定 session(store.reduceFor 不扰动 UI) - TabDescriptor:badge 角标(99+ 封顶,抛错吞掉)、onOpen/onActivate/ onClose 生命周期回调(service 路径派发,dedupe 聚焦触发 onActivate 而非 onOpen,抛错不打断流程);SidebarTab.meta JSON 可序列化跨刷新 持久化(sanitize 宽松保留) - 设置 seam 开放:PrefsSchema 增 pluginSettings 开放嵌套 map(z.dict), settings.pluginToggles(插件自有行,同 switch/text/number 控件)与 settings.render(自定义面板,含 store/service/prefs/updatePluginSetting/ close props);viewer 卡片 v0.12.0 起也有齿轮设置弹窗 - FileViewerDescriptor.load 增第三参 AbortSignal(EditorHost 生命周期中止) - UI 接线:tab 激活/关闭改走 service(跨树激活修复 + 回调触发)、+ 菜单 传 scope、TabBar badge pill 渲染;active-session scope 走原 reduce 路径 保证 notify - 测试 +23:生命周期/定向打开/updateTab/activateTab/openFile/meta 持久化/ reduceFor/能力常量(与 package.json 锁步)、consumer-types.ts 编译门、 api-surface.spec.ts(client 图零 node 依赖守护);既有 430 测试全绿 - 文档:AGENTS.md 修 available 三参示例并补全部新 API;external-plugin- guide.md 刷新至 v0.12.0(7 tab + 9 viewer、设置 seam 开放、类型导入 路径建议);README×2 服务化小节同步 未发版 npm(用户决定);分支 feat/plugin-api-v012。 --- AGENTS.md | 101 +++++++++++- README.md | 6 +- README_EN.md | 4 +- docs/external-plugin-guide.md | 199 ++++++++++++++++++++--- dsh.plugin.json | 2 +- package.json | 2 +- scripts/check-consumer-types.sh | 92 +++++++++++ src/bundle-route.ts | 9 +- src/client/EditorHost.tsx | 7 +- src/client/SideCardSection.tsx | 163 ++++++++++++++++++- src/client/Sidebar.tsx | 42 +++-- src/client/TabBar.tsx | 6 +- src/client/prefs.ts | 18 ++ src/client/service.ts | 280 ++++++++++++++++++++++++++++++-- src/client/sidebar.module.css | 15 ++ src/client/split-pane.tsx | 13 +- src/client/state.ts | 37 ++++- src/config.ts | 5 + src/context-types.ts | 41 ++++- src/index.ts | 15 +- src/prefs-shared.ts | 10 ++ src/wire.ts | 14 +- tests/api-surface.spec.ts | 67 ++++++++ tests/consumer-types.ts | 133 +++++++++++++++ tests/plugin-shape.spec.ts | 2 +- tests/service.spec.ts | 256 ++++++++++++++++++++++++++++- tests/smoke.spec.ts | 2 + tests/unit.spec.ts | 74 ++++++++- 28 files changed, 1512 insertions(+), 103 deletions(-) create mode 100755 scripts/check-consumer-types.sh create mode 100644 tests/api-surface.spec.ts create mode 100644 tests/consumer-types.ts diff --git a/AGENTS.md b/AGENTS.md index e7d9a70..8eb7446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,16 @@ 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`(别名 `./client/api`)exports 子路径暴露。 +类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。v0.12.0 起服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断): + +```ts +import type { + SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType, + SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps, +} from 'dsh-better-sidebar/client/service' +``` + +> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。 --- @@ -131,6 +140,9 @@ interface TabDescriptor { * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 * 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number * 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 + * v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置): + * `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段) + * 与 `render`(自定义设置面板,替代行列表)。 */ settings?: { toggles?: readonly { @@ -149,7 +161,48 @@ interface TabDescriptor { /** 输入框后的单位后缀(如 'px')。 */ unit?: string }[] + /** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的, + * 持久化在 `pluginSettings[]`——不需要宿主 PrefsSchema 字段。 */ + pluginToggles?: readonly { + key: string + title: string | (() => string) + desc?: string | (() => string) + type?: 'switch' | 'text' | 'number' + min?: number + max?: number + placeholder?: string + unit?: string + }[] + /** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 + * props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、 + * updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */ + render?: (props: { + store: SidebarStore + service: BetterSidebarService + prefs: SidebarPrefs + pluginSettings: Record + updatePluginSetting: (key: string, value: unknown) => void + close: () => void + }) => ReactNode } + /** + * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶), + * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价; + * 抛错会被吞掉(不显示角标,不影响渲染)。 + */ + badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined + /** + * 生命周期回调(v0.12.0+),只由 SERVICE 路径触发: + * - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦**不算**打开); + * - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活); + * - onClose:closeTab 关闭 tab 后。 + * 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调—— + * 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。 + * 回调抛错只 console.error,绝不打断打开/关闭流程。scope 携带 { sessionId, cwd? }。 + */ + onOpen?: (tab: SidebarTab, scope: SessionScope) => void + onActivate?: (tab: SidebarTab, scope: SessionScope) => void + onClose?: (tab: SidebarTab, scope: SessionScope) => void /** 渲染函数 */ component: (props: TabComponentProps) => ReactNode } @@ -214,7 +267,8 @@ ctx.effect(() => title: 'Commits', icon: , order: 70, - available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled + available: (ctx, scope, state) => hasGitRepo(state), // 三参 (ctx, scope, state);返回 false 时 + 菜单显示为 disabled + // 注意:available 只影响 + 菜单的 disabled 状态,不会拒绝 openTab(只有设置页的禁用开关会)。 dedupeKey: () => 'my-plugin:commits', component: ({ scope }) => , }) @@ -257,8 +311,9 @@ interface FileViewerDescriptor { fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ detect?: (path: string, head: Uint8Array) => boolean - /** fetchStrategy='custom' 时的加载函数 */ - load?: (path: string, scope: SessionScope) => Promise + /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer + * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */ + load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */ settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] } /** 渲染函数 */ @@ -384,19 +439,53 @@ interface BetterSidebarService { * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子, * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。 * 被设置禁用的类型是 no-op(console.warn 提示)。 + * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 + * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; + * 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。 * 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板 * 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口 * 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。 */ - openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void + openTab(seed: OpenTabSeed, scope?: SessionScope): void /** 关闭一个 tab */ closeTab(tabId: string): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void + // ── v0.12.0+ ────────────────────────────────────────────────────────── + /** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */ + readonly version: string + /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | + * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | + * 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ + readonly features: readonly string[] + /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 + * session 未激活时 state/sessionId 为 undefined。 */ + getSnapshot(): SidebarSnapshot + /** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */ + subscribeState(listener: () => void): () => void + /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */ + updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void + /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */ + activateTab(tabId: string): void + /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; + * id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ + openFile(scope: SessionScope, path: string, title?: string): void +} + +/** openTab 的 seed(v0.12.0 起导出命名类型) */ +interface OpenTabSeed { + type: string + title?: string + path?: string + diff?: SidebarTab['diff'] + id?: string + url?: string + /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */ + meta?: unknown } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 --- diff --git a/README.md b/README.md index fa77d6c..bcb7245 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 - **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板 - **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开 - **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑 -- **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md)) +- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.0 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md)) - **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新 ## 🚀 安装 @@ -214,7 +214,9 @@ export function apply(ctx: Context) { } ``` -完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。 +v0.12.0 起补齐基座能力:类型导出完整(消费者可直接命名 `SidebarTab`/`SidebarState` 等,client 声明图零 Node 依赖)、`version`/`features` 能力探测、`getSnapshot`/`subscribeState` 状态订阅、tab 角标 `badge`、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向 `openTab(seed, scope)`、`SidebarTab.meta` 跨刷新持久化、设置 seam 开放(`settings.pluginToggles` / `settings.render`,值存 `pluginSettings[id]`)。 + +完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置、版本探测):见 [`AGENTS.md`](./AGENTS.md)。 ## 🛠️ 开发与构建 diff --git a/README_EN.md b/README_EN.md index ab2a6f9..1328eab 100644 --- a/README_EN.md +++ b/README_EN.md @@ -215,7 +215,9 @@ export function apply(ctx: Context) { } ``` -Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings): see [`AGENTS.md`](./AGENTS.md). +v0.12.0+ base capabilities: complete type exports (consumers can name `SidebarTab`/`SidebarState` etc.; the client declaration graph is Node-free), `version`/`features` capability detection, `getSnapshot`/`subscribeState` state subscription, tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted `openTab(seed, scope)`, `SidebarTab.meta` persisted across reloads, and an opened settings seam (`settings.pluginToggles` / `settings.render`, stored in `pluginSettings[id]`). + +Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings, version detection): see [`AGENTS.md`](./AGENTS.md). ## 🛠️ Development & Build diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index b1b4564..79a228b 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -2,8 +2,8 @@ > 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 > -> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。 -> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 +> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;本文档其余新 API(badge/生命周期/定向打开/插件设置/版本探测等)**v0.12.0+**。当前版本 v0.12.0。 +> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 9 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 --- @@ -14,7 +14,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: - **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面; - **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。 -内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。 +内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 9 个 viewer(image / pdf / docx / xlsx / pptx / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。 关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。 @@ -69,6 +69,17 @@ import type { import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api ``` +v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断): + +```ts +import type { + SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType, + SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps, +} from 'dsh-better-sidebar/client/service' +``` + +> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。 + --- ## 3. 最小骨架(client half) @@ -149,18 +160,73 @@ interface TabDescriptor { */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null /** - * 声明式设置(v0.4.1+):见 §7。 + * 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有 + * 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。 */ - settings?: { - toggles?: readonly { - key: string - title: string | (() => string) - desc?: string | (() => string) - }[] - } + settings?: SidebarSettingsDeclaration + /** + * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶), + * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价; + * 抛错会被吞掉(不显示角标,不影响渲染)。 + */ + badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined + /** + * 生命周期回调(v0.12.0+),只由 SERVICE 路径触发: + * - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开); + * - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活); + * - onClose:closeTab 关闭 tab 后。 + * 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发 + * 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 + * service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。 + */ + onOpen?: (tab: SidebarTab, scope: SessionScope) => void + onActivate?: (tab: SidebarTab, scope: SessionScope) => void + onClose?: (tab: SidebarTab, scope: SessionScope) => void /** 渲染函数 */ component: (props: TabComponentProps) => ReactNode } + +/** 声明式设置声明(v0.12.0 完整形状;行控件见 §8)。 */ +interface SidebarSettingsDeclaration { + /** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段)。 */ + toggles?: readonly { + key: string + title: string | (() => string) + desc?: string | (() => string) + type?: 'switch' | 'text' | 'number' // v0.11.0+;缺省 'switch' + min?: number + max?: number + placeholder?: string + unit?: string + }[] + /** 插件自有设置行(v0.12.0+):形状同 toggles,key 插件局部, + * 持久化在 pluginSettings[],无需宿主 schema 字段。 */ + pluginToggles?: readonly { + key: string + title: string | (() => string) + desc?: string | (() => string) + type?: 'switch' | 'text' | 'number' + min?: number + max?: number + placeholder?: string + unit?: string + }[] + /** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 */ + render?: (props: SidebarSettingsRenderProps) => ReactNode +} + +/** settings.render 收到的 props(v0.12.0+)。 */ +interface SidebarSettingsRenderProps { + store: SidebarStore + service: BetterSidebarService + prefs: SidebarPrefs + /** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */ + pluginSettings: Record + /** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */ + updatePluginSetting(key: string, value: unknown): void + /** 关闭设置弹窗。 */ + close(): void +} ``` ### 4.2 `TabComponentProps`(你的页面组件收到的 props) @@ -263,6 +329,7 @@ ctx.effect(() => | `git` | 20 | 是 | 否 | Git 面板 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) | +| `browser` | 50 | 否(createTab 铸造 browser:``,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | 你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 @@ -289,10 +356,12 @@ interface FileViewerDescriptor { fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ detect?: (path: string, head: Uint8Array) => boolean - /** fetchStrategy='custom' 时的加载函数 */ - load?: (path: string, scope: SessionScope) => Promise - /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */ - settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] } + /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer + * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */ + load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise + /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(v0.12.0 起 viewer + * 卡片也有齿轮按钮,pluginToggles/render 同样可用) */ + settings?: SidebarSettingsDeclaration /** 渲染函数 */ component: (props: FileViewerProps) => ReactNode } @@ -336,7 +405,7 @@ interface FileViewerProps { > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `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)。 +> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / 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 即可覆盖。 ### 5.5 注册示例 @@ -453,14 +522,75 @@ interface BetterSidebarService { * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 + * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子)。 * 被设置禁用的类型是 no-op(console.warn 提示)。 + * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 + * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; + * 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。 + * 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。 */ - openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void + openTab(seed: OpenTabSeed, scope?: SessionScope): void /** 关闭一个 tab */ closeTab(tabId: string): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void + // ── v0.12.0+ ────────────────────────────────────────────────────────── + /** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */ + readonly version: string + /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | + * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | + * 'pluginSettings'——用 `features.includes('xxx')` 按能力 gate。 */ + readonly features: readonly string[] + /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 + * session 未激活时 state/sessionId 为 undefined。 */ + getSnapshot(): SidebarSnapshot + /** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */ + subscribeState(listener: () => void): () => void + /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */ + updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void + /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */ + activateTab(tabId: string): void + /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; + * id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ + openFile(scope: SessionScope, path: string, title?: string): void } + +/** openTab 的 seed(v0.12.0 起导出命名类型)。 */ +interface OpenTabSeed { + type: string + title?: string + path?: string + diff?: SidebarTab['diff'] + id?: string + url?: string + /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */ + meta?: unknown +} +``` + +**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: + +```ts +if (ctx.betterSidebar.features.includes('badge')) { + // 使用 TabDescriptor.badge +} +if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ } +``` + +**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`: + +```ts +ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', + title: 'Database', + single: true, + badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */, + onOpen: (tab, scope) => { startWatcher(scope.sessionId) }, + onClose: (tab, scope) => { stopWatcher(scope.sessionId) }, + component: ({ scope }) => , + }) +) ``` --- @@ -472,7 +602,9 @@ interface BetterSidebarService { - 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。 - 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。 - 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。 -- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。 +- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑。行控件 v0.11.0 起不限于布尔:`type: 'switch' | 'text' | 'number'`(缺省 'switch';text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行。 +- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有齿轮按钮)。 +- `settings.render`(可选,v0.12.0+):**自定义设置面板**——给出时齿轮弹窗渲染它而非行列表。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。 ```ts ctx.effect(() => @@ -481,18 +613,36 @@ ctx.effect(() => title: 'Database', order: 50, settings: { + // 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制) toggles: [{ - key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下 - title: 'Auto connect', - desc: 'Connect on open', + key: 'autoOpenSubagent', // 宿主内置键 + title: 'Auto-open', + desc: 'Open when a subagent appears', }], + // 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db'] + pluginToggles: [{ + key: 'pageSize', + title: 'Page size', + type: 'number', + min: 1, + max: 100, + unit: 'rows', + }], + // 或完全自定义面板(给定时代替行列表) + render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => ( + { updatePluginSetting(key, value) }} + onDone={close} + /> + ), }, component: ({ scope }) => , }) ) ``` -> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。 +> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 --- @@ -516,6 +666,7 @@ ctx.effect(() => | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import | +| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) | --- @@ -606,13 +757,13 @@ function parseCsv(text: string): string[][] { /* ... */ } better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"): -- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts) +- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 9 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating) - **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤) - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化) - **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件) - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 -- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据) +- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 9cb7c41..caa0c81 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.11.0", + "version": "0.12.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 4bbc496..7333a3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.11.0", + "version": "0.12.0", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh new file mode 100755 index 0000000..f7765c9 --- /dev/null +++ b/scripts/check-consumer-types.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Consumer-facing type surface check: build a tiny standalone consumer that +# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with +# skipLibCheck: false, then type-check it with the repo's tsc. +# +# This is the exact scenario the v0.12.0 API work cares about: the shipped +# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must +# resolve and type-check for a browser-only plugin author. Run AFTER +# `pnpm build` (the check reads lib/types). +set -euo pipefail +cd "$(dirname "$0")/.." + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/node_modules" +ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar" + +cat > "$WORK/check.ts" <<'EOF' +import type {} from 'dsh-better-sidebar/client/service' +import type { + BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps, + TabComponentProps, TabDescriptor, +} from 'dsh-better-sidebar/client/service' +import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service' +import type { SidebarPrefs } from 'dsh-better-sidebar/client/service' + +declare const ctx: { betterSidebar: BetterSidebarService } +const tab: TabDescriptor = { + id: 'x:tab', + title: 'X', + single: true, + badge: (_ctx, _scope, state: SidebarState) => state.expanded.length, + onOpen: (_tab: SidebarTab, _scope: SessionScope) => {}, + onClose: (_tab: SidebarTab, _scope: SessionScope) => {}, + createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }), + settings: { + toggles: [{ key: 'autoOpenSubagent', title: 'A' }], + pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }], + render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null }, + }, + component: (props: TabComponentProps) => null, +} +ctx.betterSidebar.registerTab(tab) +const viewer: FileViewerDescriptor = { + id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom', + load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) }, + component: () => null, +} +ctx.betterSidebar.registerFileViewer(viewer) +const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } } +ctx.betterSidebar.openTab(seed, { sessionId: 's1' }) +ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv') +ctx.betterSidebar.updateTab('t', { meta: { b: 2 } }) +const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot() +const prefs: SidebarPrefs = snap.prefs +const store: SidebarStore = null as unknown as SidebarStore +void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features +EOF + +cat > "$WORK/tsconfig.json" <<'EOF' +{ + "compilerOptions": { + "target": "ES2023", "module": "esnext", "moduleResolution": "bundler", + "strict": true, "noEmit": true, "skipLibCheck": false, "types": [], + "jsx": "react-jsx", "lib": ["ES2023", "DOM"] + }, + "include": ["check.ts"] +} +EOF + +echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..." +echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean" +./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true +echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean" +echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)" +set +e +./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1 +STATUS=$? +set -e +# Fail only on errors that mention OUR package / declarations / the fixture. +if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then + echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2 + grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2 + exit 1 +fi +if [ "$STATUS" -ne 0 ]; then + echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:" + grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 +fi +echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained." + diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 4c5e772..da2c664 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -16,8 +16,7 @@ import { createHash } from 'node:crypto' import { stat, readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { Context } from './context-types.ts' +import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts' /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */ export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const @@ -68,9 +67,9 @@ async function etagOf(name: ChunkName, chunkDir: string): Promise boolean, + fence: (req: SidebarHttpRequest) => boolean, chunkDir: string = LIB_DIR, -): (req: IncomingMessage, res: ServerResponse) => Promise { +): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise { return async (req, res): Promise => { if (!fence(req)) { res.writeHead(403) @@ -121,7 +120,7 @@ export function createBundleRouteHandler( } /** Register the /sidebar/bundle route (disposed with the fiber). */ -export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void { +export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void { return ctx.webServer.register({ kind: 'prefix', path: '/sidebar/bundle', diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 6e1c202..0296ac6 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -31,6 +31,9 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se useEffect(() => { let cancelled = false + // Aborts the matched viewer's `load` when the editor tears down (tab + // closed, path changed, session switched) or re-matches the viewer. + const controller = new AbortController() setLoad({ status: 'loading' }) const mediaUrlOf = (): string => mediaUrl(scope, path) const apply = (action: EditorLoadAction): void => { @@ -50,7 +53,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se }) return case 'customLoad': - void action.viewer.load?.(path, scope).then((data) => { + void action.viewer.load?.(path, scope, controller.signal).then((data) => { if (cancelled) return setLoad({ status: 'ready', viewer: action.viewer, customData: data }) }).catch((error: unknown) => { @@ -77,7 +80,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se } } apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) - return () => { cancelled = true } + return () => { cancelled = true; controller.abort() } }, [scope.sessionId, scope.cwd, path, ctx]) return ( diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 2ec4dd0..de9279b 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -62,6 +62,7 @@ import type { SidebarStore } from './state.ts' import type { BetterSidebarService, FileViewerDescriptor, + SidebarSettingsRenderProps, SidebarSettingToggle, TabDescriptor, } from './service.ts' @@ -112,6 +113,43 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean { return (prefs as unknown as Record)[key] === true } +/** Whether a feature declares any secondary settings (gear button shows). */ +function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean { + const settings = feature.settings + return settings !== undefined && ( + (settings.toggles?.length ?? 0) > 0 + || (settings.pluginToggles?.length ?? 0) > 0 + || settings.render !== undefined + ) +} + +/** A feature's display name (viewers fall back to their id). */ +function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string { + return textOf('title' in feature ? feature.title : undefined) || feature.id +} + +/** + * Render a custom settings panel (`settings.render`) with error containment: + * a throwing panel shows an inline error line instead of breaking the whole + * settings page. + */ +function SettingsRender(props: { + render: (renderProps: SidebarSettingsRenderProps) => ReactNode + renderProps: SidebarSettingsRenderProps +}) { + let content: ReactNode + try { + content = props.render(props.renderProps) + } catch (error) { + content = ( +
+ {t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)} +
+ ) + } + return <>{content} +} + /** * The custom switch: a real checkbox (hidden, native semantics and focus) * driving a styled track/thumb. Used by the general toggle rows and the @@ -240,6 +278,72 @@ function TypedRow(props: { ) } +/** + * The secondary settings popup body of one feature (tab or viewer): + * - `settings.render` (custom panel) when declared — rendered with the + * shared store/service, the live prefs, the descriptor's own plugin + * settings blob, a persistence helper, and a close callback; + * - otherwise the host-prefs `toggles` rows, then the plugin-owned + * `pluginToggles` rows (their values live in `pluginSettings[feature.id]`, + * projected onto the prefs face so the shared row renderer reads them). + */ +export function SettingsBody(props: { + feature: TabDescriptor | FileViewerDescriptor + prefs: SidebarPrefs + store: SidebarStore + service: BetterSidebarService + onToggle: (toggle: SidebarSettingToggle, next: boolean) => void + onCommit: (toggle: SidebarSettingToggle, raw: string) => string + onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void + onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string + onPluginWrite: (key: string, value: unknown) => void + onClose: () => void +}) { + const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props + const render = feature.settings?.render + if (render !== undefined) { + return ( + + ) + } + const toggles = feature.settings?.toggles ?? [] + const pluginToggles = feature.settings?.pluginToggles ?? [] + if (toggles.length === 0 && pluginToggles.length === 0) return null + // Plugin rows read/write their values through a projected prefs face so + // the shared row renderer works unchanged (its rows read prefs[key]). + const effectivePrefs = { ...prefs, ...(prefs.pluginSettings[feature.id] ?? {}) } as SidebarPrefs + return ( +
+ {toggles.length > 0 && ( + + )} + {pluginToggles.length > 0 && ( + + )} +
+ ) +} + /** * Render the Side card preferences section. * @param props - composed slot props (runtime share + injected store/service). @@ -250,7 +354,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { const [widthDraft, setWidthDraft] = useState(String(store.getPrefs().defaultWidthPercent)) const [error, setError] = useState(null) // Which feature's secondary settings popup is open (null = closed). - const [settingsFor, setSettingsFor] = useState(null) + const [settingsFor, setSettingsFor] = useState(null) // The declarative inventory: the registered tab types and file viewers. // Local state + service.subscribe (registry changes are rare — plugin @@ -368,6 +472,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { return raw } + /** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */ + const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => { + applyPref({ + pluginSettings: { + ...prefs.pluginSettings, + [descriptorId]: { ...(prefs.pluginSettings[descriptorId] ?? {}), [key]: value }, + }, + }) + } + + /** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */ + const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => { + applyPluginSetting(descriptorId, toggle.key, next) + } + + /** Commit one plugin-owned text/number row (clamped like the host rows). */ + const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => { + if (toggle.type === 'number') { + const parsed = Number(raw) + const blob = prefs.pluginSettings[descriptorId] ?? {} + const fallback = String(blob[toggle.key] ?? '') + if (!Number.isFinite(parsed)) return fallback + let clamped = Math.round(parsed) + if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped) + if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped) + applyPluginSetting(descriptorId, toggle.key, clamped) + return String(clamped) + } + applyPluginSetting(descriptorId, toggle.key, raw) + return raw + } + const commitWidth = (): void => { const parsed = Number(widthDraft) if (!Number.isFinite(parsed)) { @@ -511,8 +647,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { onToggle: (next) => { onToggleTab(tab.id, next) }, // The settings gear only while the feature is enabled: its // related settings are dormant while the feature is off. - onOpenSettings: prefs.tabsEnabled[tab.id] !== false - && (tab.settings?.toggles?.length ?? 0) > 0 + onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab) ? () => { setSettingsFor(tab) } : undefined, })} @@ -536,6 +671,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { icon: iconOf(viewer.icon, 16), enabled: prefs.viewersEnabled[viewer.id] !== false, onToggle: (next) => { onToggleViewer(viewer.id, next) }, + onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer) + ? () => { setSettingsFor(viewer) } + : undefined, })} ))} @@ -547,13 +685,16 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { Done footer (Modal chrome is the app's own). Mounted only while a feature is open — the Modal primitive runs hooks unconditionally, so a closed-but-mounted Modal would break SSR (and the - renderToString spec) under the test dual-react split. */} + renderToString spec) under the test dual-react split. + Content: `settings.render` (custom panel) when declared, else the + host-prefs `toggles` rows followed by the plugin-owned + `pluginToggles` rows (their values live in pluginSettings[id]). */} {settingsFor !== null && ( { setSettingsFor(null) }} - title={textOf(settingsFor.title)} - description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })} + title={featureNameOf(settingsFor)} + description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })} closeLabel={t('close')} className={css.popupDialog} footer={( @@ -562,11 +703,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { )} > - { onPluginToggle(settingsFor.id, toggle, next) }} + onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)} + onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }} + onClose={() => { setSettingsFor(null) }} + store={store} + service={service} /> )} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 50b8c75..20ca9de 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -32,7 +32,7 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarSessionList } from '../context-types.ts' import { appendToDraft } from './conversation-draft.ts' import { - BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, + BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, reconcileAgentTerminals, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, @@ -555,7 +555,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const current = store.getSnapshot().state const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) - store.reduce(s => closeTab(s, paneId, tabId)) + // Route through the service: the tab-bar close is the canonical close + // path (finds the pane itself, fires descriptor.onClose). + ctx.betterSidebar?.closeTab(tabId) if (tab?.type === 'terminal') { if (isAgentTabId(tabId)) { const uuid = agentUuidOf(tabId) @@ -566,13 +568,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } }, activateTab: (paneId, tabId) => { - store.reduce(s => ({ - ...s, - activePane: paneId, - splits: mapLeaf(s.splits, paneId, (leaf) => { - if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId - }), - })) + // Route through the service: same reducer (finds the pane in EITHER + // tree, sets the active pane) and fires descriptor.onActivate. + ctx.betterSidebar?.activateTab(tabId) }, focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { @@ -631,7 +629,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const descriptor = service?.getTab(optionId) if (descriptor === undefined) return const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title - service.openTab({ type: optionId, title }) + // The session scope rides along: lifecycle callbacks receive it (and + // the open stays in the current session, as before). + service.openTab({ type: optionId, title }, { sessionId, cwd }) } /** @@ -647,6 +647,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon } + /** + * The tab badge from the tab-type registry: a count (99+ capped) or a + * short text pill. A throwing badge is swallowed (no pill) — the tab + * strip must never break because a plugin's badge computation failed. + */ + const tabBadgeOf = (tab: SidebarTab): ReactNode => { + const descriptor = ctx.betterSidebar?.getTab(tab.type) + if (descriptor?.badge === undefined) return null + let value: string | number | null | undefined + try { + value = descriptor.badge(ctx, { sessionId, cwd }, state) + } catch (error) { + console.error('[dsh-better-sidebar] tab badge error:', error) + return null + } + if (value === null || value === undefined || value === '') return null + const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value) + return {text} + } + /** * Render one tab's content. `active` (from the workbench) tells whether * this tab is the active one in its pane; combined with the panel's @@ -757,6 +777,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onNewTab={onNewTab} renderTab={renderTab} getTabIcon={tabIconOf} + getTabBadge={tabBadgeOf} /> @@ -836,6 +857,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onNewTab={onNewTab} renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)} getTabIcon={tabIconOf} + getTabBadge={tabBadgeOf} /> diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 116c9af..72c325d 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -63,9 +63,12 @@ export function TabBar(props: { onDropTab: (payload: TabDragPayload, before: string | null) => void /** Icon resolver for tab labels (reads from the tab descriptor registry). */ getTabIcon?: (tab: SidebarTab) => ReactNode + /** Badge resolver for tab labels (reads the descriptor's `badge`; the + * resolver returns the rendered pill or null). */ + getTabBadge?: (tab: SidebarTab) => ReactNode }) { const { - paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, + paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge, } = props const [menuOpen, setMenuOpen] = useState(false) const [dragOver, setDragOver] = useState(false) @@ -136,6 +139,7 @@ export function TabBar(props: { }} > {getTabIcon?.(tab) ?? null} + {getTabBadge?.(tab) ?? null} {tab.title}