@keith9681/dsh-sidebar 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * "Side card" settings section: the user-facing preferences for the sidebar
3
+ * panel, rendered natively in the DSH Settings shell (nav label "Side card").
4
+ *
5
+ * The section is DECLARATIVE — it renders the enable/disable inventory from
6
+ * the sidebar service's registries instead of hardcoding rows:
7
+ * - 常规: new conversations open the panel by default (a toggle row), the
8
+ * default panel width as a percent of the window (number input row), and
9
+ * the open-path interception toggle — the DSH settings-row recipe
10
+ * (title/desc left + control right, hairline separators).
11
+ * - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
12
+ * external plugins alike), laid out in a responsive grid that wraps
13
+ * several cards per row — icon chip + title + type id, clicked to toggle
14
+ * the switch persisted in `prefs.tabsEnabled[id]`.
15
+ * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
16
+ * + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
17
+ *
18
+ * Every group lives in a container card (the DSH PluginCard recipe: l2
19
+ * hairline, 16px radius, layer-3 fill) with a heading and an inventory count
20
+ * badge (the settings catalogHeading recipe); the section opens with a
21
+ * one-line intro (the DSH section heading+intro recipe).
22
+ *
23
+ * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
24
+ * border + tinted fill + a compact switch knob at the card's far right),
25
+ * disabled = neutral and dimmed. Features that declare
26
+ * `settings.toggles` carry a labeled settings strip at the card's bottom
27
+ * edge that opens a native Modal (wider than the primitive default) with
28
+ * the related settings as title/desc + custom-switch rows and a Done
29
+ * footer; the popup body scrolls internally when a feature declares many
30
+ * rows (e.g. Terminal's six). The toggles themselves are custom
31
+ * switches: a real checkbox (native semantics and focus) driving a styled
32
+ * track/thumb.
33
+ *
34
+ * Writes ride the plugin's own fenced settings route (the host calls the
35
+ * settings seam in-process — the DSH settings RPC domain does not serve
36
+ * third-party namespaces to configuration clients); the shared SidebarStore
37
+ * is refreshed on success so the very next brand-new session seeds from the
38
+ * new values and the sidebar's consumption points (the + menu, derived
39
+ * flows) re-render immediately. Any failure reverts the optimistic UI and
40
+ * shows the wire error inline — a broken settings surface never crashes the
41
+ * shell.
42
+ */
43
+ import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
44
+ import {
45
+ IconChevronDownOutline14,
46
+ IconPlusOutline16,
47
+ IconSettingsOutline16,
48
+ Input,
49
+ Menu,
50
+ Modal,
51
+ } from '@deepseek-ai/dsh-client-ui-primitives'
52
+ import clsx from 'clsx'
53
+ // Type-only: pulls the settings shell's SlotMap merges ('settings.section').
54
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
55
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
56
+ import {
57
+ clampWidthPercent,
58
+ TITLE_BAR_STRIP_MAX,
59
+ TITLE_BAR_STRIP_MIN,
60
+ WIDTH_PERCENT_MAX,
61
+ WIDTH_PERCENT_MIN,
62
+ type SidebarPrefs,
63
+ type TitleBarScheme,
64
+ } from '../prefs-shared.ts'
65
+ import { api } from './api.ts'
66
+ import { parsePrefs } from './prefs.ts'
67
+ import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
68
+ import { t } from './locales.ts'
69
+ import { parseDesktopEnv } from './desktop-env.ts'
70
+ import { getShellPreset, getShellPresets } from './shell-presets.ts'
71
+ import type { SidebarStore } from './state.ts'
72
+ import type {
73
+ BetterSidebarService,
74
+ FileViewerDescriptor,
75
+ SidebarSettingsRenderProps,
76
+ SidebarSettingToggle,
77
+ TabDescriptor,
78
+ } from './service.ts'
79
+ import css from './SideCardSection.module.css'
80
+
81
+ /** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
82
+ export interface SideCardSectionInjected {
83
+ store: SidebarStore
84
+ service: BetterSidebarService
85
+ }
86
+
87
+ /** Full section props: the runtime share plus the injected face. */
88
+ export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
89
+
90
+ /** Map one wire failure to the inline message (the conflict gets friendly copy). */
91
+ function messageOf(error: unknown): string {
92
+ if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
93
+ return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
94
+ }
95
+ return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
96
+ }
97
+
98
+ /** Resolve an i18n-friendly string-or-function value. */
99
+ function textOf(value: string | (() => string) | undefined): string {
100
+ if (value === undefined) return ''
101
+ return typeof value === 'function' ? value() : value
102
+ }
103
+
104
+ /** Resolve a descriptor icon (ReactNode or size function). */
105
+ function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
106
+ if (icon === undefined) return null
107
+ return typeof icon === 'function' ? icon(size) : icon
108
+ }
109
+
110
+ /** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
111
+ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
112
+ if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
113
+ return (a.order ?? 100) - (b.order ?? 100)
114
+ }
115
+
116
+ /**
117
+ * The scheme dropdown's current value: the plain scheme, or `preset:<id>`
118
+ * while a preset is active. Falls back to `auto` when the stored preset id
119
+ * is no longer registered (the strip resolves to 0 then anyway).
120
+ */
121
+ function titleBarSchemeValue(prefs: SidebarPrefs): string {
122
+ if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
123
+ const preset = getShellPreset(prefs.titleBarPresetId)
124
+ return preset !== undefined ? `preset:${preset.id}` : 'auto'
125
+ }
126
+
127
+ /** Viewer inventory order: priority desc (the catch-all `code` comes last). */
128
+ function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
129
+ return (b.priority ?? 0) - (a.priority ?? 0)
130
+ }
131
+
132
+ /** Whether a feature declares any secondary settings (gear button shows). */
133
+ function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
134
+ const settings = feature.settings
135
+ return settings !== undefined && (
136
+ (settings.toggles?.length ?? 0) > 0
137
+ || (settings.pluginToggles?.length ?? 0) > 0
138
+ || settings.render !== undefined
139
+ )
140
+ }
141
+
142
+ /** A feature's display name (viewers fall back to their id). */
143
+ function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
144
+ return textOf('title' in feature ? feature.title : undefined) || feature.id
145
+ }
146
+
147
+ /**
148
+ * Merge one plugin-owned setting into a pluginSettings map (pure, v0.12.0+).
149
+ * Sequential merges are additive: each call spreads the map it was GIVEN,
150
+ * so building from the latest optimistic map keeps earlier keys intact
151
+ * (two same-tick writes must not drop each other).
152
+ */
153
+ export function mergePluginSetting(
154
+ pluginSettings: Record<string, Record<string, unknown>>,
155
+ descriptorId: string,
156
+ key: string,
157
+ value: unknown,
158
+ ): Record<string, Record<string, unknown>> {
159
+ return {
160
+ ...pluginSettings,
161
+ [descriptorId]: { ...(pluginSettings[descriptorId] ?? {}), [key]: value },
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Render a custom settings panel (`settings.render`) with error containment:
167
+ * a throwing panel shows an inline error line instead of breaking the whole
168
+ * settings page.
169
+ */
170
+ function SettingsRender(props: {
171
+ render: (renderProps: SidebarSettingsRenderProps) => ReactNode
172
+ renderProps: SidebarSettingsRenderProps
173
+ }) {
174
+ let content: ReactNode
175
+ try {
176
+ content = props.render(props.renderProps)
177
+ } catch (error) {
178
+ content = (
179
+ <div className={css.error} role="alert">
180
+ {t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
181
+ </div>
182
+ )
183
+ }
184
+ return <>{content}</>
185
+ }
186
+
187
+ /**
188
+ * The custom switch: a real checkbox (hidden, native semantics and focus)
189
+ * driving a styled track/thumb. Used by the general toggle rows and the
190
+ * secondary settings popup rows.
191
+ */
192
+ function Switch(props: {
193
+ checked: boolean
194
+ onChange: (next: boolean) => void
195
+ label: string
196
+ }) {
197
+ const { checked, onChange, label } = props
198
+ return (
199
+ <label className={css.switch}>
200
+ <input
201
+ type="checkbox"
202
+ className={css.switchInput}
203
+ checked={checked}
204
+ aria-label={label}
205
+ onChange={event => { onChange(event.currentTarget.checked) }}
206
+ />
207
+ <span className={css.switchTrack} aria-hidden="true">
208
+ <span className={css.switchThumb} />
209
+ </span>
210
+ </label>
211
+ )
212
+ }
213
+
214
+ /**
215
+ * The body of a feature's secondary settings popup: one row (title/desc +
216
+ * control) per declared setting. Switches render the custom switch; text and
217
+ * number rows render a free-form / numeric input committed on blur/Enter
218
+ * (clamped to the declared min/max). Extracted so the rows are testable
219
+ * without opening the Modal (the Modal portal renders only while open).
220
+ */
221
+ export function FeatureSettingsRows(props: {
222
+ toggles: readonly SidebarSettingToggle[]
223
+ prefs: SidebarPrefs
224
+ onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
225
+ /** Commit one text/number row; returns the canonical value the row should
226
+ * display (clamped for numbers, the current pref when the input is
227
+ * invalid). Optional: rows with no handler keep their draft. */
228
+ onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
229
+ /** Commit one select row: the picked option's value (single) or the array
230
+ * of picked values (`multi: true`). Optional: rows with no handler are
231
+ * display-only. */
232
+ onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
233
+ /** Explicit value source (v0.12.0+): when given, rows read their values
234
+ * from it instead of the `prefs` face — plugin-owned rows read their
235
+ * own blob, so a plugin key can never collide with (or silently read)
236
+ * a host pref of the same name. (Named `valueSource`, not `valueOf`:
237
+ * the latter collides with the inherited Object.prototype.valueOf.) */
238
+ valueSource?: (key: string) => unknown
239
+ }) {
240
+ const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
241
+ const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
242
+ return (
243
+ <div className={css.popupRows}>
244
+ {toggles.map(toggle => {
245
+ const title = textOf(toggle.title)
246
+ if (toggle.type === 'select') {
247
+ return (
248
+ <SelectRow
249
+ key={toggle.key}
250
+ toggle={toggle}
251
+ title={title}
252
+ value={read(toggle.key)}
253
+ onSelectValue={onSelectValue}
254
+ />
255
+ )
256
+ }
257
+ if ((toggle.type ?? 'switch') === 'switch') {
258
+ return (
259
+ <div key={toggle.key} className={css.popupRow}>
260
+ <span className={css.rowText}>
261
+ <span className={css.title}>{title}</span>
262
+ {textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
263
+ </span>
264
+ <Switch
265
+ label={title}
266
+ checked={read(toggle.key) === true}
267
+ onChange={(next) => { onToggle(toggle, next) }}
268
+ />
269
+ </div>
270
+ )
271
+ }
272
+ const value = String(read(toggle.key) ?? '')
273
+ // Keyed by the committed value: a failed commit reverts prefs, the
274
+ // key changes, and the row remounts with the stored value (typing
275
+ // never changes the key, so mid-edit drafts survive re-renders).
276
+ return (
277
+ <TypedRow
278
+ key={`${toggle.key}:${value}`}
279
+ toggle={toggle}
280
+ title={title}
281
+ value={value}
282
+ onCommit={onCommit}
283
+ />
284
+ )
285
+ })}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ /**
291
+ * One text/number row: a controlled input whose draft is local state,
292
+ * committed on blur/Enter through the parent's onCommit. The parent's
293
+ * canonical return is adopted (clamped numbers, stored value for invalid
294
+ * input); a `unit` suffix renders after the input (e.g. 'px').
295
+ */
296
+ function TypedRow(props: {
297
+ toggle: SidebarSettingToggle
298
+ title: string
299
+ value: string
300
+ onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
301
+ }) {
302
+ const { toggle, title, value, onCommit } = props
303
+ const [draft, setDraft] = useState(value)
304
+ const commit = (): void => {
305
+ const canonical = onCommit?.(toggle, draft) ?? draft
306
+ setDraft(canonical)
307
+ }
308
+ const number = toggle.type === 'number'
309
+ return (
310
+ <div className={css.popupRow}>
311
+ <span className={css.rowText}>
312
+ <span className={css.title}>{title}</span>
313
+ {textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
314
+ </span>
315
+ <span className={css.control}>
316
+ <Input
317
+ type={number ? 'number' : 'text'}
318
+ className={number ? css.typedInputNumber : css.typedInput}
319
+ value={draft}
320
+ min={toggle.min}
321
+ max={toggle.max}
322
+ step={1}
323
+ placeholder={toggle.placeholder}
324
+ aria-label={title}
325
+ onChange={event => { setDraft(event.currentTarget.value) }}
326
+ onBlur={commit}
327
+ onKeyDown={event => {
328
+ if (event.key === 'Enter') event.currentTarget.blur()
329
+ }}
330
+ />
331
+ {toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
332
+ </span>
333
+ </div>
334
+ )
335
+ }
336
+ /**
337
+ * The multi-line custom-CSS input (scheme `custom`): a monospace textarea
338
+ * whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
339
+ * the parent's handler. Keyed by the stored value so an external commit
340
+ * remounts it with the canonical text (same pattern as TypedRow).
341
+ */
342
+ function CssDraft(props: {
343
+ value: string
344
+ onCommit: (raw: string) => void
345
+ label: string
346
+ placeholder?: string
347
+ }) {
348
+ const { value, onCommit, label, placeholder } = props
349
+ const [draft, setDraft] = useState(value)
350
+ return (
351
+ <textarea
352
+ className={css.cssTextArea}
353
+ rows={6}
354
+ value={draft}
355
+ placeholder={placeholder}
356
+ aria-label={label}
357
+ spellCheck={false}
358
+ onChange={event => { setDraft(event.currentTarget.value) }}
359
+ onBlur={() => { onCommit(draft) }}
360
+ onKeyDown={event => {
361
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
362
+ }}
363
+ />
364
+ )
365
+ }
366
+
367
+ /**
368
+ * The reusable dropdown — the primitives Menu, NOT a native <select>: a
369
+ * closed anchor button (picked option text + chevron) opening one Menu item
370
+ * per option (big-icon cards when any option carries an icon). Single-pick
371
+ * commits the option's value and closes; `multi` toggles membership and
372
+ * commits the picked values as an array (in options order), staying open.
373
+ * Shared by the declarative select rows (SelectRow) and the title-bar
374
+ * scheme dropdown on the General row.
375
+ */
376
+ function SelectMenu(props: {
377
+ label: string
378
+ value: unknown
379
+ options: readonly {
380
+ value: string | number | boolean
381
+ title: string | (() => string)
382
+ desc?: string | (() => string)
383
+ icon?: ReactNode | ((size: number) => ReactNode)
384
+ }[]
385
+ multi?: boolean
386
+ onSelect: (next: unknown) => void
387
+ placeholder?: string
388
+ }) {
389
+ const { label, value, options, multi, onSelect, placeholder } = props
390
+ const [open, setOpen] = useState(false)
391
+ const hasIcons = options.some(option => option.icon !== undefined)
392
+ const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
393
+ const selected = options.filter(option => picked.includes(option.value))
394
+
395
+ /** Commit one picked option (toggle semantics under multi). */
396
+ const pick = (index: number): void => {
397
+ const option = options[index]
398
+ if (option === undefined) return
399
+ if (!multi) {
400
+ onSelect(option.value)
401
+ setOpen(false)
402
+ return
403
+ }
404
+ const current = Array.isArray(value) ? [...value] : []
405
+ const at = current.indexOf(option.value)
406
+ if (at >= 0) current.splice(at, 1)
407
+ else current.push(option.value)
408
+ // Stable wire order: follow the declared options order, not pick order.
409
+ onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
410
+ }
411
+
412
+ const anchor = (
413
+ <button
414
+ type="button"
415
+ className={css.selectAnchor}
416
+ aria-label={label}
417
+ aria-haspopup="listbox"
418
+ aria-expanded={open}
419
+ onClick={() => { setOpen(now => !now) }}
420
+ >
421
+ {!multi && hasIcons && selected[0] !== undefined && (
422
+ <span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
423
+ )}
424
+ <span className={css.selectAnchorText}>
425
+ {selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
426
+ </span>
427
+ <IconChevronDownOutline14 size={12} />
428
+ </button>
429
+ )
430
+
431
+ return (
432
+ <Menu
433
+ open={open}
434
+ anchor={anchor}
435
+ items={options.map((option, index) => ({
436
+ id: String(index),
437
+ label: hasIcons
438
+ ? (
439
+ <span className={css.selectOption}>
440
+ <span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
441
+ <span className={css.selectOptionText}>
442
+ <span className={css.title}>{textOf(option.title)}</span>
443
+ {textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
444
+ </span>
445
+ </span>
446
+ )
447
+ : textOf(option.title),
448
+ }))}
449
+ selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
450
+ selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
451
+ onSelect={(id) => { pick(Number(id)) }}
452
+ onClose={() => { setOpen(false) }}
453
+ portal
454
+ />
455
+ )
456
+ }
457
+
458
+ /**
459
+ * One select row: a dropdown over the toggle's declared `options` (the
460
+ * shared SelectMenu). When any option carries an icon, the dropdown renders
461
+ * big-icon option cards (icon + title + desc) and the closed anchor shows
462
+ * the selected option's icon as well; without icons both are a single line
463
+ * of text. Single-pick commits the option's value and closes; `multi`
464
+ * toggles membership, commits the picked values as an array (in options
465
+ * order), and stays open.
466
+ */
467
+ function SelectRow(props: {
468
+ toggle: SidebarSettingToggle
469
+ title: string
470
+ value: unknown
471
+ onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
472
+ }) {
473
+ const { toggle, title, value, onSelectValue } = props
474
+ return (
475
+ <div className={css.popupRow}>
476
+ <span className={css.rowText}>
477
+ <span className={css.title}>{title}</span>
478
+ {textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
479
+ </span>
480
+ <span className={css.control}>
481
+ <SelectMenu
482
+ label={title}
483
+ value={value}
484
+ options={toggle.options ?? []}
485
+ multi={toggle.multi === true}
486
+ onSelect={(next) => { onSelectValue?.(toggle, next) }}
487
+ />
488
+ </span>
489
+ </div>
490
+ )
491
+ }
492
+
493
+ /**
494
+ * The secondary settings popup body of one feature (tab or viewer):
495
+ * - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
496
+ * rows (their values live in `pluginSettings[feature.id]`, projected onto
497
+ * the prefs face so the shared row renderer reads them);
498
+ * - `settings.render` (custom panel) AFTER those rows when declared — the
499
+ * custom panel is an extension of the row list, not a replacement, so a
500
+ * feature can keep its declarative rows (e.g. the editor's
501
+ * open-behavior picker) and still ship a custom configuration area.
502
+ */
503
+ export function SettingsBody(props: {
504
+ feature: TabDescriptor | FileViewerDescriptor
505
+ prefs: SidebarPrefs
506
+ store: SidebarStore
507
+ service: BetterSidebarService
508
+ onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
509
+ onCommit: (toggle: SidebarSettingToggle, raw: string) => string
510
+ onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
511
+ onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
512
+ onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
513
+ onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
514
+ onPluginWrite: (key: string, value: unknown) => void
515
+ onClose: () => void
516
+ }) {
517
+ const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
518
+ const render = feature.settings?.render
519
+ const toggles = feature.settings?.toggles ?? []
520
+ const pluginToggles = feature.settings?.pluginToggles ?? []
521
+ if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
522
+ // Plugin rows read their values from the descriptor's OWN blob through
523
+ // an explicit value source — no projection onto the prefs face, so a
524
+ // plugin key can never collide with (or silently read) a host pref of
525
+ // the same name.
526
+ const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
527
+ return (
528
+ <div>
529
+ {(toggles.length > 0 || pluginToggles.length > 0) && (
530
+ <div className={css.popupRows}>
531
+ {toggles.length > 0 && (
532
+ <FeatureSettingsRows
533
+ toggles={toggles}
534
+ prefs={prefs}
535
+ onToggle={onToggle}
536
+ onCommit={onCommit}
537
+ onSelectValue={onSelectValue}
538
+ />
539
+ )}
540
+ {pluginToggles.length > 0 && (
541
+ <FeatureSettingsRows
542
+ toggles={pluginToggles}
543
+ prefs={prefs}
544
+ onToggle={onPluginToggle}
545
+ onCommit={onPluginCommit}
546
+ onSelectValue={onPluginSelectValue}
547
+ valueSource={(key) => pluginBlob[key]}
548
+ />
549
+ )}
550
+ </div>
551
+ )}
552
+ {render !== undefined && (
553
+ <SettingsRender
554
+ render={render}
555
+ renderProps={{
556
+ store,
557
+ service,
558
+ prefs,
559
+ pluginSettings: prefs.pluginSettings[feature.id] ?? {},
560
+ updatePluginSetting: onPluginWrite,
561
+ close: onClose,
562
+ }}
563
+ />
564
+ )}
565
+ </div>
566
+ )
567
+ }
568
+
569
+ /**
570
+ * Render the Side card preferences section.
571
+ * @param props - composed slot props (runtime share + injected store/service).
572
+ * @returns the section element tree.
573
+ */
574
+ export function SideCardSection({ store, service }: SideCardSectionProps) {
575
+ const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
576
+ const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
577
+ const [error, setError] = useState<string | null>(null)
578
+ // Which feature's secondary settings popup is open (null = closed).
579
+ const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
580
+ // Whether the position-compat strip popup (the gear on the 常规 row) is open.
581
+ const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
582
+ // The parsed desktop environment (URL stamps — see desktop-env.ts). Used
583
+ // ONLY to badge matching presets in the scheme dropdown ("已检测");
584
+ // nothing is auto-applied.
585
+ const detectedEnv = useMemo(() => parseDesktopEnv(), [])
586
+ // Whether the "add plugin" modal (a dashed card at the end of the
587
+ // 侧边栏内容 / 文件预览 grids) is open, and for which extension point
588
+ // (null = closed).
589
+ const [addPluginsOpen, setAddPluginsOpen] = useState<PluginKind | null>(null)
590
+ // The LATEST optimistic prefs, kept in sync with the state. Nested-map
591
+ // merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from
592
+ // this ref, not from the render-time `prefs`: two same-tick writes (e.g.
593
+ // a settings panel updating several plugin keys at once) would otherwise
594
+ // both spread the stale map and the later patch would drop the earlier
595
+ // key even though the commits are serialized.
596
+ const optimisticRef = useRef(prefs)
597
+ useEffect(() => { optimisticRef.current = prefs }, [prefs])
598
+
599
+ // The declarative inventory: the registered tab types and file viewers.
600
+ // Local state + service.subscribe (registry changes are rare — plugin
601
+ // load/unload — so a plain effect is enough; no external-store ceremony).
602
+ const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
603
+ const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
604
+ useEffect(() => service.subscribe(() => {
605
+ setTabs([...service.getTabs()].sort(tabOrder))
606
+ setViewers([...service.getFileViewers()].sort(viewerOrder))
607
+ }), [service])
608
+
609
+ // The settings document revision (guards concurrent writes). A ref: commits
610
+ // read the freshest value at execution time, no re-render needed.
611
+ const revisionRef = useRef<number | undefined>(undefined)
612
+ // Whether the user already wrote since mount: the mount read must not
613
+ // clobber a newer optimistic edit (the window is milliseconds, but a slow
614
+ // route must never silently revert a just-made change).
615
+ const dirtyRef = useRef(false)
616
+ // Serialize commits: a queued write must observe the previous write's
617
+ // revision; a failed write must not poison the queue for later ones.
618
+ const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
619
+
620
+ // Sync the persisted document once on mount: the revision and the current
621
+ // values (another tab may have changed them since the store hydrated).
622
+ useEffect(() => {
623
+ let cancelled = false
624
+ void api.settingsGet().then((view) => {
625
+ if (cancelled) return
626
+ revisionRef.current = view.revision
627
+ if (dirtyRef.current) return
628
+ const next = parsePrefs(view.value)
629
+ setPrefs(next)
630
+ setWidthDraft(String(next.defaultWidthPercent))
631
+ }).catch(() => { /* the store's defaults stay authoritative */ })
632
+ return () => { cancelled = true }
633
+ }, [])
634
+
635
+ /** Persist one patch through the settings route (serialized, revision-guarded). */
636
+ const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
637
+ dirtyRef.current = true
638
+ const run = inFlightRef.current.then(async () => {
639
+ const view = await api.settingsUpdate(
640
+ { ...patch },
641
+ revisionRef.current,
642
+ )
643
+ const next = parsePrefs(view.value)
644
+ revisionRef.current = view.revision
645
+ store.setPrefs(next)
646
+ return next
647
+ })
648
+ // A failed commit must not poison the queue: later writes still run.
649
+ inFlightRef.current = run.then(() => undefined, () => undefined)
650
+ return run.then(
651
+ (next) => ({ ok: true, prefs: next }),
652
+ (caught) => {
653
+ setError(messageOf(caught))
654
+ return { ok: false, prefs }
655
+ },
656
+ )
657
+ }
658
+
659
+ /** Settle one commit: success adopts the server values, failure reverts. */
660
+ const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
661
+ const settled = outcome.ok ? outcome.prefs : previous
662
+ setPrefs(settled)
663
+ setWidthDraft(String(settled.defaultWidthPercent))
664
+ }
665
+
666
+ /** Optimistically apply one pref patch, then commit (revert on failure). */
667
+ const applyPref = (patch: Record<string, unknown>): void => {
668
+ const previous = optimisticRef.current
669
+ const next = { ...previous, ...patch } as SidebarPrefs
670
+ optimisticRef.current = next
671
+ setPrefs(next)
672
+ setError(null)
673
+ void commit(patch).then(outcome => applyOutcome(previous, outcome))
674
+ }
675
+
676
+ const onToggle = (next: boolean): void => {
677
+ applyPref({ openByDefault: next })
678
+ }
679
+
680
+ /** Flip one per-tab enable switch (merge into the tabsEnabled map). */
681
+ const onToggleTab = (id: string, next: boolean): void => {
682
+ applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
683
+ }
684
+
685
+ /** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
686
+ const onToggleViewer = (id: string, next: boolean): void => {
687
+ applyPref({ viewersEnabled: { ...optimisticRef.current.viewersEnabled, [id]: next } })
688
+ }
689
+
690
+ /** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
691
+ const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
692
+ applyPref({ [toggle.key]: next })
693
+ }
694
+
695
+ /** Commit one declaratively-declared select row (the option's value, or an
696
+ * array of values under `multi`). */
697
+ const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
698
+ applyPref({ [toggle.key]: next })
699
+ }
700
+
701
+ /**
702
+ * Commit one declaratively-declared text/number row. Numbers are parsed
703
+ * and clamped to the toggle's declared min/max (an unparsable input falls
704
+ * back to the CURRENT stored value, mirroring the width row); text rows
705
+ * persist as-is (empty is meaningful, e.g. the theme-default font).
706
+ * Returns the canonical value the row should display.
707
+ */
708
+ const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
709
+ if (toggle.type === 'number') {
710
+ const parsed = Number(raw)
711
+ const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
712
+ if (!Number.isFinite(parsed)) return fallback
713
+ let clamped = Math.round(parsed)
714
+ if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
715
+ if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
716
+ applyPref({ [toggle.key]: clamped })
717
+ return String(clamped)
718
+ }
719
+ applyPref({ [toggle.key]: raw })
720
+ return raw
721
+ }
722
+
723
+ /**
724
+ * Pick the title-bar / shell compatibility scheme. Mirrors the legacy
725
+ * `titleBarCompat` flag (true = anything but the conservative auto) so
726
+ * documents stay readable by older plugin versions.
727
+ */
728
+ /**
729
+ * Pick the title-bar / shell compatibility scheme from the dropdown. The
730
+ * option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
731
+ * a preset stores both the scheme and its id. Mirrors the legacy
732
+ * `titleBarCompat` flag (true for preset/custom) so documents stay
733
+ * readable by older plugin versions.
734
+ */
735
+ const onSchemeSelect = (value: unknown): void => {
736
+ if (typeof value !== 'string') return
737
+ if (value === 'auto' || value === 'web' || value === 'custom') {
738
+ applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
739
+ return
740
+ }
741
+ if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
742
+ applyPref({
743
+ titleBarScheme: 'preset',
744
+ titleBarPresetId: value.slice('preset:'.length),
745
+ titleBarCompat: true,
746
+ })
747
+ }
748
+ }
749
+
750
+ /** Commit the free-form custom CSS (scheme `custom`). */
751
+ const commitCustomCss = (raw: string): void => {
752
+ applyPref({ customCss: raw })
753
+ }
754
+
755
+ /** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
756
+ const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
757
+ applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
758
+ }
759
+
760
+ /** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
761
+ const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
762
+ applyPluginSetting(descriptorId, toggle.key, next)
763
+ }
764
+
765
+ /** Commit one plugin-owned text/number row (clamped like the host rows). */
766
+ const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
767
+ if (toggle.type === 'number') {
768
+ const parsed = Number(raw)
769
+ const blob = prefs.pluginSettings[descriptorId] ?? {}
770
+ const fallback = String(blob[toggle.key] ?? '')
771
+ if (!Number.isFinite(parsed)) return fallback
772
+ let clamped = Math.round(parsed)
773
+ if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
774
+ if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
775
+ applyPluginSetting(descriptorId, toggle.key, clamped)
776
+ return String(clamped)
777
+ }
778
+ applyPluginSetting(descriptorId, toggle.key, raw)
779
+ return raw
780
+ }
781
+
782
+ const commitWidth = (): void => {
783
+ const parsed = Number(widthDraft)
784
+ if (!Number.isFinite(parsed)) {
785
+ setWidthDraft(String(prefs.defaultWidthPercent))
786
+ return
787
+ }
788
+ const clamped = clampWidthPercent(parsed)
789
+ const previous = prefs
790
+ setPrefs({ ...previous, defaultWidthPercent: clamped })
791
+ setWidthDraft(String(clamped))
792
+ setError(null)
793
+ void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
794
+ }
795
+
796
+ /**
797
+ * One SMALL toggle card for the responsive inventory grid: the card's main
798
+ * area is the switch (click to flips, visual state IS the state), the icon
799
+ * sits in a rounded chip, the check badge pins to the far right, and a
800
+ * feature that declares related settings gets a labeled SETTINGS STRIP
801
+ * across the card's bottom edge (gear icon + text) opening its settings
802
+ * popup — discoverable at rest, not a hover-only ghost corner button.
803
+ */
804
+ const renderCard = (props: {
805
+ title: string
806
+ desc: string
807
+ icon?: ReactNode
808
+ enabled: boolean
809
+ onToggle: (next: boolean) => void
810
+ /** A feature with declared related settings shows the settings strip. */
811
+ onOpenSettings?: () => void
812
+ }) => {
813
+ const hasSettings = props.onOpenSettings !== undefined
814
+ return (
815
+ <div
816
+ className={clsx(css.card, props.enabled && css.cardOn)}
817
+ >
818
+ <button
819
+ type="button"
820
+ className={css.cardMain}
821
+ aria-pressed={props.enabled}
822
+ title={props.desc}
823
+ onClick={() => { props.onToggle(!props.enabled) }}
824
+ >
825
+ <span className={css.cardTop}>
826
+ {props.icon !== null && props.icon !== undefined && (
827
+ <span className={css.cardIconChip}>{props.icon}</span>
828
+ )}
829
+ <span className={css.cardTitle}>{props.title}</span>
830
+ {props.enabled && (
831
+ <span className={css.cardSwitch} aria-hidden="true">
832
+ <span className={css.cardSwitchTrack}>
833
+ <span className={css.cardSwitchThumb} />
834
+ </span>
835
+ </span>
836
+ )}
837
+ </span>
838
+ <span className={css.cardDesc}>{props.desc}</span>
839
+ </button>
840
+ {hasSettings && (
841
+ <button
842
+ type="button"
843
+ className={css.cardSettings}
844
+ aria-label={`${props.title} ${t('settingsPopup')}`}
845
+ onClick={props.onOpenSettings}
846
+ >
847
+ <IconSettingsOutline16 size={12} />
848
+ <span>{t('settingsPopup')}</span>
849
+ </button>
850
+ )}
851
+ </div>
852
+ )
853
+ }
854
+
855
+ return (
856
+ <div className={css.section}>
857
+ <p className={css.intro}>{t('settingsIntro')}</p>
858
+
859
+ {/* The managing plugin's own identity: name + version badge, so the
860
+ section is attributable at a glance (the version is the service
861
+ instance's, kept in lockstep with package.json by
862
+ tests/service.spec.ts). */}
863
+ <div className={css.versionBadge}>
864
+ <span className={css.versionBadgeName}>DSH-better-sidebar</span>
865
+ <span className={css.versionBadgeTag}>v{service.version}</span>
866
+ </div>
867
+
868
+ {/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
869
+ <div className={css.group}>
870
+ <div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
871
+ <div className={css.row}>
872
+ <span className={css.rowText}>
873
+ <span className={css.title}>{t('settingsOpenTitle')}</span>
874
+ <span className={css.desc}>{t('settingsOpenDesc')}</span>
875
+ </span>
876
+ <Switch
877
+ label={t('settingsOpenTitle')}
878
+ checked={prefs.openByDefault}
879
+ onChange={onToggle}
880
+ />
881
+ </div>
882
+ <div className={css.row}>
883
+ <span className={css.rowText}>
884
+ <span className={css.title}>{t('settingsWidthTitle')}</span>
885
+ <span className={css.desc}>{t('settingsWidthDesc')}</span>
886
+ </span>
887
+ <span className={css.control}>
888
+ <Input
889
+ type="number"
890
+ className={css.percentInput}
891
+ value={widthDraft}
892
+ min={WIDTH_PERCENT_MIN}
893
+ max={WIDTH_PERCENT_MAX}
894
+ step={1}
895
+ aria-label={t('settingsWidthTitle')}
896
+ onChange={event => { setWidthDraft(event.currentTarget.value) }}
897
+ onBlur={commitWidth}
898
+ onKeyDown={event => {
899
+ if (event.key === 'Enter') event.currentTarget.blur()
900
+ }}
901
+ />
902
+ <span className={css.suffix}>{t('settingsWidthSuffix')}</span>
903
+ </span>
904
+ </div>
905
+ <div className={css.row}>
906
+ <span className={css.rowText}>
907
+ <span className={css.title}>{t('settingsOpenPathTitle')}</span>
908
+ <span className={css.desc}>{t('settingsOpenPathDesc')}</span>
909
+ </span>
910
+ <Switch
911
+ label={t('settingsOpenPathTitle')}
912
+ checked={prefs.interceptOpenPath}
913
+ onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
914
+ />
915
+ </div>
916
+ <div className={css.row}>
917
+ <span className={css.rowText}>
918
+ <span className={css.title}>{t('settingsTitleBarTitle')}</span>
919
+ <span className={css.desc}>{t('settingsTitleBarDesc')}</span>
920
+ </span>
921
+ <span className={css.control}>
922
+ {/*
923
+ The scheme dropdown (the shared SelectMenu — NOT a native
924
+ select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
925
+ 自定义方案. Matching presets carry a 「已检测」 desc badge
926
+ (suggestion only). The 自定义方案 row keeps its gear (the
927
+ popup with the shift distance + custom CSS) — the other
928
+ schemes need no further settings.
929
+ */}
930
+ <SelectMenu
931
+ label={t('settingsTitleBarTitle')}
932
+ value={titleBarSchemeValue(prefs)}
933
+ options={[
934
+ { value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
935
+ { value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
936
+ ...getShellPresets().map(preset => ({
937
+ value: `preset:${preset.id}`,
938
+ title: preset.title,
939
+ desc: preset.detect?.(detectedEnv) === true
940
+ ? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
941
+ : preset.desc,
942
+ })),
943
+ { value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
944
+ ]}
945
+ onSelect={onSchemeSelect}
946
+ />
947
+ {prefs.titleBarScheme === 'custom' && (
948
+ <button
949
+ type="button"
950
+ className={css.rowGear}
951
+ aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
952
+ title={t('settingsPopup')}
953
+ onClick={() => { setStripSettingsOpen(true) }}
954
+ >
955
+ <IconSettingsOutline16 size={14} />
956
+ </button>
957
+ )}
958
+ </span>
959
+ </div>
960
+ </div>
961
+
962
+ {/* 侧边栏内容: one small card per registered tab type in a responsive
963
+ grid; features declaring `settings.toggles` open their settings in
964
+ the popup (gear corner button) instead of nested inline rows. */}
965
+ <div className={css.group}>
966
+ <div className={css.groupHeading}>
967
+ <span>{t('settingsTabsTitle')}</span>
968
+ <span className={css.count}>{tabs.length}</span>
969
+ </div>
970
+ <div className={css.grid}>
971
+ {tabs.map(tab => (
972
+ <Fragment key={tab.id}>
973
+ {renderCard({
974
+ title: textOf(tab.title),
975
+ desc: tab.id,
976
+ icon: iconOf(tab.icon, 16),
977
+ enabled: prefs.tabsEnabled[tab.id] !== false,
978
+ onToggle: (next) => { onToggleTab(tab.id, next) },
979
+ // The settings gear only while the feature is enabled: its
980
+ // related settings are dormant while the feature is off.
981
+ onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab)
982
+ ? () => { setSettingsFor(tab) }
983
+ : undefined,
984
+ })}
985
+ </Fragment>
986
+ ))}
987
+ {/* The "add tab plugin" entry: same card size as the inventory,
988
+ but a dashed border — it opens the TAB-registration plugin
989
+ modal instead of toggling a feature. */}
990
+ <button
991
+ type="button"
992
+ className={clsx(css.card, css.addCard)}
993
+ onClick={() => { setAddPluginsOpen('tab') }}
994
+ >
995
+ <span className={css.cardTop}>
996
+ <span className={css.cardIconChip}>
997
+ <IconPlusOutline16 size={16} />
998
+ </span>
999
+ <span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
1000
+ </span>
1001
+ <span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
1002
+ </button>
1003
+ </div>
1004
+ </div>
1005
+
1006
+ {/* 文件预览: one small card per registered file viewer. */}
1007
+ <div className={css.group}>
1008
+ <div className={css.groupHeading}>
1009
+ <span>{t('settingsViewersTitle')}</span>
1010
+ <span className={css.count}>{viewers.length}</span>
1011
+ </div>
1012
+ <div className={css.grid}>
1013
+ {viewers.map(viewer => (
1014
+ <Fragment key={viewer.id}>
1015
+ {renderCard({
1016
+ title: textOf(viewer.title) || viewer.id,
1017
+ desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
1018
+ icon: iconOf(viewer.icon, 16),
1019
+ enabled: prefs.viewersEnabled[viewer.id] !== false,
1020
+ onToggle: (next) => { onToggleViewer(viewer.id, next) },
1021
+ onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
1022
+ ? () => { setSettingsFor(viewer) }
1023
+ : undefined,
1024
+ })}
1025
+ </Fragment>
1026
+ ))}
1027
+ {/* The "add preview plugin" entry: dashed card opening the
1028
+ FILE-PREVIEWER registration modal. */}
1029
+ <button
1030
+ type="button"
1031
+ className={clsx(css.card, css.addCard)}
1032
+ onClick={() => { setAddPluginsOpen('viewer') }}
1033
+ >
1034
+ <span className={css.cardTop}>
1035
+ <span className={css.cardIconChip}>
1036
+ <IconPlusOutline16 size={16} />
1037
+ </span>
1038
+ <span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
1039
+ </span>
1040
+ <span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
1041
+ </button>
1042
+ </div>
1043
+ </div>
1044
+
1045
+ {/* The secondary settings popup: a feature's declared related settings
1046
+ as title/desc + switch rows in a wider-than-default Modal with a
1047
+ Done footer (Modal chrome is the app's own). Mounted only while a
1048
+ feature is open — the Modal primitive runs hooks unconditionally,
1049
+ so a closed-but-mounted Modal would break SSR (and the
1050
+ renderToString spec) under the test dual-react split.
1051
+ Content: the host-prefs `toggles` rows, the plugin-owned
1052
+ `pluginToggles` rows (their values live in pluginSettings[id]),
1053
+ then the custom `settings.render` panel when declared. */}
1054
+ {settingsFor !== null && (
1055
+ <Modal
1056
+ open
1057
+ onClose={() => { setSettingsFor(null) }}
1058
+ title={featureNameOf(settingsFor)}
1059
+ description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
1060
+ closeLabel={t('close')}
1061
+ className={css.popupDialog}
1062
+ footer={(
1063
+ <button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
1064
+ {t('settingsDone')}
1065
+ </button>
1066
+ )}
1067
+ >
1068
+ <SettingsBody
1069
+ feature={settingsFor}
1070
+ prefs={prefs}
1071
+ onToggle={onToggleSetting}
1072
+ onCommit={onCommitSetting}
1073
+ onSelectValue={onSelectSetting}
1074
+ onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
1075
+ onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
1076
+ onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
1077
+ onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
1078
+ onClose={() => { setSettingsFor(null) }}
1079
+ store={store}
1080
+ service={service}
1081
+ />
1082
+ </Modal>
1083
+ )}
1084
+
1085
+ {/* The custom-scheme popup (opened by the gear next to the scheme
1086
+ dropdown when 自定义方案 is active): the shift distance in px and
1087
+ the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
1088
+ 壳预设) need no further settings — the scheme itself is chosen on
1089
+ the 常规 row. Mounted only while open (the Modal SSR rule above). */}
1090
+ {stripSettingsOpen && (
1091
+ <Modal
1092
+ open
1093
+ onClose={() => { setStripSettingsOpen(false) }}
1094
+ title={t('settingsTitleBarTitle')}
1095
+ description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
1096
+ closeLabel={t('close')}
1097
+ className={css.popupDialog}
1098
+ footer={(
1099
+ <button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
1100
+ {t('settingsDone')}
1101
+ </button>
1102
+ )}
1103
+ >
1104
+ <div className={css.popupRows}>
1105
+ <FeatureSettingsRows
1106
+ toggles={[{
1107
+ key: 'titleBarStripPx',
1108
+ type: 'number',
1109
+ title: () => t('settingsTitleBarStripTitle'),
1110
+ desc: () => t('settingsTitleBarStripDesc'),
1111
+ min: TITLE_BAR_STRIP_MIN,
1112
+ max: TITLE_BAR_STRIP_MAX,
1113
+ unit: 'px',
1114
+ }]}
1115
+ prefs={prefs}
1116
+ onToggle={onToggleSetting}
1117
+ onCommit={onCommitSetting}
1118
+ />
1119
+ <CssDraft
1120
+ key={prefs.customCss}
1121
+ value={prefs.customCss}
1122
+ label={t('settingsCustomCssTitle')}
1123
+ placeholder={t('settingsCustomCssPlaceholder')}
1124
+ onCommit={commitCustomCss}
1125
+ />
1126
+ </div>
1127
+ </Modal>
1128
+ )}
1129
+
1130
+ {/* The "add plugin" modal (opened by the dashed cards above): declares
1131
+ the extension point of the clicked kind, opens the GitHub topic,
1132
+ and lists the matching recommended plugin catalog with per-entry
1133
+ install buttons (the install flow opens a ~/.dsh terminal with
1134
+ the command pre-typed; failures render inline here, in settings
1135
+ only). Mounted only while open (Modal runs hooks unconditionally
1136
+ — same SSR rule as the settings popup above). */}
1137
+ {addPluginsOpen !== null && (
1138
+ <AddPluginModal
1139
+ service={service}
1140
+ onClose={() => { setAddPluginsOpen(null) }}
1141
+ kind={addPluginsOpen}
1142
+ />
1143
+ )}
1144
+
1145
+ {error !== null && (
1146
+ <div className={css.error} role="alert">
1147
+ {error}
1148
+ </div>
1149
+ )}
1150
+ </div>
1151
+ )
1152
+ }