From fbebcf51389682895dfeb59f747fc22e6a64cb7f Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 00:09:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=E6=9C=8D=E5=8A=A1=E5=8C=96?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E8=AE=BE=E8=AE=A1=EF=BC=88registerTab/regist?= =?UTF-8?q?erFileViewer=20+=20=E5=86=85=E7=BD=AE=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20registry=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-11-service-registry-design.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/plans/2026-08-11-service-registry-design.md 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 生成。 From 291158a3ba53e11c102b6dc0d4f2f4b774f0879b Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 00:31:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=8C=96?= =?UTF-8?q?=E6=94=B9=E9=80=A0=20=E2=80=94=20ctx.betterSidebar=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=EF=BC=88registerTab/registerFileViewer?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=86=85=E7=BD=AE=206=20tab=20+=206=20viewer?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=88=B0=20registry=EF=BC=8Cv0.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/client/service.ts: BetterSidebarService 接口 + createBetterSidebarService 工厂 - registerTab/registerFileViewer 返回 disposer(HMR-safe,挂到 cordis fiber) - matchFileViewer 三段匹配: priority desc → detect(内容嗅探) → exts - openTab 走 descriptor.dedupeKey 统一去重(替代 isSingle/editor-path/diff-id 三分支) - createTab 回调支持自定义 tab 创建(terminal 的 nextTerminal 递增) - 新增 src/client/builtins.tsx: 6 内置 tab + 6 内置 viewer 自我注册(吃狗粮) - tab: editor(hidden) / explorer / git / subagent / terminal / diff(hidden) - viewer: image / pdf / docx / xlsx / pptx / binary-download - code/markdown 保持 EditorView 内联兜底(matchFileViewer 返回 undefined 时走 fsRead+CodeMirror) - 新增 src/client/OrphanedTab.tsx: 未注册 type 的占位组件(插件未加载时降级) - 改造 src/client/index.tsx: ctx.provide('betterSidebar') + registerBuiltins - 改造 src/client/Sidebar.tsx: TabContent/buildNewTabOptions/onNewTab 查表替代 switch - 改造 src/client/EditorView.tsx: 预览路由查 matchFileViewer 替代 if/else 链 - 改造 src/client/state.ts: TabType 联合字面量→string,sanitizeNode 接受所有 string type - 改造 src/context-types.ts: declare module 'cordis' 加 betterSidebar 字段 - 改造 src/index.ts: re-export Context + 服务类型(consumer import type 触发 augmentation) - package.json: 加 ./client/service exports 子路径,version 0.4.0 - tests/service.spec.ts: 12 个新测试覆盖 register/dispose/matchFileViewer/dedupe/createTab - tests/unit.spec.ts: sanitizeState 测试更新(未知 type 不再被拒绝,渲染 OrphanedTab) --- dsh.plugin.json | 4 +- package.json | 8 +- src/client/EditorView.tsx | 105 ++++++------- src/client/OrphanedTab.tsx | 27 ++++ src/client/Sidebar.tsx | 117 ++++----------- src/client/builtins.tsx | 214 +++++++++++++++++++++++++++ src/client/icons.tsx | 1 + src/client/index.tsx | 15 ++ src/client/locales.ts | 2 + src/client/service.ts | 294 +++++++++++++++++++++++++++++++++++++ src/client/state.ts | 24 ++- src/context-types.ts | 7 + src/index.ts | 13 ++ tests/service.spec.ts | 189 ++++++++++++++++++++++++ tests/unit.spec.ts | 14 +- 15 files changed, 878 insertions(+), 156 deletions(-) create mode 100644 src/client/OrphanedTab.tsx create mode 100644 src/client/builtins.tsx create mode 100644 src/client/service.ts create mode 100644 tests/service.spec.ts 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..1f824a2 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 }) { @@ -367,30 +327,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,7 +439,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
leaf.tabs) + .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length +} + +/** The 6 built-in tab descriptors. */ +function builtinTabs(ctx: Context): readonly TabDescriptor[] { + return [ + { + id: 'editor', + title: () => t('editor'), + icon: (size: number) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.path, + component: ({ ctx, store, scope, tab }) => ( + + ), + }, + { + id: 'explorer', + title: () => t('explorer'), + icon: (size: number) => , + order: 10, + dedupeKey: () => 'explorer', + component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( + { /* no-op */ })} + onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} + onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'git', + title: () => t('git'), + icon: (size: number) => , + order: 20, + dedupeKey: () => 'git', + component: ({ ctx, store, scope, onOpenDiff }) => ( + { openSidebarFile(ctx, store, scope.sessionId, path) }} + onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'subagent', + title: () => t('subagent'), + icon: (size: number) => , + order: 30, + dedupeKey: () => 'subagent', + component: ({ ctx, scope, visible, onSubagentJump }) => ( + { onSubagentJump?.(address.childSessionId) }} + /> + ), + }, + { + id: 'terminal', + title: () => t('terminal'), + icon: (size: number) => , + order: 40, + available: (state) => uiTerminalCount(state) < TERMINAL_LIMIT, + createTab: (state) => { + const count = uiTerminalCount(state) + if (count >= TERMINAL_LIMIT) return null + return { + tab: { + id: `terminal:${state.nextTerminal}`, + type: 'terminal', + title: `${t('terminal')} ${state.nextTerminal}`, + }, + patch: { nextTerminal: state.nextTerminal + 1 }, + } + }, + component: ({ scope, tab, store }) => ( + + ), + }, + { + id: 'diff', + title: () => t('git'), + icon: (size: number) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.id, + component: ({ scope, tab }) => ( + tab.diff === undefined ? null + : + ), + }, + ] +} + +/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */ +const builtinViewers: readonly FileViewerDescriptor[] = [ + { + id: 'image', + exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'], + fetchStrategy: 'mediaUrl', + component: ({ mediaUrl: url, title }) => ( +
+ {title} +
+ ), + }, + { + id: 'pdf', + exts: ['pdf'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'docx', + exts: ['docx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'xlsx', + exts: ['xlsx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'pptx', + exts: ['pptx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'binary-download', + exts: ['doc', 'xls', 'ppt'], + priority: -50, + fetchStrategy: 'binary-download', + component: ({ scope, path }) => ( +
+ {t('binaryNoPreview')} + + {t('downloadToView')} + +
+ ), + }, +] + +/** + * Register all built-in tabs and viewers with the service. Returns a + * disposer that unregisters everything (cordis auto-invokes it on fiber + * disposal). The `ctx` is threaded into tab descriptors that need it + * (EditorView reads `ctx.betterSidebar` for file-viewer matching). + */ +export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void { + const disposers: (() => void)[] = [] + for (const tab of builtinTabs(ctx)) { + disposers.push(service.registerTab(tab)) + } + for (const viewer of builtinViewers) { + disposers.push(service.registerFileViewer(viewer)) + } + return () => { + for (const d of disposers) { + try { d() } catch { /* already disposed */ } + } + } +} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index e9b2b90..2e0e30e 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -40,5 +40,6 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode { case 'subagent': return case 'editor': return case 'diff': return + default: return null } } diff --git a/src/client/index.tsx b/src/client/index.tsx index b725752..9589995 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -12,6 +12,8 @@ import { Component, createElement, type ErrorInfo, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' import { createSidebarStore } from './state.ts' +import { createBetterSidebarService } from './service.ts' +import { registerBuiltins } from './builtins.tsx' import { Sidebar } from './Sidebar.tsx' import { registerTurnTailInterception } from './intercept.tsx' import { loadPrefs } from './prefs.ts' @@ -69,6 +71,19 @@ export function apply(ctx: Context): void { // registrations (the official createXXXStore() factory rule — no // module-level singleton). const sidebarStore = createSidebarStore() + // The sidebar registry service: external plugins register tab types and + // file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`. + // Published before the panel mounts so consumers injecting 'betterSidebar' + // are ready by the time the sidebar renders. + const service = createBetterSidebarService(sidebarStore) + ctx.provide('betterSidebar', service) + // Register the plugin's own built-in tabs and viewers through the same + // service (eating our own dogfood). The disposer unregisters them on + // fiber disposal (HMR-safe). + ctx.effect( + () => registerBuiltins(ctx, service), + 'dsh-better-sidebar: register built-in tabs and viewers', + ) // A failure anywhere in the client lifecycle must never take the app down // silently: log with the plugin prefix and pin a visible diagnostic strip // to the page so a blank panel is never the only symptom. diff --git a/src/client/locales.ts b/src/client/locales.ts index 87f057a..f96655b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -87,6 +87,7 @@ const zh = { disconnected: '终端连接断开,重连中…', exited: '终端进程已退出', noSession: '选择一个会话以使用侧边栏', + pluginNotLoaded: '插件未加载,标签页暂不可用:', hiddenFiles: '隐藏文件', parent: '上级目录', copied: '已复制', @@ -219,6 +220,7 @@ const en: Record = { disconnected: 'Terminal disconnected, reconnecting…', exited: 'Terminal process exited', noSession: 'Select a conversation to use the sidebar', + pluginNotLoaded: 'Plugin not loaded; tab unavailable:', hiddenFiles: 'Hidden files', parent: 'Parent directory', copied: 'Copied', diff --git a/src/client/service.ts b/src/client/service.ts new file mode 100644 index 0000000..b9fe077 --- /dev/null +++ b/src/client/service.ts @@ -0,0 +1,294 @@ +/** + * The BetterSidebar client service: a registry that external plugins use + * to contribute sidebar tab types and file previewers. The service is + * published to the cordis context as `ctx.betterSidebar` (see + * {@link ../types.ts}); consumers declare it in `inject` and call + * `registerTab` / `registerFileViewer`, both returning a disposer that + * cordis auto-invokes on fiber disposal (HMR-safe). + * + * Design notes: + * - The registry is synchronous-snapshot (Map + listener set) so React + * can read it through `useSyncExternalStore` without tearing. + * - `dedupeKey` unifies the three open-tab strategies the builtins used to + * hardcode: single-instance (`() => type`), per-path (`tab => tab.path`), + * and per-id (`tab => tab.id` for diff tabs whose id is change-derived). + * - `createTab` lets a descriptor own tab instantiation (the terminal + * builtin uses it to mint `terminal:` ids and bump `nextTerminal`). + * - `matchFileViewer` resolves viewers by priority (desc), then `detect` + * (content sniff), then `exts` (lowercase, no dot). + */ +import type { ReactNode } from 'react' +import type { Context } from '../context-types.ts' +import { + activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf, + type SidebarState, type SidebarStore, type SidebarTab, +} from './state.ts' +import type { SessionScope } from './api.ts' + +/** Props every tab component receives (builtins and external alike). */ +export interface TabComponentProps { + ctx: Context + store: SidebarStore + scope: SessionScope + tab: SidebarTab + /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */ + visible: boolean + /** The explorer's expanded directory set (ExplorerView). */ + expanded?: string[] + onToggleDir?: (path: string) => void + onReferenceFile?: (path: string) => void + onOpenFile?: (path: string) => void + onOpenDiff?: (tab: SidebarTab) => void + onSubagentJump?: (childSessionId: string) => void +} + +/** Describes one kind of sidebar tab (builtins register themselves too). */ +export interface TabDescriptor { + /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */ + id: string + title: string | (() => string) + icon?: ReactNode | ((size: number) => ReactNode) + /** + menu sort order (ascending); default 100. */ + order?: number + /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */ + hidden?: boolean + /** + menu disabled predicate (e.g. terminal at capacity). */ + available?: (state: SidebarState) => boolean + /** + * If provided, opening a tab whose `dedupeKey(tab)` matches an existing + * tab's key focuses the existing one instead of creating a new one. + * Returning `undefined` means "no dedup — always open a new tab". + * Builtins: explorer/git/subagent use `() => id`; editor uses `tab => tab.path`; + * diff uses `tab => tab.id` (openDiffTab mints change-derived ids). + */ + dedupeKey?: (tab: SidebarTab) => string | undefined + /** + * Custom tab creation (minting the `SidebarTab` and any state patches). + * Return `null` to refuse creation. The terminal builtin uses this to + * mint `terminal:` ids and bump `nextTerminal`. + * When omitted, a default `{ id, type, title }` tab is created. + */ + createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + component: (props: TabComponentProps) => ReactNode +} + +/** How the host loads a file's bytes for one viewer. */ +export type FileFetchStrategy = + | 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves) + | 'fsRead' // text read through /sidebar/api fs.read + | 'mediaUrl' // the viewer gets a media URL string + | 'custom' // the viewer's load() fetches its own bytes + | 'binary-download' // show a download button (no client-side renderer) + +/** Props every file viewer component receives. */ +export interface FileViewerProps { + ctx: Context + store: SidebarStore + scope: SessionScope + path: string + title: string + /** fsRead text content (fetchStrategy='fsRead'). */ + content?: string + truncated?: boolean + /** mediaUrl for the path (fetchStrategy='mediaUrl'). */ + mediaUrl?: string + /** custom load() return value (fetchStrategy='custom'). */ + customData?: unknown +} + +/** Describes one file previewer (builtins register themselves too). */ +export interface FileViewerDescriptor { + /** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */ + id: string + /** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */ + exts: readonly string[] + /** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */ + priority?: number + fetchStrategy: FileFetchStrategy + /** + * Content sniff (overrides exts when `head` bytes are available). + * The first descriptor (priority desc) whose `detect` returns true wins. + */ + detect?: (path: string, head: Uint8Array) => boolean + /** fetchStrategy='custom' loader. */ + load?: (path: string, scope: SessionScope) => Promise + component: (props: FileViewerProps) => ReactNode +} + +/** The registry service published as `ctx.betterSidebar`. */ +export interface BetterSidebarService { + registerTab(descriptor: TabDescriptor): () => void + registerFileViewer(descriptor: FileViewerDescriptor): () => void + getTabs(): readonly TabDescriptor[] + getFileViewers(): readonly FileViewerDescriptor[] + /** Find a tab descriptor by id (undefined if not registered). */ + getTab(id: string): TabDescriptor | undefined + /** Find a file viewer for a path (priority desc → detect → exts). */ + matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined + /** Open a tab (used by external tabs and the + menu). */ + openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void + /** Close a tab by id. */ + closeTab(tabId: string): void + /** Subscribe to registry changes (register/dispose). */ + subscribe(listener: () => void): () => void +} + +/** Extract the lowercase extension without leading dot from a path. */ +function extOfPath(path: string): string { + const at = path.lastIndexOf('.') + if (at === -1) return '' + const base = path.slice(at + 1).toLowerCase() + return base.includes('/') || base.includes('\\') ? '' : base +} + +/** + * Create one BetterSidebar service bound to a store. The service owns the + * tab/viewer registries (Map + listener set) and proxies openTab/closeTab + * to the store's reducer. One instance per client plugin activation. + */ +export function createBetterSidebarService(store: SidebarStore): BetterSidebarService { + const tabs = new Map() + const viewers = new Map() + const listeners = new Set<() => void>() + + const notify = (): void => { + for (const fn of [...listeners]) fn() + } + + const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + } + + const registerTab = (descriptor: TabDescriptor): (() => void) => { + if (tabs.has(descriptor.id)) { + throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`) + } + tabs.set(descriptor.id, descriptor) + notify() + return () => { + if (tabs.get(descriptor.id) === descriptor) { + tabs.delete(descriptor.id) + notify() + } + } + } + + const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => { + if (viewers.has(descriptor.id)) { + throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`) + } + viewers.set(descriptor.id, descriptor) + notify() + return () => { + if (viewers.get(descriptor.id) === descriptor) { + viewers.delete(descriptor.id) + notify() + } + } + } + + const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values()) + const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values()) + const getTab = (id: string): TabDescriptor | undefined => tabs.get(id) + + const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => { + const ext = extOfPath(path) + // Priority descending; stable order for equal priorities (insertion order). + const sorted = Array.from(viewers.values()).sort( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0), + ) + // Pass 1: content sniff (detect overrides exts when head bytes are available). + if (head !== undefined) { + for (const v of sorted) { + if (v.detect !== undefined && v.detect(path, head)) return v + } + } + // Pass 2: extension match ([] is catch-all, skipped here). + for (const v of sorted) { + if (v.exts.length === 0) continue + if (v.exts.includes(ext)) return v + } + // Pass 3: catch-all (exts === []). + for (const v of sorted) { + if (v.exts.length === 0) return v + } + return undefined + } + + const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void => { + store.reduce((state) => { + const descriptor = tabs.get(seed.type) + if (descriptor === undefined) return state + // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). + if (descriptor.createTab !== undefined) { + const result = descriptor.createTab(state) + if (result === null) return state + const next = applyDedupe(state, result.tab, descriptor) + return result.patch !== undefined ? { ...next, ...result.patch } : next + } + const tab: SidebarTab = { + id: seed.type, + type: seed.type, + title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title, + ...(seed.path !== undefined ? { path: seed.path } : {}), + ...(seed.diff !== undefined ? { diff: seed.diff } : {}), + } + return applyDedupe(state, tab, descriptor) + }) + } + + const closeTab = (tabId: string): void => { + store.reduce((state) => { + const paneId = findPaneIdOf(state, tabId) + if (paneId === '') return state + return closeTabReducer(state, paneId, tabId) + }) + } + + return { + registerTab, + registerFileViewer, + getTabs, + getFileViewers, + getTab, + matchFileViewer, + openTab, + closeTab, + subscribe, + } +} + +/** + * Apply dedup: if a tab with the same dedupeKey exists, focus it instead. + * Mirrors the three branches the old `openTab` hardcoded (single/editor/diff). + */ +function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { + const key = descriptor.dedupeKey?.(tab) + if (key !== undefined) { + for (const leaf of allLeaves(state.splits)) { + const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key) + if (existing !== undefined) return activateTab(state, leaf.id, existing.id) + } + } + let targetId = state.activePane ?? firstLeaf(state.splits).id + if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) { + targetId = firstLeaf(state.splits).id + } + return { + ...state, + activePane: targetId, + splits: mapLeaf(state.splits, targetId, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }), + } +} + +/** Find which pane hosts a tab id ('' if none). */ +function findPaneIdOf(state: SidebarState, tabId: string): string { + for (const leaf of allLeaves(state.splits)) { + if (leaf.tabs.some(t => t.id === tabId)) return leaf.id + } + return state.activePane ?? '' +} diff --git a/src/client/state.ts b/src/client/state.ts index be76828..82eb64a 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 = @@ -622,13 +633,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..2a36ba1 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -644,10 +644,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' From e40039ceec5b709963a815a36bd4b0a63be9024c Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 01:39:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=E5=AE=8C=E5=85=A8=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E5=86=85=E7=BD=AE=20tab=20=E2=80=94=20dedupe=20?= =?UTF-8?q?=E5=BD=92=20service=EF=BC=8C=E5=9B=BE=E6=A0=87=E5=BD=92=20descr?= =?UTF-8?q?iptor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.openTab 简化为 '添加到 pane + id dedupe',移除 isSingle/editor-path/diff-id 三分支 (dedupe 策略归 service.openTab 通过 descriptor.dedupeKey 统一) - intercept.tsx openSidebarFile 改走 service.openTab(editor descriptor 的 dedupeKey 接管 per-path 去重) - Sidebar.tsx 两处 subagent 触发(auto-activation + jump-back)改走 service.openTab - service.openTab seed 加可选 id 字段(editor 需要 path-derived id) - TabBar.tsx 加 getTabIcon 回调,tab 标签图标查 descriptor.icon 替代 tabTypeIcon - split-pane.tsx 移除 tabTypeIcon 兜底(buildNewTabOptions 已从 descriptor 填 icon) - Workbench/NodeView/LeafView 透传 getTabIcon - tests/unit.spec.ts 更新 openTab 测试(id dedupe 替代 type dedupe) --- src/client/Sidebar.tsx | 25 ++++++++++++------------- src/client/TabBar.tsx | 7 ++++--- src/client/intercept.tsx | 11 ++++------- src/client/service.ts | 6 +++--- src/client/split-pane.tsx | 16 +++++++++++----- src/client/state.ts | 36 ++++++++++-------------------------- tests/unit.spec.ts | 13 ++++++++----- 7 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 1f824a2..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, @@ -185,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 @@ -208,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 }) @@ -443,6 +437,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { actions={actions} onNewTab={onNewTab} renderTab={renderTab} + getTabIcon={(tab) => { + 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 82eb64a..c6ce084 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -309,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 @@ -326,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, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 2a36ba1..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 => ({ From 7687204b43587ed8641a73f038f97b406ea981bb Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 01:44:00 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E6=9C=8D=E5=8A=A1=E5=8C=96?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=96=87=E6=A1=A3=EF=BC=88AGENTS.md=EF=BC=89?= =?UTF-8?q?+=20README=20=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: 完整接入文档(消费插件开发者面向) - TabDescriptor / FileViewerDescriptor 全字段 - fetchStrategy 对照 + matchFileViewer 匹配算法 - HMR 生命周期 + 平台约束陷阱 - 完整最小示例(package.json + client half + profile 注册) - README.md: 加「服务化(v0.4.0+)」章节,链接到 AGENTS.md --- AGENTS.md | 447 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 24 +++ 2 files changed, 471 insertions(+) create mode 100644 AGENTS.md 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 列表生效)