@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,563 @@
1
+ /**
2
+ * The editor tab host: the single FILES WINDOW. It resolves a file's
3
+ * previewer through the sidebar registry (`matchFileViewer`), fetches bytes
4
+ * per the matched viewer's fetch strategy, and renders its component — or
5
+ * the shared download pane when nothing can render the file. A tab without
6
+ * a path (the seeded "Files" home) renders an empty-state hint instead of
7
+ * the viewer loading flow; that path-less window IS the file explorer.
8
+ *
9
+ * The chrome depends on the `editorExplorer` mode (read reactively so
10
+ * toggling it re-renders without a reload):
11
+ * - merged (in-place): tree click / path-input Enter switch the CURRENT
12
+ * tab in place (updateTab rewrites path/title; the tab keeps its id and
13
+ * meta, so treeOpen/treeWidth survive the switch);
14
+ * - split: they open through `openSidebarFile` (a per-path dedupe tab),
15
+ * and a PATH-LESS window is the standalone explorer — it renders ONLY
16
+ * the tree panel (search + FileTree, full-window), no editor chrome.
17
+ * Editor tabs (with a path) keep the full chrome in both modes.
18
+ * The tree's context menu offers the explicit escapes in both modes: open
19
+ * in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
20
+ * rightward split of the current pane).
21
+ *
22
+ * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
23
+ * editor-load.ts); this component only wires it to the host APIs.
24
+ */
25
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
26
+ import { createElement } from 'react'
27
+ import clsx from 'clsx'
28
+ import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
29
+ import type { Context } from '../context-types.ts'
30
+ import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
31
+ import { BinaryDownload } from './binary-download.tsx'
32
+ import { FenceErrorNotice } from './FenceErrorNotice.tsx'
33
+ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
34
+ import { baseName } from './FileTree.tsx'
35
+ import { createFrameBatcher } from './frame-batcher.ts'
36
+ import { openSidebarFile } from './intercept.tsx'
37
+ import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
38
+ import { updatePluginSettings } from './plugin-settings.ts'
39
+ import { TreePanel } from './TreePanel.tsx'
40
+ import { t } from './locales.ts'
41
+ import { relativeTo } from './paths.ts'
42
+ import { resolveSidebarPath } from './produced-files.ts'
43
+ import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
44
+ import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
45
+ import css from './sidebar.module.css'
46
+
47
+ type EditorLoad =
48
+ | { status: 'loading' }
49
+ | { status: 'error'; message: string }
50
+ | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
51
+ | { status: 'binary' }
52
+
53
+ /** The docked tree panel's width bounds (drag-resize clamps into them). */
54
+ const TREE_WIDTH_DEFAULT = 240
55
+ const TREE_WIDTH_MIN = 160
56
+ const TREE_WIDTH_MAX = 480
57
+
58
+ /** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
59
+ * would change identity every snapshot and loop useSyncExternalStore). */
60
+ const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
61
+
62
+ /** The tab's persisted meta object (a malformed meta reads as empty). */
63
+ function metaOf(tab: SidebarTab): Record<string, unknown> {
64
+ return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
65
+ ? tab.meta as Record<string, unknown>
66
+ : {}
67
+ }
68
+
69
+ /** Read the persisted tree-panel flag of one editor tab: an explicit
70
+ * boolean meta wins; otherwise path-less tabs (the seeded home) default
71
+ * open and file tabs default closed. */
72
+ function treeOpenOf(tab: SidebarTab): boolean {
73
+ const treeOpen = metaOf(tab).treeOpen
74
+ return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
75
+ }
76
+
77
+ /** Read the persisted tree-panel width (clamped; default 240). */
78
+ function treeWidthOf(tab: SidebarTab): number {
79
+ const width = metaOf(tab).treeWidth
80
+ return typeof width === 'number' && Number.isFinite(width)
81
+ ? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
82
+ : TREE_WIDTH_DEFAULT
83
+ }
84
+
85
+ /** Merge a patch into the tab's persisted meta (rides the layout). */
86
+ function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
87
+ ctx.get('betterSidebar')?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
88
+ }
89
+
90
+ /** Clamp one dock width into the contract range. */
91
+ function clampTreeWidth(value: number): number {
92
+ return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
93
+ }
94
+
95
+ export function EditorHost(props: {
96
+ ctx: Context
97
+ store: SidebarStore
98
+ scope: SessionScope
99
+ tab: SidebarTab
100
+ expanded: string[]
101
+ revealed: string[]
102
+ onToggleDir: (path: string) => void
103
+ onReferenceFile: (path: string) => void
104
+ }) {
105
+ const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
106
+ const path = tab.path ?? ''
107
+ const title = tab.title
108
+ // A folder window: the model's `sidebar_open` (or any caller) opens a
109
+ // directory as an editor tab carrying `meta.dir: true` with the directory
110
+ // as its path. It renders the file tree rooted at that folder instead of
111
+ // the viewer loading flow (a directory is not a file).
112
+ const isDir = metaOf(tab).dir === true
113
+ const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
114
+ // Manual refresh (issue #167): bumping the sequence re-runs the load effect
115
+ // with the same path/scope — the only reload entry besides open/close.
116
+ const [reloadSeq, setReloadSeq] = useState(0)
117
+
118
+ // Manual refresh (issue #167 + PR #228): a dirty draft is dropped by the
119
+ // reload (the editor instance remounts), so confirm before discarding it.
120
+ const refreshFile = (): void => {
121
+ if (toolbar?.dirty === true) {
122
+ const confirmed = typeof window.confirm === 'function'
123
+ ? window.confirm(t('refreshUnsavedConfirm'))
124
+ : false
125
+ if (!confirmed) return
126
+ }
127
+ setReloadSeq(sequence => sequence + 1)
128
+ }
129
+
130
+ // Reactive prefs read: flipping editorExplorer re-renders this tab with no
131
+ // reload. The snapshot is the bare boolean so unrelated store churn never
132
+ // re-renders the editor.
133
+ const inPlace = useSyncExternalStore(
134
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
135
+ useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
136
+ )
137
+ // The file tree's "open with" configuration (pluginSettings['editor']): a
138
+ // blob subscription, so a pin click or a settings-page edit re-renders the
139
+ // menu immediately. The parsed config also drives which targets are shown
140
+ // (SSH mode hides the host-local ones).
141
+ const editorBlob = useSyncExternalStore(
142
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
143
+ useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
144
+ )
145
+ const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
146
+ const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
147
+ // A path-less tab shows the empty-state hint in merged mode — and in split
148
+ // mode it is the standalone explorer (tree-only, see the render below). A
149
+ // folder tab is a folder window in BOTH modes: the tree rooted at the
150
+ // folder, no editor chrome.
151
+ const showEmpty = path === ''
152
+ const treeOnly = showEmpty && !inPlace
153
+ const folderRoot = isDir ? path : undefined
154
+
155
+ /**
156
+ * Open a file from THIS window (tree click / search row / path input):
157
+ * merged mode switches this tab in place (stable id, meta survives);
158
+ * split mode opens a per-path dedupe tab through openSidebarFile.
159
+ */
160
+ const openFile = (absolute: string): void => {
161
+ if (inPlace) {
162
+ ctx.get('betterSidebar')?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
163
+ } else {
164
+ openSidebarFile(ctx, store, scope.sessionId, absolute)
165
+ }
166
+ }
167
+
168
+ /** The context menu's explicit "new tab" escape (per-path dedupe). */
169
+ const openFileNewTab = (absolute: string): void => {
170
+ openSidebarFile(ctx, store, scope.sessionId, absolute)
171
+ }
172
+
173
+ /**
174
+ * The context menu's "open to the side": a fresh editor tab (uid id — the
175
+ * `'editor:' + path` convention would clash with the id safety net on a
176
+ * second side-open of the same file) in a rightward split of THIS pane.
177
+ */
178
+ const openFileSide = (absolute: string): void => {
179
+ store.reduce((state) => {
180
+ const key = treeOf(state, tab.id)
181
+ const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
182
+ const fresh: SidebarTab = {
183
+ id: mintTabId(),
184
+ type: 'editor',
185
+ title: baseName(absolute),
186
+ path: absolute,
187
+ meta: { treeOpen: false },
188
+ }
189
+ const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
190
+ return { ...state, [key]: node, activePane: leafId }
191
+ })
192
+ }
193
+
194
+ /** The context menu's "open with" action: reveal the path in the OS file
195
+ * manager, or hand the target's URL (a local `file` URL, or the SSH-remote
196
+ * form for VSCode-family editors in remote mode) to the host's external
197
+ * opener. Failures are logged only — a missing handler is the OS's
198
+ * dialog, not a sidebar error. */
199
+ const openWith = (targetId: string, absolute: string): void => {
200
+ const target = openWithTargets.find(item => item.id === targetId)
201
+ if (target === undefined) return
202
+ if (target.kind === 'reveal') {
203
+ void api.openExternal({ action: 'reveal', path: absolute }).catch(
204
+ (error: unknown) => { console.error('open external failed', error) },
205
+ )
206
+ return
207
+ }
208
+ const url = openWithUrl(target, absolute, openWithConfig)
209
+ if (url === undefined) return
210
+ void api.openExternal({ action: 'url', url }).catch(
211
+ (error: unknown) => { console.error('open external failed', error) },
212
+ )
213
+ }
214
+
215
+ /** Toggle one target's pinned state. The write is serialized (see
216
+ * plugin-settings.ts) and the menu re-renders when the store prefs land. */
217
+ const toggleOpenWithPin = (targetId: string): void => {
218
+ updatePluginSettings(store, 'editor', (blob) => {
219
+ const config = parseOpenWithConfig(blob.openWith)
220
+ const pinned = config.pinned.includes(targetId)
221
+ ? config.pinned.filter(id => id !== targetId)
222
+ : [...config.pinned, targetId]
223
+ return { ...blob, openWith: { ...config, pinned } }
224
+ })
225
+ }
226
+
227
+ // The viewer's toolbar, hoisted into THIS header: the text editor reports
228
+ // its state and registers its commands (both null/absent for viewers
229
+ // without a toolbar — image, pdf, binary download).
230
+ const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
231
+ const controlsRef = useRef<EditorToolbarControls | null>(null)
232
+ const onToolbarState = useCallback((next: EditorToolbarState) => {
233
+ setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
234
+ }, [])
235
+ const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
236
+ controlsRef.current = controls
237
+ }, [])
238
+
239
+ // The docked panel's drag-resize: pointer capture on the handle itself
240
+ // (no window listeners — the captured pointer keeps tracking even off the
241
+ // handle). Local width while dragging, persisted into meta.treeWidth on
242
+ // release. The panel docks right, so dragging LEFT widens it.
243
+ // Moves are BATCHED per frame (createFrameBatcher): applying every
244
+ // pointermove is a setState that re-renders this host AND the editor
245
+ // viewer below it (CodeMirror re-lays out on the width change) at event
246
+ // cadence — the visible drag lag on slower CPUs (#315). The batch applies
247
+ // the latest width once per frame; release flushes it and commits.
248
+ const [dragWidth, setDragWidth] = useState<number | null>(null)
249
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
250
+ const pendingWidthRef = useRef(0)
251
+ const dragBatcher = useRef(createFrameBatcher()).current
252
+ useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
253
+ const treeWidth = dragWidth ?? treeWidthOf(tab)
254
+
255
+ const onResizeStart = (event: React.PointerEvent): void => {
256
+ event.preventDefault()
257
+ // jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
258
+ event.currentTarget.setPointerCapture?.(event.pointerId)
259
+ dragRef.current = { startX: event.clientX, startWidth: treeWidth }
260
+ }
261
+ const onResizeMove = (event: React.PointerEvent): void => {
262
+ const drag = dragRef.current
263
+ if (drag === null) return
264
+ pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
265
+ dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
266
+ }
267
+ const onResizeEnd = (event: React.PointerEvent): void => {
268
+ const drag = dragRef.current
269
+ if (drag === null) return
270
+ // Flush the last pending frame (a release can land with the final move
271
+ // still queued; without the flush a stray frame would re-apply the
272
+ // drag width AFTER the null below). Both setStates batch into this same
273
+ // event, so the committed treeWidth wins visually.
274
+ dragBatcher.flushNow()
275
+ dragRef.current = null
276
+ setDragWidth(null)
277
+ const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
278
+ if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
279
+ }
280
+
281
+ useEffect(() => {
282
+ // A (re)load or a path-less tab clears any hoisted toolbar state — the
283
+ // fresh viewer re-registers its own.
284
+ setToolbar(null)
285
+ // The seeded home tab (no path) never loads a viewer — the empty-state
286
+ // hint renders until the user picks a file. A folder tab never loads a
287
+ // viewer either — its tree is rooted at the folder.
288
+ if (showEmpty || isDir) return
289
+ let cancelled = false
290
+ // Aborts the matched viewer's `load` when the editor tears down (tab
291
+ // closed, path changed, session switched) or re-matches the viewer.
292
+ const controller = new AbortController()
293
+ setLoad({ status: 'loading' })
294
+ const mediaUrlOf = (): string => mediaUrl(scope, path)
295
+ const apply = (action: EditorLoadAction): void => {
296
+ if (cancelled) return
297
+ switch (action.kind) {
298
+ case 'binary':
299
+ setLoad({ status: 'binary' })
300
+ return
301
+ case 'render':
302
+ setLoad({
303
+ status: 'ready',
304
+ viewer: action.viewer,
305
+ content: action.content,
306
+ truncated: action.truncated,
307
+ mediaUrl: action.mediaUrl,
308
+ customData: action.customData,
309
+ })
310
+ return
311
+ case 'customLoad':
312
+ void action.viewer.load?.(path, scope, controller.signal).then((data) => {
313
+ if (cancelled) return
314
+ setLoad({ status: 'ready', viewer: action.viewer, customData: data })
315
+ }).catch((error: unknown) => {
316
+ if (cancelled) return
317
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
318
+ })
319
+ return
320
+ case 'fetchFsRead':
321
+ api.fsRead(scope, path).then((result) => {
322
+ if (cancelled) return
323
+ // Binary reads carry the head bytes for the detect re-match.
324
+ const outcome = planFsReadOutcome(action.viewer, {
325
+ binary: result.kind === 'binary',
326
+ content: result.kind === 'text' ? result.content : '',
327
+ truncated: result.truncated,
328
+ head: result.kind === 'binary' ? result.head : undefined,
329
+ }, (head) => ctx.get('betterSidebar')?.matchFileViewer(path, head), mediaUrlOf)
330
+ apply(outcome)
331
+ }).catch((error: unknown) => {
332
+ if (cancelled) return
333
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
334
+ })
335
+ return
336
+ }
337
+ }
338
+ apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
339
+ return () => { cancelled = true; controller.abort() }
340
+ }, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
341
+
342
+ // Save-then-refresh in preview mode (issue #167 part C): the edge into
343
+ // 'saved' (never a lingering 'saved' state) triggers exactly one reload, so
344
+ // a preview-mode Ctrl+S shows the fresh content immediately. Edit mode is
345
+ // left alone — reloading would remount the editor and drop the caret.
346
+ const prevSaveState = useRef<EditorToolbarState['saveState'] | undefined>(undefined)
347
+ useEffect(() => {
348
+ const current = toolbar?.saveState
349
+ if (prevSaveState.current !== 'saved' && current === 'saved' && toolbar?.mode === 'preview') {
350
+ setReloadSeq(sequence => sequence + 1)
351
+ }
352
+ prevSaveState.current = current
353
+ }, [toolbar?.saveState, toolbar?.mode])
354
+
355
+ const treeOpen = treeOpenOf(tab)
356
+ /** Persist the panel flag on the tab (survives reloads with the layout). */
357
+ const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
358
+ const saveLabel = toolbar === null ? ''
359
+ : toolbar.saveState === 'saving' ? t('loading')
360
+ : toolbar.saveState === 'saved' ? t('saved')
361
+ : toolbar.saveState === 'failed' ? t('saveFailed') : ''
362
+
363
+ // Split mode: the path-less window IS the standalone explorer — the tree
364
+ // panel fills the whole tab (search + FileTree, full form), no editor
365
+ // chrome. File opens land in new per-path tabs through openFile above.
366
+ // A folder window (meta.dir, any mode) renders the SAME surface rooted
367
+ // at the folder instead of the session cwd.
368
+ if (treeOnly || folderRoot !== undefined) {
369
+ return (
370
+ <div className={css.editor}>
371
+ <TreePanel
372
+ full
373
+ store={store}
374
+ sessionId={scope.sessionId}
375
+ cwd={folderRoot ?? scope.cwd}
376
+ expanded={expanded}
377
+ revealed={revealed}
378
+ onToggle={onToggleDir}
379
+ onOpenFile={openFile}
380
+ onOpenFileNewTab={openFileNewTab}
381
+ onOpenFileSide={openFileSide}
382
+ openWithTargets={openWithTargets}
383
+ openWithPinned={openWithConfig.pinned}
384
+ openWithSsh={openWithSshActive(openWithConfig)}
385
+ onOpenWith={openWith}
386
+ onToggleOpenWithPin={toggleOpenWithPin}
387
+ onReferenceFile={onReferenceFile}
388
+ />
389
+ </div>
390
+ )
391
+ }
392
+
393
+ return (
394
+ <div className={css.editor}>
395
+ <div className={css.editorHeader}>
396
+ <EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
397
+ {toolbar?.modes === true && (
398
+ <div className={css.editorModeToggle}>
399
+ <button
400
+ type="button"
401
+ className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
402
+ onClick={() => {
403
+ // Issue #167 part B: returning from edit to preview reloads so
404
+ // the preview renders the just-saved content. A dirty draft
405
+ // (or a failed save) suppresses the reload — the draft only
406
+ // lives in the editor instance and a remount would drop it.
407
+ if (toolbar.mode === 'edit' && toolbar.dirty !== true && toolbar.saveState !== 'failed') {
408
+ setReloadSeq(sequence => sequence + 1)
409
+ }
410
+ controlsRef.current?.setMode('preview')
411
+ }}
412
+ >
413
+ {t('preview')}
414
+ </button>
415
+ <button
416
+ type="button"
417
+ className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
418
+ onClick={() => { controlsRef.current?.setMode('edit') }}
419
+ >
420
+ {t('edit')}
421
+ </button>
422
+ </div>
423
+ )}
424
+ {toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
425
+ {toolbar?.editable === true && (
426
+ <button
427
+ type="button"
428
+ className={css.iconButton}
429
+ aria-label={t('save')}
430
+ title={`${t('save')} (Ctrl/Cmd+S)`}
431
+ onClick={() => { controlsRef.current?.save() }}
432
+ >
433
+ <IconCheckOutline16 size={14} />
434
+ </button>
435
+ )}
436
+ {saveLabel !== '' && (
437
+ <span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
438
+ )}
439
+ {toolbar !== null && (
440
+ <button
441
+ type="button"
442
+ className={css.iconButton}
443
+ aria-label={t('refresh')}
444
+ title={t('refresh')}
445
+ onClick={refreshFile}
446
+ >
447
+ <IconRefreshOutline14 size={14} />
448
+ </button>
449
+ )}
450
+ <button
451
+ type="button"
452
+ className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
453
+ aria-label={t('editorTreeToggle')}
454
+ title={t('editorTreeToggle')}
455
+ aria-pressed={treeOpen}
456
+ onClick={toggleTree}
457
+ >
458
+ <IconFolderOpen16 size={14} />
459
+ </button>
460
+ </div>
461
+ <div className={css.editorBody}>
462
+ <div className={css.editorMain}>
463
+ {showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
464
+ {!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
465
+ {!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message)
466
+ ? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
467
+ : <div className={css.editorError}>{load.message}</div>)}
468
+ {!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
469
+ {!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
470
+ ctx, store, scope, path, title,
471
+ viewerId: load.viewer.id,
472
+ content: load.content,
473
+ truncated: load.truncated,
474
+ mediaUrl: load.mediaUrl,
475
+ customData: load.customData,
476
+ // The viewer's toolbar always hoists into this host's header.
477
+ toolbar: 'host',
478
+ onToolbarState,
479
+ onToolbarControls,
480
+ })}
481
+ </div>
482
+ {treeOpen && (
483
+ <div className={css.editorTreeDock} style={{ width: treeWidth }}>
484
+ <div
485
+ className={css.editorTreeResize}
486
+ role="separator"
487
+ aria-orientation="vertical"
488
+ aria-label={t('editorTreeToggle')}
489
+ onPointerDown={onResizeStart}
490
+ onPointerMove={onResizeMove}
491
+ onPointerUp={onResizeEnd}
492
+ onPointerCancel={onResizeEnd}
493
+ />
494
+ <TreePanel
495
+ store={store}
496
+ sessionId={scope.sessionId}
497
+ cwd={scope.cwd}
498
+ expanded={expanded}
499
+ revealed={revealed}
500
+ onToggle={onToggleDir}
501
+ onOpenFile={openFile}
502
+ onOpenFileNewTab={openFileNewTab}
503
+ onOpenFileSide={openFileSide}
504
+ openWithTargets={openWithTargets}
505
+ openWithPinned={openWithConfig.pinned}
506
+ openWithSsh={openWithSshActive(openWithConfig)}
507
+ onOpenWith={openWith}
508
+ onToggleOpenWithPin={toggleOpenWithPin}
509
+ onReferenceFile={onReferenceFile}
510
+ />
511
+ </div>
512
+ )}
513
+ </div>
514
+ </div>
515
+ )
516
+ }
517
+
518
+ /**
519
+ * The header's path input: shows the current file relative to the session
520
+ * cwd (absolute when outside it). Enter resolves the typed path (relative
521
+ * input joins onto the cwd — the same resolution `openSidebarFile` uses)
522
+ * and opens it through the parent's mode-aware open (in-place switch or a
523
+ * per-path dedupe tab); Escape/blur restores the current value. The parent
524
+ * keys it by `path` so an in-place switch remounts and reseeds the draft.
525
+ */
526
+ function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
527
+ const { path, cwd, onOpen } = props
528
+ const display = path === '' ? '' : relativeTo(cwd ?? '', path)
529
+ const [value, setValue] = useState(display)
530
+
531
+ const commit = (): void => {
532
+ const input = value.trim()
533
+ if (input === '' || input === display) {
534
+ setValue(display)
535
+ return
536
+ }
537
+ onOpen(resolveSidebarPath(cwd, input))
538
+ // Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
539
+ // path stays, so the input falls back to its own display value. (Merged
540
+ // mode remounts this input on the new path; the reset is harmless.)
541
+ setValue(display)
542
+ }
543
+
544
+ return (
545
+ <input
546
+ className={css.editorPathInput}
547
+ value={value}
548
+ placeholder={t('editorPathPlaceholder')}
549
+ title={path}
550
+ spellCheck={false}
551
+ onChange={(event) => { setValue(event.target.value) }}
552
+ onKeyDown={(event) => {
553
+ if (event.key === 'Enter') {
554
+ event.preventDefault()
555
+ commit()
556
+ } else if (event.key === 'Escape') {
557
+ setValue(display)
558
+ }
559
+ }}
560
+ onBlur={() => { setValue(display) }}
561
+ />
562
+ )
563
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The workspace-fence refusal surface. The raw wire text (`path "..." is
3
+ * outside workspace`) is never shown as-is: the editor / file-tree error
4
+ * slots render the localized reason plus a one-click global off — the click
5
+ * flips the `workspaceFence` pref through the settings route, adopts the
6
+ * returned document into the store (so every prefs reader — GitView's open
7
+ * guard, the settings page — flips with it), and calls `onDisabled` so the
8
+ * caller retries the failed operation immediately.
9
+ */
10
+ import { useState } from 'react'
11
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
12
+ import { api } from './api.ts'
13
+ import { t } from './locales.ts'
14
+ import { parsePrefs } from './prefs.ts'
15
+ import css from './sidebar.module.css'
16
+ import type { SidebarStore } from './state.ts'
17
+
18
+ export function FenceErrorNotice(props: { store: SidebarStore; onDisabled: () => void }) {
19
+ const { store, onDisabled } = props
20
+ const [busy, setBusy] = useState(false)
21
+ const disable = (): void => {
22
+ if (busy) return
23
+ setBusy(true)
24
+ api.settingsUpdate({ workspaceFence: false }).then((view) => {
25
+ store.setPrefs(parsePrefs(view.value))
26
+ onDisabled()
27
+ }).catch((error: unknown) => {
28
+ // The fence stays armed on failure (the notice remains, button
29
+ // re-enabled); the console carries the cause.
30
+ console.error('workspace fence disable failed', error)
31
+ setBusy(false)
32
+ })
33
+ }
34
+ return (
35
+ <div className={css.fenceError}>
36
+ <span>{t('fenceErrorReason')}</span>
37
+ <Button variant="outline" disabled={busy} onClick={disable}>{t('fenceDisableAction')}</Button>
38
+ </div>
39
+ )
40
+ }