# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面 > 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 > > 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**。当前版本 **v0.24.0**(peer 下限 `^0.2.0-rc.1`,仅支持 DSH **0.2.0-rc.1+**;0.1.7 线请用 v0.23.0——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 会被 0.2.0 宿主的启动预检静默禁用)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。 > 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 5 tab + 3 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 > 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。 --- ## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起) 从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、会话内持久化;**其中不再有终端**——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`: - `registerTab` / `registerFileViewer` 签名不变; - `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台 / `'side'` 落**原生栏的第二个格**,见下表「在侧边打开」); - `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。 行为差异(写在这里以免踩坑): | 事项 | 说明 | |---|---| | 生命周期回调 | 原生面只有「一次打开」,不区分新建/聚焦,因此只触发 `onOpen`(`onActivate` 仅在插件自己的底部工作台里触发) | | 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 | | 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | | 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | | 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位。**但认领是有选择的**:宿主自己的文档预览已经覆盖的格式(表格 / PDF / 图片 / Office,清单见 §5.4)由 `editor.canOpen` 主动**拒绝**,地址交回内置 `text` 档 | | 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一套词法解析 | | 链接接管(v0.21.1+) | DOM 层只接管**有类型通过 `urlTarget` 声明认领**的外链(Ctrl/Cmd/Shift/Alt 点击一律放行);一个都没认领到时**不阻止默认行为**,交回宿主。见 §4.1 的 `urlTarget` | | path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) | | 终端(已交还宿主) | 插件**不再提供任何终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 | | 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 | | 在侧边打开(`target: 'side'`,v0.22.0+) | 原生栏里的 tab **不在插件底部工作台的分栏树里**,所以「在侧边打开」不能写 `bottomSplits`(那样会落到用户没展开的底部工作台 = 点了没反应)。服务改为把这一步交给宿主:带 path 的 `editor` seed 走 `openResource(address, { preferNewPane: true, revealIfOpened: false })`——宿主先按自己的两格上限与空间规则尝试分栏,分不了才回退到当前格;`revealIfOpened: false` 允许与已打开的同名资源并存,因此对同一个文件再点一次也会新开一格。其余类型若传 `target: 'side'`,同样以 `preferNewPane` 落原生栏(组件型 tab 的 path 仍是组件种子)。path-less 的 `editor`(文件页)与 `'bottom'` 行为不变 | | 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / 文件变动 / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条 | | 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 | | alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 | | 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略);**插件自带的终端与浏览器 tab 类型**(宿主 0.1.6/0.1.7 自带两者)与**只读文件预览**(image / pdf / Office / 表格,宿主 0.1.7 的 `ui-sidebar-documentpreview` 接手)同样不再内置,详见 §4.4 与 §5.4 | --- ## 1. 总览:你能扩展什么 better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: - **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面; - **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 markdown/html/code)。**宿主自己也有文档预览**——DSH 0.1.7 的 `ui-sidebar-documentpreview` 拿走了表格 / PDF / 图片 / Office 等只读格式,本插件因此**拒绝**认领那些扩展名,你的 viewer 也接不到它们(清单与原因见 §5.4)。 内置的 5 个 tab(editor / git——「文件变动」统一 tab(Git 视角 + 本轮文件视角)/ subagent / sidechat / diff)和 3 个 viewer(markdown / html / code)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。**不再内置 terminal / browser 两个 tab 类型**:DSH 0.1.6 自带右列终端(`ui-sidebar-terminal`、kind `terminal`),浏览器是 0.1.6 的 `ui-sidebar-browser`(kind `browser`,**0.1.7 起只在 desktop profile 挂载**,Web profile 没有这个 kind);插件的同名类型会让读者看到两份实现。同理,0.1.7 的 `ui-sidebar-documentpreview` 让插件的 image / pdf / Office / 表格预览失去意义,那三个 viewer 描述符(image / pdf / binary-download)已删除。 关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。 > ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。 --- ## 2. 前置:类型合并与依赖声明 ### 2.1 类型合并(统一 `@deepseek-ai/cordis`) DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上: ```ts import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并 ``` 这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。 ### 2.2 package.json 声明 ```jsonc { "name": "my-plugin", "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*" }, "peerDependenciesMeta": { "dsh-better-sidebar": { "optional": true } } } ``` - `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例; - `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。 > ⚠️ **计划上架 DSH 插件市场**的插件还须遵守市场 manifest 约束(`dependencies`/`peerDependencies`/`optionalDependencies` 一律不得出现 `cordis`、`scripts` 不得含 install 类钩子),详见 [AGENTS.md](../AGENTS.md) 硬约束一节。 ### 2.3 类型导入路径 ```ts // 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型) import type { BetterSidebarService, TabDescriptor, TabComponentProps, FileViewerDescriptor, FileViewerProps, FileFetchStrategy, } from 'dsh-better-sidebar' // 方式二:子路径(与主入口等价) import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api ``` v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断): ```ts import type { SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType, SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps, } from 'dsh-better-sidebar/client/service' ``` > 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。 --- ## 3. 最小骨架(client half) ```ts // my-plugin/src/client/index.ts import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 import type { Context } from '@deepseek-ai/cordis' export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需) export function apply(ctx: Context): void { // 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe) ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: () => 'Database', icon: , order: 50, component: ({ scope }) => , }) ) // 注册一个文件预览器 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 }) => , }) ) } ``` 要点: - **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。 - `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。 - 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。 --- ## 4. 新页面(Tab)注册 API ### 4.1 `TabDescriptor` 完整字段 ```ts // 设置行控件形状(settings.toggles / settings.pluginToggles 共用;text/number v0.11.0+,select v0.13.0+): interface SettingRow { key: string // toggles: SidebarPrefs 字段名;pluginToggles: 插件局部 key title: string | (() => string) desc?: string | (() => string) type?: 'switch' | 'text' | 'number' | 'select' // 缺省 'switch' min?: number; max?: number // number 行提交钳制 placeholder?: string; unit?: string // text 行占位符 / 单位后缀(如 'px') options?: readonly { // select 行选项 value: string | number | boolean title: string | (() => string) desc?: string | (() => string) icon?: ReactNode | ((size: number) => ReactNode) }[] multi?: boolean // select 多选(缺省 false;存 value 数组,按 options 顺序提交) } // text/number 行 blur/Enter 提交;select 任一项带 icon 时渲染大图标选项卡,否则单行文本。 interface TabDescriptor { /** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */ id: string /** 标题(i18n 友好:传字符串或返回字符串的函数) */ title: string | (() => string) /** * 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。 * **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`), * 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。 * 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上 * 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。 */ description?: string | (() => string) /** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */ icon?: ReactNode | ((size: number) => ReactNode) /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35 */ order?: number /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ hidden?: boolean /** + 菜单禁用判定(用量配额一类)。返回 false 只影响菜单 disabled,不拦截 openTab(只有设置页禁用开关会)。 */ available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean /** * 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有 * 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。 */ single?: boolean /** * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 * 内置策略:git/subagent 用 single;editor 用 tab.path;diff 用 tab.id。 * 必须纯函数:每次 open 求值两次,抛错向外传播。 */ dedupeKey?: (tab: SidebarTab) => string | undefined /** * 自定义 tab 创建(minting SidebarTab + 状态 patch)。 * 返回 null 拒绝创建。sidechat 用它实现「一个线程一个 tab」。 * 省略时用默认 { id, type, title } + seed 里的 path/diff。 */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null /** * 外链认领(v0.13.0+,`features.includes('urlTarget')` gate):声明后,DOM 层 * 捕获到的外链点击里**只有被某个启用类型认领的那些**会被接管——第一个 * urlTarget 返回 true 的类型以 openTab({ type, url, title: hostname }) 打开, * URL 预填 tab.path。先到先得;谓词抛错被吞。 * * **没被认领的链接一律放行**(插件不 preventDefault):DSH 0.1.7 起正文链接的 * 去向由宿主的用户设置 `linkOpening` 决定(进侧栏还是新标签页),插件自绘 * markdown 里则走 `` 自己的默认行为。**v0.21.1 起那三个「按协议分流」 * 的旧设置项已删除**(旧文档里的键会被忽略),本插件不再有任何接管总闸—— * 认领与否完全由你的 urlTarget 决定;Ctrl/Cmd/Shift/Alt 点击永远绕过接管。 * 认领成功但目标类型在打开那一刻已不可用(插件卸载 / 被设置关闭)时,点击 * 兜底为 `window.open(url, '_blank', 'noopener,noreferrer')`,不会变成静默无反应。 * * 多 URL 并存需 createTab 铸造 per-URL id,否则二次点击被 id 安全网聚焦、不覆写 path。 */ urlTarget?: (url: URL) => boolean /** * 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有 * 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。 */ settings?: SidebarSettingsDeclaration /** * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶), * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价; * 抛错会被吞掉(不显示角标,不影响渲染)。 */ badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined /** * 生命周期回调(v0.12.0+),只由 SERVICE 路径触发: * - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开); * - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活); * - onClose:closeTab 关闭 tab 后。 * 内置专属流程(diff 拆分放置、sidechat 线程重开)直接改 state,不触发 * 回调——但它们只作用于内置类型(diff / sidechat),外部插件的 tab 永远走 * service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。 * openTab 回调 scope 携带调用者传入的 { sessionId, cwd? }; * closeTab/activateTab 仅显式传 scope 时带 cwd。 */ onOpen?: (tab: SidebarTab, scope: SessionScope) => void onActivate?: (tab: SidebarTab, scope: SessionScope) => void onClose?: (tab: SidebarTab, scope: SessionScope) => void /** 渲染函数 */ component: (props: TabComponentProps) => ReactNode } /** 声明式设置声明(行控件形状见上方 SettingRow)。 */ interface SidebarSettingsDeclaration { /** 偏好字段行(key 必须落在本插件的 `PrefsSchema` 里——它已并入本插件 Loader 行的 `Config`,内置键清单见 §8)。 */ toggles?: readonly SettingRow[] /** 插件自有设置行(v0.12.0+):key 插件局部, * 持久化在 pluginSettings[],无需宿主 schema 字段。 */ pluginToggles?: readonly SettingRow[] /** 自定义设置面板(v0.12.0+):追加渲染在行列表之后,可单独存在; * 抛错被吞并显示内联错误。 */ render?: (props: SidebarSettingsRenderProps) => ReactNode } /** settings.render 收到的 props(v0.12.0+)。 */ interface SidebarSettingsRenderProps { store: SidebarStore service: BetterSidebarService prefs: SidebarPrefs /** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */ pluginSettings: Record /** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */ updatePluginSetting(key: string, value: unknown): void /** 关闭设置弹窗。 */ close(): void } ``` ### 4.2 `TabComponentProps`(你的页面组件收到的 props) ```ts interface TabComponentProps { ctx: Context // client cordis context store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等) scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带 tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?/meta?) visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等) // 以下由内置 tab 使用,外部 tab 可忽略: expanded?: string[] // 文件树的展开目录集 onToggleDir?: (path: string) => void // 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/`, // 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip // (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。 onReferenceFile?: (path: string, isDir: boolean) => void onOpenFile?: (path: string) => void onOpenDiff?: (tab: SidebarTab) => void onSubagentJump?: (childSessionId: string) => void } ``` 实践建议: - **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。 - **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。 ### 4.3 注册示例 **最简单实例 tab**(+ 菜单可见): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:notes', title: 'Notes', icon: , order: 50, single: true, // ≡ 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' }) ``` **条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:commits', title: 'Commits', icon: , order: 70, available: (ctx, scope, state) => hasGitRepo(state), dedupeKey: () => 'my-plugin:commits', component: ({ scope }) => , }) ) ``` **自定义创建**(mint 自增 id): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:console', title: 'Console', order: 80, createTab: (state) => ({ tab: { id: `console:${state.nextBrowser}`, type: 'my-plugin:console', title: `Console ${state.nextBrowser}` }, patch: { nextBrowser: state.nextBrowser + 1 }, // 借内置计数器;也可自建 state 字段 }), component: ({ tab, scope }) => , }) ) ``` **认领外链点击**(v0.13.0+,`urlTarget` + `createTab` 组合): ```ts ctx.effect(() => { if (!ctx.betterSidebar.features.includes('urlTarget')) return // 老版本优雅降级 return ctx.betterSidebar.registerTab({ id: 'my-plugin:web-docs', title: () => 'Docs', order: 80, urlTarget: (url) => url.hostname === 'docs.my-site.com', createTab: (state) => ({ // per-URL id,多 URL 并存 tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' }, patch: { nextBrowser: state.nextBrowser + 1 }, }), component: ({ tab, scope }) => , }) }) ``` ### 4.4 内置 tab 清单(不可重复注册) | id | order | single | hidden | 用途 | |---|---|---|---|---| | `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 | | `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) | | `subagent` | 30 | 是 | 否 | 任务管理(工作流图/树、团队任务板、后台任务抽屉) | | `sidechat` | 35 | 否(`sidechat:`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(changes tab 的预览面板「展开为独立页签」触发,同一渲染栈) | 你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 **自 v0.20.0 开发线起删除的类型(迁移提示;0.20.0 从未发布,变更落在 v0.21.1)**: | 原 id | 现状 | 你该怎么做 | |---|---|---| | `terminal` | 宿主自带 `kind: 'terminal'`(`@deepseek-ai/dsh-client-ui-sidebar-terminal`) | 不要再注册同名 kind。插件侧 API(`api.ptyClose` / `agentPtyClose` / `agentSkipWait` / `terminalDeps` / `shellGet`)与 `SidebarTab.pin` / `SidebarState.nextTerminal` / `agentWaits` / `isAgentTabId` / `agentUuidOf` / `reconcileAgentTerminals` / `mirrorAgentWaits` / `setTabPin` 全部**已删除** | | `browser` | 宿主自带 `kind: 'browser'`(`@deepseek-ai/dsh-client-ui-sidebar-browser`),**但 DSH 0.1.7 起只在 desktop profile 挂载**(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`)——Web profile 上**没有这个 kind**,`openTab('browser', …)` 必然失败 | 不要再注册同名 kind。要打开网页:desktop profile 走 `ctx.sidebarRight.openTab('browser', { params: { url } })`(务必 try/catch 兜底,宿主未装该包时 `openTab` 会抛);Web profile 只能自己提供页面,或把链接交回宿主(正文链接由宿主的 `linkOpening` 设置决定去向)。插件侧的 `api.browserProbe` / `SidebarPrefs.browserNoSandbox` / `browserAllowedLoopback` **以及三个「按协议分流」的外链接管偏好键全部已删除**;`urlTarget` 机制保留,但不再有接管总闸(见 §4.1) | **同时删除的还有**:轮尾产物行接管。DSH 0.1.6 把 `conversation.chat.turnTail` 从 `chain` 改成 `list`(list 槽要求 `options.id`,且只能**追加**,不能替换宿主的产物行),插件因此整体移除了 `registerTurnTailInterception` 与 `selectProducedFiles`。要往那个位置加东西,按 list 契约注册 `{ name: 'conversation.chat.turnTail', id: '<你的 id>', order }`;`openSidebarFile` 作为通用打开工具留在 `dsh-better-sidebar/src/client/sidebar-file.ts`(`resolveSidebarPath` 移到 `src/client/paths.ts`)。 --- ## 5. 文件预览器(FileViewer)注册 API ### 5.1 `FileViewerDescriptor` 完整字段 ```ts interface FileViewerDescriptor { /** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */ id: string /** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */ title?: string | (() => string) /** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */ icon?: ReactNode | ((size: number) => ReactNode) /** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级盲命中有效) */ exts: readonly string[] /** 优先级(高优先);默认 0。内置:markdown/html=0,code=-100 */ priority?: number /** 字节获取策略 */ fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ detect?: (path: string, head: Uint8Array) => boolean /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(§4.1 的 * toggles/pluginToggles/render;v0.12.0 起 viewer 卡片也有齿轮按钮) */ settings?: SidebarSettingsDeclaration /** 渲染函数 */ component: (props: FileViewerProps) => ReactNode } ``` ### 5.2 `FileViewerProps` ```ts interface FileViewerProps { ctx: Context store: SidebarStore scope: SessionScope path: string title: string viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv') content?: string // fetchStrategy='fsRead' 时 truncated?: boolean // fetchStrategy='fsRead' 时 mediaUrl?: string // fetchStrategy='mediaUrl' 时 customData?: unknown // fetchStrategy='custom' 时(load() 的返回值) // 以下为内置文本编辑器与 EditorHost 内部协作字段,外部 viewer 忽略: toolbar?: 'self' | 'host' onToolbarState?: (state: EditorToolbarState) => void onToolbarControls?: (controls: EditorToolbarControls | null) => void } ``` ### 5.3 `fetchStrategy` 对照 | 策略 | 字节来源 | 传给 component 的字段 | 适用 | |---|---|---|---| | `none` | 不需要字节 | (无) | 自渲染(如纯 UI) | | `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) | | `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) | | `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) | | `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式。**本插件现在没有任何内置 viewer 用这条策略**(`binary-download` viewer 已让给宿主);策略本身仍在契约里,你的 viewer 可以照用 | ### 5.4 匹配算法(`matchFileViewer`) `matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor: 1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领); 2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。 即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。 > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 > **内置 viewer**(不可重复注册,全部 3 个):markdown(0, fsRead;内嵌 HTML 支持:DOMPurify 白名单消毒、`
` 跨段嵌套、本地媒体 src 重写走 `/sidebar/file`;≥3 标题时浮动目录大纲。实现 `markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,[设计文档](plans/2026-08-24-markdown-html-toc-design.md)) / html(0, fsRead, 沙箱 iframe 预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个宿主没有的逃生门) / code(-100, catch-all, fsRead, **可编辑** CodeMirror + 保存)。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配找不到 sniffer,落到编辑器的下载面板。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖内置的那三个。 > > ⚠️ **让给宿主的格式(重要,影响你的 viewer 能不能被调用)**:DSH 0.1.7 的 `ui-sidebar-documentpreview` 自带 code / excel(xlsx,xls,csv,tsv) / office(宿主侧转 PDF)/ pdf / image / html / markdown / text 预览,并带缩放与**按目录自动刷新**——本插件原有的 image / pdf / binary-download 三个 viewer 描述符因此删除。更重要的是**路由**:插件的 `editor` 类型在 `canOpen` 里对下列扩展名一律返回 false(`src/client/native/index.ts` 的 `HOST_OWNED_EXTS`),把地址让给内置 `text` 档,**根本不会进入本插件的 `matchFileViewer`**: > > `xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx` > > **这份清单现在是「宿主渲染器实际覆盖的集合」,不多不少**:`v0.21.1` 收回了 9 个宿主其实**没有渲染器**的扩展名(`xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods`——前两个落在宿主的 `document/unviewable.ts`「已知二进制、无渲染器」表里,其余落回 text 兜底后按二进制判定失败,一律只有「暂不支持预览」),它们重新由插件的 `code` catch-all 认领并落到下载面板。`fods` 保留让出:宿主会用纯文本显示那段扁平 XML,比下载面板有用。`tests/native-surface.spec.ts` 双向钉住这条边界(宿主能渲染的必须拒绝、宿主不能渲染的必须认领)。 > > 后果:**你注册这些扩展名的 viewer 仍然合法(注册表不冻结 id,`matchFileViewer` 也会命中),但通过聊天 / 文件树 / 编辑器的正常文件打开路径拿不到它们**——那是宿主的文档预览。要在这些格式上做文章,只能自己在页面里渲染(或像 [Office 预览插件](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) 那样在宿主接管前的旧版本上生效)。**可用的是 md/markdown / html/htm 与 code catch-all 三类**(外加任何不在上表里的扩展名)。未知二进制(`.zip` / `.wasm`)仍走 code 认领 → `fs.read` 判 binary → head 重匹配无 sniffer → 编辑器渲染下载面板,功能不回归。 ### 5.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 }) => , }) ) ``` **覆盖内置 markdown viewer**(如自家渲染管线的只读预览): ```ts ctx.effect(() => ctx.betterSidebar.registerFileViewer({ id: 'my-plugin:md-pro', exts: ['md', 'markdown'], priority: 10, // 高于内置 markdown 的 0 fetchStrategy: 'fsRead', component: ({ content }) => , }) ) ``` > ⚠️ 只有内置仍认领的格式能被这样覆盖(md/markdown、html/htm,以及 code catch-all 接的其它扩展名)。`.svg` / `.png` / `.pdf` / `.docx` 之类**不要**照这个例子注册——地址在 `canOpen` 就被让给宿主的文档预览了,你的 viewer 永远不会被调用(原因与完整清单见 §5.4)。 **内容嗅探**(按 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 }) => , }) ) ``` --- ## 6. 页面内如何访问数据(/sidebar API) 你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式): ```ts // POST /sidebar/api/,body 带 sessionId + cwd(可选) 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() // 错误时 { ok: false, error: { code, message } } ``` 常用方法(完整清单见 `src/client/api.ts`): | 方法 | 说明 | |---|---| | `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) | | `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) | | `fs.trees` | **批量**目录列表(v0.23.0+):入参 `{ sessionId, cwd?, paths: string[] }`(最多 64 条,绝对或会话相对),一次请求返回 `{ levels: [{ path, entries, truncated, error? }] }`。每层与 `fs.tree` 同解析、同缓存;**某一层失败只在该层带 `error`**(`entries: []`),整个批次仍是成功响应——树的一次挂载/刷新因此是 1 个请求而不是 N 个 | | `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) | | `fs.write` | 原子写文件 | | `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 | | `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) | | `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 | > **文件路径语义(v0.23.0 起:无包含检查)**:`fs.tree`、`fs.trees`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 仍以请求对应 session 的权威 `cwd` 作为**相对路径的基准**,但**不再把 `cwd` 当边界**:绝对路径原样使用,`..` 只做词法折叠,符号链接跟随,越界不再是 403。安全影响见 §8.1 的醒目声明。 ### 6.1 打包下载(`archive.build` / `archive.status` / `/sidebar/archive`,v0.22.0+) 把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(与 `fs.tree` 同一套词法解析——**无包含检查**,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。 ```ts // 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形) const build = await fetch('/sidebar/api/archive.build', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, cwd, paths: ['/w/src', '/w/notes.md'], name: '报告.zip' }), }).then(r => r.json()) // → { ok: true, value: { id: 'ar-…', entries: 5 } } // 2) 轮询:state 为 building | ready | error;done/total 是条目进度,bytes 是已读未压缩字节 const status = await fetch('/sidebar/api/archive.status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: build.value.id, sessionId }), }).then(r => r.json()) // 3) 下载(仅 ready 时):同一 id 只能取一次,取完即释放 const url = `/sidebar/archive?${new URLSearchParams({ sessionId, id: build.value.id })}` ``` 约束与状态码:任务表最多 **4 个并发构建**(超出时 `archive.build` 回 `bad-request`,HTTP 409),完成/失败后保留 **5 分钟**(过期即消失);`id` 只对**创建它的 session** 可用(其他 session 读是 `forbidden` 403);`/sidebar/archive` 在构建中回 **409**、构建失败回 **410**(消息即失败原因)、未知/过期/已下载回 **404**。响应头为 `content-type: application/zip` + `content-disposition: attachment; filename=""; filename*=UTF-8''<百分号编码>`(非 latin1 文件名走 RFC 5987,ASCII 档位对旧客户端生效)。上限沿用 `src/zip.ts` 的 `ZIP_MAX_ENTRIES = 10_000` 与 `ZIP_MAX_BYTES = 256 MiB`(未压缩总量)。 媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`): ```ts // 媒体 URL(图片等直接 ):/sidebar/file?sessionId=...&path=... const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}` ``` > 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。 --- ## 7. 服务方法完整清单 ```ts interface BetterSidebarService { /** 注册 tab 类型;返回 disposer */ registerTab(descriptor: TabDescriptor): () => void /** 注册文件预览器;返回 disposer */ registerFileViewer(descriptor: FileViewerDescriptor): () => void /** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */ registerFileIcon(descriptor: FileIconDescriptor): () => void /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */ getTabs(): readonly TabDescriptor[] /** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */ getFileViewers(): readonly FileViewerDescriptor[] /** 当前已注册的文件图标描述符快照(v0.19.0+) */ getFileIcons(): readonly FileIconDescriptor[] /** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再 * 具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用 * fileIcon/folderIcon 全链解析器。 */ matchFileIcon(path: string): FileIconDescriptor | undefined /** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省), * 再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序; * 返回 undefined = 回退内置 VscFolder/VscFolderOpened */ matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined /** 文件图标权威解析器(v0.19.0+),完整回退链: * ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包) * → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。 * 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */ fileIcon(path: string, size: number): ReactNode /** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 → * 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化), * open 会传给工厂,一条注册即可渲染开/合两态。 */ folderIcon(path: string, open: boolean, size: number): ReactNode /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined /** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */ isTabEnabled(id: string): boolean /** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */ isViewerEnabled(id: string): boolean /** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined /** * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor 分落点:底部工作台(target: 'bottom')由 createTab * 整体铸造 tab,忽略 seed 的 title/path/id(url 种子仍预填新建 tab 的 path); * 原生右侧栏只忽略 id(原生 tab id 由宿主铸造,seed.id 仅影响 onOpen 收到的 * 合成 tab),createTab 铸造的 title/meta 作缺省、seed 字段优先(v0.19.2+ 起 * path 也随导航 params 下发,见下)。 * path 可选:含义跟随类型——editor(唯一认领 dsh-resource://file/** 的 * 类型,但会拒绝宿主的文档预览格式,见 §5.4)把 path 转成资源地址打开(文件落在 * 编辑器);其余类型 path 是组件种子,随导航 params 落到 tab.path(v0.19.2+; * 0.19.0/0.19.1 把一切 path seed 都改道文件资源打开,组件型 tab 的组件不会挂载,#632)。 * url 可选:把**新建** tab 的 path 预填为 URL(网页类 tab 的导航种子); * 聚焦既有 tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。 * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; * 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为 * 与之前完全一致。 * 内容型打开(带 path/url seed)在落点面板折叠时自动展开,保证落点可见; * 类型型打开(+ 菜单等)不展开。 */ openTab(seed: OpenTabSeed, scope?: SessionScope): void /** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+) * 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */ closeTab(tabId: string, scope?: SessionScope): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void // ── v0.12.0+ ────────────────────────────────────────────────────────── /** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */ readonly version: string /** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'): * 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' | * 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' | * 'settingSelect' | 'fileIcons' * ——用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ getSnapshot(): SidebarSnapshot /** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */ subscribeState(listener: () => void): () => void /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate; * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ activateTab(tabId: string, scope?: SessionScope): void /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; * id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件可并排打开)。 * 注意:path 派生 id 只对 openFile/openSidebarFile 成立;editorExplorer 合并模式的 * 原地切换经 updateTab 重写 path/title,tab id 保持稳定、不再对应 path。 */ openFile(scope: SessionScope, path: string, title?: string): void } /** openTab 的 seed(v0.12.0 起导出命名类型)。 */ interface OpenTabSeed { type: string title?: string /** 文件路径:editor = 打开文件资源;其余类型 = 组件种子(落在 tab.path,v0.19.2+) */ path?: string diff?: SidebarTab['diff'] id?: string url?: string /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复); * undefined = 不改,null = 显式清除 */ meta?: unknown /** 落点。省略 / 'right' = 原生右侧栏当前停靠格(默认);'bottom' = 插件的 * 底部工作台;**'side' = 原生右侧栏的第二个格**——原生承载面提供 * `openResource(address, { preferNewPane: true, revealIfOpened: false })`: * 先按宿主的两格上限与空间规则尝试分栏,分不了才回退到当前格;允许与 * 已打开的同名资源并存,所以「在侧边打开同一个文件」真的会新开一格。 * path-less 的 editor seed 是文件页(files),'side' 只影响带 path 的打开。*/ target?: 'right' | 'bottom' | 'side' } /** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */ interface FileIconDescriptor { /** 唯一 id(如 'my-plugin:icons') */ id: string /** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行 * 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)—— * 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。 * [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体 * names/扩展名与内置 glyph 永远优先于它)。 * **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */ exts?: readonly string[] /** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])—— * 图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */ names?: readonly string[] /** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])—— * 图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的 * 目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */ folderNames?: readonly string[] /** priority 高者胜,缺省 0(同级按注册先后) */ priority?: number /** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。 * 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是 * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 * open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */ icon: (path: string, size: number, open?: boolean) => ReactNode } ``` **图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认): ```ts if (ctx.betterSidebar.features.includes('fileIcons')) { ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:icons', exts: ['csv', 'tsv'], icon: (path, size) => , // 彩色也可以 }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开 names: ['package.json', 'Dockerfile'], icon: (path, size) => , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:folders', // 只认领列出的目录名 folderNames: ['node_modules', 'src'], icon: (path, size, open) => open === true ? : , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名) exts: ['folder', 'folder-open'], icon: (path, size, open) => open === true ? : , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件 exts: [], icon: (path, size) => , }) ) } ``` **消费表面与回退链**(由本插件内置消费,插件无需自己接线): - 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)` ——具体 `names`/扩展名注册 → catch-all 全局默认(`exts: []`)→ **DSH 官方图标**。 - 文件树目录行(含根行):`folderIcon(path, open, size)`——`folderNames` 命中 → `'folder'`/`'folder-open'` 保留扩展名 → DSH 官方的文件夹图形。 注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 (console.error 后跳到回退链下一级),不会空白行。 **内置图标 = DSH 官方图形**(v0.19.0+,插件不含任何图标数据): - 回退链末端是 `FileTypeIcon` / `CodeFileIcon`(`@deepseek-ai/dsh-client-ui-primitives`, DSH 0.1.5-rc.2+):48 个代码/配置类目的官方全彩图形 + markdown / 图片 / PDF / Word / Excel / PPT / 视频 / 文件夹 / 通用文档的类目色板图形,分类器是宿主的 `classifyFileType`(精确文件名 → 前缀/后缀 → 项目上下文 → 扩展名)。本插件因此**没有** 自己的扩展名表、**没有**图标 chunk、**没有**图标主题开关——彩色是唯一形态。 - **语义后果(重要)**:宿主分类器覆盖任意路径,所以「插件自己已经能画这个扩展名」不再是 拦住 catch-all 的理由——**注册了 `exts: []` 的插件会接管全部未具体命中的行**(优先级降序、 同优先级按注册序)。只想补几个扩展名就照常用 `exts`/`names`,别用 catch-all 兜底。 - 插件自己注册的图标颜色由注册方负责(见 §12):品牌色是内容标识而非 chrome。 **版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: ```ts if (ctx.betterSidebar.features.includes('badge')) { // 使用 TabDescriptor.badge } if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ } ``` **生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`: ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: 'Database', single: true, badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */, onOpen: (tab, scope) => { startWatcher(scope.sessionId) }, onClose: (tab, scope) => { stopWatcher(scope.sessionId) }, component: ({ scope }) => , }) ) ``` --- ## 8. 声明式设置(v0.4.1+) 每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码): - 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。 - 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。 - 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。 - `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉(工作区路径围栏与它的开关已移除,见 §8.1)。 - `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。 - `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。 ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: 'Database', order: 50, settings: { // 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制) toggles: [{ key: 'autoOpenSubagent', // 宿主内置键 title: 'Auto-open', desc: 'Open when a subagent appears', }], // 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db'] pluginToggles: [{ key: 'pageSize', title: 'Page size', type: 'number', min: 1, max: 100, unit: 'rows', }], // 或完全自定义面板(追加在行列表之后) render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => ( { updatePluginSetting(key, value) }} onDone={close} /> ), }, component: ({ scope }) => , }) ) ``` > ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`workspaceFence`(v0.23.0,围栏整体移除,见 §8.1)/ `agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 ### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`) **0.1.7 把可注册的设置命名空间整体删除了。** `ctx.settings.register(ns, schema)` 与它返回的 `get` / `watch` / `update` / `replace` 都已不存在,取而代之的是 `SettingsForms`——一套**按 profile 条目寻址**的表单服务: ```ts interface SettingsForms { /** 只列出「导出了 Config 且 fiber 处于 ACTIVE」的条目;ns 就是 Loader 行的 entry id。 */ describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[] /** 把 patch 合并进该条目的 config(revision 不符时抛 SettingsConflictError)。 */ update(ns: string, patch: object, expectedRevision?: number): Promise /** 重置全部 live 字段后再写入给定字段(普通 config 保留)。 */ replace(ns: string, section: object, expectedRevision?: number): Promise /** 按路径逐字段改(持不完整视图——例如被脱敏的秘密字段——时用它,别用 replace 重述整节)。 */ mutate(ns: string, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise /** 声明本插件实例的页面策略(自带设置页的插件写 auto: false);返回 disposer。 */ configure(presentation: { auto?: boolean }, owner?: Fiber): () => void get writable(): boolean get documentPath(): string prepareDocument(): Promise } interface SettingsDescriptor { ns: string // profile 条目 id —— 不是插件自选的命名空间 autoGenerate: boolean schema: unknown value: unknown // 已解析(defaults → composition base → 用户层) revision: number base?: unknown user?: unknown // 只读的 profile 覆盖层;为空 = 该条目还没被用户写过 applies: 'live' secrets?: RedactedSecret[] } ``` 四条必须记住的新规则: 1. **命名空间 = 插件 Loader 行的 `entry.options.id`**,不是插件自选的字符串。聚合包会用别的 id 挂同一个包,所以**运行时自发现**(本插件 `src/index.ts` 的 `ownEntryId(ctx)`:按 `entry.options.name === '<包名>'` + `entry.fiber === ctx.fiber` 匹配,回退到第一个启用的同名行),**不要硬编码** id。 2. **schema 来自插件模块导出的 `Config`**(`entry.fiber.runtime.Config`)。用户可见的字段必须**并进这个 `Config`**——本插件把 `PrefsSchema` 合并进 `Config`(`src/config.ts`),并把每个偏好字段标 `meta.volatile = true`;**这一个标记就是「改设置实时生效、不重挂插件」的全部机制**(Loader 的 `equalExceptVolatile` 跳过 volatile 字段,纯 volatile 变更提交进运行中的引用并发 `loader/volatile-update`,普通字段仍是重挂语义)。 3. **自带设置页的插件要主动退出自动表单**:`ctx.effect(() => ctx.settings.configure({ auto: false }, ctx.fiber))`,否则 Settings 会把同一批字段渲染两遍(策略不影响读写)。 4. **`settings/document-updated` 你监听不到,别写**:事件签名是 `'settings/document-updated'(ns, revision)`(语义是「该条目的表单值 / 可用性 / 页面策略变了,表单客户端重新读取 schema、解析值与 revision」),但它在 **settings 服务自己的 context 上 `emit`**,而 cordis 事件只向该 ctx 的**祖先**冒泡——插件 fiber 是兄弟,`ctx.on(...)` 永远不会触发。**替代做法:每次重新读取表单时重新求值**(本插件的 `settingsFace.get()` 在返回当前值前跑一遍门控同步;客户端侧另经 `remote` 服务的 `$on('settings/document-updated')` 镜像触发重新拉取)。**旧文档里的 `settings/updated` 事件在 0.1.7 已不存在**。 ### 8.1 工作区包含检查已移除(v0.23.0,权限放开) **`workspaceFence` 这个键已经不存在了,也没有替代开关。** 侧栏的文件系统路由(`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload` / `archive.build`)**不再做任何工作区包含检查**:路径只做词法解析(会话相对路径拼到会话 cwd 下、`resolve()` 折叠 `..`),不再 realpath、不再比对前缀、**不再有 403 `forbidden` 分支**。符号链接被照常跟随。 > ## ⚠️ 安全声明(必读) > > 这是一次**权限放开**:插件侧边栏的 fs 路由现在能读写**宿主用户能访问的任意路径**,只受 OS 权限约束。也就是说,一个能访问到本插件 `/sidebar/*` 路由的同源页面/脚本(**包括你写的第三方消费插件**)可以借这些路由读取、改写、删除该用户权限内的任何文件——例如 `~/.ssh/`、`~/.dsh/`、其他项目的源码。 > > 缓解手段只有两块,别指望有第三块:① 每条路由都过同一个**浏览器信任栅栏**(Host 头 loopback / `trustedHosts`,见 §0 与 §10),所以前提是攻击面已经能发同源请求;② 宿主用户的 OS 权限本身的边界。**不要**再把「工作区围栏」当成安全边界来设计你的插件——它已经不在了。 历史背景(`< v0.23.0`):旧的实现用 realpath 解析后比对工作区前缀,越界回 403,并有一个 `workspaceFence` 开关(默认开)可临时关闭;开启时编辑器/文件树错误面会显示原因 + 一键关闭按钮(`FenceErrorNotice`)。用户要求删除该检查,上述代码路径随之删除(`src/path-security.ts` 现在是纯词法解析)。旧 profile 里遗留的 `workspaceFence` 键只是**未知键**——schemastery 的 object schema 对未知键是容忍的,既不报错也不再有任何效果。 --- ## 9. 生命周期与 HMR - **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。 - **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。 - **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。 - **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。 --- ## 10. 平台约束与陷阱 | 陷阱 | 说明 | |---|---| | **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 | | **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——host/client 包对同名成员声明不同会 TS2717)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 | | **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`) | | **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import | | **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) | | **家族右面板互斥(v0.13.0+)** | `aionui-panel` 设置的 `rightPanel` 解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 返回 `externalDisable: true`,挂载门 + 接管停用)。**DSH 0.1.7 起 `settings/updated` 事件已删除**,保留的 `settings/document-updated` 是在 **settings 服务自己的 context 上 emit 的、插件 fiber 监听不到**(见 §8.2 第 4 条)——所以实时性由「每次重新读取时重新求值 + 客户端经 `remote` 镜像重新拉取」提供,不要写成 `ctx.on('settings/document-updated')` | | **设置持久化位置(0.1.7 起)** | 偏好不再写 `~/.dsh/settings.yaml`,而是落在 **profile 的 cordis patch 文档**里,按插件 Loader 行的 entry id 组织(本插件默认 `better-sidebar`)。0.1.6 线的 `dsh-better-sidebar` 段由插件在**首次启动时一次性回迁**(只在该行用户层仍为空时执行,且只迁移当前 `Config` 仍声明的字段),所以用户设置不会丢。排障时先看 profile patch,而不是旧的 `settings.yaml`(服务已把它改名为 `settings.yaml.imported`) | | **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` | | **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op | | **懒加载 chunk** | 重依赖(CodeMirror / mermaid / 19 份第三语言词典)在独立 bundle(`lib/client-.js`,现存 `editor` / `mermaid` / `locale` 三个),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 | | **聊天文件打开漏斗(0.1.5 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点);`remote.session.openWorkspacePath` 在 0.1.5 客户端**已无调用者**,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 `dsh-resource://file/**`(见 [AGENTS.md §3](AGENTS.md) 第 10 条);不要假设 `ctx.workspaces.openPath`(已删除)或旧 `remote.session` 漏斗存在 | | **原生 tab 体的高度契约** | 你的 tab 组件被挂在**全高列 flex 宿主**里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 `height:100%` 的 `sidebar.module.css` `.nativeTabHost`(带 `data-dsh-native-tab-host`)——根用 `flex: 1` / `height: 100%` 并加 `min-height: 0` 填满面板。宿主的面板体 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器,不是 flex 容器**,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)正是因此在根上声明 `height: 100%` | --- ## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**) 自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。 - 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在); - 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败; - 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点;同一文件再开一格用 `target: 'side'`)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。 --- ## 12. 皮肤兼容(令牌驱动) > better-sidebar 所有视觉值消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),**不做每皮肤适配**。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 `--dsw-alias-*` 层;`tests/theme.spec.ts` 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。 > > **没有任何豁免面**:文件与文件夹图标是 **DSH 官方的 `FileTypeIcon` 图形**(宿主自己 > 的调色板,插件不画像素也不存数据),插件画的每个 glyph(含内置 tab 的彩色图标)颜色 > 都来自 `--dsw-alias-*`——`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」与 > 「样式表里每条 `color` 都解析到令牌」。插件自己注册的图标(`registerFileIcon`)颜色由 > 注册方负责,不受本节令牌约束,但**不要**把彩色图标数据塞进核心 bundle 的常驻渲染路径 > (要按需加载就照 `src/client/chunk-loader.ts` 的懒加载 chunk 走)。 ### 12.1 规则 - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 - **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 - **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 `#root [data-slot="main.conversation"]` 解析进列并跳过 `display: contents` 祖先(`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 同时认 `main.conversation` 与 alpha.1 的 `conversation`),定位器从槽宿主向上取第一个非 `contents` 的祖先;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 - **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。 - **四方案**:`auto`(默认,只信 WCO——"为某壳做的兼容在另一个壳会再坏",核心不做壳专属分支)/ `web`(强制 0)/ `preset`(`src/client/shell-presets.ts`,准入:issue/PR 提及且 GitHub ⭐>100;命中环境显示「已检测」后缀,绝不自动启用)/ `custom`(用户 CSS + 手动 px,齿轮弹窗)。 - **迁移**:`titleBarScheme` 无默认值;旧文档已有值(`titleBarCompat === true` 或 `titleBarStripPx` 非 40)→ 迁 `custom`;干净文档 → `auto`。 - **用户空间 CSS**:预设/自定义 css 注入 `