/** * "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 compact switch knob at the card's far right), * disabled = neutral and dimmed. Features that declare * `settings.toggles` carry a labeled settings strip at the card's bottom * edge 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 popup body scrolls internally when a feature declares many * rows (e.g. Terminal's six). 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, useMemo, useRef, useState, type ReactNode } from 'react' import { IconChevronDownOutline14, IconPlusOutline16, IconSettingsOutline16, Input, Menu, 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 { TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_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 { parseDesktopEnv } from './desktop-env.ts' import { getShellPreset, getShellPresets } from './shell-presets.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) } /** * The scheme dropdown's current value: the plain scheme, or `preset:` * while a preset is active. Falls back to `auto` when the stored preset id * is no longer registered (the strip resolves to 0 then anyway). */ function titleBarSchemeValue(prefs: SidebarPrefs): string { if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme const preset = getShellPreset(prefs.titleBarPresetId) return preset !== undefined ? `preset:${preset.id}` : 'auto' } /** 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 /** Commit one select row: the picked option's value (single) or the array * of picked values (`multi: true`). Optional: rows with no handler are * display-only. */ onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void /** 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, onSelectValue, 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 === 'select') { return ( ) } 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 multi-line custom-CSS input (scheme `custom`): a monospace textarea * whose draft is local state, committed on blur or Cmd/Ctrl+Enter through * the parent's handler. Keyed by the stored value so an external commit * remounts it with the canonical text (same pattern as TypedRow). */ function CssDraft(props: { value: string onCommit: (raw: string) => void label: string placeholder?: string }) { const { value, onCommit, label, placeholder } = props const [draft, setDraft] = useState(value) return (