From 0e204136415baa18f1de9f45fa1cbfebb7a552f2 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 14:27:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E5=B0=8F=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E7=BD=91=E6=A0=BC=20+=20=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E8=AE=BE=E7=BD=AE=E5=8E=9F=E7=94=9F=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清单改为小卡片,置于 repeat(auto-fill, minmax(148px,1fr)) 响应式网格, 一行自适应排多个、随宽度换行;卡片 = 顶行(图标+标题省略+勾选徽标 钉在最右端)+ 类型/扩展名描述行(省略号 + title 完整值) - 声明了 settings.toggles 的卡片右下角渲染齿轮角标(IconSettingsOutline16, 仅父级启用时显示),点击打开原生 Modal 弹窗:DSH 原生设置行 (标题/描述 + 品牌强调原生复选框 + hairline 分隔) - 弹窗行体抽为 FeatureSettingsRows 供直测;Modal 条件挂载 (常闭挂载在测试 dual-react 分裂下会崩) - 全部 token 使用 DSH 原生别名(interactive-bg-active/button-primary-fill/ label-* 等);规格测试改为断言网格卡片与齿轮 - 文档(README/AGENTS.md/设计文档偏差记录 5/6)同步 --- AGENTS.md | 2 +- README.md | 5 +- ...-11-declarative-sidebar-settings-design.md | 1 + src/client/SideCardSection.module.css | 164 ++++++++++--- src/client/SideCardSection.tsx | 230 ++++++++++++------ src/client/locales.ts | 2 + tests/side-card-section.spec.tsx | 106 +++++--- 7 files changed, 363 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0bc6cc2..55d76c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -370,7 +370,7 @@ 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 丢弃。 +> **声明式设置(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 丢弃。 --- diff --git a/README.md b/README.md index 0435565..733f397 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,10 @@ dsh registry enable dsh-external/dsh-better-sidebar - 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档: - **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局) - **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30) -- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项是一张**可点击的卡片**——图标 + 类型 id + 描述,**点击整卡切换开/关,卡片高亮(品牌描边 + 勾选徽标)即代表启用**,未启用呈灰色降级态: - - **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开);子代理的「检测到子代理时自动展开子代理页面」、终端的「为模型注入终端工具」作为**相关设置**以小卡片嵌套在其行下(父级关闭时隐藏) +- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项是一张**小卡片**,在响应式网格中**一行自适应排多个**——图标 + 类型 id + 描述,**点击整卡切换开/关,卡片高亮(品牌描边 + 勾选徽标钉在卡片最右端)即代表启用**,未启用呈灰色降级态: + - **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开) - **文件预览**:图片 / PDF / Word / Excel / PPT / Markdown / 代码 / 二进制下载 等查看器,卡片上显示其覆盖的扩展名。关闭某查看器后文件预览落到下一个匹配的查看器(关闭兜底的代码查看器后无法预览的文件显示下载按钮) +- **二级设置弹窗**:声明了相关设置的卡片(子代理、终端)右下角有齿轮按钮,点击打开原生弹窗(Modal),内为 DSH 原生设置行——「检测到子代理时自动展开子代理页面」「为模型注入终端工具」等以复选框行呈现;父级卡片关闭时齿轮隐藏(相关设置随功能休眠) - **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 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 index 3e1c93c..7a11aad 100644 --- a/docs/plans/2026-08-11-declarative-sidebar-settings-design.md +++ b/docs/plans/2026-08-11-declarative-sidebar-settings-design.md @@ -134,3 +134,4 @@ isViewerEnabled(id: string): boolean 3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。 4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。 5. **开关改为卡片点击(v0.5.0 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——` - ) + /** A feature with declared related settings shows the gear corner button. */ + onOpenSettings?: () => void + }) => { + const hasSettings = props.onOpenSettings !== undefined + return ( +
+ + {hasSettings && ( + + )} +
+ ) + } return (
@@ -282,46 +348,66 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
- {/* 侧边栏内容: one card per registered tab type, icon + type id; the - card's `settings.toggles` render as nested smaller cards while the - tab itself is enabled. */} + {/* 侧边栏内容: one small card per registered tab type in a responsive + grid; features declaring `settings.toggles` open their settings in + the popup (gear corner button) instead of nested inline rows. */}
{t('settingsTabsTitle')}
- {tabs.map(tab => ( - - {renderCard({ - title: textOf(tab.title), - desc: tab.id, - icon: iconOf(tab.icon, 16), - enabled: prefs.tabsEnabled[tab.id] !== false, - onToggle: (next) => { onToggleTab(tab.id, next) }, - })} - {prefs.tabsEnabled[tab.id] !== false && (tab.settings?.toggles ?? []).map(toggle => ( - - {renderCard({ - title: textOf(toggle.title), - desc: textOf(toggle.desc), - enabled: prefBool(prefs, toggle.key), - onToggle: (next) => { onToggleSetting(toggle, next) }, - sub: true, - })} - - ))} - - ))} +
+ {tabs.map(tab => ( + + {renderCard({ + title: textOf(tab.title), + desc: tab.id, + icon: iconOf(tab.icon, 16), + enabled: prefs.tabsEnabled[tab.id] !== false, + onToggle: (next) => { onToggleTab(tab.id, next) }, + // The settings gear only while the feature is enabled: its + // related settings are dormant while the feature is off. + onOpenSettings: prefs.tabsEnabled[tab.id] !== false + && (tab.settings?.toggles?.length ?? 0) > 0 + ? () => { setSettingsFor(tab) } + : undefined, + })} + + ))} +
- {/* 文件预览: one card per registered file viewer, icon + title + exts. */} + {/* 文件预览: one small card per registered file viewer. */}
{t('settingsViewersTitle')}
- {viewers.map(viewer => ( - - {renderCard({ - title: textOf(viewer.title) || viewer.id, - desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '), - icon: iconOf(viewer.icon, 16), - enabled: prefs.viewersEnabled[viewer.id] !== false, - onToggle: (next) => { onToggleViewer(viewer.id, next) }, - })} - - ))} +
+ {viewers.map(viewer => ( + + {renderCard({ + title: textOf(viewer.title) || viewer.id, + desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '), + icon: iconOf(viewer.icon, 16), + enabled: prefs.viewersEnabled[viewer.id] !== false, + onToggle: (next) => { onToggleViewer(viewer.id, next) }, + })} + + ))} +
+ + {/* The secondary settings popup: a feature's declared related settings + as native checkbox rows (Modal chrome is the app's own). Mounted + only while a feature is open — the Modal primitive runs hooks + unconditionally, so a closed-but-mounted Modal would break SSR + (and the renderToString spec) under the test dual-react split. */} + {settingsFor !== null && ( + { setSettingsFor(null) }} + title={textOf(settingsFor.title)} + closeLabel={t('close')} + > + + + )} + {error !== null && (
{error} diff --git a/src/client/locales.ts b/src/client/locales.ts index a6140cc..9e7cb42 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -124,6 +124,7 @@ const zh = { settingsTabsTitle: '侧边栏内容', settingsViewersTitle: '文件预览', settingsGeneralTitle: '常规', + settingsPopup: '功能设置', settingsViewerCatchAll: '兜底:任意文件', viewerImage: '图片', viewerPdf: 'PDF', @@ -269,6 +270,7 @@ const en: Record = { settingsTabsTitle: 'Sidebar content', settingsViewersTitle: 'File viewers', settingsGeneralTitle: 'General', + settingsPopup: 'Feature settings', settingsViewerCatchAll: 'Catch-all: any file', viewerImage: 'Image', viewerPdf: 'PDF', diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 3b70119..db825be 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -1,13 +1,13 @@ /** * Side card settings section render tests: the section is DECLARATIVE — - * every card (icon, title, type id, extensions, on/off state) derives from - * the sidebar service's tab/viewer registries instead of hardcoded copy. - * The toggles are CARDS: the whole card is the switch, the visual state IS - * the state (highlighted = enabled), announced via `aria-pressed`. 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 pressed=false, and a disabled tab hides its - * declared nested cards. + * every small card (icon, title, type id, extensions, on/off state) derives + * from the sidebar service's tab/viewer registries instead of hardcoded + * copy. The toggles are CARDS in a responsive grid: the card's main area is + * the switch, the visual state IS the state (highlighted = enabled), + * announced via `aria-pressed`, and the check badge sits at the far right. + * Features that declare related settings carry a gear corner button whose + * popup rows (native checkboxes) are tested through the extracted + * FeatureSettingsRows component (the Modal portal renders only while open). * * Rendered with renderToString (mount effects — the settings RPC sync — do * not run in SSR; the initial store prefs are the render input). @@ -17,7 +17,8 @@ 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' +import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' +import { FeatureSettingsRows, 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 } { @@ -68,7 +69,7 @@ function pressedCount(html: string, value: string): number { } describe('SideCardSection declarative inventory', () => { - it('renders one card per registered tab: icon + title + type id + pressed state', () => { + it('renders one small card per registered tab: icon + title + type id + pressed state', () => { const { store, service } = mount() const html = renderSection(store, service) expect(html).toContain('data-icon="explorer"') @@ -77,32 +78,15 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>explorer<') expect(html).toContain('data-icon="subagent"') expect(html).toContain('>Subagents<') - // Default prefs: openByDefault + both tabs + the nested auto-open + the - // image viewer are all enabled → 5 cards pressed, none pressed=false. - expect(pressedCount(html, 'true')).toBe(5) + // Default prefs: openByDefault + both tabs + the image viewer are all + // enabled → 4 cards pressed, none pressed=false. The nested auto-open + // toggle is NOT an inline card (it lives in the popup). + expect(pressedCount(html, 'true')).toBe(4) expect(pressedCount(html, 'false')).toBe(0) - }) - - it('renders the declared nested card 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 → pressed). - expect(html).toContain('Auto-open Subagents') - expect(html).toContain('Expand on new subagent') - }) - - it('hides the nested cards while the parent tab is disabled', () => { - const { store, service } = mount() - store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } }) - const html = renderSection(store, service) - // The parent card stays (so the user can re-enable it) but pressed=false. - expect(html).toContain('>Subagents<') expect(html).not.toContain('Auto-open Subagents') - expect(pressedCount(html, 'false')).toBe(1) }) - it('renders one card per registered viewer: icon + title + exts', () => { + it('renders one small card per registered viewer: icon + title + exts', () => { const { store, service } = mount() const html = renderSection(store, service) expect(html).toContain('data-icon="image"') @@ -111,12 +95,62 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('png · jpg') }) - it('a disabled viewer renders pressed=false', () => { + it('renders the gear corner button on features that declare related settings', () => { const { store, service } = mount() - store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } }) const html = renderSection(store, service) + // Subagents declares a toggle → its card carries the settings gear + // (aria-label = " Feature settings"); Explorer and Image declare + // none → no gear. + expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1) + }) + + it('a disabled feature renders pressed=false', () => { + const { store, service } = mount() + store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } }) + const html = renderSection(store, service) + expect(html).toContain('>Subagents<') expect(html).toContain('>Image<') - expect(pressedCount(html, 'false')).toBe(1) - expect(pressedCount(html, 'true')).toBe(4) + expect(pressedCount(html, 'false')).toBe(2) + expect(pressedCount(html, 'true')).toBe(2) + }) + + it('hides the gear of a disabled feature (its related settings are dormant)', () => { + const { store, service } = mount() + store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } }) + const html = renderSection(store, service) + expect(html).not.toContain('Feature settings') + }) +}) + +describe('FeatureSettingsRows (the secondary settings popup body)', () => { + const prefs: typeof SIDEBAR_PREFS_DEFAULTS = { + ...SIDEBAR_PREFS_DEFAULTS, + autoOpenSubagent: false, + } + const toggles = [{ + key: 'autoOpenSubagent', + title: () => 'Auto-open Subagents', + desc: () => 'Expand on new subagent', + }] + + it('renders one native checkbox row per declared toggle with its current value', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles, + prefs, + onToggle: () => {}, + })) + expect(html).toContain('Auto-open Subagents') + expect(html).toContain('Expand on new subagent') + // The checkbox reflects the prefs value (false here → unchecked). + expect(html).not.toContain('checked=""') + }) + + it('checks the row when the pref is on', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles, + prefs: { ...prefs, autoOpenSubagent: true }, + onToggle: () => {}, + })) + expect(html).toContain('checked=""') }) })