feat(settings): 侧边栏设置声明式化 — 注册表驱动的开关清单 + 内置支持插头化
- SidebarPrefs 新增 tabsEnabled / viewersEnabled 开放 map(缺省 = 启用), PrefsSchema 用 z.dict 校验,旧文档零迁移 - TabDescriptor/FileViewerDescriptor 新增声明式设置面:viewer 补 title/icon, 两者补 settings.toggles(子代理声明 autoOpenSubagent、终端声明 agentTerminalTools) - 服务新增 isTabEnabled/isViewerEnabled;openTab 拒绝禁用类型、matchFileViewer 跳过禁用 viewer - SideCardSection 重写为注册表驱动清单:侧边栏内容(图标+类型id+开关+嵌套开关) + 文件预览(图标+标题+扩展名+开关),开关持久化 + 乐观提交回滚 - 消费点 gating:+ 菜单过滤、子代理自动展开、agent 终端 reconcile、 产出文件拦截(editor 关闭时回落默认行)、新会话 explorer 预置 - 内置 viewer 补 6 个手绘 outline 图标;store 快照携带 prefs 使开关变更即时生效 - 测试:service gating / builtins 声明 / parsePrefs map 校验 / schema 断言 / 新增 SideCardSection renderToString 规格 - 文档:README 设置小节、AGENTS.md 描述符与服务方法、新增设计文档;版本 0.5.0
This commit is contained in:
@@ -117,6 +117,19 @@ interface TabDescriptor {
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.5.0+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
@@ -211,6 +224,10 @@ ctx.effect(() =>
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.5.0+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.5.0+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
@@ -221,6 +238,8 @@ interface FileViewerDescriptor {
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.5.0+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
@@ -325,18 +344,23 @@ interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用) */
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照 */
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts) */
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.5.0+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.5.0+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
@@ -346,6 +370,8 @@ interface BetterSidebarService {
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.5.0+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里(图标 + 标题 + 类型 id + 开关,viewer 行还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的嵌套开关(如子代理的 `autoOpenSubagent`)仅父级启用时显示;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生命周期与 HMR
|
||||
@@ -452,8 +478,9 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档(含实施偏差记录)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -154,8 +154,11 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
|
||||
- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项显示**类型 id + 图标 + 开关**,可直接打开/关闭:
|
||||
- **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开);子代理的「检测到子代理时自动展开子代理页面」、终端的「为模型注入终端工具」作为**相关设置**嵌套在其行下(父级关闭时隐藏)
|
||||
- **文件预览**:图片 / PDF / Word / Excel / PPT / Markdown / 代码 / 二进制下载 等查看器,行内显示其覆盖的扩展名。关闭某查看器后文件预览落到下一个匹配的查看器(关闭兜底的代码查看器后无法预览的文件显示下载按钮)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 侧边栏设置声明式化 + 内置支持"插头化"设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.5.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
|
||||
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
|
||||
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
|
||||
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
|
||||
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
|
||||
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
|
||||
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
|
||||
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
|
||||
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
|
||||
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
openByDefault: boolean
|
||||
defaultWidthPercent: number
|
||||
autoOpenSubagent: boolean
|
||||
agentTerminalTools: boolean
|
||||
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
|
||||
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
|
||||
}
|
||||
```
|
||||
|
||||
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
|
||||
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
|
||||
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
|
||||
|
||||
### 4.2 声明式描述符(`client/service.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarSettingToggle {
|
||||
key: string // SidebarPrefs 字段名('autoOpenSubagent')
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
interface SidebarSettingsDeclaration {
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
// TabDescriptor 增加:
|
||||
settings?: SidebarSettingsDeclaration
|
||||
|
||||
// FileViewerDescriptor 增加(设置清单展示用):
|
||||
title?: string | (() => string) // 缺省回退 id
|
||||
icon?: ReactNode | ((size) => ReactNode)
|
||||
settings?: SidebarSettingsDeclaration
|
||||
```
|
||||
|
||||
服务新增:
|
||||
```ts
|
||||
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
|
||||
isViewerEnabled(id: string): boolean
|
||||
```
|
||||
|
||||
行为变化:
|
||||
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
|
||||
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
|
||||
|
||||
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
|
||||
|
||||
| tab | 相关设置声明 |
|
||||
|---|---|
|
||||
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
|
||||
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
|
||||
| explorer/git/editor/diff | 无(仅开关) |
|
||||
|
||||
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
|
||||
|
||||
### 4.4 Store 快照携带 prefs(`client/state.ts`)
|
||||
|
||||
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
|
||||
|
||||
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
|
||||
|
||||
### 4.5 消费点 gating
|
||||
|
||||
| 消费点 | 行为 |
|
||||
|---|---|
|
||||
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
|
||||
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
|
||||
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
|
||||
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
|
||||
| `openTab` / `matchFileViewer` | 见 §4.2 |
|
||||
| 新会话默认布局 | explorer 禁用 → 空 pane |
|
||||
|
||||
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
|
||||
|
||||
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
|
||||
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
|
||||
- 行结构:
|
||||
- **常规**:`openByDefault`、宽度(既有逻辑)。
|
||||
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
|
||||
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
|
||||
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
|
||||
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
|
||||
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
|
||||
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
|
||||
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
|
||||
|
||||
## 6. 限制与失败模式
|
||||
|
||||
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
|
||||
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
|
||||
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
|
||||
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
|
||||
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
|
||||
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
|
||||
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -25,6 +25,43 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Nested declarative toggle (a tab's related setting): indented child row. */
|
||||
.subRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0 10px 28px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inventory section heading (侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 18px 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Icon of one inventory row, inline with the title. */
|
||||
.rowIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Title with the icon beside it. */
|
||||
.titleLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+159
-52
@@ -1,20 +1,30 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Three rows:
|
||||
* - new conversations open the panel by default (native checkbox),
|
||||
* - the default panel width as a percent of the window (number input + %),
|
||||
* - auto-open the Subagent page when a new subagent appears (native checkbox,
|
||||
* on by default).
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (native checkbox),
|
||||
* and the default panel width as a percent of the window (number input).
|
||||
* - 侧边栏内容: one row per REGISTERED tab type (built-ins and external
|
||||
* plugins alike) — icon + title + type id + an on/off switch persisted in
|
||||
* `prefs.tabsEnabled[id]`. A tab's `settings.toggles` declaration renders
|
||||
* as nested switches under its row while the tab is enabled (e.g. the
|
||||
* Subagent page's "auto-open when a subagent appears").
|
||||
* - 文件预览: one row per REGISTERED file viewer — icon + title + the
|
||||
* extensions it covers + an on/off switch persisted in
|
||||
* `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
* new values and the sidebar's consumption points (the + menu, derived
|
||||
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
@@ -29,11 +39,18 @@ import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
@@ -47,16 +64,54 @@ function messageOf(error: unknown): string {
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value. */
|
||||
function textOf(value: string | (() => string) | undefined): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
/** Resolve a descriptor icon (ReactNode or size function). */
|
||||
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
||||
if (icon === undefined) return null
|
||||
return typeof icon === 'function' ? icon(size) : icon
|
||||
}
|
||||
|
||||
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
||||
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
}
|
||||
|
||||
/** Read one boolean pref by declarative key (missing = false). */
|
||||
function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
return (prefs as unknown as Record<string, boolean>)[key] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
// load/unload — so a plain effect is enough; no external-store ceremony).
|
||||
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
||||
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
||||
useEffect(() => service.subscribe(() => {
|
||||
setTabs([...service.getTabs()].sort(tabOrder))
|
||||
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
||||
}), [service])
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
@@ -84,7 +139,7 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
@@ -114,25 +169,31 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
togglePref({ openByDefault: next })
|
||||
}
|
||||
|
||||
const onToggleSubagent = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, autoOpenSubagent: next })
|
||||
setError(null)
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
const onToggleTools = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, agentTerminalTools: next })
|
||||
setError(null)
|
||||
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
@@ -149,6 +210,34 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/** One enable/disable row: icon + title + desc + checkbox. */
|
||||
const renderRow = (props: {
|
||||
title: string
|
||||
desc: string
|
||||
icon?: ReactNode
|
||||
checked: boolean
|
||||
label: string
|
||||
onToggle: (next: boolean) => void
|
||||
sub?: boolean
|
||||
}) => (
|
||||
<label className={props.sub === true ? css.subRow : css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.titleLine}>
|
||||
{props.icon !== null && props.icon !== undefined && <span className={css.rowIcon}>{props.icon}</span>}
|
||||
<span className={css.title}>{props.title}</span>
|
||||
</span>
|
||||
<span className={css.desc}>{props.desc}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={props.checked}
|
||||
aria-label={props.label}
|
||||
onChange={event => { props.onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
@@ -187,32 +276,50 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsSubagentTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.autoOpenSubagent}
|
||||
aria-label={t('settingsSubagentTitle')}
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsToolsTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsToolsDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.agentTerminalTools}
|
||||
aria-label={t('settingsToolsTitle')}
|
||||
onChange={event => { onToggleTools(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 侧边栏内容: one row per registered tab type, icon + type id + switch,
|
||||
plus each tab's declaratively-declared nested toggles (shown only
|
||||
while the tab itself is enabled). */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderRow({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
checked: prefs.tabsEnabled[tab.id] !== false,
|
||||
label: textOf(tab.title),
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
})}
|
||||
{prefs.tabsEnabled[tab.id] !== false && (tab.settings?.toggles ?? []).map(toggle => (
|
||||
<Fragment key={`${tab.id}:${toggle.key}`}>
|
||||
{renderRow({
|
||||
title: textOf(toggle.title),
|
||||
desc: textOf(toggle.desc),
|
||||
checked: prefBool(prefs, toggle.key),
|
||||
label: textOf(toggle.title),
|
||||
onToggle: (next) => { onToggleSetting(toggle, next) },
|
||||
sub: true,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 文件预览: one row per registered file viewer, icon + title + exts + switch. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderRow({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
checked: prefs.viewersEnabled[viewer.id] !== false,
|
||||
label: textOf(viewer.title) || viewer.id,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
+17
-9
@@ -65,12 +65,14 @@ function TabContent(props: {
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option. */
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden)
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
@@ -127,6 +129,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
@@ -145,7 +149,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => reconcileAgentTerminals(s, list))
|
||||
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
@@ -171,12 +177,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed) and the
|
||||
* "auto open" pref is on, open the panel (if collapsed) and focus the
|
||||
* Subagent page (single-instance: an existing tab is focused, never
|
||||
* duplicated). Switching to a session that already has subagents never
|
||||
* triggers — its baseline starts at the current count — so a deliberate
|
||||
* layout is never fought.
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Subagent tab type is enabled in settings,
|
||||
* open the panel (if collapsed) and focus the Subagent page
|
||||
* (single-instance: an existing tab is focused, never duplicated).
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
@@ -185,6 +192,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
@@ -80,6 +80,15 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switch renders under this row in
|
||||
// the Side card settings page (the Subagent page's own related setting).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
@@ -95,6 +104,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch renders
|
||||
// under this row in the Side card settings page (the Terminal page's
|
||||
// own related setting; the host gates the toolset on it independently).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
title: () => t('settingsToolsTitle'),
|
||||
desc: () => t('settingsToolsDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
|
||||
@@ -5,13 +5,27 @@
|
||||
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DocxView, XlsxView } from '../office-view.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { PptxView } from '../PptxView.tsx'
|
||||
import { TextEditor } from '../TextEditor.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 8 built-in file viewer descriptors. */
|
||||
@@ -19,6 +33,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
title: () => t('viewerImage'),
|
||||
icon: (size: number) => <IconImageOutline16 size={size} />,
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
@@ -29,6 +45,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: () => t('viewerPdf'),
|
||||
icon: (size: number) => <IconPdfOutline16 size={size} />,
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -37,6 +55,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -45,6 +65,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -53,6 +75,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -61,12 +85,16 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
@@ -74,6 +102,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
|
||||
@@ -26,3 +26,60 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
export const IconImageOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PDF viewer glyph: a document frame with the "PDF" label. */
|
||||
export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -149,13 +149,14 @@ export function apply(ctx: Context): void {
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
// fenced settings route, keeps the shared store in sync, and renders the
|
||||
// declarative enable/disable inventory from the tab/viewer registry.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
inject: () => ({ store: sidebarStore, service }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
|
||||
@@ -60,7 +60,13 @@ export function SidebarProducedFiles(props: {
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
// deliverables behavior instead of offering chips that cannot open.
|
||||
select: (owner) => {
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
|
||||
@@ -121,6 +121,17 @@ const zh = {
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
@@ -254,6 +265,17 @@ const en: Record<keyof typeof zh, string> = {
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
|
||||
+17
-2
@@ -21,8 +21,7 @@ export type { SidebarPrefs }
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/**
|
||||
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
@@ -44,9 +43,25 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 /
|
||||
* drops the entry — an absent key means the feature stays enabled.
|
||||
*/
|
||||
function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, boolean> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof item === 'boolean') out[key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
|
||||
+69
-3
@@ -27,6 +27,29 @@ import {
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
/** Row title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
@@ -81,6 +104,13 @@ export interface TabDescriptor {
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -114,6 +144,10 @@ export interface FileViewerProps {
|
||||
export interface FileViewerDescriptor {
|
||||
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
|
||||
id: string
|
||||
/** Display name for the settings inventory (falls back to `id` when absent). */
|
||||
title?: string | (() => string)
|
||||
/** Icon shown in the settings inventory. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
|
||||
exts: readonly string[]
|
||||
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
|
||||
@@ -126,6 +160,11 @@ export interface FileViewerDescriptor {
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -137,13 +176,25 @@ export interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** Find a file viewer for a path (priority desc; detect first, then exts). */
|
||||
/**
|
||||
* Whether a tab type is enabled in the side card prefs. An absent
|
||||
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
|
||||
* disables the type (hidden from the + menu, `openTab` refuses, and
|
||||
* derived flows gate on it).
|
||||
*/
|
||||
isTabEnabled(id: string): boolean
|
||||
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/**
|
||||
* Find a file viewer for a path (priority desc; detect first, then exts).
|
||||
* Disabled viewers are skipped, so files fall through to the next match.
|
||||
*/
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored.
|
||||
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** Close a tab by id. */
|
||||
@@ -211,6 +262,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||
|
||||
// The enable switches come from the user's side card prefs (the shared
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Single pass in priority order (descending; stable for equal
|
||||
@@ -218,10 +274,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// its own turn: `detect` (when head bytes are available) beats its own
|
||||
// `exts`, and `exts: []` is a catch-all matching any path — so the
|
||||
// catch-all `code` viewer (-100) only sees paths no higher-priority
|
||||
// descriptor claimed.
|
||||
// descriptor claimed. Disabled viewers are skipped entirely.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (!isViewerEnabled(v.id)) continue
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
@@ -241,6 +298,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
if (!isTabEnabled(seed.type)) {
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
@@ -278,6 +342,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
getTabs,
|
||||
getFileViewers,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
|
||||
+36
-13
@@ -114,11 +114,16 @@ function maxCounterId(parsed: unknown): number {
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
return {
|
||||
panelOpen,
|
||||
width,
|
||||
@@ -510,6 +515,12 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||||
export interface SidebarSnapshot {
|
||||
sessionId: string | undefined
|
||||
state: SidebarState | undefined
|
||||
/**
|
||||
* The current side card prefs. Carried IN the snapshot (not a separate
|
||||
* subscription) so prefs changes re-render the consumers that gate on
|
||||
* them — the + menu hides a tab type the moment its switch flips.
|
||||
*/
|
||||
prefs: SidebarPrefs
|
||||
}
|
||||
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
@@ -535,13 +546,14 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), and the
|
||||
// panel starts open only when the preference says so.
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,15 +671,26 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
/** The session-scoped store: one state per conversation, localStorage-backed. */
|
||||
export class SidebarStore {
|
||||
private readonly bySession = new Map<string, SidebarState>()
|
||||
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
|
||||
private snapshot: SidebarSnapshot = {
|
||||
sessionId: undefined,
|
||||
state: undefined,
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
/**
|
||||
* Replace the side card prefs (the settings RPC result / settings page
|
||||
* write). Notifies like any store change: the snapshot carries the prefs,
|
||||
* so consumers that gate on enable switches (the + menu, derived flows)
|
||||
* re-render with the new values immediately.
|
||||
*/
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
this.snapshot = { ...this.snapshot, prefs: this.prefs }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
@@ -679,7 +702,7 @@ export class SidebarStore {
|
||||
setSession(sessionId: string | undefined): void {
|
||||
if (this.snapshot.sessionId === sessionId) return
|
||||
if (sessionId === undefined) {
|
||||
this.snapshot = { sessionId: undefined, state: undefined }
|
||||
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
@@ -691,7 +714,7 @@ export class SidebarStore {
|
||||
// pane/split ids can never collide with its tree.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
this.snapshot = { sessionId, state }
|
||||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
@@ -713,7 +736,7 @@ export class SidebarStore {
|
||||
const draft = structuredClone(state)
|
||||
mutator(draft)
|
||||
this.bySession.set(sessionId, draft)
|
||||
this.snapshot = { sessionId, state: draft }
|
||||
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, draft)
|
||||
this.notify()
|
||||
}
|
||||
@@ -740,7 +763,7 @@ export class SidebarStore {
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next }
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
@@ -80,4 +80,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
})
|
||||
|
||||
@@ -27,6 +27,23 @@ export interface SidebarPrefs {
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
|
||||
* and derived flows like subagent auto-open / agent-terminal tabs stop).
|
||||
* Already-open tabs of a disabled type keep rendering (closing one
|
||||
* prevents reopening), matching the "existing conversations keep their
|
||||
* own layouts" rule.
|
||||
*/
|
||||
tabsEnabled: Record<string, boolean>
|
||||
/**
|
||||
* Per-viewer enable switches, keyed by file viewer descriptor id
|
||||
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
|
||||
* viewer is skipped by `matchFileViewer` so files fall through to the
|
||||
* next matching viewer (or the download button when none match).
|
||||
*/
|
||||
viewersEnabled: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
@@ -40,6 +57,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
|
||||
@@ -39,6 +39,25 @@ describe('built-in tab registrations', () => {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools related setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
|
||||
})
|
||||
|
||||
it('every built-in tab carries the settings-surface icon', () => {
|
||||
const { service } = setup()
|
||||
for (const tab of service.getTabs()) {
|
||||
expect(tab.icon, tab.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
@@ -88,6 +107,14 @@ describe('built-in file viewer registrations', () => {
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
|
||||
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
|
||||
const { service } = setup()
|
||||
for (const viewer of service.getFileViewers()) {
|
||||
expect(viewer.title, viewer.id).toBeDefined()
|
||||
expect(viewer.icon, viewer.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in disposer', () => {
|
||||
|
||||
@@ -48,12 +48,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,57 @@ describe('BetterSidebar service', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('enable switches (declarative settings)', () => {
|
||||
/** A fresh store + service with one tab and one viewer registered. */
|
||||
const setup = () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
service.registerFileViewer({ id: 'image', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
it('an absent map key means enabled (default state)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
expect(service.isViewerEnabled('image')).toBe(true)
|
||||
// Unknown ids are enabled too (nothing registered → the check is open).
|
||||
expect(service.isTabEnabled('whatever')).toBe(true)
|
||||
})
|
||||
|
||||
it('only an explicit false disables a tab type', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(false)
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: true } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
})
|
||||
|
||||
it('openTab refuses a disabled tab type (no tab lands, no createTab)', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
|
||||
const { store, service } = setup()
|
||||
service.registerFileViewer({ id: 'code', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
// image claims .png; disabling it lets the catch-all take over.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
|
||||
// Disabling the catch-all too → no viewer at all.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false, code: false } })
|
||||
expect(service.matchFileViewer('photo.png')).toBeUndefined()
|
||||
// Re-enabling restores the image viewer.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: {} })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFileViewer', () => {
|
||||
it('matches by extension', () => {
|
||||
const store = createSidebarStore()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Side card settings section render tests: the section is DECLARATIVE —
|
||||
* every row (icon, title, type id, extensions, enable switch) derives from
|
||||
* the sidebar service's tab/viewer registries instead of hardcoded copy.
|
||||
* These specs pin that derivation with a fake store + a small registry:
|
||||
* registered tabs/viewers appear with their declared icon/title/settings,
|
||||
* disabled states render unchecked, and a disabled tab hides its declared
|
||||
* nested toggles.
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'explorer',
|
||||
title: () => 'Explorer',
|
||||
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
|
||||
order: 10,
|
||||
component: () => null,
|
||||
})
|
||||
service.registerTab({
|
||||
id: 'subagent',
|
||||
title: () => 'Subagents',
|
||||
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
|
||||
order: 30,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto-open Subagents',
|
||||
desc: () => 'Expand on new subagent',
|
||||
}],
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
service.registerFileViewer({
|
||||
id: 'image',
|
||||
title: () => 'Image',
|
||||
icon: () => createElement('svg', { 'data-icon': 'image' }),
|
||||
exts: ['png', 'jpg'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: () => null,
|
||||
})
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
|
||||
return renderToString(createElement(
|
||||
SideCardSection,
|
||||
{ store, service } as unknown as SideCardSectionProps,
|
||||
))
|
||||
}
|
||||
|
||||
describe('SideCardSection declarative inventory', () => {
|
||||
it('renders one row per registered tab: icon + title + type id + switch', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="explorer"')
|
||||
expect(html).toContain('>Explorer<')
|
||||
// The type id is the row's desc (the declarative "type" surface).
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Both tabs are enabled by default: their switches render checked.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('renders the declared nested toggle under an enabled tab', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
// The subagent tab's declared related setting appears while enabled,
|
||||
// bound to its prefs key (default true → checked).
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
})
|
||||
|
||||
it('hides the nested toggles while the parent tab is disabled', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
// The parent row stays (so the user can re-enable it) but unchecked.
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders one row per registered viewer: icon + title + exts + switch', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="image"')
|
||||
expect(html).toContain('>Image<')
|
||||
// The covered extensions are the row's desc.
|
||||
expect(html).toContain('png · jpg')
|
||||
})
|
||||
|
||||
it('a disabled viewer renders unchecked', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('>Image<')
|
||||
})
|
||||
|
||||
it('a disabled tab renders unchecked (its switch reflects the prefs map)', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('>explorer<')
|
||||
})
|
||||
})
|
||||
+9
-1
@@ -502,7 +502,15 @@ describe('side card settings routes', () => {
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false },
|
||||
value: {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
},
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
|
||||
+58
-5
@@ -829,17 +829,38 @@ describe('side card preferences', () => {
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true })
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
agentTerminalTools: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
.toEqual({
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
|
||||
@@ -851,13 +872,26 @@ describe('side card preferences', () => {
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
|
||||
// A non-object map falls back to {} (everything enabled).
|
||||
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
|
||||
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
|
||||
// Non-boolean entries are dropped; boolean entries survive verbatim.
|
||||
const parsed = await loadPrefs(wire({
|
||||
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
|
||||
viewersEnabled: { image: false, code: 1 },
|
||||
}))
|
||||
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
|
||||
expect(parsed.viewersEnabled).toEqual({ image: false })
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -868,6 +902,22 @@ describe('side card preferences', () => {
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
})
|
||||
|
||||
it('derives the default width from the window percent with clamps', () => {
|
||||
expect(defaultWidthFor(1440, 30)).toBe(432)
|
||||
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
|
||||
@@ -878,6 +928,9 @@ describe('side card preferences', () => {
|
||||
expect(makeDefaultState().panelOpen).toBe(true)
|
||||
expect(makeDefaultState(400, false).panelOpen).toBe(false)
|
||||
expect(makeDefaultState(400, false).width).toBe(400)
|
||||
// The seedExplorer flag controls the default explorer tab.
|
||||
expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf')
|
||||
expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user