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