From 89ddf8a9e7f9bc170c490e2a15cd76959ed3e886 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 14:08:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=A3=B0=E6=98=8E=E5=BC=8F=E5=8C=96=20?= =?UTF-8?q?=E2=80=94=20=E6=B3=A8=E5=86=8C=E8=A1=A8=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E5=BC=80=E5=85=B3=E6=B8=85=E5=8D=95=20+=20=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=94=AF=E6=8C=81=E6=8F=92=E5=A4=B4=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 39 +++- README.md | 7 +- ...-11-declarative-sidebar-settings-design.md | 135 +++++++++++ dsh.plugin.json | 2 +- package.json | 2 +- src/client/SideCardSection.module.css | 37 +++ src/client/SideCardSection.tsx | 211 +++++++++++++----- src/client/Sidebar.tsx | 26 ++- src/client/builtins/tabs.tsx | 19 ++ src/client/builtins/viewers.tsx | 30 +++ src/client/icons.tsx | 57 +++++ src/client/index.tsx | 5 +- src/client/intercept.tsx | 8 +- src/client/locales.ts | 22 ++ src/client/prefs.ts | 19 +- src/client/service.ts | 72 +++++- src/client/state.ts | 49 ++-- src/config.ts | 5 + src/prefs-shared.ts | 19 ++ tests/builtins.spec.ts | 27 +++ tests/plugin-shape.spec.ts | 5 +- tests/service.spec.ts | 51 +++++ tests/side-card-section.spec.tsx | 117 ++++++++++ tests/smoke.spec.ts | 10 +- tests/unit.spec.ts | 63 +++++- 25 files changed, 938 insertions(+), 99 deletions(-) create mode 100644 docs/plans/2026-08-11-declarative-sidebar-settings-design.md create mode 100644 tests/side-card-section.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index 9ca4918..49b795e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,19 @@ interface TabDescriptor { * 省略时用默认 { id, type, title } + seed 里的 path/diff。 */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | 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 + /** 声明式设置(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 的真实用法。 diff --git a/README.md b/README.md index 0b5a16f..205f8e3 100644 --- a/README.md +++ b/README.md @@ -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 终端 ## ⌨️ 快捷键速查 diff --git a/docs/plans/2026-08-11-declarative-sidebar-settings-design.md b/docs/plans/2026-08-11-declarative-sidebar-settings-design.md new file mode 100644 index 0000000..6711b70 --- /dev/null +++ b/docs/plans/2026-08-11-declarative-sidebar-settings-design.md @@ -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 // 按 tab descriptor id;缺省 = 启用 + viewersEnabled: Record // 按 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 兜底最后)。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 82ce434..d61dc8a 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -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": { diff --git a/package.json b/package.json index 2953b64..3c5d300 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 0ded885..9853932 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -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; diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 295fba4..4ba2b82 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -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)[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(() => store.getPrefs()) const [widthDraft, setWidthDraft] = useState(String(store.getPrefs().defaultWidthPercent)) const [error, setError] = useState(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(() => [...service.getTabs()].sort(tabOrder)) + const [viewers, setViewers] = useState(() => [...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(undefined) @@ -84,7 +139,7 @@ export function SideCardSection({ store }: SideCardSectionProps) { }, []) /** Persist one patch through the settings route (serialized, revision-guarded). */ - const commit = (patch: Partial): Promise<{ ok: boolean; prefs: SidebarPrefs }> => { + const commit = (patch: Record): 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): 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 + }) => ( + + ) + return (
- - + + {/* 侧边栏内容: 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). */} +
{t('settingsTabsTitle')}
+ {tabs.map(tab => ( + + {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 => ( + + {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, + })} + + ))} + + ))} + + {/* 文件预览: one row per registered file viewer, icon + title + exts + switch. */} +
{t('settingsViewersTitle')}
+ {viewers.map(viewer => ( + + {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) }, + })} + + ))} {error !== null && (
{error} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d72b3af..47f3895 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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(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]) diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 874ac4d..295d5eb 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -80,6 +80,15 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { icon: (size: number) => , 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 }) => ( , 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 diff --git a/src/client/builtins/viewers.tsx b/src/client/builtins/viewers.tsx index 0d1cbcd..dd7b5aa 100644 --- a/src/client/builtins/viewers.tsx +++ b/src/client/builtins/viewers.tsx @@ -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) => , 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) => , 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) => , 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) => , 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) => , 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) => , exts: ['md', 'markdown'], fetchStrategy: 'fsRead', component: (props) => , }, { id: 'code', + title: () => t('viewerCode'), + icon: (size: number) => , exts: [], priority: -100, fetchStrategy: 'fsRead', @@ -74,6 +102,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] { }, { id: 'binary-download', + title: () => t('viewerBinary'), + icon: (size: number) => , exts: ['doc', 'xls', 'ppt'], priority: -50, fetchStrategy: 'binary-download', diff --git a/src/client/icons.tsx b/src/client/icons.tsx index b53224e..4b6c208 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -26,3 +26,60 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => ( ) + +// ── 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) => ( + + + + + +) + +/** PDF viewer glyph: a document frame with the "PDF" label. */ +export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + +) + +/** Word viewer glyph: a document frame with a "W". */ +export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) + +/** Excel viewer glyph: a spreadsheet grid. */ +export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + +) + +/** PowerPoint viewer glyph: a chart with rising bars. */ +export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** Markdown viewer glyph: the classic "M with a down arrow" badge. */ +export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) diff --git a/src/client/index.tsx b/src/client/index.tsx index c2d63b1..0af52ee 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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) diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index 94ca887..a34cf0d 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -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) => ({ diff --git a/src/client/locales.ts b/src/client/locales.ts index f96655b..6850a31 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 3693240..c7d3d82 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -21,8 +21,7 @@ export type { SidebarPrefs } /** The settings wire face the preferences need (a subset of the plugin api). */ export type SidebarSettingsClient = Pick -/** - * 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 { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} + const out: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + 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). diff --git a/src/client/service.ts b/src/client/service.ts index 24554c0..38eae1b 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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 } | 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 + /** + * 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, diff --git a/src/client/state.ts b/src/client/state.ts index 93ec300..c6c6348 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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, reid: Map() - 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() } diff --git a/src/config.ts b/src/config.ts index 7ab2209..3b4774a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,4 +80,9 @@ export const PrefsSchema: z = 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({}), }) diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 386b60c..2b1742f 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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 + /** + * 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 } /** 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). */ diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 87c335f..84b4c6d 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -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', () => { diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 18909f6..b473251 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record })({ 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: {} }) }) }) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 49999e1..e35adff 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -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() diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx new file mode 100644 index 0000000..8c11774 --- /dev/null +++ b/tests/side-card-section.spec.tsx @@ -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<') + }) +}) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 4a11829..0f81ce8 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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, }) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 4001499..df8d379 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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) }) })