feat(settings): 小卡片响应式网格 + 二级设置原生弹窗

- 清单改为小卡片,置于 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)同步
This commit is contained in:
Menghuan1918
2026-08-11 14:27:16 +08:00
parent 1b0d2085bb
commit 0e20413641
7 changed files with 363 additions and 147 deletions
+1 -1
View File
@@ -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 丢弃。
---
+3 -2
View File
@@ -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 终端
@@ -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` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
6. **小卡片响应式网格 + 二级设置弹窗(v0.5.0 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
+128 -36
View File
@@ -1,11 +1,14 @@
/**
* Side card settings section chrome: the toggle cards are the switch — a
* clickable block whose visual state IS the state (enabled = brand border +
* tinted fill + check badge; disabled = neutral hairline + dimmed text).
* Nested declarative toggles render as smaller indented cards; the width
* input stays a native settings row; the section heading groups the three
* inventories. The shell strips the last row's separator via the
* trailing-child rule.
* Side card settings section chrome (DSH-native tokens throughout):
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping);
* - a card's on/off state IS its visual state: enabled = brand border +
* tinted fill + a check badge pinned to the far right of the top row,
* disabled = neutral hairline + dimmed text;
* - features that declare related settings carry a gear corner button
* opening a native Modal whose rows are the DSH settings-row recipe
* (title/desc left + native accent checkbox, hairline separators);
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
@@ -19,19 +22,23 @@
border-bottom: none;
}
/* One toggle card: whole-card click, `aria-pressed` announces the state. */
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
.card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
flex-direction: column;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
@@ -41,21 +48,39 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.card:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
/* Enabled = highlighted: brand border + tinted fill. */
.cardOn {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-interactive-bg-active);
}
/* Nested declarative toggle (a feature's related setting): smaller, indented. */
.cardSub {
margin-left: 28px;
/* The card's switch: the whole main area (top row + desc) flips the feature. */
.cardMain {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: inherit;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.cardMain:focus-visible,
.cardGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.cardTop {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cardIcon {
@@ -69,38 +94,68 @@
color: var(--dsw-alias-button-primary-fill);
}
.cardText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cardTitle {
font-size: 14px;
line-height: 22px;
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardTitle {
color: var(--dsw-alias-label-primary);
}
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
.cardCheck {
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
}
.cardDesc {
font-size: 12px;
line-height: 17px;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardDesc {
color: var(--dsw-alias-label-secondary);
}
.cardCheck {
/* The gear corner button: bottom-right of cards that declare related
settings; the desc gets right padding so the ellipsis never runs under it. */
.cardWithGear .cardDesc {
padding-right: 18px;
}
.cardGear {
position: absolute;
right: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.cardGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
@@ -113,7 +168,7 @@
color: var(--dsw-alias-label-secondary);
}
/* The width input stays a native settings row (it is a value, not a switch). */
/* Native settings row recipe (the width input row + popup rows). */
.row {
display: flex;
align-items: center;
@@ -142,6 +197,43 @@
color: var(--dsw-alias-label-secondary);
}
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
.toggle {
flex: none;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
}
.toggle:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
/* Secondary settings popup body: stacked native rows, trailing separator
stripped by the trailing-child rule. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
}
.popupRows > :last-child {
border-bottom: none;
}
.control {
flex: none;
display: flex;
+158 -72
View File
@@ -6,16 +6,19 @@
* the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (a toggle card), and
* the default panel width as a percent of the window (number input row).
* - 侧边栏内容: one CARD per REGISTERED tab type (built-ins and external
* plugins alike) — icon + title + type id, clicked to toggle the switch
* persisted in `prefs.tabsEnabled[id]`. A card's `settings.toggles`
* declaration renders as nested smaller cards under it while the tab is
* enabled (e.g. the Subagent page's "auto-open when a subagent appears").
* - 文件预览: one CARD per REGISTERED file viewer — icon + title + the
* extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
* external plugins alike), laid out in a responsive grid that wraps
* several cards per row — icon + title + type id, clicked to toggle the
* switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
*
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
* border + tinted fill + check badge), disabled = neutral and dimmed.
* border + tinted fill + a check badge pinned to the card's far right),
* disabled = neutral and dimmed. Features that declare `settings.toggles`
* carry a gear corner button that opens a native Modal with the related
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
* when a subagent appears", the Terminal page's model terminal tools).
*
* 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
@@ -27,7 +30,13 @@
* shell.
*/
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import { IconCheckOutline16, IconPanelLeftOutline16, Input } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconCheckOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -95,6 +104,41 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true
}
/**
* The body of a feature's secondary settings popup: one native checkbox row
* per declared toggle. Extracted so the rows are testable without opening
* the Modal (the Modal portal renders only while open).
*/
export function FeatureSettingsRows(props: {
toggles: readonly SidebarSettingToggle[]
prefs: SidebarPrefs
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
}) {
const { toggles, prefs, onToggle } = props
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
return (
<label key={toggle.key} className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefBool(prefs, toggle.key)}
aria-label={title}
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
/>
</label>
)
})}
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service).
@@ -104,6 +148,8 @@ 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)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
@@ -214,10 +260,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
}
/**
* One toggle CARD: the whole card is the switch — clicking it flips the
* feature, and the visual state IS the state (highlighted = enabled, with
* a check badge; neutral + dimmed = disabled). Nested related settings
* render as smaller indented cards (`sub`).
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the
* check badge sits at the far right, and a feature that declares related
* settings carries a gear corner button opening its settings popup.
*/
const renderCard = (props: {
title: string
@@ -225,28 +271,48 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
icon?: ReactNode
enabled: boolean
onToggle: (next: boolean) => void
sub?: boolean
}) => (
<button
type="button"
className={clsx(css.card, props.sub === true && css.cardSub, props.enabled && css.cardOn)}
aria-pressed={props.enabled}
onClick={() => { props.onToggle(!props.enabled) }}
>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
)}
<span className={css.cardText}>
<span className={css.cardTitle}>{props.title}</span>
<span className={css.cardDesc}>{props.desc}</span>
</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
</span>
)}
</button>
)
/** A feature with declared related settings shows the gear corner button. */
onOpenSettings?: () => void
}) => {
const hasSettings = props.onOpenSettings !== undefined
return (
<div
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
>
<button
type="button"
className={css.cardMain}
aria-pressed={props.enabled}
title={props.desc}
onClick={() => { props.onToggle(!props.enabled) }}
>
<span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
</span>
)}
</span>
<span className={css.cardDesc}>{props.desc}</span>
</button>
{hasSettings && (
<button
type="button"
className={css.cardGear}
aria-label={`${props.title} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={14} />
</button>
)}
</div>
)
}
return (
<div className={css.section}>
@@ -282,46 +348,66 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</span>
</div>
{/* 侧边栏内容: 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. */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
{tabs.map(tab => (
<Fragment key={tab.id}>
{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 => (
<Fragment key={`${tab.id}:${toggle.key}`}>
{renderCard({
title: textOf(toggle.title),
desc: textOf(toggle.desc),
enabled: prefBool(prefs, toggle.key),
onToggle: (next) => { onToggleSetting(toggle, next) },
sub: true,
})}
</Fragment>
))}
</Fragment>
))}
<div className={css.grid}>
{tabs.map(tab => (
<Fragment key={tab.id}>
{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,
})}
</Fragment>
))}
</div>
{/* 文件预览: one card per registered file viewer, icon + title + exts. */}
{/* 文件预览: one small card per registered file viewer. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{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) },
})}
</Fragment>
))}
<div className={css.grid}>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{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) },
})}
</Fragment>
))}
</div>
{/* 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 && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
closeLabel={t('close')}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
prefs={prefs}
onToggle={onToggleSetting}
/>
</Modal>
)}
{error !== null && (
<div className={css.error} role="alert">
{error}
+2
View File
@@ -124,6 +124,7 @@ const zh = {
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
viewerImage: '图片',
viewerPdf: 'PDF',
@@ -269,6 +270,7 @@ const en: Record<keyof typeof zh, string> = {
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file',
viewerImage: 'Image',
viewerPdf: 'PDF',
+70 -36
View File
@@ -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 = "<title> 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=""')
})
})