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:
1 parent
288a499c7f
commit
82a69546d2
28 files changed
+1512
-103
No files matched your search
@@ -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 起也有齿轮)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
Executable
+92
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user