dsh-better-sidebar 0.10.0

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -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 +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,426 @@
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 card), and
8
+ * the default panel width as a percent of the window (number input row).
9
+ * - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
10
+ * external plugins alike), laid out in a responsive grid that wraps
11
+ * several cards per row — icon + title + type id, clicked to toggle the
12
+ * switch persisted in `prefs.tabsEnabled[id]`.
13
+ * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
14
+ * the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
15
+ *
16
+ * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
17
+ * border + tinted fill + a check badge pinned to the card's far right),
18
+ * disabled = neutral and dimmed. Features that declare `settings.toggles`
19
+ * carry a gear corner button that opens a native Modal with the related
20
+ * settings as native checkbox rows (e.g. the Subagent page's "auto-open
21
+ * when a subagent appears", the Terminal page's model terminal tools).
22
+ *
23
+ * Writes ride the plugin's own fenced settings route (the host calls the
24
+ * settings seam in-process — the DSH settings RPC domain does not serve
25
+ * third-party namespaces to configuration clients); the shared SidebarStore
26
+ * is refreshed on success so the very next brand-new session seeds from the
27
+ * new values and the sidebar's consumption points (the + menu, derived
28
+ * flows) re-render immediately. Any failure reverts the optimistic UI and
29
+ * shows the wire error inline — a broken settings surface never crashes the
30
+ * shell.
31
+ */
32
+ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
33
+ import {
34
+ IconCheckOutline16,
35
+ IconCodeOutline16,
36
+ IconPanelLeftOutline16,
37
+ IconSettingsOutline16,
38
+ Input,
39
+ Modal,
40
+ } from '@deepseek-ai/dsh-client-ui-primitives'
41
+ import clsx from 'clsx'
42
+ // Type-only: pulls the settings shell's SlotMap merges ('settings.section').
43
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
44
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
45
+ import {
46
+ clampWidthPercent,
47
+ WIDTH_PERCENT_MAX,
48
+ WIDTH_PERCENT_MIN,
49
+ type SidebarPrefs,
50
+ } from '../prefs-shared.ts'
51
+ import { api } from './api.ts'
52
+ import { parsePrefs } from './prefs.ts'
53
+ import { t } from './locales.ts'
54
+ import type { SidebarStore } from './state.ts'
55
+ import type {
56
+ BetterSidebarService,
57
+ FileViewerDescriptor,
58
+ SidebarSettingToggle,
59
+ TabDescriptor,
60
+ } from './service.ts'
61
+ import css from './SideCardSection.module.css'
62
+
63
+ /** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
64
+ export interface SideCardSectionInjected {
65
+ store: SidebarStore
66
+ service: BetterSidebarService
67
+ }
68
+
69
+ /** Full section props: the runtime share plus the injected face. */
70
+ export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
71
+
72
+ /** Map one wire failure to the inline message (the conflict gets friendly copy). */
73
+ function messageOf(error: unknown): string {
74
+ if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
75
+ return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
76
+ }
77
+ return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
78
+ }
79
+
80
+ /** Resolve an i18n-friendly string-or-function value. */
81
+ function textOf(value: string | (() => string) | undefined): string {
82
+ if (value === undefined) return ''
83
+ return typeof value === 'function' ? value() : value
84
+ }
85
+
86
+ /** Resolve a descriptor icon (ReactNode or size function). */
87
+ function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
88
+ if (icon === undefined) return null
89
+ return typeof icon === 'function' ? icon(size) : icon
90
+ }
91
+
92
+ /** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
93
+ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
94
+ if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
95
+ return (a.order ?? 100) - (b.order ?? 100)
96
+ }
97
+
98
+ /** Viewer inventory order: priority desc (the catch-all `code` comes last). */
99
+ function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
100
+ return (b.priority ?? 0) - (a.priority ?? 0)
101
+ }
102
+
103
+ /** Read one boolean pref by declarative key (missing = false). */
104
+ function prefBool(prefs: SidebarPrefs, key: string): boolean {
105
+ return (prefs as unknown as Record<string, boolean>)[key] === true
106
+ }
107
+
108
+ /**
109
+ * The body of a feature's secondary settings popup: one native checkbox row
110
+ * per declared toggle. Extracted so the rows are testable without opening
111
+ * the Modal (the Modal portal renders only while open).
112
+ */
113
+ export function FeatureSettingsRows(props: {
114
+ toggles: readonly SidebarSettingToggle[]
115
+ prefs: SidebarPrefs
116
+ onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
117
+ }) {
118
+ const { toggles, prefs, onToggle } = props
119
+ return (
120
+ <div className={css.popupRows}>
121
+ {toggles.map(toggle => {
122
+ const title = textOf(toggle.title)
123
+ return (
124
+ <label key={toggle.key} className={css.popupRow}>
125
+ <span className={css.rowText}>
126
+ <span className={css.title}>{title}</span>
127
+ {textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
128
+ </span>
129
+ <input
130
+ type="checkbox"
131
+ className={css.toggle}
132
+ checked={prefBool(prefs, toggle.key)}
133
+ aria-label={title}
134
+ onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
135
+ />
136
+ </label>
137
+ )
138
+ })}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Render the Side card preferences section.
145
+ * @param props - composed slot props (runtime share + injected store/service).
146
+ * @returns the section element tree.
147
+ */
148
+ export function SideCardSection({ store, service }: SideCardSectionProps) {
149
+ const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
150
+ const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
151
+ const [error, setError] = useState<string | null>(null)
152
+ // Which feature's secondary settings popup is open (null = closed).
153
+ const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
154
+
155
+ // The declarative inventory: the registered tab types and file viewers.
156
+ // Local state + service.subscribe (registry changes are rare — plugin
157
+ // load/unload — so a plain effect is enough; no external-store ceremony).
158
+ const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
159
+ const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
160
+ useEffect(() => service.subscribe(() => {
161
+ setTabs([...service.getTabs()].sort(tabOrder))
162
+ setViewers([...service.getFileViewers()].sort(viewerOrder))
163
+ }), [service])
164
+
165
+ // The settings document revision (guards concurrent writes). A ref: commits
166
+ // read the freshest value at execution time, no re-render needed.
167
+ const revisionRef = useRef<number | undefined>(undefined)
168
+ // Whether the user already wrote since mount: the mount read must not
169
+ // clobber a newer optimistic edit (the window is milliseconds, but a slow
170
+ // route must never silently revert a just-made change).
171
+ const dirtyRef = useRef(false)
172
+ // Serialize commits: a queued write must observe the previous write's
173
+ // revision; a failed write must not poison the queue for later ones.
174
+ const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
175
+
176
+ // Sync the persisted document once on mount: the revision and the current
177
+ // values (another tab may have changed them since the store hydrated).
178
+ useEffect(() => {
179
+ let cancelled = false
180
+ void api.settingsGet().then((view) => {
181
+ if (cancelled) return
182
+ revisionRef.current = view.revision
183
+ if (dirtyRef.current) return
184
+ const next = parsePrefs(view.value)
185
+ setPrefs(next)
186
+ setWidthDraft(String(next.defaultWidthPercent))
187
+ }).catch(() => { /* the store's defaults stay authoritative */ })
188
+ return () => { cancelled = true }
189
+ }, [])
190
+
191
+ /** Persist one patch through the settings route (serialized, revision-guarded). */
192
+ const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
193
+ dirtyRef.current = true
194
+ const run = inFlightRef.current.then(async () => {
195
+ const view = await api.settingsUpdate(
196
+ { ...patch },
197
+ revisionRef.current,
198
+ )
199
+ const next = parsePrefs(view.value)
200
+ revisionRef.current = view.revision
201
+ store.setPrefs(next)
202
+ return next
203
+ })
204
+ // A failed commit must not poison the queue: later writes still run.
205
+ inFlightRef.current = run.then(() => undefined, () => undefined)
206
+ return run.then(
207
+ (next) => ({ ok: true, prefs: next }),
208
+ (caught) => {
209
+ setError(messageOf(caught))
210
+ return { ok: false, prefs }
211
+ },
212
+ )
213
+ }
214
+
215
+ /** Settle one commit: success adopts the server values, failure reverts. */
216
+ const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
217
+ const settled = outcome.ok ? outcome.prefs : previous
218
+ setPrefs(settled)
219
+ setWidthDraft(String(settled.defaultWidthPercent))
220
+ }
221
+
222
+ /** Optimistically flip one boolean pref, then commit (revert on failure). */
223
+ const togglePref = (patch: Record<string, unknown>): void => {
224
+ const previous = prefs
225
+ setPrefs({ ...previous, ...patch } as SidebarPrefs)
226
+ setError(null)
227
+ void commit(patch).then(outcome => applyOutcome(previous, outcome))
228
+ }
229
+
230
+ const onToggle = (next: boolean): void => {
231
+ togglePref({ openByDefault: next })
232
+ }
233
+
234
+ /** Flip one per-tab enable switch (merge into the tabsEnabled map). */
235
+ const onToggleTab = (id: string, next: boolean): void => {
236
+ togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
237
+ }
238
+
239
+ /** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
240
+ const onToggleViewer = (id: string, next: boolean): void => {
241
+ togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
242
+ }
243
+
244
+ /** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
245
+ const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
246
+ togglePref({ [toggle.key]: next })
247
+ }
248
+
249
+ const commitWidth = (): void => {
250
+ const parsed = Number(widthDraft)
251
+ if (!Number.isFinite(parsed)) {
252
+ setWidthDraft(String(prefs.defaultWidthPercent))
253
+ return
254
+ }
255
+ const clamped = clampWidthPercent(parsed)
256
+ const previous = prefs
257
+ setPrefs({ ...previous, defaultWidthPercent: clamped })
258
+ setWidthDraft(String(clamped))
259
+ setError(null)
260
+ void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
261
+ }
262
+
263
+ /**
264
+ * One SMALL toggle card for the responsive inventory grid: the card's main
265
+ * area is the switch (click to flips, visual state IS the state), the
266
+ * check badge sits at the far right, and a feature that declares related
267
+ * settings carries a gear corner button opening its settings popup.
268
+ */
269
+ const renderCard = (props: {
270
+ title: string
271
+ desc: string
272
+ icon?: ReactNode
273
+ enabled: boolean
274
+ onToggle: (next: boolean) => void
275
+ /** A feature with declared related settings shows the gear corner button. */
276
+ onOpenSettings?: () => void
277
+ }) => {
278
+ const hasSettings = props.onOpenSettings !== undefined
279
+ return (
280
+ <div
281
+ className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
282
+ >
283
+ <button
284
+ type="button"
285
+ className={css.cardMain}
286
+ aria-pressed={props.enabled}
287
+ title={props.desc}
288
+ onClick={() => { props.onToggle(!props.enabled) }}
289
+ >
290
+ <span className={css.cardTop}>
291
+ {props.icon !== null && props.icon !== undefined && (
292
+ <span className={css.cardIcon}>{props.icon}</span>
293
+ )}
294
+ <span className={css.cardTitle}>{props.title}</span>
295
+ {props.enabled && (
296
+ <span className={css.cardCheck}>
297
+ <IconCheckOutline16 size={14} />
298
+ </span>
299
+ )}
300
+ </span>
301
+ <span className={css.cardDesc}>{props.desc}</span>
302
+ </button>
303
+ {hasSettings && (
304
+ <button
305
+ type="button"
306
+ className={css.cardGear}
307
+ aria-label={`${props.title} ${t('settingsPopup')}`}
308
+ title={t('settingsPopup')}
309
+ onClick={props.onOpenSettings}
310
+ >
311
+ <IconSettingsOutline16 size={14} />
312
+ </button>
313
+ )}
314
+ </div>
315
+ )
316
+ }
317
+
318
+ return (
319
+ <div className={css.section}>
320
+ <div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
321
+ {renderCard({
322
+ title: t('settingsOpenTitle'),
323
+ desc: t('settingsOpenDesc'),
324
+ icon: <IconPanelLeftOutline16 size={16} />,
325
+ enabled: prefs.openByDefault,
326
+ onToggle,
327
+ })}
328
+ <div className={css.row}>
329
+ <span className={css.rowText}>
330
+ <span className={css.title}>{t('settingsWidthTitle')}</span>
331
+ <span className={css.desc}>{t('settingsWidthDesc')}</span>
332
+ </span>
333
+ <span className={css.control}>
334
+ <Input
335
+ type="number"
336
+ className={css.percentInput}
337
+ value={widthDraft}
338
+ min={WIDTH_PERCENT_MIN}
339
+ max={WIDTH_PERCENT_MAX}
340
+ step={1}
341
+ aria-label={t('settingsWidthTitle')}
342
+ onChange={event => { setWidthDraft(event.currentTarget.value) }}
343
+ onBlur={commitWidth}
344
+ onKeyDown={event => {
345
+ if (event.key === 'Enter') event.currentTarget.blur()
346
+ }}
347
+ />
348
+ <span className={css.suffix}>{t('settingsWidthSuffix')}</span>
349
+ </span>
350
+ </div>
351
+ {renderCard({
352
+ title: t('settingsOpenPathTitle'),
353
+ desc: t('settingsOpenPathDesc'),
354
+ icon: <IconCodeOutline16 size={16} />,
355
+ enabled: prefs.interceptOpenPath,
356
+ onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
357
+ })}
358
+
359
+ {/* 侧边栏内容: one small card per registered tab type in a responsive
360
+ grid; features declaring `settings.toggles` open their settings in
361
+ the popup (gear corner button) instead of nested inline rows. */}
362
+ <div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
363
+ <div className={css.grid}>
364
+ {tabs.map(tab => (
365
+ <Fragment key={tab.id}>
366
+ {renderCard({
367
+ title: textOf(tab.title),
368
+ desc: tab.id,
369
+ icon: iconOf(tab.icon, 16),
370
+ enabled: prefs.tabsEnabled[tab.id] !== false,
371
+ onToggle: (next) => { onToggleTab(tab.id, next) },
372
+ // The settings gear only while the feature is enabled: its
373
+ // related settings are dormant while the feature is off.
374
+ onOpenSettings: prefs.tabsEnabled[tab.id] !== false
375
+ && (tab.settings?.toggles?.length ?? 0) > 0
376
+ ? () => { setSettingsFor(tab) }
377
+ : undefined,
378
+ })}
379
+ </Fragment>
380
+ ))}
381
+ </div>
382
+
383
+ {/* 文件预览: one small card per registered file viewer. */}
384
+ <div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
385
+ <div className={css.grid}>
386
+ {viewers.map(viewer => (
387
+ <Fragment key={viewer.id}>
388
+ {renderCard({
389
+ title: textOf(viewer.title) || viewer.id,
390
+ desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
391
+ icon: iconOf(viewer.icon, 16),
392
+ enabled: prefs.viewersEnabled[viewer.id] !== false,
393
+ onToggle: (next) => { onToggleViewer(viewer.id, next) },
394
+ })}
395
+ </Fragment>
396
+ ))}
397
+ </div>
398
+
399
+ {/* The secondary settings popup: a feature's declared related settings
400
+ as native checkbox rows (Modal chrome is the app's own). Mounted
401
+ only while a feature is open — the Modal primitive runs hooks
402
+ unconditionally, so a closed-but-mounted Modal would break SSR
403
+ (and the renderToString spec) under the test dual-react split. */}
404
+ {settingsFor !== null && (
405
+ <Modal
406
+ open
407
+ onClose={() => { setSettingsFor(null) }}
408
+ title={textOf(settingsFor.title)}
409
+ closeLabel={t('close')}
410
+ >
411
+ <FeatureSettingsRows
412
+ toggles={settingsFor.settings?.toggles ?? []}
413
+ prefs={prefs}
414
+ onToggle={onToggleSetting}
415
+ />
416
+ </Modal>
417
+ )}
418
+
419
+ {error !== null && (
420
+ <div className={css.error} role="alert">
421
+ {error}
422
+ </div>
423
+ )}
424
+ </div>
425
+ )
426
+ }