feat(settings): 侧边栏设置声明式化 — 注册表驱动的开关清单 + 内置支持插头化

- SidebarPrefs 新增 tabsEnabled / viewersEnabled 开放 map(缺省 = 启用),
  PrefsSchema 用 z.dict 校验,旧文档零迁移
- TabDescriptor/FileViewerDescriptor 新增声明式设置面:viewer 补 title/icon,
  两者补 settings.toggles(子代理声明 autoOpenSubagent、终端声明 agentTerminalTools)
- 服务新增 isTabEnabled/isViewerEnabled;openTab 拒绝禁用类型、matchFileViewer
  跳过禁用 viewer
- SideCardSection 重写为注册表驱动清单:侧边栏内容(图标+类型id+开关+嵌套开关)
  + 文件预览(图标+标题+扩展名+开关),开关持久化 + 乐观提交回滚
- 消费点 gating:+ 菜单过滤、子代理自动展开、agent 终端 reconcile、
  产出文件拦截(editor 关闭时回落默认行)、新会话 explorer 预置
- 内置 viewer 补 6 个手绘 outline 图标;store 快照携带 prefs 使开关变更即时生效
- 测试:service gating / builtins 声明 / parsePrefs map 校验 / schema 断言 /
  新增 SideCardSection renderToString 规格
- 文档:README 设置小节、AGENTS.md 描述符与服务方法、新增设计文档;版本 0.5.0
This commit is contained in:
Menghuan1918
2026-08-11 14:08:55 +08:00
parent 89d2c65707
commit 89ddf8a9e7
25 changed files with 938 additions and 99 deletions
+159 -52
View File
@@ -1,20 +1,30 @@
/**
* "Side card" settings section: the user-facing preferences for the sidebar
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
* Three rows:
* - new conversations open the panel by default (native checkbox),
* - the default panel width as a percent of the window (number input + %),
* - auto-open the Subagent page when a new subagent appears (native checkbox,
* on by default).
*
* The section is DECLARATIVE — it renders the enable/disable inventory from
* the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (native checkbox),
* and the default panel width as a percent of the window (number input).
* - 侧边栏内容: one row per REGISTERED tab type (built-ins and external
* plugins alike) — icon + title + type id + an on/off switch persisted in
* `prefs.tabsEnabled[id]`. A tab's `settings.toggles` declaration renders
* as nested switches under its row while the tab is enabled (e.g. the
* Subagent page's "auto-open when a subagent appears").
* - 文件预览: one row per REGISTERED file viewer — icon + title + the
* extensions it covers + an on/off switch persisted in
* `prefs.viewersEnabled[id]`.
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
* third-party namespaces to configuration clients); the shared SidebarStore
* is refreshed on success so the very next brand-new session seeds from the
* new values. Any failure reverts the optimistic UI and shows the wire error
* inline — a broken settings surface never crashes the shell.
* new values and the sidebar's consumption points (the + menu, derived
* flows) re-render immediately. Any failure reverts the optimistic UI and
* shows the wire error inline — a broken settings surface never crashes the
* shell.
*/
import { useEffect, useRef, useState } from 'react'
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -29,11 +39,18 @@ import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import type {
BetterSidebarService,
FileViewerDescriptor,
SidebarSettingToggle,
TabDescriptor,
} from './service.ts'
import css from './SideCardSection.module.css'
/** Injected business face: the shared store (prefs cache). */
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
export interface SideCardSectionInjected {
store: SidebarStore
service: BetterSidebarService
}
/** Full section props: the runtime share plus the injected face. */
@@ -47,16 +64,54 @@ function messageOf(error: unknown): string {
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
}
/** Resolve an i18n-friendly string-or-function value. */
function textOf(value: string | (() => string) | undefined): string {
if (value === undefined) return ''
return typeof value === 'function' ? value() : value
}
/** Resolve a descriptor icon (ReactNode or size function). */
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
if (icon === undefined) return null
return typeof icon === 'function' ? icon(size) : icon
}
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
return (a.order ?? 100) - (b.order ?? 100)
}
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
return (b.priority ?? 0) - (a.priority ?? 0)
}
/** Read one boolean pref by declarative key (missing = false). */
function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store).
* @param props - composed slot props (runtime share + injected store/service).
* @returns the section element tree.
*/
export function SideCardSection({ store }: SideCardSectionProps) {
export function SideCardSection({ store, service }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
// load/unload — so a plain effect is enough; no external-store ceremony).
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
useEffect(() => service.subscribe(() => {
setTabs([...service.getTabs()].sort(tabOrder))
setViewers([...service.getFileViewers()].sort(viewerOrder))
}), [service])
// The settings document revision (guards concurrent writes). A ref: commits
// read the freshest value at execution time, no re-render needed.
const revisionRef = useRef<number | undefined>(undefined)
@@ -84,7 +139,7 @@ export function SideCardSection({ store }: SideCardSectionProps) {
}, [])
/** Persist one patch through the settings route (serialized, revision-guarded). */
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
dirtyRef.current = true
const run = inFlightRef.current.then(async () => {
const view = await api.settingsUpdate(
@@ -114,25 +169,31 @@ export function SideCardSection({ store }: SideCardSectionProps) {
setWidthDraft(String(settled.defaultWidthPercent))
}
/** Optimistically flip one boolean pref, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => {
const previous = prefs
setPrefs({ ...previous, ...patch } as SidebarPrefs)
setError(null)
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggle = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, openByDefault: next })
setError(null)
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
togglePref({ openByDefault: next })
}
const onToggleSubagent = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, autoOpenSubagent: next })
setError(null)
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
}
const onToggleTools = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, agentTerminalTools: next })
setError(null)
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
const onToggleViewer = (id: string, next: boolean): void => {
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
}
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
togglePref({ [toggle.key]: next })
}
const commitWidth = (): void => {
@@ -149,6 +210,34 @@ export function SideCardSection({ store }: SideCardSectionProps) {
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
}
/** One enable/disable row: icon + title + desc + checkbox. */
const renderRow = (props: {
title: string
desc: string
icon?: ReactNode
checked: boolean
label: string
onToggle: (next: boolean) => void
sub?: boolean
}) => (
<label className={props.sub === true ? css.subRow : css.row}>
<span className={css.rowText}>
<span className={css.titleLine}>
{props.icon !== null && props.icon !== undefined && <span className={css.rowIcon}>{props.icon}</span>}
<span className={css.title}>{props.title}</span>
</span>
<span className={css.desc}>{props.desc}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={props.checked}
aria-label={props.label}
onChange={event => { props.onToggle(event.currentTarget.checked) }}
/>
</label>
)
return (
<div className={css.section}>
<label className={css.row}>
@@ -187,32 +276,50 @@ export function SideCardSection({ store }: SideCardSectionProps) {
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsSubagentTitle')}</span>
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.autoOpenSubagent}
aria-label={t('settingsSubagentTitle')}
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
/>
</label>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsToolsTitle')}</span>
<span className={css.desc}>{t('settingsToolsDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.agentTerminalTools}
aria-label={t('settingsToolsTitle')}
onChange={event => { onToggleTools(event.currentTarget.checked) }}
/>
</label>
{/* 侧边栏内容: one row per registered tab type, icon + type id + switch,
plus each tab's declaratively-declared nested toggles (shown only
while the tab itself is enabled). */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
{tabs.map(tab => (
<Fragment key={tab.id}>
{renderRow({
title: textOf(tab.title),
desc: tab.id,
icon: iconOf(tab.icon, 16),
checked: prefs.tabsEnabled[tab.id] !== false,
label: textOf(tab.title),
onToggle: (next) => { onToggleTab(tab.id, next) },
})}
{prefs.tabsEnabled[tab.id] !== false && (tab.settings?.toggles ?? []).map(toggle => (
<Fragment key={`${tab.id}:${toggle.key}`}>
{renderRow({
title: textOf(toggle.title),
desc: textOf(toggle.desc),
checked: prefBool(prefs, toggle.key),
label: textOf(toggle.title),
onToggle: (next) => { onToggleSetting(toggle, next) },
sub: true,
})}
</Fragment>
))}
</Fragment>
))}
{/* 文件预览: one row per registered file viewer, icon + title + exts + switch. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{renderRow({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
icon: iconOf(viewer.icon, 16),
checked: prefs.viewersEnabled[viewer.id] !== false,
label: textOf(viewer.title) || viewer.id,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
{error !== null && (
<div className={css.error} role="alert">
{error}