titleBarStripPx 新偏好(0–120px,默认 40):侧边栏按钮与内容的下移量改为 CSS 变量 --dsh-title-bar-strip 驱动,Sidebar shell 随 prefs 写入。设置页 「常规」的位置兼容模式行在开启时显示齿轮小图标,弹窗内用数字行设置下移 距离(复用 FeatureSettingsRows 行控件与提交钳制)。
878 lines
35 KiB
TypeScript
878 lines
35 KiB
TypeScript
/**
|
|
* "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<string, Record<string, unknown>>,
|
|
descriptorId: string,
|
|
key: string,
|
|
value: unknown,
|
|
): Record<string, Record<string, unknown>> {
|
|
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 = (
|
|
<div className={css.error} role="alert">
|
|
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
|
|
</div>
|
|
)
|
|
}
|
|
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 (
|
|
<label className={css.switch}>
|
|
<input
|
|
type="checkbox"
|
|
className={css.switchInput}
|
|
checked={checked}
|
|
aria-label={label}
|
|
onChange={event => { onChange(event.currentTarget.checked) }}
|
|
/>
|
|
<span className={css.switchTrack} aria-hidden="true">
|
|
<span className={css.switchThumb} />
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown>)[key])
|
|
return (
|
|
<div className={css.popupRows}>
|
|
{toggles.map(toggle => {
|
|
const title = textOf(toggle.title)
|
|
if ((toggle.type ?? 'switch') === 'switch') {
|
|
return (
|
|
<div 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>
|
|
<Switch
|
|
label={title}
|
|
checked={read(toggle.key) === true}
|
|
onChange={(next) => { onToggle(toggle, next) }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
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 (
|
|
<TypedRow
|
|
key={`${toggle.key}:${value}`}
|
|
toggle={toggle}
|
|
title={title}
|
|
value={value}
|
|
onCommit={onCommit}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div 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>
|
|
<span className={css.control}>
|
|
<Input
|
|
type={number ? 'number' : 'text'}
|
|
className={number ? css.typedInputNumber : css.typedInput}
|
|
value={draft}
|
|
min={toggle.min}
|
|
max={toggle.max}
|
|
step={1}
|
|
placeholder={toggle.placeholder}
|
|
aria-label={title}
|
|
onChange={event => { setDraft(event.currentTarget.value) }}
|
|
onBlur={commit}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter') event.currentTarget.blur()
|
|
}}
|
|
/>
|
|
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<SettingsRender
|
|
render={render}
|
|
renderProps={{
|
|
store,
|
|
service,
|
|
prefs,
|
|
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
|
updatePluginSetting: onPluginWrite,
|
|
close: onClose,
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
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 (
|
|
<div className={css.popupRows}>
|
|
{toggles.length > 0 && (
|
|
<FeatureSettingsRows
|
|
toggles={toggles}
|
|
prefs={prefs}
|
|
onToggle={onToggle}
|
|
onCommit={onCommit}
|
|
/>
|
|
)}
|
|
{pluginToggles.length > 0 && (
|
|
<FeatureSettingsRows
|
|
toggles={pluginToggles}
|
|
prefs={prefs}
|
|
onToggle={onPluginToggle}
|
|
onCommit={onPluginCommit}
|
|
valueSource={(key) => pluginBlob[key]}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<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 | FileViewerDescriptor | null>(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<PluginKind | null>(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<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)
|
|
// 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<unknown>>(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<string, unknown>): 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<string, unknown>): 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<string, unknown>)[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 (
|
|
<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.cardIconChip}>{props.icon}</span>
|
|
)}
|
|
<span className={css.cardTitle}>{props.title}</span>
|
|
{props.enabled && (
|
|
<span className={css.cardCheck}>
|
|
<IconCheckOutline16 size={12} />
|
|
</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={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={css.section}>
|
|
<p className={css.intro}>{t('settingsIntro')}</p>
|
|
|
|
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
|
<div className={css.group}>
|
|
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
|
<div className={css.row}>
|
|
<span className={css.rowText}>
|
|
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
|
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
|
</span>
|
|
<Switch
|
|
label={t('settingsOpenTitle')}
|
|
checked={prefs.openByDefault}
|
|
onChange={onToggle}
|
|
/>
|
|
</div>
|
|
<div className={css.row}>
|
|
<span className={css.rowText}>
|
|
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
|
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
|
</span>
|
|
<span className={css.control}>
|
|
<Input
|
|
type="number"
|
|
className={css.percentInput}
|
|
value={widthDraft}
|
|
min={WIDTH_PERCENT_MIN}
|
|
max={WIDTH_PERCENT_MAX}
|
|
step={1}
|
|
aria-label={t('settingsWidthTitle')}
|
|
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
|
onBlur={commitWidth}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter') event.currentTarget.blur()
|
|
}}
|
|
/>
|
|
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
|
</span>
|
|
</div>
|
|
<div className={css.row}>
|
|
<span className={css.rowText}>
|
|
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
|
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
|
</span>
|
|
<Switch
|
|
label={t('settingsOpenPathTitle')}
|
|
checked={prefs.interceptOpenPath}
|
|
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
|
/>
|
|
</div>
|
|
<div className={css.row}>
|
|
<span className={css.rowText}>
|
|
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
|
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
|
|
</span>
|
|
<span className={css.control}>
|
|
{/*
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
className={css.rowGear}
|
|
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
|
|
title={t('settingsPopup')}
|
|
onClick={() => { setStripSettingsOpen(true) }}
|
|
>
|
|
<IconSettingsOutline16 size={14} />
|
|
</button>
|
|
)}
|
|
<Switch
|
|
label={t('settingsTitleBarTitle')}
|
|
checked={prefs.titleBarCompat}
|
|
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
|
/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 侧边栏内容: 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.group}>
|
|
<div className={css.groupHeading}>
|
|
<span>{t('settingsTabsTitle')}</span>
|
|
<span className={css.count}>{tabs.length}</span>
|
|
</div>
|
|
<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 && hasSettings(tab)
|
|
? () => { setSettingsFor(tab) }
|
|
: undefined,
|
|
})}
|
|
</Fragment>
|
|
))}
|
|
{/* 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. */}
|
|
<button
|
|
type="button"
|
|
className={clsx(css.card, css.addCard)}
|
|
onClick={() => { setAddPluginsOpen('tab') }}
|
|
>
|
|
<span className={css.cardTop}>
|
|
<span className={css.cardIconChip}>
|
|
<IconPlusOutline16 size={16} />
|
|
</span>
|
|
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
|
|
</span>
|
|
<span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 文件预览: one small card per registered file viewer. */}
|
|
<div className={css.group}>
|
|
<div className={css.groupHeading}>
|
|
<span>{t('settingsViewersTitle')}</span>
|
|
<span className={css.count}>{viewers.length}</span>
|
|
</div>
|
|
<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) },
|
|
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
|
|
? () => { setSettingsFor(viewer) }
|
|
: undefined,
|
|
})}
|
|
</Fragment>
|
|
))}
|
|
{/* The "add preview plugin" entry: dashed card opening the
|
|
FILE-PREVIEWER registration modal. */}
|
|
<button
|
|
type="button"
|
|
className={clsx(css.card, css.addCard)}
|
|
onClick={() => { setAddPluginsOpen('viewer') }}
|
|
>
|
|
<span className={css.cardTop}>
|
|
<span className={css.cardIconChip}>
|
|
<IconPlusOutline16 size={16} />
|
|
</span>
|
|
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
|
|
</span>
|
|
<span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<Modal
|
|
open
|
|
onClose={() => { setSettingsFor(null) }}
|
|
title={featureNameOf(settingsFor)}
|
|
description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
|
|
closeLabel={t('close')}
|
|
className={css.popupDialog}
|
|
footer={(
|
|
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
|
|
{t('settingsDone')}
|
|
</button>
|
|
)}
|
|
>
|
|
<SettingsBody
|
|
feature={settingsFor}
|
|
prefs={prefs}
|
|
onToggle={onToggleSetting}
|
|
onCommit={onCommitSetting}
|
|
onPluginToggle={(toggle, next) => { 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}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<Modal
|
|
open
|
|
onClose={() => { setStripSettingsOpen(false) }}
|
|
title={t('settingsTitleBarTitle')}
|
|
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
|
|
closeLabel={t('close')}
|
|
className={css.popupDialog}
|
|
footer={(
|
|
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
|
|
{t('settingsDone')}
|
|
</button>
|
|
)}
|
|
>
|
|
<FeatureSettingsRows
|
|
toggles={[{
|
|
key: 'titleBarStripPx',
|
|
type: 'number',
|
|
title: () => t('settingsTitleBarStripTitle'),
|
|
desc: () => t('settingsTitleBarStripDesc'),
|
|
min: TITLE_BAR_STRIP_MIN,
|
|
max: TITLE_BAR_STRIP_MAX,
|
|
unit: 'px',
|
|
}]}
|
|
prefs={prefs}
|
|
onToggle={onToggleSetting}
|
|
onCommit={onCommitSetting}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<AddPluginModal
|
|
service={service}
|
|
onClose={() => { setAddPluginsOpen(null) }}
|
|
kind={addPluginsOpen}
|
|
/>
|
|
)}
|
|
|
|
{error !== null && (
|
|
<div className={css.error} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|