/** * "Side card" settings section: the user-facing preferences for the sidebar * panel, rendered natively in the DSH Settings shell (nav label "Side card"). * * 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 (a toggle row), the * default panel width as a percent of the window (number input row), and * the open-path interception toggle — the DSH settings-row recipe * (title/desc left + control right, hairline separators). * - 侧边栏内容: 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 chip + title + type id, clicked to toggle * the switch persisted in `prefs.tabsEnabled[id]`. * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title * + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. * * Every group lives in a container card (the DSH PluginCard recipe: l2 * hairline, 16px radius, layer-3 fill) with a heading and an inventory count * badge (the settings catalogHeading recipe); the section opens with a * one-line intro (the DSH section heading+intro recipe). * * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand * border + tinted fill + a circular 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 * (wider than the primitive default) with the related settings as * title/desc + custom-switch rows and a Done footer. The toggles * themselves are custom switches: a real checkbox (native semantics and * focus) driving a styled track/thumb. * * 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 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 { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, IconPlusOutline16, 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' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { clampWidthPercent, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, type SidebarPrefs, } from '../prefs-shared.ts' import { api } from './api.ts' import { parsePrefs } from './prefs.ts' import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx' import { t } from './locales.ts' import type { SidebarStore } from './state.ts' import type { BetterSidebarService, FileViewerDescriptor, SidebarSettingsRenderProps, SidebarSettingToggle, TabDescriptor, } from './service.ts' import css from './SideCardSection.module.css' /** 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. */ export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected /** Map one wire failure to the inline message (the conflict gets friendly copy). */ function messageOf(error: unknown): string { if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') { return `${t('settingsSaveFailed')} ${t('settingsConflict')}` } 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) } /** Whether a feature declares any secondary settings (gear button shows). */ function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean { const settings = feature.settings return settings !== undefined && ( (settings.toggles?.length ?? 0) > 0 || (settings.pluginToggles?.length ?? 0) > 0 || settings.render !== undefined ) } /** A feature's display name (viewers fall back to their id). */ function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string { return textOf('title' in feature ? feature.title : undefined) || feature.id } /** * Merge one plugin-owned setting into a pluginSettings map (pure, v0.12.0+). * Sequential merges are additive: each call spreads the map it was GIVEN, * so building from the latest optimistic map keeps earlier keys intact * (two same-tick writes must not drop each other). */ export function mergePluginSetting( pluginSettings: Record>, descriptorId: string, key: string, value: unknown, ): Record> { return { ...pluginSettings, [descriptorId]: { ...(pluginSettings[descriptorId] ?? {}), [key]: value }, } } /** * Render a custom settings panel (`settings.render`) with error containment: * a throwing panel shows an inline error line instead of breaking the whole * settings page. */ function SettingsRender(props: { render: (renderProps: SidebarSettingsRenderProps) => ReactNode renderProps: SidebarSettingsRenderProps }) { let content: ReactNode try { content = props.render(props.renderProps) } catch (error) { content = (
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
) } return <>{content} } /** * The custom switch: a real checkbox (hidden, native semantics and focus) * driving a styled track/thumb. Used by the general toggle rows and the * secondary settings popup rows. */ function Switch(props: { checked: boolean onChange: (next: boolean) => void label: string }) { const { checked, onChange, label } = props return ( ) } /** * The body of a feature's secondary settings popup: one row (title/desc + * control) per declared setting. Switches render the custom switch; text and * number rows render a free-form / numeric input committed on blur/Enter * (clamped to the declared min/max). 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 /** Commit one text/number row; returns the canonical value the row should * display (clamped for numbers, the current pref when the input is * invalid). Optional: rows with no handler keep their draft. */ onCommit?: (toggle: SidebarSettingToggle, raw: string) => string /** Explicit value source (v0.12.0+): when given, rows read their values * from it instead of the `prefs` face — plugin-owned rows read their * own blob, so a plugin key can never collide with (or silently read) * a host pref of the same name. (Named `valueSource`, not `valueOf`: * the latter collides with the inherited Object.prototype.valueOf.) */ valueSource?: (key: string) => unknown }) { const { toggles, prefs, onToggle, onCommit, valueSource } = props const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record)[key]) return (
{toggles.map(toggle => { const title = textOf(toggle.title) if ((toggle.type ?? 'switch') === 'switch') { return (
{title} {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} { onToggle(toggle, next) }} />
) } const value = String(read(toggle.key) ?? '') // Keyed by the committed value: a failed commit reverts prefs, the // key changes, and the row remounts with the stored value (typing // never changes the key, so mid-edit drafts survive re-renders). return ( ) })}
) } /** * One text/number row: a controlled input whose draft is local state, * committed on blur/Enter through the parent's onCommit. The parent's * canonical return is adopted (clamped numbers, stored value for invalid * input); a `unit` suffix renders after the input (e.g. 'px'). */ function TypedRow(props: { toggle: SidebarSettingToggle title: string value: string onCommit?: (toggle: SidebarSettingToggle, raw: string) => string }) { const { toggle, title, value, onCommit } = props const [draft, setDraft] = useState(value) const commit = (): void => { const canonical = onCommit?.(toggle, draft) ?? draft setDraft(canonical) } const number = toggle.type === 'number' return (
{title} {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} { setDraft(event.currentTarget.value) }} onBlur={commit} onKeyDown={event => { if (event.key === 'Enter') event.currentTarget.blur() }} /> {toggle.unit !== undefined && {toggle.unit}}
) } /** * The secondary settings popup body of one feature (tab or viewer): * - `settings.render` (custom panel) when declared — rendered with the * shared store/service, the live prefs, the descriptor's own plugin * settings blob, a persistence helper, and a close callback; * - otherwise the host-prefs `toggles` rows, then the plugin-owned * `pluginToggles` rows (their values live in `pluginSettings[feature.id]`, * projected onto the prefs face so the shared row renderer reads them). */ export function SettingsBody(props: { feature: TabDescriptor | FileViewerDescriptor prefs: SidebarPrefs store: SidebarStore service: BetterSidebarService onToggle: (toggle: SidebarSettingToggle, next: boolean) => void onCommit: (toggle: SidebarSettingToggle, raw: string) => string onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string onPluginWrite: (key: string, value: unknown) => void onClose: () => void }) { const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props const render = feature.settings?.render if (render !== undefined) { return ( ) } const toggles = feature.settings?.toggles ?? [] const pluginToggles = feature.settings?.pluginToggles ?? [] if (toggles.length === 0 && pluginToggles.length === 0) return null // Plugin rows read their values from the descriptor's OWN blob through // an explicit value source — no projection onto the prefs face, so a // plugin key can never collide with (or silently read) a host pref of // the same name. const pluginBlob = prefs.pluginSettings[feature.id] ?? {} return (
{toggles.length > 0 && ( )} {pluginToggles.length > 0 && ( pluginBlob[key]} /> )}
) } /** * Render the Side card preferences section. * @param props - composed slot props (runtime share + injected store/service). * @returns the section element tree. */ 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) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(null) // Whether the position-compat strip popup (the gear on the 常规 row) is open. const [stripSettingsOpen, setStripSettingsOpen] = useState(false) // Whether the "add plugin" modal (a dashed card at the end of the // 侧边栏内容 / 文件预览 grids) is open, and for which extension point // (null = closed). const [addPluginsOpen, setAddPluginsOpen] = useState(null) // The LATEST optimistic prefs, kept in sync with the state. Nested-map // merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from // this ref, not from the render-time `prefs`: two same-tick writes (e.g. // a settings panel updating several plugin keys at once) would otherwise // both spread the stale map and the later patch would drop the earlier // key even though the commits are serialized. const optimisticRef = useRef(prefs) useEffect(() => { optimisticRef.current = prefs }, [prefs]) // 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) // Whether the user already wrote since mount: the mount read must not // clobber a newer optimistic edit (the window is milliseconds, but a slow // route must never silently revert a just-made change). const dirtyRef = useRef(false) // Serialize commits: a queued write must observe the previous write's // revision; a failed write must not poison the queue for later ones. const inFlightRef = useRef>(Promise.resolve()) // Sync the persisted document once on mount: the revision and the current // values (another tab may have changed them since the store hydrated). useEffect(() => { let cancelled = false void api.settingsGet().then((view) => { if (cancelled) return revisionRef.current = view.revision if (dirtyRef.current) return const next = parsePrefs(view.value) setPrefs(next) setWidthDraft(String(next.defaultWidthPercent)) }).catch(() => { /* the store's defaults stay authoritative */ }) return () => { cancelled = true } }, []) /** Persist one patch through the settings route (serialized, revision-guarded). */ const commit = (patch: Record): Promise<{ ok: boolean; prefs: SidebarPrefs }> => { dirtyRef.current = true const run = inFlightRef.current.then(async () => { const view = await api.settingsUpdate( { ...patch }, revisionRef.current, ) const next = parsePrefs(view.value) revisionRef.current = view.revision store.setPrefs(next) return next }) // A failed commit must not poison the queue: later writes still run. inFlightRef.current = run.then(() => undefined, () => undefined) return run.then( (next) => ({ ok: true, prefs: next }), (caught) => { setError(messageOf(caught)) return { ok: false, prefs } }, ) } /** Settle one commit: success adopts the server values, failure reverts. */ const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => { const settled = outcome.ok ? outcome.prefs : previous setPrefs(settled) setWidthDraft(String(settled.defaultWidthPercent)) } /** Optimistically apply one pref patch, then commit (revert on failure). */ const applyPref = (patch: Record): void => { const previous = optimisticRef.current const next = { ...previous, ...patch } as SidebarPrefs optimisticRef.current = next setPrefs(next) setError(null) void commit(patch).then(outcome => applyOutcome(previous, outcome)) } const onToggle = (next: boolean): void => { applyPref({ openByDefault: next }) } /** Flip one per-tab enable switch (merge into the tabsEnabled map). */ const onToggleTab = (id: string, next: boolean): void => { applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } }) } /** Flip one per-viewer enable switch (merge into the viewersEnabled map). */ const onToggleViewer = (id: string, next: boolean): void => { applyPref({ viewersEnabled: { ...optimisticRef.current.viewersEnabled, [id]: next } }) } /** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */ const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => { applyPref({ [toggle.key]: next }) } /** * Commit one declaratively-declared text/number row. Numbers are parsed * and clamped to the toggle's declared min/max (an unparsable input falls * back to the CURRENT stored value, mirroring the width row); text rows * persist as-is (empty is meaningful, e.g. the theme-default font). * Returns the canonical value the row should display. */ const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => { if (toggle.type === 'number') { const parsed = Number(raw) const fallback = String((prefs as unknown as Record)[toggle.key] ?? '') if (!Number.isFinite(parsed)) return fallback let clamped = Math.round(parsed) if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped) if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped) applyPref({ [toggle.key]: clamped }) return String(clamped) } applyPref({ [toggle.key]: raw }) return raw } /** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */ const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => { applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) }) } /** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */ const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => { applyPluginSetting(descriptorId, toggle.key, next) } /** Commit one plugin-owned text/number row (clamped like the host rows). */ const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => { if (toggle.type === 'number') { const parsed = Number(raw) const blob = prefs.pluginSettings[descriptorId] ?? {} const fallback = String(blob[toggle.key] ?? '') if (!Number.isFinite(parsed)) return fallback let clamped = Math.round(parsed) if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped) if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped) applyPluginSetting(descriptorId, toggle.key, clamped) return String(clamped) } applyPluginSetting(descriptorId, toggle.key, raw) return raw } const commitWidth = (): void => { const parsed = Number(widthDraft) if (!Number.isFinite(parsed)) { setWidthDraft(String(prefs.defaultWidthPercent)) return } const clamped = clampWidthPercent(parsed) const previous = prefs setPrefs({ ...previous, defaultWidthPercent: clamped }) setWidthDraft(String(clamped)) setError(null) void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome)) } /** * 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 icon * sits in a rounded chip, the check badge pins to the far right, and a * feature that declares related settings carries a gear corner button * opening its settings popup. */ const renderCard = (props: { title: string desc: string icon?: ReactNode enabled: boolean onToggle: (next: boolean) => void /** A feature with declared related settings shows the gear corner button. */ onOpenSettings?: () => void }) => { const hasSettings = props.onOpenSettings !== undefined return (
{hasSettings && ( )}
) } return (

{t('settingsIntro')}

{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
{t('settingsGeneralTitle')}
{t('settingsOpenTitle')} {t('settingsOpenDesc')}
{t('settingsWidthTitle')} {t('settingsWidthDesc')} { setWidthDraft(event.currentTarget.value) }} onBlur={commitWidth} onKeyDown={event => { if (event.key === 'Enter') event.currentTarget.blur() }} /> {t('settingsWidthSuffix')}
{t('settingsOpenPathTitle')} {t('settingsOpenPathDesc')} { applyPref({ interceptOpenPath: next }) }} />
{t('settingsTitleBarTitle')} {t('settingsTitleBarDesc')} {/* The position-compat row's gear (same popup pattern as the feature cards): opens a Modal with the strip-height number row. Hidden while the mode is off — its related setting is dormant then (the feature-card convention). */} {prefs.titleBarCompat && ( )} { applyPref({ titleBarCompat: next }) }} />
{/* 侧边栏内容: 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.length}
{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 && hasSettings(tab) ? () => { setSettingsFor(tab) } : undefined, })} ))} {/* The "add tab plugin" entry: same card size as the inventory, but a dashed border — it opens the TAB-registration plugin modal instead of toggling a feature. */}
{/* 文件预览: one small card per registered file viewer. */}
{t('settingsViewersTitle')} {viewers.length}
{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) }, onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer) ? () => { setSettingsFor(viewer) } : undefined, })} ))} {/* The "add preview plugin" entry: dashed card opening the FILE-PREVIEWER registration modal. */}
{/* The secondary settings popup: a feature's declared related settings as title/desc + switch rows in a wider-than-default Modal with a Done footer (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. Content: `settings.render` (custom panel) when declared, else the host-prefs `toggles` rows followed by the plugin-owned `pluginToggles` rows (their values live in pluginSettings[id]). */} {settingsFor !== null && ( { setSettingsFor(null) }} title={featureNameOf(settingsFor)} description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })} closeLabel={t('close')} className={css.popupDialog} footer={( )} > { onPluginToggle(settingsFor.id, toggle, next) }} onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)} onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }} onClose={() => { setSettingsFor(null) }} store={store} service={service} /> )} {/* The position-compat strip popup (opened by the gear on the 常规 row): one number row for the reserved strip height in px. Same Modal chrome and row machinery as the feature popups — mounted only while open (the Modal SSR rule above). */} {stripSettingsOpen && ( { setStripSettingsOpen(false) }} title={t('settingsTitleBarTitle')} description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })} closeLabel={t('close')} className={css.popupDialog} footer={( )} > t('settingsTitleBarStripTitle'), desc: () => t('settingsTitleBarStripDesc'), min: TITLE_BAR_STRIP_MIN, max: TITLE_BAR_STRIP_MAX, unit: 'px', }]} prefs={prefs} onToggle={onToggleSetting} onCommit={onCommitSetting} /> )} {/* The "add plugin" modal (opened by the dashed cards above): declares the extension point of the clicked kind, opens the GitHub topic, and lists the matching recommended plugin catalog with per-entry install buttons (the install flow opens a ~/.dsh terminal with the command pre-typed; failures render inline here, in settings only). Mounted only while open (Modal runs hooks unconditionally — same SSR rule as the settings popup above). */} {addPluginsOpen !== null && ( { setAddPluginsOpen(null) }} kind={addPluginsOpen} /> )} {error !== null && (
{error}
)}
) }