feat: 公共 API 基座化(v0.12.0,纯增量)— 类型/能力/设置 seam 全面补齐

- 类型生态:service 模块 re-export SidebarTab/SidebarState/SidebarStore/
  SidebarSnapshot/SidebarDiffRef/TabType/SessionScope/SidebarPrefs 等公共
  状态词汇(此前 TS2459 无法命名);context-types 去 Node 依赖(结构镜像
  SidebarHttpRequest/Response/UpgradeSocket/Head),client 可达声明图零
  node:* 引用,外部消费者无 @types/node + skipLibCheck:false 可编译
  (scripts/check-consumer-types.sh 守护)
- 服务能力:version/features 版本与能力探测(单调只增)、getSnapshot/
  subscribeState 状态订阅、updateTab(title/path/meta)、activateTab、
  openFile(路径派生 id 与内置拦截一致)、openTab(seed, scope?) 定向到
  指定 session(store.reduceFor 不扰动 UI)
- TabDescriptor:badge 角标(99+ 封顶,抛错吞掉)、onOpen/onActivate/
  onClose 生命周期回调(service 路径派发,dedupe 聚焦触发 onActivate
  而非 onOpen,抛错不打断流程);SidebarTab.meta JSON 可序列化跨刷新
  持久化(sanitize 宽松保留)
- 设置 seam 开放:PrefsSchema 增 pluginSettings 开放嵌套 map(z.dict),
  settings.pluginToggles(插件自有行,同 switch/text/number 控件)与
  settings.render(自定义面板,含 store/service/prefs/updatePluginSetting/
  close props);viewer 卡片 v0.12.0 起也有齿轮设置弹窗
- FileViewerDescriptor.load 增第三参 AbortSignal(EditorHost 生命周期中止)
- UI 接线:tab 激活/关闭改走 service(跨树激活修复 + 回调触发)、+ 菜单
  传 scope、TabBar badge pill 渲染;active-session scope 走原 reduce 路径
  保证 notify
- 测试 +23:生命周期/定向打开/updateTab/activateTab/openFile/meta 持久化/
  reduceFor/能力常量(与 package.json 锁步)、consumer-types.ts 编译门、
  api-surface.spec.ts(client 图零 node 依赖守护);既有 430 测试全绿
- 文档:AGENTS.md 修 available 三参示例并补全部新 API;external-plugin-
  guide.md 刷新至 v0.12.0(7 tab + 9 viewer、设置 seam 开放、类型导入
  路径建议);README×2 服务化小节同步

未发版 npm(用户决定);分支 feat/plugin-api-v012。
This commit is contained in:
Menghuan1918 committed 2026-08-14 20:44:43 +08:00
1 parent 288a499c7f
commit 82a69546d2
28 files changed
+1512 -103

No files matched your search

+95 -6
View File
@@ -86,7 +86,16 @@ export function apply(ctx: Context): void {
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar' import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
``` ```
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。 类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。v0.12.0 起服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
```ts
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
```
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。
--- ---
@@ -131,6 +140,9 @@ interface TabDescriptor {
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number * 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 * 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
* 与 `render`(自定义设置面板,替代行列表)。
*/ */
settings?: { settings?: {
toggles?: readonly { toggles?: readonly {
@@ -149,7 +161,48 @@ interface TabDescriptor {
/** 输入框后的单位后缀(如 'px')。 */ /** 输入框后的单位后缀(如 'px')。 */
unit?: string unit?: string
}[] }[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
pluginToggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
render?: (props: {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
pluginSettings: Record<string, unknown>
updatePluginSetting: (key: string, value: unknown) => void
close: () => void
}) => ReactNode
} }
/**
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
* 抛错会被吞掉(不显示角标,不影响渲染)。
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦**不算**打开);
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
* - onClose:closeTab 关闭 tab 后。
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
* 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。
* 回调抛错只 console.error,绝不打断打开/关闭流程。scope 携带 { sessionId, cwd? }。
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
/** 渲染函数 */ /** 渲染函数 */
component: (props: TabComponentProps) => ReactNode component: (props: TabComponentProps) => ReactNode
} }
@@ -214,7 +267,8 @@ ctx.effect(() =>
title: 'Commits', title: 'Commits',
icon: <CommitIcon />, icon: <CommitIcon />,
order: 70, order: 70,
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled available: (ctx, scope, state) => hasGitRepo(state), // 三参 (ctx, scope, state);返回 false 时 + 菜单显示为 disabled
// 注意:available 只影响 + 菜单的 disabled 状态,不会拒绝 openTab(只有设置页的禁用开关会)。
dedupeKey: () => 'my-plugin:commits', dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />, component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
}) })
@@ -257,8 +311,9 @@ interface FileViewerDescriptor {
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数 */ /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
load?: (path: string, scope: SessionScope) => Promise<unknown> * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */ /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] } settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
/** 渲染函数 */ /** 渲染函数 */
@@ -384,19 +439,53 @@ interface BetterSidebarService {
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子, * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。 * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
* 被设置禁用的类型是 no-op(console.warn 提示)。 * 被设置禁用的类型是 no-op(console.warn 提示)。
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板 * 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口 * 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。 * 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
*/ */
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** 关闭一个 tab */ /** 关闭一个 tab */
closeTab(tabId: string): void closeTab(tabId: string): void
/** 订阅注册表变化(register/dispose 时触发) */ /** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void subscribe(listener: () => void): () => void
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
getSnapshot(): SidebarSnapshot
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
subscribeState(listener: () => void): () => void
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */
activateTab(tabId: string): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** openTab 的 seed(v0.12.0 起导出命名类型) */
interface OpenTabSeed {
type: string
title?: string
path?: string
diff?: SidebarTab['diff']
id?: string
url?: string
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
meta?: unknown
} }
``` ```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。 > **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
--- ---
+4 -2
View File
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板 - **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开 - **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑 - **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md)) - **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.0 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新 - **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
## 🚀 安装 ## 🚀 安装
@@ -214,7 +214,9 @@ export function apply(ctx: Context) {
} }
``` ```
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。 v0.12.0 起补齐基座能力:类型导出完整(消费者可直接命名 `SidebarTab`/`SidebarState` 等,client 声明图零 Node 依赖)、`version`/`features` 能力探测、`getSnapshot`/`subscribeState` 状态订阅、tab 角标 `badge`、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向 `openTab(seed, scope)`、`SidebarTab.meta` 跨刷新持久化、设置 seam 开放(`settings.pluginToggles` / `settings.render`,值存 `pluginSettings[id]`)。
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置、版本探测):见 [`AGENTS.md`](./AGENTS.md)。
## 🛠️ 开发与构建 ## 🛠️ 开发与构建
+3 -1
View File
@@ -215,7 +215,9 @@ export function apply(ctx: Context) {
} }
``` ```
Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings): see [`AGENTS.md`](./AGENTS.md). v0.12.0+ base capabilities: complete type exports (consumers can name `SidebarTab`/`SidebarState` etc.; the client declaration graph is Node-free), `version`/`features` capability detection, `getSnapshot`/`subscribeState` state subscription, tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted `openTab(seed, scope)`, `SidebarTab.meta` persisted across reloads, and an opened settings seam (`settings.pluginToggles` / `settings.render`, stored in `pluginSettings[id]`).
Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings, version detection): see [`AGENTS.md`](./AGENTS.md).
## 🛠️ Development & Build ## 🛠️ Development & Build
+175 -24
View File
@@ -2,8 +2,8 @@
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 > 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
> >
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。 > 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;本文档其余新 API(badge/生命周期/定向打开/插件设置/版本探测等)**v0.12.0+**。当前版本 v0.12.0。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 > 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 9 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
--- ---
@@ -14,7 +14,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面; - **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。 - **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。 内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 9 个 viewer(image / pdf / docx / xlsx / pptx / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。 关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
@@ -69,6 +69,17 @@ import type {
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
``` ```
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
```ts
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
```
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
--- ---
## 3. 最小骨架(client half) ## 3. 最小骨架(client half)
@@ -149,18 +160,73 @@ interface TabDescriptor {
*/ */
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/** /**
* 声明式设置(v0.4.1+):见 §7。 * 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
* 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
*/ */
settings?: { settings?: SidebarSettingsDeclaration
toggles?: readonly { /**
key: string * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
title: string | (() => string) * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
desc?: string | (() => string) * 抛错会被吞掉(不显示角标,不影响渲染)。
}[] */
} badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开);
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
* - onClose:closeTab 关闭 tab 后。
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发
* 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走
* service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
/** 渲染函数 */ /** 渲染函数 */
component: (props: TabComponentProps) => ReactNode component: (props: TabComponentProps) => ReactNode
} }
/** 声明式设置声明(v0.12.0 完整形状;行控件见 §8)。 */
interface SidebarSettingsDeclaration {
/** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段)。 */
toggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number' // v0.11.0+;缺省 'switch'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,key 插件局部,
* 持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
pluginToggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 */
render?: (props: SidebarSettingsRenderProps) => ReactNode
}
/** settings.render 收到的 props(v0.12.0+)。 */
interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
pluginSettings: Record<string, unknown>
/** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
updatePluginSetting(key: string, value: unknown): void
/** 关闭设置弹窗。 */
close(): void
}
``` ```
### 4.2 `TabComponentProps`(你的页面组件收到的 props) ### 4.2 `TabComponentProps`(你的页面组件收到的 props)
@@ -263,6 +329,7 @@ ctx.effect(() =>
| `git` | 20 | 是 | 否 | Git 面板 | | `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) | | `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
@@ -289,10 +356,12 @@ interface FileViewerDescriptor {
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数 */ /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
load?: (path: string, scope: SessionScope) => Promise<unknown> * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] } /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(v0.12.0 起 viewer
* 卡片也有齿轮按钮,pluginToggles/render 同样可用) */
settings?: SidebarSettingsDeclaration
/** 渲染函数 */ /** 渲染函数 */
component: (props: FileViewerProps) => ReactNode component: (props: FileViewerProps) => ReactNode
} }
@@ -336,7 +405,7 @@ interface FileViewerProps {
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。 > **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 5.5 注册示例 ### 5.5 注册示例
@@ -453,14 +522,75 @@ interface BetterSidebarService {
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名); * title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子)。
* 被设置禁用的类型是 no-op(console.warn 提示)。 * 被设置禁用的类型是 no-op(console.warn 提示)。
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
*/ */
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** 关闭一个 tab */ /** 关闭一个 tab */
closeTab(tabId: string): void closeTab(tabId: string): void
/** 订阅注册表变化(register/dispose 时触发) */ /** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void subscribe(listener: () => void): () => void
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings'——用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
getSnapshot(): SidebarSnapshot
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
subscribeState(listener: () => void): () => void
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */
activateTab(tabId: string): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
openFile(scope: SessionScope, path: string, title?: string): void
} }
/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
interface OpenTabSeed {
type: string
title?: string
path?: string
diff?: SidebarTab['diff']
id?: string
url?: string
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
meta?: unknown
}
```
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
```ts
if (ctx.betterSidebar.features.includes('badge')) {
// 使用 TabDescriptor.badge
}
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }
```
**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`:
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
single: true,
badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
``` ```
--- ---
@@ -472,7 +602,9 @@ interface BetterSidebarService {
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。 - 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。 - 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。 - 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。 - `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑。行控件 v0.11.0 起不限于布尔:`type: 'switch' | 'text' | 'number'`(缺省 'switch';text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行。
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 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 ```ts
ctx.effect(() => ctx.effect(() =>
@@ -481,18 +613,36 @@ ctx.effect(() =>
title: 'Database', title: 'Database',
order: 50, order: 50,
settings: { settings: {
// 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
toggles: [{ toggles: [{
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下 key: 'autoOpenSubagent', // 宿主内置键
title: 'Auto connect', title: 'Auto-open',
desc: 'Connect on 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 }) => (
<MySettingsPanel
values={pluginSettings}
onChange={(key, value) => { updatePluginSetting(key, value) }}
onDone={close}
/>
),
}, },
component: ({ scope }) => <DbView sessionId={scope.sessionId} />, component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}) })
) )
``` ```
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。 > ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
--- ---
@@ -516,6 +666,7 @@ ctx.effect(() =>
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import | | **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
--- ---
@@ -606,13 +757,13 @@ function parseCsv(text: string): string[][] { /* ... */ }
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"): better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts) - **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 9 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤) - **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化) - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件) - **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据) - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
调试时直接读这些文件即可看到所有 API 的真实用法。 调试时直接读这些文件即可看到所有 API 的真实用法。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "dsh-external/dsh-better-sidebar", "id": "dsh-external/dsh-better-sidebar",
"version": "0.11.0", "version": "0.12.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-better-sidebar", "name": "dsh-better-sidebar",
"version": "0.11.0", "version": "0.12.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module", "type": "module",
"repository": { "repository": {
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Consumer-facing type surface check: build a tiny standalone consumer that
# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with
# skipLibCheck: false, then type-check it with the repo's tsc.
#
# This is the exact scenario the v0.12.0 API work cares about: the shipped
# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must
# resolve and type-check for a browser-only plugin author. Run AFTER
# `pnpm build` (the check reads lib/types).
set -euo pipefail
cd "$(dirname "$0")/.."
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/node_modules"
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
cat > "$WORK/check.ts" <<'EOF'
import type {} from 'dsh-better-sidebar/client/service'
import type {
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
TabComponentProps, TabDescriptor,
} from 'dsh-better-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-better-sidebar/client/service'
declare const ctx: { betterSidebar: BetterSidebarService }
const tab: TabDescriptor = {
id: 'x:tab',
title: 'X',
single: true,
badge: (_ctx, _scope, state: SidebarState) => state.expanded.length,
onOpen: (_tab: SidebarTab, _scope: SessionScope) => {},
onClose: (_tab: SidebarTab, _scope: SessionScope) => {},
createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }),
settings: {
toggles: [{ key: 'autoOpenSubagent', title: 'A' }],
pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }],
render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null },
},
component: (props: TabComponentProps) => null,
}
ctx.betterSidebar.registerTab(tab)
const viewer: FileViewerDescriptor = {
id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom',
load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) },
component: () => null,
}
ctx.betterSidebar.registerFileViewer(viewer)
const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } }
ctx.betterSidebar.openTab(seed, { sessionId: 's1' })
ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv')
ctx.betterSidebar.updateTab('t', { meta: { b: 2 } })
const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
const prefs: SidebarPrefs = snap.prefs
const store: SidebarStore = null as unknown as SidebarStore
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
EOF
cat > "$WORK/tsconfig.json" <<'EOF'
{
"compilerOptions": {
"target": "ES2023", "module": "esnext", "moduleResolution": "bundler",
"strict": true, "noEmit": true, "skipLibCheck": false, "types": [],
"jsx": "react-jsx", "lib": ["ES2023", "DOM"]
},
"include": ["check.ts"]
}
EOF
echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..."
echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean"
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true
echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean"
echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)"
set +e
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1
STATUS=$?
set -e
# Fail only on errors that mention OUR package / declarations / the fixture.
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
exit 1
fi
if [ "$STATUS" -ne 0 ]; then
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5
fi
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
+4 -5
View File
@@ -16,8 +16,7 @@ import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises' import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { IncomingMessage, ServerResponse } from 'node:http' import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
import type { Context } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */ /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
@@ -68,9 +67,9 @@ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undef
* chunk scripts live in (overridable for tests). * chunk scripts live in (overridable for tests).
*/ */
export function createBundleRouteHandler( export function createBundleRouteHandler(
fence: (req: IncomingMessage) => boolean, fence: (req: SidebarHttpRequest) => boolean,
chunkDir: string = LIB_DIR, chunkDir: string = LIB_DIR,
): (req: IncomingMessage, res: ServerResponse) => Promise<void> { ): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
return async (req, res): Promise<void> => { return async (req, res): Promise<void> => {
if (!fence(req)) { if (!fence(req)) {
res.writeHead(403) res.writeHead(403)
@@ -121,7 +120,7 @@ export function createBundleRouteHandler(
} }
/** Register the /sidebar/bundle route (disposed with the fiber). */ /** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void { export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
return ctx.webServer.register({ return ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/sidebar/bundle', path: '/sidebar/bundle',
+5 -2
View File
@@ -31,6 +31,9 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
const controller = new AbortController()
setLoad({ status: 'loading' }) setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path) const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => { const apply = (action: EditorLoadAction): void => {
@@ -50,7 +53,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
}) })
return return
case 'customLoad': case 'customLoad':
void action.viewer.load?.(path, scope).then((data) => { void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data }) setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => { }).catch((error: unknown) => {
@@ -77,7 +80,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
} }
} }
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true } return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx]) }, [scope.sessionId, scope.cwd, path, ctx])
return ( return (
+155 -8
View File
@@ -62,6 +62,7 @@ import type { SidebarStore } from './state.ts'
import type { import type {
BetterSidebarService, BetterSidebarService,
FileViewerDescriptor, FileViewerDescriptor,
SidebarSettingsRenderProps,
SidebarSettingToggle, SidebarSettingToggle,
TabDescriptor, TabDescriptor,
} from './service.ts' } from './service.ts'
@@ -112,6 +113,43 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true return (prefs as unknown as Record<string, boolean>)[key] === true
} }
/** Whether a feature declares any secondary settings (gear button shows). */
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
const settings = feature.settings
return settings !== undefined && (
(settings.toggles?.length ?? 0) > 0
|| (settings.pluginToggles?.length ?? 0) > 0
|| settings.render !== undefined
)
}
/** A feature's display name (viewers fall back to their id). */
function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
return textOf('title' in feature ? feature.title : undefined) || feature.id
}
/**
* Render a custom settings panel (`settings.render`) with error containment:
* a throwing panel shows an inline error line instead of breaking the whole
* settings page.
*/
function SettingsRender(props: {
render: (renderProps: SidebarSettingsRenderProps) => ReactNode
renderProps: SidebarSettingsRenderProps
}) {
let content: ReactNode
try {
content = props.render(props.renderProps)
} catch (error) {
content = (
<div className={css.error} role="alert">
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
</div>
)
}
return <>{content}</>
}
/** /**
* The custom switch: a real checkbox (hidden, native semantics and focus) * The custom switch: a real checkbox (hidden, native semantics and focus)
* driving a styled track/thumb. Used by the general toggle rows and the * driving a styled track/thumb. Used by the general toggle rows and the
@@ -240,6 +278,72 @@ function TypedRow(props: {
) )
} }
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
* shared store/service, the live prefs, the descriptor's own plugin
* settings blob, a persistence helper, and a close callback;
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
* projected onto the prefs face so the shared row renderer reads them).
*/
export function SettingsBody(props: {
feature: TabDescriptor | FileViewerDescriptor
prefs: SidebarPrefs
store: SidebarStore
service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginWrite: (key: string, value: unknown) => void
onClose: () => void
}) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)
}
const toggles = feature.settings?.toggles ?? []
const pluginToggles = feature.settings?.pluginToggles ?? []
if (toggles.length === 0 && pluginToggles.length === 0) return null
// Plugin rows read/write their values through a projected prefs face so
// the shared row renderer works unchanged (its rows read prefs[key]).
const effectivePrefs = { ...prefs, ...(prefs.pluginSettings[feature.id] ?? {}) } as SidebarPrefs
return (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
/>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={effectivePrefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
/>
)}
</div>
)
}
/** /**
* Render the Side card preferences section. * Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service). * @param props - composed slot props (runtime share + injected store/service).
@@ -250,7 +354,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent)) const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed). // Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null) const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
// The declarative inventory: the registered tab types and file viewers. // The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin // Local state + service.subscribe (registry changes are rare — plugin
@@ -368,6 +472,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return raw return raw
} }
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
applyPref({
pluginSettings: {
...prefs.pluginSettings,
[descriptorId]: { ...(prefs.pluginSettings[descriptorId] ?? {}), [key]: value },
},
})
}
/** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
applyPluginSetting(descriptorId, toggle.key, next)
}
/** Commit one plugin-owned text/number row (clamped like the host rows). */
const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
if (toggle.type === 'number') {
const parsed = Number(raw)
const blob = prefs.pluginSettings[descriptorId] ?? {}
const fallback = String(blob[toggle.key] ?? '')
if (!Number.isFinite(parsed)) return fallback
let clamped = Math.round(parsed)
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
applyPluginSetting(descriptorId, toggle.key, clamped)
return String(clamped)
}
applyPluginSetting(descriptorId, toggle.key, raw)
return raw
}
const commitWidth = (): void => { const commitWidth = (): void => {
const parsed = Number(widthDraft) const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@@ -511,8 +647,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onToggle: (next) => { onToggleTab(tab.id, next) }, onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its // The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off. // related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab)
&& (tab.settings?.toggles?.length ?? 0) > 0
? () => { setSettingsFor(tab) } ? () => { setSettingsFor(tab) }
: undefined, : undefined,
})} })}
@@ -536,6 +671,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
icon: iconOf(viewer.icon, 16), icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false, enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) }, onToggle: (next) => { onToggleViewer(viewer.id, next) },
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
? () => { setSettingsFor(viewer) }
: undefined,
})} })}
</Fragment> </Fragment>
))} ))}
@@ -547,13 +685,16 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
Done footer (Modal chrome is the app's own). Mounted only while a Done footer (Modal chrome is the app's own). Mounted only while a
feature is open — the Modal primitive runs hooks unconditionally, feature is open — the Modal primitive runs hooks unconditionally,
so a closed-but-mounted Modal would break SSR (and the so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split. */} renderToString spec) under the test dual-react split.
Content: `settings.render` (custom panel) when declared, else the
host-prefs `toggles` rows followed by the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]). */}
{settingsFor !== null && ( {settingsFor !== null && (
<Modal <Modal
open open
onClose={() => { setSettingsFor(null) }} onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)} title={featureNameOf(settingsFor)}
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })} description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
closeLabel={t('close')} closeLabel={t('close')}
className={css.popupDialog} className={css.popupDialog}
footer={( footer={(
@@ -562,11 +703,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</button> </button>
)} )}
> >
<FeatureSettingsRows <SettingsBody
toggles={settingsFor.settings?.toggles ?? []} feature={settingsFor}
prefs={prefs} prefs={prefs}
onToggle={onToggleSetting} onToggle={onToggleSetting}
onCommit={onCommitSetting} onCommit={onCommitSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }}
store={store}
service={service}
/> />
</Modal> </Modal>
)} )}
+32 -10
View File
@@ -32,7 +32,7 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts' import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts' import { appendToDraft } from './conversation-draft.ts'
import { import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals, reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
@@ -555,7 +555,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const current = store.getSnapshot().state const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId)) // Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose).
ctx.betterSidebar?.closeTab(tabId)
if (tab?.type === 'terminal') { if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) { if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId) const uuid = agentUuidOf(tabId)
@@ -566,13 +568,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
} }
}, },
activateTab: (paneId, tabId) => { activateTab: (paneId, tabId) => {
store.reduce(s => ({ // Route through the service: same reducer (finds the pane in EITHER
...s, // tree, sets the active pane) and fires descriptor.onActivate.
activePane: paneId, ctx.betterSidebar?.activateTab(tabId)
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
}, },
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
@@ -631,7 +629,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const descriptor = service?.getTab(optionId) const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
service.openTab({ type: optionId, title }) // The session scope rides along: lifecycle callbacks receive it (and
// the open stays in the current session, as before).
service.openTab({ type: optionId, title }, { sessionId, cwd })
} }
/** /**
@@ -647,6 +647,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
} }
/**
* The tab badge from the tab-type registry: a count (99+ capped) or a
* short text pill. A throwing badge is swallowed (no pill) — the tab
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-better-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
return <span className={css.tabBadge}>{text}</span>
}
/** /**
* Render one tab's content. `active` (from the workbench) tells whether * Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's * this tab is the active one in its pane; combined with the panel's
@@ -757,6 +777,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={renderTab} renderTab={renderTab}
getTabIcon={tabIconOf} getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/> />
</div> </div>
</div> </div>
@@ -836,6 +857,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)} renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={tabIconOf} getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/> />
</div> </div>
</div> </div>
+5 -1
View File
@@ -63,9 +63,12 @@ export function TabBar(props: {
onDropTab: (payload: TabDragPayload, before: string | null) => void onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */ /** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
* resolver returns the rendered pill or null). */
getTabBadge?: (tab: SidebarTab) => ReactNode
}) { }) {
const { const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
} = props } = props
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
@@ -136,6 +139,7 @@ export function TabBar(props: {
}} }}
> >
{getTabIcon?.(tab) ?? null} {getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span> <span className={css.tabTitle}>{tab.title}</span>
<button <button
type="button" type="button"
+18
View File
@@ -73,9 +73,27 @@ export function parsePrefs(value: unknown): SidebarPrefs {
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks, : SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
tabsEnabled: booleanMapOf(record.tabsEnabled), tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
} }
} }
/**
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
* value } }`, nested open maps. Any non-object value (or a malformed whole)
* falls back to the empty map — the schema defaults already guard the wire
* shape, this is the client's second line.
*/
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, Record<string, unknown>> = {}
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
out[id] = blob as Record<string, unknown>
}
}
return out
}
/** /**
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values * Validate one enable-switch map (per-tab / per-viewer). Only boolean values
* survive; a non-object or a non-boolean entry falls back to the empty map / * survive; a non-object or a non-boolean entry falls back to the empty map /
+268 -12
View File
@@ -22,11 +22,30 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf, activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
type SidebarState, type SidebarStore, type SidebarTab, openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts' } from './state.ts'
import { isNarrowWidth } from './breakpoints.ts' import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts' import type { SessionScope } from './api.ts'
import type { SidebarPrefs } from '../prefs-shared.ts'
/**
* Public state vocabulary re-exported for consumers (type-only; the values
* stay internal). External plugins name these types in their descriptors —
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
* or `badge: (…, state: SidebarState) => …`.
*/
export type {
SidebarTab,
SidebarState,
SidebarStore,
SidebarSnapshot,
SidebarDiffRef,
TabType,
} from './state.ts'
export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */ /** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
@@ -56,6 +75,19 @@ export interface SidebarSettingToggle {
unit?: string unit?: string
} }
/** Props of a descriptor's custom settings panel (`settings.render`). */
export interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
pluginSettings: Record<string, unknown>
/** Persist one plugin-owned setting of this descriptor. */
updatePluginSetting(key: string, value: unknown): void
/** Close the settings popup. */
close(): void
}
/** Declarative settings of one registered tab or file viewer. */ /** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration { export interface SidebarSettingsDeclaration {
/** /**
@@ -66,6 +98,21 @@ export interface SidebarSettingsDeclaration {
* by the settings seam. * by the settings seam.
*/ */
toggles?: readonly SidebarSettingToggle[] toggles?: readonly SidebarSettingToggle[]
/**
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
* (switch/text/number), but the keys are plugin-local and persisted in
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
* — no host PrefsSchema field needed. Values must be JSON-serializable
* (the row controls produce strings / numbers / booleans).
*/
pluginToggles?: readonly SidebarSettingToggle[]
/**
* Custom settings panel (v0.12.0+): when given, the gear popup renders
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
* the shared store/service, the live prefs, the descriptor's own
* `pluginSettings` blob, and a persistence helper.
*/
render?: (props: SidebarSettingsRenderProps) => ReactNode
} }
/** Props every tab component receives (builtins and external alike). */ /** Props every tab component receives (builtins and external alike). */
@@ -129,6 +176,27 @@ export interface TabDescriptor {
* (e.g. the subagent tab's 'autoOpenSubagent'). * (e.g. the subagent tab's 'autoOpenSubagent').
*/ */
settings?: SidebarSettingsDeclaration settings?: SidebarSettingsDeclaration
/**
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
* the icon — a number renders as a count (99+ capped), a string renders
* as-is, null/undefined hides the badge. Called on every tab-bar render,
* so keep it cheap; a throw is swallowed (no badge shown).
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
* when a tab is focused (dedupe focus, id-safety-net focus, or the
* tab-bar activation), `onClose` when a tab is closed through
* `closeTab`. Builtin-only flows that mutate state directly (the diff
* split placement, agent-terminal reconcile) never touch external tabs
* and fire no callbacks. A throwing callback is logged and never breaks
* the open/close/activate flow.
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
component: (props: TabComponentProps) => ReactNode component: (props: TabComponentProps) => ReactNode
} }
@@ -176,8 +244,9 @@ export interface FileViewerDescriptor {
* is consulted before its `exts` (per-descriptor, in priority order). * is consulted before its `exts` (per-descriptor, in priority order).
*/ */
detect?: (path: string, head: Uint8Array) => boolean detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' loader. */ /** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
load?: (path: string, scope: SessionScope) => Promise<unknown> * teardown / re-match; loaders that ignore it keep working. */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/** /**
* Declarative settings shown in the Side card settings page: every * Declarative settings shown in the Side card settings page: every
* registered viewer gets an enable/disable switch (icon + title + exts). * registered viewer gets an enable/disable switch (icon + title + exts).
@@ -186,7 +255,26 @@ export interface FileViewerDescriptor {
component: (props: FileViewerProps) => ReactNode component: (props: FileViewerProps) => ReactNode
} }
/** The registry service published as `ctx.betterSidebar`. */ /** One `openTab` request. */
export interface OpenTabSeed {
type: string
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
title?: string
/** A file path (the editor tab's content seed). */
path?: string
/** A diff reference (the diff tab's content seed). */
diff?: SidebarTab['diff']
/** Explicit tab id (defaults to the type). */
id?: string
/** A URL the tab navigates to on mount (the browser tab's seed). */
url?: string
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
meta?: unknown
}
/**
* The registry service published as `ctx.betterSidebar`.
*/
export interface BetterSidebarService { export interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void registerTab(descriptor: TabDescriptor): () => void
registerFileViewer(descriptor: FileViewerDescriptor): () => void registerFileViewer(descriptor: FileViewerDescriptor): () => void
@@ -217,18 +305,49 @@ export interface BetterSidebarService {
* usually pairs it with a hostname `title`). A disabled tab type is a * usually pairs it with a hostname `title`). A disabled tab type is a
* no-op. * no-op.
* *
* `scope` (v0.12.0+) targets a specific session: when given, the open
* lands in THAT session's sidebar state (loading it if it has none yet)
* without switching the UI's active session; when absent the open lands
* in the currently active session (the pre-0.12 behavior).
*
* A CONTENT open (a `path` or `url` seed) must land in sight: when the * A CONTENT open (a `path` or `url` seed) must land in sight: when the
* panel hosting the landing pane is collapsed, it is expanded * panel hosting the landing pane is collapsed, it is expanded
* automatically (the right panel by default, the bottom panel when the * automatically (the right panel by default, the bottom panel when the
* active pane lives there; on narrow viewports the merged drawer opens). * active pane lives there; on narrow viewports the merged drawer opens).
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand — * Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
* the panel behavior is their caller's business. * the panel behavior is their caller's business.
*
* Note: `available` gates the + menu's disabled state only — it does NOT
* refuse `openTab` (only the settings disable switch does).
*/ */
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** Close a tab by id. */ /** Close a tab by id. */
closeTab(tabId: string): void closeTab(tabId: string): void
/** Subscribe to registry changes (register/dispose). */ /** Subscribe to registry changes (register/dispose). */
subscribe(listener: () => void): () => void subscribe(listener: () => void): () => void
/** The plugin version this service instance was built from ('0.12.0'). */
readonly version: string
/**
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
* gate new API usage on membership.
*/
readonly features: readonly string[]
/**
* The current sidebar snapshot: the active session id, its state (panel
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
* `state`/`sessionId` are undefined until a session becomes active.
*/
getSnapshot(): SidebarSnapshot
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
subscribeState(listener: () => void): () => void
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** Activate an open tab (the tab-bar activation path; fires descriptor.onActivate). */
activateTab(tabId: string): void
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
openFile(scope: SessionScope, path: string, title?: string): void
} }
/** Extract the lowercase extension without leading dot from a path. */ /** Extract the lowercase extension without leading dot from a path. */
@@ -239,6 +358,50 @@ function extOfPath(path: string): string {
return base.includes('/') || base.includes('\\') ? '' : base return base.includes('/') || base.includes('\\') ? '' : base
} }
/** The file name of a path (both separators). */
function baseNameOf(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.12.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
* are never removed). Each string names a v0.12.0+ capability:
* - 'badge': TabDescriptor.badge
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
* - 'updateTab': BetterSidebarService.updateTab
* - 'openFile': BetterSidebarService.openFile
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
* - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
*/
export const SIDEBAR_FEATURES = [
'badge',
'tabLifecycle',
'updateTab',
'openFile',
'targetedOpen',
'stateSubscription',
'tabMeta',
'pluginSettings',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
function safeCall(fn: () => void): void {
try {
fn()
} catch (error) {
console.error('[dsh-better-sidebar] plugin callback error:', error)
}
}
/** /**
* Create one BetterSidebar service bound to a store. The service owns the * Create one BetterSidebar service bound to a store. The service owns the
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab * tab/viewer registries (Map + listener set) and proxies openTab/closeTab
@@ -325,7 +488,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
return undefined return undefined
} }
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => { const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
// A type the user disabled in settings never opens — neither from the // A type the user disabled in settings never opens — neither from the
// + menu nor from derived flows (file opens, subagent auto-open, // + menu nor from derived flows (file opens, subagent auto-open,
// external plugins). Already-open tabs keep rendering. // external plugins). Already-open tabs keep rendering.
@@ -333,9 +496,18 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`) console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
return return
} }
store.reduce((state) => { const descriptor = tabs.get(seed.type)
const descriptor = tabs.get(seed.type) if (descriptor === undefined) return
if (descriptor === undefined) return state // A scope targets another session: the open lands in THAT session's
// state (loaded on demand) without switching the UI's active session.
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
if (targetSessionId === undefined) return
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
// Lifecycle capture: `created` when the open minted a NEW tab (a
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
let created: SidebarTab | undefined
let activated: SidebarTab | undefined
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab let tab: SidebarTab
let next: SidebarState let next: SidebarState
@@ -354,6 +526,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title), title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}), ...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}), ...(seed.diff !== undefined ? { diff: seed.diff } : {}),
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
} }
next = applyDedupe(state, tab, descriptor) next = applyDedupe(state, tab, descriptor)
} }
@@ -369,6 +542,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} else { } else {
landed = next landed = next
} }
// Lifecycle capture (before the auto-expand block, which early-returns).
if (!tabOpenIn(state, tab.id)) {
created = tab
} else {
// A focus happened (dedupeKey or the id safety net): resolve the
// tab that is actually active now and report THAT to onActivate.
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open (file / browser) must land in sight: when the panel // A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow // hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer // viewports the two workbenches merge into one drawer, so the drawer
@@ -395,15 +582,77 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} }
} }
return landed return landed
}) }
// A scope targeting ANOTHER session lands the open there without
// switching the UI; a scope naming the active session (or no scope)
// takes the regular reduce path so the UI notifies and re-renders.
const activeSessionId = store.getSnapshot().sessionId
if (scope !== undefined && scope.sessionId !== activeSessionId) {
store.reduceFor(scope.sessionId, reducer)
} else {
store.reduce(reducer)
}
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
} }
const closeTab = (tabId: string): void => { const closeTab = (tabId: string): void => {
let closed: SidebarTab | undefined
store.reduce((state) => { store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId) const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state if (paneId === '') return state
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
return closeTabReducer(state, paneId, tabId) return closeTabReducer(state, paneId, tabId)
}) })
if (closed !== undefined) {
const sessionId = store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(closed.type)
safeCall(() => descriptor?.onClose?.(closed!, { sessionId }))
}
}
}
/** The snapshot the store publishes (state/prefs carry the active session). */
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
/** Store changes: session switch, state mutations, prefs writes. */
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
/** Patch an open tab's display fields (a missing tab id is a no-op). */
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
store.reduce((state) => patchTab(state, tabId, {
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}))
}
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
const activateTab = (tabId: string): void => {
let activated: SidebarTab | undefined
store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
return activateTabReducer(state, paneId, tabId)
})
if (activated !== undefined) {
const sessionId = store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(activated.type)
safeCall(() => descriptor?.onActivate?.(activated!, { sessionId }))
}
}
}
/** Open a file in the sidebar editor of `scope`'s session (title defaults
* to the file name; the tab id is path-derived, like the internal
* open-path interception, so distinct files open side by side). */
const openFile = (scope: SessionScope, path: string, title?: string): void => {
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
} }
return { return {
@@ -418,6 +667,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
openTab, openTab,
closeTab, closeTab,
subscribe, subscribe,
version: SIDEBAR_SERVICE_VERSION,
features: SIDEBAR_FEATURES,
getSnapshot,
subscribeState,
updateTab,
activateTab,
openFile,
} }
} }
@@ -436,7 +692,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
// bottom panel focuses an existing instance wherever it lives. // bottom panel focuses an existing instance wherever it lives.
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key) const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id) if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
} }
} }
return openTabInActivePane(state, tab) return openTabInActivePane(state, tab)
+15
View File
@@ -522,6 +522,21 @@
white-space: nowrap; white-space: nowrap;
} }
/* One tab's descriptor badge (a count capped at 99+, or a short text). */
.tabBadge {
flex: none;
min-width: 16px;
height: 15px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-brand-primary);
}
.tabClose { .tabClose {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+10 -3
View File
@@ -123,8 +123,9 @@ function LeafView(props: {
onNewTab: (optionId: string) => void onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) { }) {
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null) const [dropZone, setDropZone] = useState<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1] const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
@@ -178,6 +179,7 @@ function LeafView(props: {
onNewTab={onNewTab} onNewTab={onNewTab}
newTabOptions={newTabOptions} newTabOptions={newTabOptions}
getTabIcon={getTabIcon} getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
onDropTab={(payload, before) => { onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center') if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before) else actions.moveTabBefore(payload, leaf.id, before)
@@ -217,8 +219,9 @@ function NodeView(props: {
onNewTab: (optionId: string) => void onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) { }) {
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
if (node.kind === 'leaf') { if (node.kind === 'leaf') {
return ( return (
<LeafView <LeafView
@@ -228,6 +231,7 @@ function NodeView(props: {
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={renderTab} renderTab={renderTab}
getTabIcon={getTabIcon} getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/> />
) )
} }
@@ -254,6 +258,7 @@ function NodeView(props: {
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={renderTab} renderTab={renderTab}
getTabIcon={getTabIcon} getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/> />
</div> </div>
</Fragment> </Fragment>
@@ -274,8 +279,9 @@ export function Workbench(props: {
onNewTab: (optionId: string) => void onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) { }) {
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
return ( return (
<div className={css.workbench}> <div className={css.workbench}>
<NodeView <NodeView
@@ -286,6 +292,7 @@ export function Workbench(props: {
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={renderTab} renderTab={renderTab}
getTabIcon={getTabIcon} getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/> />
</div> </div>
) )
+34 -3
View File
@@ -26,13 +26,17 @@ export type SidebarDiffRef =
| { kind: 'commit'; hash: string; hashFull: string; subject: string } | { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git); /** One open tab. `path` carries the file (editor) or is absent (explorer/git);
* `diff` carries the change a diff tab shows. */ * `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab { export interface SidebarTab {
id: string id: string
type: TabType type: TabType
title: string title: string
path?: string path?: string
diff?: SidebarDiffRef diff?: SidebarDiffRef
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
* persisted with the layout and restored verbatim on reload. */
meta?: unknown
} }
/** A tab group. */ /** A tab group. */
@@ -432,14 +436,14 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
} }
} }
/** Update the display fields of one open tab (title / path) without /** Update the display fields of one open tab (title / path / meta) without
* re-opening it. The browser tab persists its current URL and hostname * re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A * title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. The tab may live in either tree. */ * missing tab id is a no-op. The tab may live in either tree. */
export function patchTab( export function patchTab(
state: SidebarState, state: SidebarState,
tabId: string, tabId: string,
patch: { title?: string; path?: string }, patch: { title?: string; path?: string; meta?: unknown },
): SidebarState { ): SidebarState {
let changed = false let changed = false
const walk = (node: SplitNode): SplitNode => { const walk = (node: SplitNode): SplitNode => {
@@ -451,6 +455,7 @@ export function patchTab(
...tab, ...tab,
...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}), ...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
} }
}) })
return tabs === node.tabs ? node : { ...node, tabs } return tabs === node.tabs ? node : { ...node, tabs }
@@ -886,11 +891,15 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// accept any string type here — an unregistered type renders an // accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later. // <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined if (typeof candidate.type !== 'string') return undefined
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
tabs.push({ tabs.push({
id: candidate.id, id: candidate.id,
type: candidate.type, type: candidate.type,
title: candidate.title, title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}), ...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
}) })
} }
const active = typeof record.active === 'string' ? record.active : null const active = typeof record.active === 'string' ? record.active : null
@@ -1020,6 +1029,28 @@ export class SidebarStore {
this.notify() this.notify()
} }
/**
* Apply a pure reducer to a TARGET session's state (not the active one),
* loading it on demand and persisting the result — WITHOUT switching the
* active snapshot or notifying (the UI must not follow along). Used by the
* service's targeted `openTab(seed, scope)`: the open lands in the target
* session's layout and is visible whenever the user switches to it.
*/
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
this.bySession.set(sessionId, state)
} else {
// Re-seed the uid counter past THIS session's persisted ids, exactly
// like setSession's cache-hit path.
nextIdCounter = maxCounterId(state)
}
const next = reducer(state)
this.bySession.set(sessionId, next)
this.schedulePersist(sessionId, next)
}
private schedulePersist(sessionId: string, state: SidebarState): void { private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer) window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => { this.persistTimer = window.setTimeout(() => {
+5
View File
@@ -100,4 +100,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
// (everything on) with no migration. Non-boolean values fail validation. // (everything on) with no migration. Non-boolean values fail validation.
tabsEnabled: z.dict(z.boolean()).default({}), tabsEnabled: z.dict(z.boolean()).default({}),
viewersEnabled: z.dict(z.boolean()).default({}), viewersEnabled: z.dict(z.boolean()).default({}),
// Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any
// descriptor id may carry any JSON-serializable values. This is the
// "settings seam" opening — without it the seam would drop third-party
// keys as unknown schema fields.
pluginSettings: z.dict(z.dict(z.any())).default({}),
}) })
+37 -4
View File
@@ -16,23 +16,56 @@
* - slots: the client runtime SlotRegistry * - slots: the client runtime SlotRegistry
* - effect: the DSH-vendored cordis lifecycle helper * - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file. * Drift from upstream is contained to this file.
*
* This file must stay FREE of Node.js types (`node:http`, `node:stream`,
* `Buffer`): it is part of the CLIENT-reachable declaration graph (the
* `Context` in `TabComponentProps` and the `declare module` augmentation),
* so a Node import here would leak into browser-only consumer builds. The
* webServer faces below are therefore structural mirrors with plain
* interfaces (the host casts to real Node types at the few boundaries that
* need them — e.g. the `ws` upgrade hook in src/index.ts).
*/ */
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { BetterSidebarService } from './client/service.ts' import type { BetterSidebarService } from './client/service.ts'
/** The request face route handlers see (structural subset of node's
* IncomingMessage: the URL/method/header reads and the async body
* iteration `readJsonBody` uses). */
export interface SidebarHttpRequest {
url?: string
method?: string
headers: Record<string, string | string[] | undefined>
[Symbol.asyncIterator](): AsyncIterator<string | Uint8Array>
}
/** The response face route handlers write to (structural subset of node's
* ServerResponse: the status/header/body writes the routes use). */
export interface SidebarHttpResponse {
statusCode: number
writeHead(status: number, headers?: Record<string, string>): void
end(body?: string | Uint8Array): void
}
/** The upgrade socket face (structural subset: the destroy the fences use). */
export interface SidebarUpgradeSocket {
destroy(): void
}
/** The upgrade head bytes (Buffer at runtime; typed as bytes so no Node
* global leaks into the declaration graph). */
export type SidebarUpgradeHead = Uint8Array
/** One named webserver route (mirror of the host-webserver WebRoute). */ /** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute { export interface SidebarWebRoute {
kind: 'exact' | 'prefix' kind: 'exact' | 'prefix'
path: string path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> handler: (req: SidebarHttpRequest, res: SidebarHttpResponse) => void | Promise<void>
} }
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */ /** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
export interface SidebarWebUpgradeRoute { export interface SidebarWebUpgradeRoute {
path: string path: string
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void> handler: (req: SidebarHttpRequest, socket: SidebarUpgradeSocket, head: SidebarUpgradeHead) => void | Promise<void>
} }
/** The webServer service face this plugin uses. */ /** The webServer service face this plugin uses. */
+9 -6
View File
@@ -15,8 +15,9 @@
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, isAbsolute, join } from 'node:path' import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http' import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import { WebSocket, WebSocketServer } from 'ws' import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts' import type { Context, SidebarHttpRequest } from './context-types.ts'
import { import {
Config, Config,
PrefsSchema, PrefsSchema,
@@ -436,7 +437,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ensureSpawnHelper() ensureSpawnHelper()
const resolved = resolveSidebarConfig(config) const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx) const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts) const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// The agent-owned terminal registry: parallel to the UI-tab ptyManager, // The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
@@ -669,7 +670,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy() socket.destroy()
return return
} }
wss.handleUpgrade(req, socket, head, (ws) => { // The structural request/socket/head faces satisfy the shared fence;
// the `ws` package wants the real Node types — cast at this boundary.
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved) void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
}) })
}, },
@@ -691,7 +694,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy() socket.destroy()
return return
} }
agentListWss.handleUpgrade(req, socket, head, (ws) => { agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req) void attachAgentList(agentPtyRegistry, ws, req)
}) })
}, },
@@ -710,7 +713,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
async function attachAgentList( async function attachAgentList(
registry: AgentPtyRegistry, registry: AgentPtyRegistry,
ws: WebSocket, ws: WebSocket,
req: IncomingMessage, req: SidebarHttpRequest,
): Promise<void> { ): Promise<void> {
try { try {
const url = new URL(req.url ?? '/', 'http://dsh.internal') const url = new URL(req.url ?? '/', 'http://dsh.internal')
@@ -750,7 +753,7 @@ async function attachTerminal(
ptyManager: PtyManager, ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry, agentPtyRegistry: AgentPtyRegistry,
ws: WebSocket, ws: WebSocket,
req: IncomingMessage, req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig, resolved: ResolvedSidebarConfig,
): Promise<void> { ): Promise<void> {
try { try {
+10
View File
@@ -109,6 +109,15 @@ export interface SidebarPrefs {
* next matching viewer (or the download button when none match). * next matching viewer (or the download button when none match).
*/ */
viewersEnabled: Record<string, boolean> viewersEnabled: Record<string, boolean>
/**
* Plugin-owned settings blobs (v0.12.0+), keyed by descriptor id: each
* registered tab/viewer that declares `settings.pluginToggles` (or writes
* through `settings.render`'s `updatePluginSetting`) persists its values
* here — an open map, so third-party keys need no host PrefsSchema field.
* Values are JSON-serializable (the row controls produce strings /
* numbers / booleans; custom panels are responsible for their own).
*/
pluginSettings: Record<string, Record<string, unknown>>
} }
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */ /** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -138,6 +147,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
browserInterceptLinks: true, browserInterceptLinks: true,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {},
} }
/** Clamp one width percent into the contract range (shared by schema and client reads). */ /** Clamp one width percent into the contract range (shared by schema and client reads). */
+8 -6
View File
@@ -4,7 +4,7 @@
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}` * `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
* (HTTP 4xx/5xx matching the code) on failure. * (HTTP 4xx/5xx matching the code) on failure.
*/ */
import type { IncomingMessage, ServerResponse } from 'node:http' import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** Machine-readable error codes of the sidebar API. */ /** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode = export type SidebarErrorCode =
@@ -41,11 +41,13 @@ export interface SidebarOk<T> { ok: true; value: T }
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } } export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
/** Read and parse the JSON request body (bounded; malformed → bad-request). */ /** Read and parse the JSON request body (bounded; malformed → bad-request). */
export async function readJsonBody(req: IncomingMessage): Promise<unknown> { export async function readJsonBody(req: SidebarHttpRequest): Promise<unknown> {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let total = 0 let total = 0
for await (const chunk of req) { for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk // The structural request yields string | Uint8Array; Buffer.from accepts
// both (and the real runtime chunks are node Buffers anyway).
const buffer = Buffer.from(chunk)
total += buffer.length total += buffer.length
if (total > MAX_BODY_BYTES) { if (total > MAX_BODY_BYTES) {
throw new SidebarError('bad-request', 'request body too large') throw new SidebarError('bad-request', 'request body too large')
@@ -62,19 +64,19 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
} }
/** Write a JSON response with the given status. */ /** Write a JSON response with the given status. */
export function writeJson(res: ServerResponse, status: number, body: unknown): void { export function writeJson(res: SidebarHttpResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body) const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(payload) res.end(payload)
} }
/** Write the success envelope. */ /** Write the success envelope. */
export function writeOk(res: ServerResponse, value: unknown): void { export function writeOk(res: SidebarHttpResponse, value: unknown): void {
writeJson(res, 200, { ok: true, value }) writeJson(res, 200, { ok: true, value })
} }
/** Write the failure envelope for any thrown value (unknown → internal 500). */ /** Write the failure envelope for any thrown value (unknown → internal 500). */
export function writeError(res: ServerResponse, error: unknown): void { export function writeError(res: SidebarHttpResponse, error: unknown): void {
if (error instanceof SidebarError) { if (error instanceof SidebarError) {
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } }) writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
return return
+67
View File
@@ -0,0 +1,67 @@
/**
* Public API surface guard (v0.12.0+):
* - The CLIENT-REACHABLE source graph stays free of Node.js types: the
* shipped `Context` (TabComponentProps / the cordis augmentation) must
* not leak `node:*` imports or the `Buffer` global into browser-only
* consumer builds. context-types.ts is the shared file — a regression
* here breaks external consumers with `skipLibCheck: false`.
* - The version/features constants exist and are consistent (the detailed
* package.json lockstep assertion lives in service.spec.ts).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
const ROOT = resolve(import.meta.dirname, '..')
/** The client-reachable source files (the declaration graph consumers pull). */
function clientGraphFiles(): string[] {
const files: string[] = []
const walk = (dir: string): void => {
for (const name of readdirSync(dir)) {
const path = join(dir, name)
if (statSync(path).isDirectory()) {
if (name !== 'chunks') walk(path)
} else if (/\.(ts|tsx)$/.test(name)) {
files.push(path)
}
}
}
walk(join(ROOT, 'src', 'client'))
for (const extra of ['src/context-types.ts', 'src/html-route.ts', 'src/prefs-shared.ts']) {
files.push(join(ROOT, extra))
}
return files
}
describe('public API surface (v0.12.0)', () => {
it('the client-reachable graph imports no node:* modules', () => {
const offenders = clientGraphFiles().filter(path => {
const text = readFileSync(path, 'utf8')
return /from\s+['"]node:/.test(text)
})
expect(offenders).toEqual([])
})
it('the client-reachable graph never references the Buffer global', () => {
const offenders = clientGraphFiles().filter(path => {
const text = readFileSync(path, 'utf8')
return text.split('\n').some(line =>
/\bBuffer\b/.test(line)
&& !/^\s*\*/.test(line) // doc comments
&& !/^\s*\/\//.test(line) // line comments
&& !/\/\*/.test(line), // block comment openers
)
})
expect(offenders).toEqual([])
})
it('advertises the capability list and version (values come from service.ts)', () => {
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(SIDEBAR_FEATURES.length).toBeGreaterThanOrEqual(8)
for (const feature of ['badge', 'tabLifecycle', 'updateTab', 'openFile', 'targetedOpen', 'stateSubscription', 'tabMeta', 'pluginSettings']) {
expect(SIDEBAR_FEATURES).toContain(feature)
}
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* Consumer-facing type surface compile gate (v0.12.0+): this file exercises
* EVERY public type and descriptor field exactly the way an external plugin
* would, so `pnpm typecheck` fails the moment the shipped declaration
* surface (service.ts re-exports / descriptor fields / service methods)
* drifts from what consumers can name. Type-only — erased at runtime, never
* executed by vitest (no `*.spec` suffix).
*
* Mirrors the "external consumer" fixture: what is importable here from
* `../src/client/service.ts` must also be importable from
* `dsh-better-sidebar/client/service` in the built package.
*/
import type {} from '../src/client/service.ts'
import type {
BetterSidebarService,
FileFetchStrategy,
FileViewerDescriptor,
FileViewerProps,
OpenTabSeed,
SidebarSettingsDeclaration,
SidebarSettingsRenderProps,
SidebarSettingToggle,
SidebarSettingToggleType,
TabComponentProps,
TabDescriptor,
} from '../src/client/service.ts'
import type {
SessionScope,
SidebarDiffRef,
SidebarPrefs,
SidebarSnapshot,
SidebarState,
SidebarStore,
SidebarTab,
TabType,
} from '../src/client/service.ts'
/** A full-featured external tab descriptor using every v0.12.0 field. */
const tab: TabDescriptor = {
id: 'my-plugin:db',
title: () => 'Database',
icon: (size: number) => null,
order: 50,
hidden: false,
available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null,
single: false,
dedupeKey: (t: SidebarTab) => t.id,
createTab: (state: SidebarState) => ({
tab: { id: `my-plugin:db:${state.nextTerminal}`, type: 'my-plugin:db', title: 'DB', meta: { n: state.nextTerminal } },
patch: { nextTerminal: state.nextTerminal + 1 },
}),
badge: (ctx, scope, state) => (state.expanded.length > 0 ? state.expanded.length : null),
onOpen: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
onActivate: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
onClose: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
settings: {
toggles: [{ key: 'autoOpenSubagent', title: 'Auto-open', type: 'switch' }],
pluginToggles: [{
key: 'pageSize',
title: 'Page size',
type: 'number',
min: 1,
max: 100,
unit: 'rows',
}],
render: (props: SidebarSettingsRenderProps) => {
props.updatePluginSetting('refresh', true)
props.close()
return null
},
},
component: (props: TabComponentProps) => {
const { ctx, store, scope, tab, visible } = props
void ctx; void store; void scope; void tab; void visible
return null
},
}
/** A full-featured external viewer using the v0.12.0 load signal. */
const viewer: FileViewerDescriptor = {
id: 'my-plugin:csv',
title: 'CSV',
exts: ['csv'],
priority: 10,
fetchStrategy: 'custom',
detect: (_path, head: Uint8Array) => head.length > 0,
load: async (path: string, scope: SessionScope, signal?: AbortSignal) => {
void path; void scope; void signal
return { rows: [] }
},
settings: { pluginToggles: [{ key: 'delimiter', title: 'Delimiter', type: 'text', placeholder: ',' }] },
component: (props: FileViewerProps) => {
const { customData, content, truncated, mediaUrl, viewerId, path, title } = props
void customData; void content; void truncated; void mediaUrl; void viewerId; void path; void title
return null
},
}
/** The full service surface, exercised exactly as consumers call it. */
declare const ctx: { betterSidebar: BetterSidebarService }
const service: BetterSidebarService = ctx.betterSidebar
service.registerTab(tab)
service.registerFileViewer(viewer)
service.getTabs()
service.getFileViewers()
service.getTab('my-plugin:db')
service.isTabEnabled('my-plugin:db')
service.isViewerEnabled('my-plugin:csv')
service.matchFileViewer('a.csv', new Uint8Array([1]))
service.closeTab('tab:1')
service.subscribe(() => {})
const seed: OpenTabSeed = { type: 'my-plugin:db', title: 'DB', path: '/p', id: 'x', meta: { a: 1 } }
service.openTab(seed)
service.openTab(seed, { sessionId: 's1', cwd: '/p' })
service.version
service.features.includes('badge')
const snapshot: SidebarSnapshot | undefined = service.getSnapshot()
void snapshot
service.subscribeState(() => {})
service.updateTab('tab:1', { title: 'T', path: '/p', meta: 1 })
service.activateTab('tab:1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
/** Named state vocabulary stays importable (the pre-0.12 gap). */
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
const store: SidebarStore = null as unknown as SidebarStore
const toggleType: SidebarSettingToggleType = 'number'
const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType }
const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] }
const typeName: TabType = 'my-plugin:db'
const strategy: FileFetchStrategy = 'mediaUrl'
void diff; void prefs; void store; void declaration; void typeName; void strategy
+1 -1
View File
@@ -63,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
}) })
}) })
+254 -2
View File
@@ -20,8 +20,8 @@ if (g.localStorage === undefined) {
} }
} }
import { createBetterSidebarService } from '../src/client/service.ts' import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts' import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => { describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => { it('registerTab adds to the registry and dispose removes it', () => {
@@ -597,3 +597,255 @@ describe('service.openTab auto-expand for content opens', () => {
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1) expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
}) })
}) })
describe('version and feature detection (v0.12.0)', () => {
it('reports the plugin version in lockstep with package.json', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = JSON.parse(require('node:fs').readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
expect(SIDEBAR_SERVICE_VERSION).toBe(pkg.version)
expect(createBetterSidebarService(createSidebarStore()).version).toBe(SIDEBAR_SERVICE_VERSION)
})
it('advertises every v0.12.0 capability in the features list', () => {
const service = createBetterSidebarService(createSidebarStore())
for (const feature of SIDEBAR_FEATURES) {
expect(service.features).toContain(feature)
}
})
})
describe('state subscription (v0.12.0)', () => {
it('getSnapshot mirrors the store snapshot (sessionId/state/prefs)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
store.setSession('s1')
const snapshot = service.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(snapshot.state).toBeDefined()
expect(snapshot.prefs.openByDefault).toBe(true)
})
it('subscribeState fires on state changes but NOT on registry changes', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
store.setSession('s1')
let calls = 0
const unsub = service.subscribeState(() => { calls++ })
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(calls).toBe(1)
service.registerTab({ id: 'x', title: 'X', component: () => null })
expect(calls).toBe(1) // registry changes are the registry subscription's job
unsub()
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(calls).toBe(1)
})
})
describe('updateTab (v0.12.0)', () => {
it('patches title / path / meta of an open tab', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'doc', title: 'Doc', component: () => null })
store.setSession('s1')
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1' })
service.updateTab('doc:1', { title: 'Compiling…', meta: { progress: 42 } })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
expect(tab.title).toBe('Compiling…')
expect(tab.meta).toEqual({ progress: 42 })
// A missing tab id is a no-op (does not throw).
service.updateTab('doc:missing', { title: 'X' })
})
})
describe('activateTab (v0.12.0)', () => {
it('activates a tab in either tree and fires onActivate with the session scope', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const seen: Array<{ tab: string; sessionId: string }> = []
service.registerTab({
id: 'git',
title: 'Git',
single: true,
onActivate: (tab, scope) => { seen.push({ tab: tab.id, sessionId: scope.sessionId }) },
component: () => null,
})
store.setSession('s1')
// Land in the bottom tree by switching the active pane.
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'git', title: 'Git' })
const gitTab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
expect(gitTab).toBeDefined()
service.activateTab(gitTab.id)
expect(seen).toEqual([{ tab: gitTab.id, sessionId: 's1' }])
// The active pane followed the tab into the bottom tree.
expect(store.getSnapshot().state!.activePane).toBe(gitTab.id === '' ? null : allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
})
})
describe('targeted openTab (v0.12.0)', () => {
it('lands the open in the target session without switching the UI session', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
// The UI snapshot still shows s1, untouched (its default explorer tab
// is the only one — no notes tab landed there).
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(allLeaves(snapshot.state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
// Switching to s2 reveals the tab.
store.setSession('s2')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs.map(t => t.id)).toContain('notes:1')
})
it('a scope naming the active session behaves exactly like a plain open (notify included)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
let calls = 0
store.subscribe(() => { calls++ })
service.openTab({ type: 'explorer', title: 'Explorer' }, { sessionId: 's1' })
expect(calls).toBe(1)
expect(store.getSnapshot().state!.splits).not.toBe(undefined)
})
it('dedupe runs against the TARGET session (opens there focus an existing tab of that session)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
store.setSession('s2')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
expect(tabs).toHaveLength(1)
})
})
describe('openFile (v0.12.0)', () => {
it('opens the file in the editor tab of the scope session with a basename title', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
store.setSession('s1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
expect(tab?.title).toBe('main.ts')
expect(tab?.path).toBe('/p/src/main.ts')
// Windows separators are handled too.
service.openFile({ sessionId: 's1' }, 'C:\\x\\y\\spec.ts', 'custom title')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
expect(tabs[tabs.length - 1]?.title).toBe('custom title')
expect(tabs[tabs.length - 1]?.path).toBe('C:\\x\\y\\spec.ts')
})
})
describe('tab lifecycle callbacks (v0.12.0)', () => {
/** A descriptor with recording callbacks, plus a count of onOpen/onActivate/onClose. */
const setup = () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const events: string[] = []
service.registerTab({
id: 'life',
title: 'Life',
single: true,
onOpen: () => { events.push('open') },
onActivate: () => { events.push('activate') },
onClose: () => { events.push('close') },
component: () => null,
})
return { store, service, events }
}
it('onOpen fires on creation; a dedupe-focus fires onActivate instead', () => {
const { store, service, events } = setup()
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual(['open'])
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual(['open', 'activate'])
})
it('onClose fires when closeTab closes the tab', () => {
const { store, service, events } = setup()
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
events.length = 0
service.closeTab(tab.id)
expect(events).toEqual(['close'])
// Closing a missing tab fires nothing.
events.length = 0
service.closeTab('nope')
expect(events).toEqual([])
})
it('lifecycle callbacks receive the session scope (cwd included when given)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
let seen: { sessionId: string; cwd?: string } | undefined
service.registerTab({
id: 'scoped',
title: 'Scoped',
onOpen: (_tab, scope) => { seen = scope },
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'scoped', title: 'Scoped' }, { sessionId: 's1', cwd: '/work' })
expect(seen).toEqual({ sessionId: 's1', cwd: '/work' })
})
it('a throwing callback is swallowed and never breaks the open', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'boom',
title: 'Boom',
onOpen: () => { throw new Error('plugin bug') },
onClose: () => { throw new Error('plugin bug') },
component: () => null,
})
store.setSession('s1')
expect(() => service.openTab({ type: 'boom', title: 'Boom' })).not.toThrow()
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
expect(() => service.closeTab(tab.id)).not.toThrow()
})
it('a disabled tab type still refuses to open and fires no callbacks', () => {
const { store, service, events } = setup()
store.setSession('s1')
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { life: false } })
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual([])
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
})
})
describe('tab meta (v0.12.0)', () => {
it('a seed meta rides onto the minted tab and survives a reload round-trip', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'db', title: 'DB', component: () => null })
store.setSession('s1')
service.openTab({ type: 'db', title: 'DB', id: 'db:1', meta: { table: 'users', page: 3 } })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(tab.meta).toEqual({ table: 'users', page: 3 })
// Reload round-trip: the persisted shape sanitizes back with meta intact.
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
const restored = allLeaves(sanitized!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(restored.meta).toEqual({ table: 'users', page: 3 })
})
it('older persisted tabs (no meta) sanitize unchanged', () => {
const state = makeDefaultState(400, true, true)
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state)))
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
expect(tabs[0]?.meta).toBeUndefined()
})
})
+2
View File
@@ -527,6 +527,8 @@ describe('side card settings routes', () => {
// The enable-switch maps default to {} (everything on). // The enable-switch maps default to {} (everything on).
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
// The plugin-owned settings map defaults to {} too.
pluginSettings: {},
}, },
revision: 0, revision: 0,
}) })
+69 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts' import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
@@ -1169,6 +1169,7 @@ describe('side card preferences', () => {
browserInterceptLinks: true, browserInterceptLinks: true,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {},
}) })
}) })
@@ -1190,6 +1191,7 @@ describe('side card preferences', () => {
browserInterceptLinks: true, browserInterceptLinks: true,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {},
}) })
}) })
@@ -1211,6 +1213,7 @@ describe('side card preferences', () => {
browserInterceptLinks: true, browserInterceptLinks: true,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {},
}) })
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true) .toBe(true)
@@ -1271,9 +1274,9 @@ describe('side card preferences', () => {
const store = createSidebarStore() const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT, // Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference. // while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession('fresh-session') store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
const snapshot = store.getSnapshot() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false) expect(snapshot.state?.panelOpen).toBe(false)
@@ -1309,7 +1312,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => { it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore() const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
store.setSession('no-explorer') store.setSession('no-explorer')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1317,7 +1320,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf') expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again. // Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore() const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setSession('with-explorer') openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
@@ -1647,3 +1650,64 @@ describe('open-path interception wiring', () => {
expect(ctx.workspaces.openPath).toBe(original) expect(ctx.workspaces.openPath).toBe(original)
}) })
}) })
describe('v0.12.0 store additions', () => {
// These blocks exercise store reduce/reduceFor (which schedule the
// localStorage persist through window timers) and sanitizeState (which
// reads window.innerHeight). Stub the browser globals ONLY inside this
// scope so the earlier describes keep their window-less environment.
beforeEach(() => {
const g = globalThis as Record<string, unknown>
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
g.localStorage = { getItem: () => null, setItem: () => {} }
})
afterEach(() => {
const g = globalThis as Record<string, unknown>
delete g.window
delete g.localStorage
})
describe('store.reduceFor (targeted opens, v0.12.0)', () => {
it('mutates the target session, persists it, and leaves the active snapshot untouched', () => {
const store = createSidebarStore()
store.setSession('s1')
let calls = 0
store.subscribe(() => { calls++ })
store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] }))
// No notify, no snapshot switch.
expect(calls).toBe(0)
expect(store.getSnapshot().sessionId).toBe('s1')
// The target session's state updated and loads back on switch.
store.setSession('s2')
expect(store.getSnapshot().state?.expanded).toEqual(['/x'])
})
it('loads a fresh state for a never-visited target session', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false }))
store.setSession('brand-new')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
expect(store.getSnapshot().state?.splits).toBeDefined()
})
})
describe('tab meta persistence (v0.12.0)', () => {
it('sanitizeState carries plugin meta through a reload round-trip', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduce(s => ({
...s,
splits: {
kind: 'leaf' as const,
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }],
active: 'tab:1',
},
}))
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs)
expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 })
})
})
})