diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e740899 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,447 @@ +# dsh-better-sidebar 插件接入文档 + +> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 + +better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。 + +--- + +## 1. 服务定位 + +- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`) +- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布) +- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`) +- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并 + +> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。 + +--- + +## 2. 消费插件的最小骨架 + +### 2.1 `package.json` + +```jsonc +{ + "name": "my-plugin", + "peerDependencies": { + "cordis": "^4.0.0-rc.7", + "dsh-better-sidebar": "workspace:*" + }, + "peerDependenciesMeta": { + "dsh-better-sidebar": { "optional": true } + } +} +``` + +- `dsh-better-sidebar` 必须声明为 **peerDependency**(不是 dependency,避免重复实例化) +- 标记 `optional: true` 让你的插件在 better-sidebar 未安装时也能加载(注册代码会因为 `ctx.betterSidebar` 为 undefined 而跳过) + +### 2.2 client half 入口 + +```ts +// my-plugin/src/client/index.ts +import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 + +export const inject = ['betterSidebar', 'slots'] // 声明服务依赖 + +export function apply(ctx: Context): void { + // 注册一个 sidebar tab + ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', + title: () => 'Database', + icon: , + order: 50, + component: ({ ctx, scope, tab }) => , + }) + ) + + // 注册一个文件预览器 + ctx.effect(() => + ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:csv', + exts: ['csv'], + fetchStrategy: 'custom', + load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)), + component: ({ customData, path }) => , + }) + ) +} +``` + +> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。 + +### 2.3 类型导入 + +```ts +import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar' +``` + +类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service` exports 子路径暴露。 + +--- + +## 3. Tab 注册 API + +### 3.1 `TabDescriptor` 完整字段 + +```ts +interface TabDescriptor { + /** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */ + id: string + /** 标题(i18n 友好:传字符串或返回字符串的函数) */ + title: string | (() => string) + /** 图标:ReactNode 或 (size: number) => ReactNode */ + icon?: ReactNode | ((size: number) => ReactNode) + /** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */ + order?: number + /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ + hidden?: boolean + /** + 菜单禁用判定(如 terminal 配额满) */ + available?: (state: SidebarState) => boolean + /** + * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 + * 返回 undefined 表示不去重(每次都新开)。 + * 内置策略:explorer/git/subagent 用 () => id(单实例);editor 用 tab => tab.path;diff 用 tab => tab.id。 + */ + dedupeKey?: (tab: SidebarTab) => string | undefined + /** + * 自定义 tab 创建(minting SidebarTab + 状态 patch)。 + * 返回 null 拒绝创建。terminal 用它生成 terminal: id 并递增 nextTerminal。 + * 省略时用默认 { id, type, title } + seed 里的 path/diff。 + */ + createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + /** 渲染函数 */ + component: (props: TabComponentProps) => ReactNode +} +``` + +### 3.2 `TabComponentProps` + +```ts +interface TabComponentProps { + ctx: Context // client cordis context + store: SidebarStore // better-sidebar 的状态 store(可调 reduce/openTab 等) + scope: SessionScope // { sessionId, cwd? } + tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?) + visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等) + // 以下由内置 tab 使用,外部 tab 可忽略: + expanded?: string[] // explorer 的展开目录集 + onToggleDir?: (path: string) => void + onReferenceFile?: (path: string) => void + onOpenFile?: (path: string) => void + onOpenDiff?: (tab: SidebarTab) => void + onSubagentJump?: (childSessionId: string) => void +} +``` + +### 3.3 注册示例 + +**最简 tab**(单实例、+ 菜单可见): +```ts +ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:notes', + title: 'Notes', + icon: , + order: 50, + dedupeKey: () => 'my-plugin:notes', // 单实例 + component: ({ scope }) => , + }) +) +``` + +**多实例 tab**(每次新开、带自定义 id): +```ts +ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:doc', + title: 'Doc', + icon: , + order: 60, + // 不设 dedupeKey:每次 openTab 都新开 + component: ({ tab, scope }) => , + }) +) +// 外部触发打开: +ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' }) +``` + +**条件可见**(仅 git 仓库时显示): +```ts +ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:commits', + title: 'Commits', + icon: , + order: 70, + available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled + dedupeKey: () => 'my-plugin:commits', + component: ({ scope }) => , + }) +) +``` + +### 3.4 内置 tab(不可重复注册) + +| id | order | single | hidden | 用途 | +|---|---|---|---|---| +| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) | +| `explorer` | 10 | 是 | 否 | 文件资源管理器 | +| `git` | 20 | 是 | 否 | Git 面板 | +| `subagent` | 30 | 是 | 否 | 子代理拓扑 | +| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) | +| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | + +你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 + +--- + +## 4. FileViewer 注册 API + +### 4.1 `FileViewerDescriptor` 完整字段 + +```ts +interface FileViewerDescriptor { + /** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */ + id: string + /** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */ + exts: readonly string[] + /** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */ + priority?: number + /** 字节获取策略 */ + 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 + /** 渲染函数 */ + component: (props: FileViewerProps) => ReactNode +} +``` + +### 4.2 `FileViewerProps` + +```ts +interface FileViewerProps { + ctx: Context + store: SidebarStore + scope: SessionScope + path: string + title: string + content?: string // fetchStrategy='fsRead' 时 + truncated?: boolean // fetchStrategy='fsRead' 时 + mediaUrl?: string // fetchStrategy='mediaUrl' 时 + customData?: unknown // fetchStrategy='custom' 时(load() 的返回值) +} +``` + +### 4.3 `fetchStrategy` 对照 + +| 策略 | 字节来源 | 传给 component 的字段 | 适用 | +|---|---|---|---| +| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) | +| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) | +| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) | +| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) | +| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 | + +### 4.4 匹配算法 + +`matchFileViewer(path, head?)` 按以下顺序解析: + +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 兜底(按扩展名选语法高亮) + +> **内置 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 即可覆盖。 + +### 4.5 注册示例 + +**CSV 预览器**(自定义加载 + 渲染): +```ts +ctx.effect(() => + ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:csv', + exts: ['csv'], + fetchStrategy: 'custom', + load: async (path, scope) => { + const text = await fetchText(scope, path) + return parseCsv(text) + }, + component: ({ customData, path }) => , + }) +) +``` + +**覆盖内置 image viewer**(如想用自定义的 SVG 优化渲染): +```ts +ctx.effect(() => + ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:svg-pro', + exts: ['svg'], + priority: 10, // 高于内置 image 的 0 + fetchStrategy: 'mediaUrl', + component: ({ mediaUrl }) => , + }) +) +``` + +**内容嗅探**(按 magic bytes 路由,忽略扩展名): +```ts +ctx.effect(() => + ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:magic-parquet', + exts: [], // catch-all,但 priority 高 + detect 精确命中 + priority: 100, + fetchStrategy: 'custom', + detect: (_path, head) => head.length >= 4 + && head[0] === 0x50 && head[1] === 0x41 + && head[2] === 0x52 && head[3] === 0x31, // 'PAR1' + load: async (path, scope) => parseParquet(await fetchBytes(scope, path)), + component: ({ customData }) => , + }) +) +``` + +--- + +## 5. 服务方法完整清单 + +```ts +interface BetterSidebarService { + /** 注册 tab 类型;返回 disposer */ + registerTab(descriptor: TabDescriptor): () => void + /** 注册文件预览器;返回 disposer */ + registerFileViewer(descriptor: FileViewerDescriptor): () => void + /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用) */ + getTabs(): readonly TabDescriptor[] + /** 当前已注册的 file viewer 描述符快照 */ + getFileViewers(): readonly FileViewerDescriptor[] + /** 按 id 查 tab 描述符 */ + getTab(id: string): TabDescriptor | undefined + /** 按 path 匹配 file viewer(priority desc → 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 */ + closeTab(tabId: string): void + /** 订阅注册表变化(register/dispose 时触发) */ + subscribe(listener: () => void): () => void +} +``` + +--- + +## 6. 生命周期与 HMR + +- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。 +- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。 +- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。 +- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。 + +--- + +## 7. 平台约束与陷阱 + +| 陷阱 | 说明 | +|---|---| +| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 | +| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 | +| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 | +| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | +| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | +| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | + +--- + +## 8. 完整最小示例 + +> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。 + +**`my-plugin/package.json`**: +```jsonc +{ + "name": "my-plugin", + "version": "0.1.0", + "main": "lib/index.js", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } + }, + "peerDependencies": { + "cordis": "^4.0.0-rc.7", + "dsh-better-sidebar": "workspace:*", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "dsh-better-sidebar": { "optional": true } + } +} +``` + +**`my-plugin/src/client/index.tsx`**: +```tsx +import { createElement } from 'react' +import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 +import type { Context } from 'cordis' + +export const inject = ['betterSidebar'] + +export function apply(ctx: Context): void { + // Database tab + ctx.effect(() => + ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', + title: () => 'Database', + order: 50, + dedupeKey: () => 'my-plugin:db', + component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }), + }) + ) + + // CSV viewer + ctx.effect(() => + ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:csv', + exts: ['csv'], + fetchStrategy: 'custom', + load: async (path, scope) => { + const res = await fetch('/sidebar/api/fs.read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: scope.sessionId, path }), + }) + const { value } = await res.json() + return parseCsv(value.content) + }, + component: ({ customData, path }) => + createElement(CsvGrid, { rows: customData as string[][], path }), + }) + ) +} + +function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ } +function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ } +function parseCsv(text: string): string[][] { /* ... */ } +``` + +**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。 + +--- + +## 9. 参考实现 + +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/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 +- **`tests/service.spec.ts`**:12 个测试覆盖 register/dispose/matchFileViewer/dedupe/createTab +- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档 + +调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/README.md b/README.md index f86edc7..af21455 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,30 @@ pnpm watch # tsdown --watch(client bundle 热重建) - client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号 - client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联 +## 🔌 服务化(v0.4.0+):让其他插件注册 tab 和文件预览器 + +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)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。 + +**消费插件最小骨架**: +```ts +import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 +export const inject = ['betterSidebar'] +export function apply(ctx: Context) { + ctx.effect(() => ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', + title: 'Database', + component: ({ scope }) => , + })) +} +``` + +**完整接入文档**:见 [`AGENTS.md`](./AGENTS.md) ——含 `TabDescriptor` / `FileViewerDescriptor` 全字段、`fetchStrategy` 对照、匹配算法、HMR 陷阱、完整最小示例。 + ## 🔐 安全边界 - 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效) diff --git a/docs/plans/2026-08-11-service-registry-design.md b/docs/plans/2026-08-11-service-registry-design.md new file mode 100644 index 0000000..14e811a --- /dev/null +++ b/docs/plans/2026-08-11-service-registry-design.md @@ -0,0 +1,361 @@ +# 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 // 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 | `` | +| `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 | `` | +| `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)`;未找到渲染 `` | +| `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 +return +``` + +4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。 + +## 11. 持久化与降级 + +- localStorage 里 `orphaned` tab:渲染 `` + 关闭按钮 +- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用 + +## 12. Consumer 插件使用范例 + +### 12.1 Consumer `package.json` + +```jsonc +{ + "peerDependencies": { + "cordis": "^4.0.0-rc.7", + "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: , + order: 50, + component: ({ scope, store, tab }) => , + }) + ) + + 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 }) => , + }) + ) +} +``` + +## 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 → 渲染 ``,注册后恢复 + +### 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. 加 `` 组件 + sanitize 降级 +10. 写测试(service / matchFileViewer / builtins / orphaned) +11. typecheck + test + build 三件套 +12. 重建 lib/,提交,推送,更新 profile web + +详细实施计划由 writing-plans skill 生成。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 315fb0c..82ce434 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,8 +1,8 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.3.0", + "version": "0.4.0", "main": "./lib/index.js", - "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离", + "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { "dsh": ">=0.0.1" }, diff --git a/package.json b/package.json index 91c82e2..c4e76db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-better-sidebar", - "version": "0.3.0", - "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.", + "version": "0.4.0", + "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "private": true, "type": "module", "main": "lib/index.js", @@ -19,6 +19,10 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./client/service": { + "types": "./lib/types/client/service.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index 02b380c..86f9e39 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -7,23 +7,20 @@ * and unsaved edits survive the toggle. Reads cap at the host's 512KB bound * (a banner marks truncation). */ -import { useEffect, useRef, useState } from 'react' +import { createElement, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { EditorState } from '@codemirror/state' import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { Context } from '../context-types.ts' import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts' import { languageForPath } from './lang.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { t } from './locales.ts' -import { officeKindForExt } from './office-types.ts' -import { DocxView, XlsxView } from './office-view.tsx' -import { isPdfExt } from './pdf-types.ts' -import { PdfView } from './PdfView.tsx' -import { PptxView } from './PptxView.tsx' -import { isImageExt } from './image-types.ts' +import type { FileViewerDescriptor } from './service.ts' +import type { SidebarStore } from './state.ts' import css from './sidebar.module.css' const MD_EXT = ['.md', '.markdown'] @@ -31,14 +28,14 @@ const MD_EXT = ['.md', '.markdown'] type EditorLoad = | { status: 'loading' } | { status: 'error'; message: string } - | { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean } + | { status: 'ready'; viewer?: FileViewerDescriptor; kind?: 'text' | 'md'; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } | { status: 'binary' } // unknown binary / OLE legacy formats → download button /** Previewable files (rendered output vs source editing). */ type ViewMode = 'preview' | 'edit' -export function EditorView(props: { scope: SessionScope; path: string; title: string }) { - const { scope, path, title } = props +export function EditorView(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { + const { ctx, store, scope, path, title } = props const [load, setLoad] = useState({ status: 'loading' }) const [mode, setMode] = useState('preview') /** The editor's current text (null while clean); preview renders this. */ @@ -62,30 +59,44 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st setDraft(null) setDirty(false) setSaveState('idle') - // Office preview short-circuit: the host's readText would NUL-probe every - // Office file as binary (a wasted round-trip), and .docx/.xlsx have their - // own renderers below. Detect by extension and skip fsRead entirely — - // the renderer fetches bytes through the media route itself. const ext = extOfPath(path) - // Images are binary by nature; short-circuit before fsRead's NUL probe or - // every PNG/JPEG would be classified as an unsupported binary file. - if (isImageExt(ext)) { - setLoad({ status: 'ready', kind: 'image', content: '', truncated: false }) - return - } - if (isPdfExt(ext)) { - setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false }) - return - } - const officeKind = officeKindForExt(ext) - if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') { - setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false }) - return - } - if (officeKind === 'download-only') { - setLoad({ status: 'binary' }) - return + // Registry-driven viewer match: image/pdf/office/binary-download etc. + // are registered through ctx.betterSidebar; code/markdown have no + // registered viewer and fall through to the fsRead + CodeMirror path. + const viewer = ctx.betterSidebar?.matchFileViewer(path) + if (viewer !== undefined) { + switch (viewer.fetchStrategy) { + case 'mediaUrl': + case 'none': + setLoad({ status: 'ready', viewer, mediaUrl: mediaUrl(scope, path) }) + return + case 'binary-download': + setLoad({ status: 'binary' }) + return + case 'custom': + void viewer.load?.(path, scope).then((data) => { + if (cancelled) return + setLoad({ status: 'ready', viewer, customData: data }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + case 'fsRead': + // An fsRead viewer (e.g. external CSV viewer) gets its content + // through the host's fs.read and renders through viewer.component. + api.fsRead(scope, path).then((result) => { + if (cancelled) return + if (result.kind === 'binary') { setLoad({ status: 'binary' }); return } + setLoad({ status: 'ready', viewer, content: result.content, truncated: result.truncated }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + } } + // Fallback: code/markdown (no registered viewer) — fsRead + CodeMirror. api.fsRead(scope, path).then((result) => { if (cancelled) return if (result.kind === 'binary') { @@ -103,7 +114,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) }) return () => { cancelled = true } - }, [scope.sessionId, scope.cwd, path]) + }, [scope.sessionId, scope.cwd, path, ctx]) // Create the CodeMirror editor once the file is loaded. The view owns the // document; React only tracks dirty/draft state through the update @@ -112,7 +123,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st // colors live in a compartment so a scheme flip reconfigures only that // part — the document, undo history and scroll position survive. useEffect(() => { - if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return + if (load.status !== 'ready' || load.viewer !== undefined || (load.kind !== 'text' && load.kind !== 'md')) return const host = hostRef.current if (host === null) return const language = languageForPath(path) @@ -248,26 +259,16 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st /> )} - {load.status === 'ready' && load.kind === 'image' && ( -
- {title} -
- )} - {load.status === 'ready' && load.kind === 'docx' && ( - - )} - {load.status === 'ready' && load.kind === 'xlsx' && ( - - )} - {load.status === 'ready' && load.kind === 'pptx' && ( - - )} - {load.status === 'ready' && load.kind === 'pdf' && ( - - )} + {load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, { + ctx, store, scope, path, title, + content: load.content, + truncated: load.truncated, + mediaUrl: load.mediaUrl, + customData: load.customData, + })} {previewable && mode === 'preview' && (
- +
)} diff --git a/src/client/OrphanedTab.tsx b/src/client/OrphanedTab.tsx new file mode 100644 index 0000000..c62ce8b --- /dev/null +++ b/src/client/OrphanedTab.tsx @@ -0,0 +1,27 @@ +/** + * Placeholder shown when a persisted tab's type is not registered (the + * contributing plugin is not installed / not yet loaded). The tab is kept + * in state so it can recover if the plugin loads later; the user can close + * it through the tab bar's X button as usual. This is the graceful- + * degradation path for the open tab-type set: {@link Sidebar} renders this + * instead of crashing on an unknown type. + */ +import type { ReactNode } from 'react' +import { t } from './locales.ts' +import css from './sidebar.module.css' +import type { TabComponentProps } from './service.ts' + +export function OrphanedTab(props: TabComponentProps): ReactNode { + const { tab } = props + return ( +
+
+ {tab.title} +
+
+ {t('pluginNotLoaded')} + {tab.type} +
+
+ ) +} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 77bdab8..39b0356 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -15,7 +15,7 @@ import clsx from 'clsx' import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { - agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab, + agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, reconcileAgentTerminals, resizeSplit, setWidth, toggleExpanded, togglePanel, TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, @@ -23,18 +23,11 @@ import { import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' -import { tabTypeIcon } from './icons.tsx' import { relativeTo } from './paths.ts' -import { ExplorerView } from './ExplorerView.tsx' -import { EditorView } from './EditorView.tsx' -import { TerminalView } from './TerminalView.tsx' -import { GitView } from './GitView.tsx' -import { DiffTab } from './DiffTab.tsx' -import { SubagentView } from './SubagentView.tsx' +import { OrphanedTab } from './OrphanedTab.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' import { t } from './locales.ts' import { api } from './api.ts' -import { openSidebarFile } from './intercept.tsx' import css from './sidebar.module.css' /** How many consecutive reconnect failures stop the agent-terminals push loop @@ -60,64 +53,31 @@ function TabContent(props: { }) { const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props const scope = { sessionId, cwd } - switch (tab.type) { - case 'explorer': - return ( - { openSidebarFile(ctx, store, sessionId, path) }} - onReferenceFile={onReferenceFile} - /> - ) - case 'git': - return ( - { openSidebarFile(ctx, store, sessionId, path) }} - onOpenDiff={onOpenDiff} - /> - ) - case 'diff': - return tab.diff === undefined - ? null - : - case 'terminal': - return - case 'editor': - return - case 'subagent': - return ( - { onSubagentJump(address.childSessionId) }} - /> - ) + const descriptor = ctx.betterSidebar?.getTab(tab.type) + if (descriptor === undefined) { + return } + return descriptor.component({ + ctx, store, scope, tab, visible, expanded, + onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, + }) } -/** The + menu options for the current state (UI-tab terminal cap applied). - * Agent-owned terminals (`agent:` tabs) are the model's, not the user's: - * they never count toward the per-session UI cap. */ -function buildNewTabOptions(state: SidebarState): NewTabOption[] { - const uiTerminalCount = allLeaves(state.splits) - .flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length - return [ - { id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) }, - { id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) }, - { id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) }, - { - id: 'terminal', - label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), - disabled: uiTerminalCount >= TERMINAL_LIMIT, - icon: tabTypeIcon('terminal', 16), - }, - ] +/** The + menu options for the current state, driven by the tab registry. + * Hidden tabs (editor/diff) never show; `available` returning false shows + * a disabled row (e.g. terminal at capacity) instead of hiding the option. */ +function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] { + const service = ctx.betterSidebar + if (service === undefined) return [] + return service.getTabs() + .filter(d => !d.hidden) + .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) + .map(d => ({ + id: d.id, + label: typeof d.title === 'function' ? d.title() : d.title, + disabled: !(d.available?.(state) ?? true), + icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, + })) } export function Sidebar(props: { ctx: Context; store: SidebarStore }) { @@ -225,12 +185,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (sessionId === undefined || prev === undefined) return if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return if (!store.getPrefs().autoOpenSubagent) return - store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), { - id: 'subagent', - type: 'subagent', - title: t('subagent'), - })) - }, [sessionList, sessionId, store]) + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) + }, [sessionList, sessionId, store, ctx]) /** * Topology jump-back: clicking a subagent node on the Subagent page calls @@ -248,12 +205,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const pending = subagentJumpRef.current if (pending === undefined || sessionId !== pending) return subagentJumpRef.current = undefined - store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), { - id: 'subagent', - type: 'subagent', - title: t('subagent'), - })) - }, [sessionId, store]) + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) + }, [sessionId, store, ctx]) // Panel width drag (left edge strip). const widthDrag = useRef({ startX: 0, startWidth: 0 }) @@ -367,30 +321,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } const onNewTab = (optionId: string): void => { - store.reduce((s) => { - if (optionId === 'explorer') { - return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') }) - } - if (optionId === 'git') { - return openTab(s, { id: 'git', type: 'git', title: t('git') }) - } - if (optionId === 'subagent') { - return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') }) - } - if (optionId === 'terminal') { - const count = allLeaves(s.splits) - .flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length - if (count >= TERMINAL_LIMIT) return s - const tab: SidebarTab = { - id: `terminal:${s.nextTerminal}`, - type: 'terminal', - title: `${t('terminal')} ${s.nextTerminal}`, - } - return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 } - } - return s - }) + const service = ctx.betterSidebar + const descriptor = service?.getTab(optionId) + if (descriptor === undefined) return + const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title + service.openTab({ type: optionId, title }) } /** @@ -498,10 +433,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
{ + const descriptor = ctx.betterSidebar?.getTab(tab.type) + if (descriptor === undefined) return null + return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon + }} />
diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 493f99b..116c9af 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -11,7 +11,6 @@ import { IconCloseFill14, IconPlusOutline16, Menu, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarTab } from './state.ts' -import { tabTypeIcon } from './icons.tsx' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -62,9 +61,11 @@ export function TabBar(props: { newTabOptions: NewTabOption[] /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */ onDropTab: (payload: TabDragPayload, before: string | null) => void + /** Icon resolver for tab labels (reads from the tab descriptor registry). */ + getTabIcon?: (tab: SidebarTab) => ReactNode }) { const { - paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, + paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, } = props const [menuOpen, setMenuOpen] = useState(false) const [dragOver, setDragOver] = useState(false) @@ -134,7 +135,7 @@ export function TabBar(props: { } }} > - {tabTypeIcon(tab.type, 14)} + {getTabIcon?.(tab) ?? null} {tab.title} ))} @@ -123,8 +122,9 @@ function LeafView(props: { actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode + getTabIcon?: (tab: SidebarTab) => ReactNode }) { - const { leaf, newTabOptions, actions, onNewTab, renderTab } = props + const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props const [dropZone, setDropZone] = useState(null) const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1] @@ -177,6 +177,7 @@ function LeafView(props: { onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }} onNewTab={onNewTab} newTabOptions={newTabOptions} + getTabIcon={getTabIcon} onDropTab={(payload, before) => { if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center') else actions.moveTabBefore(payload, leaf.id, before) @@ -215,8 +216,9 @@ function NodeView(props: { actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode + getTabIcon?: (tab: SidebarTab) => ReactNode }) { - const { node, state, newTabOptions, actions, onNewTab, renderTab } = props + const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props if (node.kind === 'leaf') { return ( ) } @@ -250,6 +253,7 @@ function NodeView(props: { actions={actions} onNewTab={onNewTab} renderTab={renderTab} + getTabIcon={getTabIcon} /> @@ -265,8 +269,9 @@ export function Workbench(props: { actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode + getTabIcon?: (tab: SidebarTab) => ReactNode }) { - const { state, newTabOptions, actions, onNewTab, renderTab } = props + const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props return (
) diff --git a/src/client/state.ts b/src/client/state.ts index be76828..c6ce084 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -11,7 +11,18 @@ */ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' -export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff' +/** + * Tab type identifier. Builtins use the strings below; external plugins + * register their own ids (e.g. `'my-plugin:db'`) through the sidebar + * service. Kept as `string` so the registry is open; {@link SIDEBAR_BUILTIN_TAB_TYPES} + * lists the builtins for back-compat checks. + */ +export type TabType = string + +/** The tab type ids the plugin itself registers (see {@link ./builtins.ts}). */ +export const SIDEBAR_BUILTIN_TAB_TYPES = [ + 'explorer', 'git', 'editor', 'terminal', 'subagent', 'diff', +] as readonly string[] /** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */ export type SidebarDiffRef = @@ -298,15 +309,13 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string): } } -/** Whether a tab type is single-instance per session (explorer/git/subagent). */ -function isSingle(type: TabType): boolean { - return type === 'explorer' || type === 'git' || type === 'subagent' -} - /** - * Open a tab (or focus its existing instance). Single-instance types focus - * the existing tab wherever it lives; editors dedupe by path. The tab lands - * in the active pane (or the first pane when the active one is gone). + * Open a tab (or focus its existing instance by id). Dedup strategies + * (single-instance, per-path, per-change) are owned by the tab descriptor + * through {@link BetterSidebarService.openTab} / `dedupeKey`; this reducer + * only handles the id-based safety net (reconcile/openDiffTab already + * check existence before calling) and lands the tab in the active pane + * (or the first pane when the active one is gone). */ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState { let targetId = state.activePane ?? firstLeaf(state.splits).id @@ -315,24 +324,10 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState { if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) { targetId = firstLeaf(state.splits).id } - if (isSingle(tab.type)) { - for (const leaf of allLeaves(state.splits)) { - const existing = leaf.tabs.find(candidate => candidate.type === tab.type) - if (existing !== undefined) return activateTab(state, leaf.id, existing.id) - } - } - if (tab.type === 'editor' && tab.path !== undefined) { - for (const leaf of allLeaves(state.splits)) { - const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path) - if (existing !== undefined) return activateTab(state, leaf.id, existing.id) - } - } - if (tab.type === 'diff') { - // One tab per change: the same path+side (or commit) focuses its instance. - for (const leaf of allLeaves(state.splits)) { - const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id) - if (existing !== undefined) return activateTab(state, leaf.id, existing.id) - } + // Id-based safety net: if a tab with the same id exists, focus it. + for (const leaf of allLeaves(state.splits)) { + const existing = leaf.tabs.find(candidate => candidate.id === tab.id) + if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } return { ...state, @@ -622,13 +617,10 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map at view time and recovers if its plugin loads later. + if (typeof candidate.type !== 'string') return undefined tabs.push({ id: candidate.id, type: candidate.type, diff --git a/src/context-types.ts b/src/context-types.ts index 7994734..aab1b7a 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -20,6 +20,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' import type { Context } from 'cordis' +import type { BetterSidebarService } from './client/service.ts' /** One named webserver route (mirror of the host-webserver WebRoute). */ export interface SidebarWebRoute { @@ -309,6 +310,12 @@ declare module 'cordis' { settings: SidebarSettingsService invariants: SidebarInvariantsService tools: SidebarToolsService + /** + * The client-side sidebar registry: external plugins register tab types + * and file previewers here. Provided by the client half (see + * {@link ./client/index.tsx}); undefined on the host side. + */ + betterSidebar: BetterSidebarService /** * Register a lifecycle callback (DSH-vendored cordis): runs at plugin * activation; its returned cleanup runs at disposal. diff --git a/src/index.ts b/src/index.ts index 0c5ddda..274720c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,19 @@ import { readJsonBody, requireString, SidebarError, writeError, writeJson, write export { Config } export type { SidebarConfig, ResolvedSidebarConfig } +// Re-export the Context augmentation (declare module 'cordis') so consumers +// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`. +// Also re-export the service descriptor types so consumers can type their +// registerTab / registerFileViewer arguments without reaching into /client. +export type { Context } from './context-types.ts' +export type { + BetterSidebarService, + TabDescriptor, + TabComponentProps, + FileViewerDescriptor, + FileViewerProps, + FileFetchStrategy, +} from './client/service.ts' /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' diff --git a/tests/service.spec.ts b/tests/service.spec.ts new file mode 100644 index 0000000..d929d2f --- /dev/null +++ b/tests/service.spec.ts @@ -0,0 +1,189 @@ +/** + * Tests for the BetterSidebar service registry: register/dispose lifecycle, + * matchFileViewer priority/exts/detect algorithm, and openTab dedupe. + */ +import { describe, it, expect } from 'vitest' + +// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout) +const g = globalThis as Record +if (g.window === undefined) { + g.window = { + clearTimeout: () => {}, + setTimeout: (_fn: () => void) => 0, + innerWidth: 1024, + } +} +if (g.localStorage === undefined) { + g.localStorage = { + getItem: () => null, + setItem: () => {}, + } +} + +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore, allLeaves } from '../src/client/state.ts' + +describe('BetterSidebar service', () => { + it('registerTab adds to the registry and dispose removes it', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + expect(service.getTabs()).toHaveLength(0) + const dispose = service.registerTab({ + id: 'test:tab', + title: 'Test', + component: () => null, + }) + expect(service.getTabs()).toHaveLength(1) + expect(service.getTab('test:tab')?.id).toBe('test:tab') + dispose() + expect(service.getTabs()).toHaveLength(0) + expect(service.getTab('test:tab')).toBeUndefined() + }) + + it('registerTab throws on duplicate id', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'dup', title: 'A', component: () => null }) + expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow() + }) + + it('registerFileViewer adds and dispose removes', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + expect(service.getFileViewers()).toHaveLength(0) + const dispose = service.registerFileViewer({ + id: 'csv', + exts: ['csv'], + fetchStrategy: 'custom', + component: () => null, + }) + expect(service.getFileViewers()).toHaveLength(1) + dispose() + expect(service.getFileViewers()).toHaveLength(0) + }) + + it('subscribe fires on register and dispose', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + let calls = 0 + const unsub = service.subscribe(() => { calls++ }) + const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null }) + expect(calls).toBe(1) + dispose() + expect(calls).toBe(2) + unsub() + service.registerTab({ id: 'y', title: 'Y', component: () => null }) + expect(calls).toBe(2) + }) +}) + +describe('matchFileViewer', () => { + it('matches by extension', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('photo.png')?.id).toBe('img') + expect(service.matchFileViewer('photo.JPG')?.id).toBe('img') + expect(service.matchFileViewer('doc.txt')).toBeUndefined() + }) + + it('higher priority wins on extension conflict', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null }) + service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null }) + expect(service.matchFileViewer('x.png')?.id).toBe('advanced') + }) + + it('catch-all (exts: []) matches anything at lowest priority', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null }) + service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('x.png')?.id).toBe('img') + expect(service.matchFileViewer('x.txt')?.id).toBe('catchall') + }) + + it('detect overrides exts when head bytes are available', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null }) + service.registerFileViewer({ + id: 'by-magic', + exts: [], + priority: 5, + fetchStrategy: 'fsRead', + detect: (_path, head) => head[0] === 0x89, + component: () => null, + }) + // Without head bytes, by-ext wins (priority 10 > 5, and .bin matches). + expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext') + // With magic bytes, by-magic wins (detect overrides). + expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic') + }) + + it('returns undefined when no viewer matches and no catch-all', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('doc.txt')).toBeUndefined() + }) +}) + +describe('service.openTab dedupe', () => { + it('dedupeKey focuses existing tab instead of duplicating', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'singleton', + title: 'Singleton', + dedupeKey: () => 'singleton', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'singleton', title: 'Singleton' }) + service.openTab({ type: 'singleton', title: 'Singleton' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs) + expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1) + }) + + it('no dedupeKey opens a new tab each time', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'multi', + title: 'Multi', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'multi', title: 'Multi' }) + service.openTab({ type: 'multi', title: 'Multi' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs) + expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2) + }) + + it('createTab mints custom ids and patches state', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'counter', + title: 'Counter', + createTab: (state) => ({ + tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` }, + patch: { nextTerminal: state.nextTerminal + 1 }, + }), + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'counter', title: 'Counter' }) + service.openTab({ type: 'counter', title: 'Counter' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter') + expect(tabs).toHaveLength(2) + expect(tabs[0]!.id).toBe('counter:1') + expect(tabs[1]!.id).toBe('counter:2') + expect(state.nextTerminal).toBe(3) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index b4a9fa9..c16fdc2 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -214,23 +214,26 @@ describe('git parsing', () => { describe('sidebar state', () => { const state = (): SidebarState => makeDefaultState() - it('opens tabs into the active pane and dedupes single-instance types', () => { + it('opens tabs into the active pane and dedupes by id (safety net)', () => { let s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } s = openTab(s, gitTab) expect(s.splits.kind).toBe('leaf') expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) - // Reopening git focuses the existing tab instead of duplicating. - const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' }) + // Reopening with the SAME id focuses the existing tab instead of duplicating. + const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' }) expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) + // A different id opens a new tab (type-level dedupe is the service's job). + const after2 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' }) + expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3) }) - it('dedupes editors by path', () => { + it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => { let s = state() const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) - expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1']) + expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2']) }) const diffTab = (id: string): SidebarTab => ({ @@ -644,10 +647,16 @@ describe('persisted state sanitization', () => { const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400))) withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] } expect(sanitizeState(withSplit)).toBeUndefined() - // Unknown tab types (older layouts) are rejected. - const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400))) - withBadTab.splits.tabs[0].type = 'watcher' - expect(sanitizeState(withBadTab)).toBeUndefined() + // Unknown tab types (external plugins not yet loaded) are accepted — + // they render as at view time and recover if the plugin + // loads later. Only diff tabs are dropped (ephemeral). + const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400))) + withExternalTab.splits.tabs[0].type = 'my-plugin:db' + const externalClean = sanitizeState(withExternalTab) + expect(externalClean).toBeDefined() + if (externalClean !== undefined && externalClean.splits.kind === 'leaf') { + expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db') + } // An active id that no tab carries is rejected. const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400))) withBadActive.splits.active = 'ghost-tab'