synabun 2.0.0 → 2.0.7

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 (507) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/NOTICE +5 -0
  3. package/README.md +2 -1
  4. package/THIRD-PARTY-LICENSES.md +59 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +153 -18
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +26 -3
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/lib/child-path.js +303 -0
  14. package/lib/claude-hooks.js +2 -1
  15. package/lib/data-home-migration.js +2 -0
  16. package/lib/desktop-app.js +265 -0
  17. package/lib/external-tools.js +7 -3
  18. package/lib/log-redact.js +102 -0
  19. package/lib/packaged-runtime.js +138 -11
  20. package/lib/start-launcher.js +88 -1
  21. package/mcp-server/dist/http.d.ts +3 -1
  22. package/mcp-server/dist/http.d.ts.map +1 -1
  23. package/mcp-server/dist/http.js +15 -4
  24. package/mcp-server/dist/http.js.map +1 -1
  25. package/mcp-server/dist/index.d.ts.map +1 -1
  26. package/mcp-server/dist/index.js +9 -6
  27. package/mcp-server/dist/index.js.map +1 -1
  28. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  29. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  30. package/mcp-server/dist/services/budget-judgments.js +153 -0
  31. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  32. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  33. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  34. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  35. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  36. package/mcp-server/dist/services/identity.d.ts +11 -0
  37. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  38. package/mcp-server/dist/services/identity.js +24 -0
  39. package/mcp-server/dist/services/identity.js.map +1 -1
  40. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  41. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  42. package/mcp-server/dist/services/neural-interface.js +34 -2
  43. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  44. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  45. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  46. package/mcp-server/dist/services/typesafe-config.js +4 -1
  47. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  48. package/mcp-server/dist/tools/agents.d.ts +10 -7
  49. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  50. package/mcp-server/dist/tools/agents.js +34 -13
  51. package/mcp-server/dist/tools/agents.js.map +1 -1
  52. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  53. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  54. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  55. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  56. package/mcp-server/dist/tui.js +2 -1
  57. package/mcp-server/dist/tui.js.map +1 -1
  58. package/mcp-server/dist/version.d.ts +5 -0
  59. package/mcp-server/dist/version.d.ts.map +1 -0
  60. package/mcp-server/dist/version.js +21 -0
  61. package/mcp-server/dist/version.js.map +1 -0
  62. package/mcp-server/package-lock.json +2 -2
  63. package/mcp-server/package.json +1 -3
  64. package/mcp-server/run.mjs +15 -9
  65. package/mcp-server/src/http.ts +18 -3
  66. package/mcp-server/src/index.ts +9 -6
  67. package/mcp-server/src/services/budget-judgments.ts +183 -0
  68. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  69. package/mcp-server/src/services/identity.ts +27 -0
  70. package/mcp-server/src/services/neural-interface.ts +34 -2
  71. package/mcp-server/src/services/typesafe-config.ts +4 -1
  72. package/mcp-server/src/tools/agents.ts +33 -14
  73. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  74. package/mcp-server/src/tui.ts +2 -1
  75. package/mcp-server/src/version.ts +22 -0
  76. package/neural-interface/i18n/en.json +2126 -209
  77. package/neural-interface/i18n/pt-BR.json +2123 -207
  78. package/neural-interface/lib/account-usage.js +163 -0
  79. package/neural-interface/lib/assistant-api.js +316 -57
  80. package/neural-interface/lib/assistant-brains/claude.js +19 -2
  81. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  82. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  83. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  84. package/neural-interface/lib/assistant-budget-split.js +408 -0
  85. package/neural-interface/lib/assistant-budget.js +94 -95
  86. package/neural-interface/lib/assistant-catalog.js +179 -27
  87. package/neural-interface/lib/assistant-config.js +91 -14
  88. package/neural-interface/lib/assistant-dispatch.js +494 -138
  89. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  90. package/neural-interface/lib/assistant-jev.js +26 -3
  91. package/neural-interface/lib/assistant-memory.js +24 -10
  92. package/neural-interface/lib/assistant-persona.js +73 -12
  93. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  94. package/neural-interface/lib/assistant-pricing.js +21 -6
  95. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  96. package/neural-interface/lib/assistant-route-gate.js +19 -3
  97. package/neural-interface/lib/assistant-router.js +373 -42
  98. package/neural-interface/lib/assistant-runtime.js +488 -59
  99. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  100. package/neural-interface/lib/assistant-test-density.js +142 -0
  101. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  102. package/neural-interface/lib/assistant-user-context.js +908 -0
  103. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  104. package/neural-interface/lib/assistant-user-learn.js +404 -0
  105. package/neural-interface/lib/assistant-user-threads.js +226 -0
  106. package/neural-interface/lib/assistant-user-writer.js +183 -0
  107. package/neural-interface/lib/augmented-path.js +317 -53
  108. package/neural-interface/lib/browser-background-tab.js +134 -0
  109. package/neural-interface/lib/browser-screenshot.js +678 -61
  110. package/neural-interface/lib/claude-accounts.js +342 -31
  111. package/neural-interface/lib/claude-agent-bridge.js +123 -9
  112. package/neural-interface/lib/claude-executable.js +6 -1
  113. package/neural-interface/lib/claude-model-catalog.js +33 -5
  114. package/neural-interface/lib/claude-panel-auth.js +221 -0
  115. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  116. package/neural-interface/lib/claude-temporary.js +46 -7
  117. package/neural-interface/lib/cli-install-api.js +137 -0
  118. package/neural-interface/lib/cli-install.js +797 -0
  119. package/neural-interface/lib/cli-path.js +130 -0
  120. package/neural-interface/lib/cli-resolver.js +744 -0
  121. package/neural-interface/lib/cli-update-planner.js +143 -7
  122. package/neural-interface/lib/cloudflared/install.js +533 -0
  123. package/neural-interface/lib/cloudflared/manager.js +545 -0
  124. package/neural-interface/lib/cloudflared/pins.js +115 -0
  125. package/neural-interface/lib/cloudflared/pins.json +63 -0
  126. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  127. package/neural-interface/lib/codex-account-auth.js +178 -0
  128. package/neural-interface/lib/codex-accounts.js +489 -0
  129. package/neural-interface/lib/codex-capabilities.js +2 -1
  130. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  131. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  132. package/neural-interface/lib/codex-panel-auth.js +62 -0
  133. package/neural-interface/lib/codex-session-settings.js +1 -1
  134. package/neural-interface/lib/codex-settings-schema.js +120 -0
  135. package/neural-interface/lib/desktop/config.js +3 -0
  136. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  137. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  138. package/neural-interface/lib/editor-completion.js +714 -0
  139. package/neural-interface/lib/editor-config.js +149 -0
  140. package/neural-interface/lib/editor-files-api.js +268 -0
  141. package/neural-interface/lib/editor-git-api.js +134 -0
  142. package/neural-interface/lib/file-explorer-api.js +1039 -0
  143. package/neural-interface/lib/file-explorer-system.js +355 -0
  144. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  145. package/neural-interface/lib/file-explorer-watch.js +239 -0
  146. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  147. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  148. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  149. package/neural-interface/lib/git-bash/install.js +529 -0
  150. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  151. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  152. package/neural-interface/lib/git-bash/manager.js +268 -0
  153. package/neural-interface/lib/git-bash/pins.js +206 -0
  154. package/neural-interface/lib/git-bash/pins.json +34 -0
  155. package/neural-interface/lib/git-bash/resolver.js +194 -0
  156. package/neural-interface/lib/git-diff-api.js +103 -0
  157. package/neural-interface/lib/git-read.js +234 -0
  158. package/neural-interface/lib/greeting/api.js +89 -0
  159. package/neural-interface/lib/greeting/budgets.js +90 -0
  160. package/neural-interface/lib/greeting/engine.js +461 -0
  161. package/neural-interface/lib/greeting/facts.js +320 -0
  162. package/neural-interface/lib/greeting/sources.js +252 -0
  163. package/neural-interface/lib/greeting/writer.js +139 -0
  164. package/neural-interface/lib/http-guards.js +189 -13
  165. package/neural-interface/lib/install-details.js +145 -0
  166. package/neural-interface/lib/machine-names.js +88 -0
  167. package/neural-interface/lib/mcp-installer.js +14 -2
  168. package/neural-interface/lib/memory-api.js +31 -7
  169. package/neural-interface/lib/native-loop-providers.js +26 -8
  170. package/neural-interface/lib/native-loop-runtime.js +9 -4
  171. package/neural-interface/lib/os-path.js +319 -0
  172. package/neural-interface/lib/plugin-installer.js +4 -2
  173. package/neural-interface/lib/project-storage.js +5 -7
  174. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  175. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  176. package/neural-interface/lib/pty-host/core.js +2 -1
  177. package/neural-interface/lib/pty-host/manager.js +27 -2
  178. package/neural-interface/lib/remote-policy.js +20 -11
  179. package/neural-interface/lib/request-gate.js +589 -0
  180. package/neural-interface/lib/session-title-generator.js +4 -3
  181. package/neural-interface/lib/system-about.js +340 -0
  182. package/neural-interface/lib/system-activity.js +66 -0
  183. package/neural-interface/lib/terminal-env.js +11 -3
  184. package/neural-interface/lib/terminal-path.js +55 -0
  185. package/neural-interface/lib/ui-blob-api.js +108 -0
  186. package/neural-interface/lib/ui-blob-store.js +68 -0
  187. package/neural-interface/lib/virtual-socket.js +12 -1
  188. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  189. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  190. package/neural-interface/lib/whatsapp/commands.js +13 -14
  191. package/neural-interface/lib/whatsapp/config.js +134 -20
  192. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  193. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  194. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  195. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  196. package/neural-interface/lib/whatsapp/manager.js +92 -32
  197. package/neural-interface/lib/whatsapp/paths.js +11 -1
  198. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  199. package/neural-interface/lib/whatsapp/redact.js +63 -0
  200. package/neural-interface/lib/whatsapp/service.js +343 -91
  201. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  202. package/neural-interface/package-lock.json +701 -2
  203. package/neural-interface/package.json +13 -2
  204. package/neural-interface/public/index.html +2 -0
  205. package/neural-interface/public/index2d.html +2 -0
  206. package/neural-interface/public/install-app.html +9 -6
  207. package/neural-interface/public/offline.html +19 -3
  208. package/neural-interface/public/onboarding.html +107 -2
  209. package/neural-interface/public/shared/account-i18n.js +7 -0
  210. package/neural-interface/public/shared/account-menu.js +539 -0
  211. package/neural-interface/public/shared/account-usage.js +19 -0
  212. package/neural-interface/public/shared/account-view.js +335 -0
  213. package/neural-interface/public/shared/agent-runtime-options.js +50 -14
  214. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  215. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  216. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  217. package/neural-interface/public/shared/assistant/asst-brain-picker.js +430 -81
  218. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  219. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  220. package/neural-interface/public/shared/assistant/asst-mascot.js +140 -61
  221. package/neural-interface/public/shared/assistant/asst-menu.js +27 -11
  222. package/neural-interface/public/shared/assistant/asst-panel.js +198 -42
  223. package/neural-interface/public/shared/assistant/asst-render.js +197 -23
  224. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  225. package/neural-interface/public/shared/assistant/asst-route.js +95 -343
  226. package/neural-interface/public/shared/assistant/asst-routes-editor.js +534 -0
  227. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  228. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  229. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  230. package/neural-interface/public/shared/assistant/asst-state.js +186 -2
  231. package/neural-interface/public/shared/assistant/asst-styles.js +247 -11
  232. package/neural-interface/public/shared/blob-store.js +111 -22
  233. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  234. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  235. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  236. package/neural-interface/public/shared/cdx/cdx-panel.js +80 -118
  237. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  238. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  239. package/neural-interface/public/shared/cdx/cdx-styles.js +164 -107
  240. package/neural-interface/public/shared/cdx/cdx-tabs.js +348 -668
  241. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  242. package/neural-interface/public/shared/claude-account-view.js +3 -0
  243. package/neural-interface/public/shared/cli-install-view.js +392 -0
  244. package/neural-interface/public/shared/cli-status.js +282 -22
  245. package/neural-interface/public/shared/constants.js +6 -1
  246. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  247. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  248. package/neural-interface/public/shared/cp/cp-styles.js +61 -0
  249. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  250. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  251. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  252. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  253. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  254. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  255. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  256. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  257. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  258. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  259. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  260. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  261. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  262. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  263. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  264. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  265. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  266. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  267. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  268. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  269. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  270. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  271. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  272. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  273. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  274. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  275. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  276. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  277. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  278. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  279. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  280. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  281. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  282. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  283. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  284. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  285. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  286. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  287. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  288. package/neural-interface/public/shared/file-links.js +339 -0
  289. package/neural-interface/public/shared/git-bash-view.js +145 -0
  290. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  291. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  292. package/neural-interface/public/shared/html-shell.js +161 -165
  293. package/neural-interface/public/shared/i18n.js +19 -1
  294. package/neural-interface/public/shared/install-details-view.js +86 -0
  295. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  296. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  297. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +131 -17
  298. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  299. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +22 -0
  300. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +158 -0
  301. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  302. package/neural-interface/public/shared/pwa-install-page.js +32 -2
  303. package/neural-interface/public/shared/pwa-install.css +3 -3
  304. package/neural-interface/public/shared/pwa-install.js +100 -1
  305. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  306. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  307. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  308. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  309. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  310. package/neural-interface/public/shared/settings/settings-ia.js +33 -4
  311. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  312. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  313. package/neural-interface/public/shared/start-bridge.js +238 -19
  314. package/neural-interface/public/shared/storage.js +30 -3
  315. package/neural-interface/public/shared/styles.css +1710 -770
  316. package/neural-interface/public/shared/synabun-mascot.js +68 -58
  317. package/neural-interface/public/shared/tunnel-view.js +167 -0
  318. package/neural-interface/public/shared/ui-claude-panel.js +369 -229
  319. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  320. package/neural-interface/public/shared/ui-detail.js +15 -0
  321. package/neural-interface/public/shared/ui-explorer.js +22 -0
  322. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  323. package/neural-interface/public/shared/ui-file-links.js +103 -0
  324. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  325. package/neural-interface/public/shared/ui-help.js +26 -0
  326. package/neural-interface/public/shared/ui-invite.js +126 -116
  327. package/neural-interface/public/shared/ui-judgments.js +10 -1
  328. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  329. package/neural-interface/public/shared/ui-loading.js +187 -19
  330. package/neural-interface/public/shared/ui-menubar.js +25 -33
  331. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  332. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  333. package/neural-interface/public/shared/ui-navbar.js +48 -22
  334. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  335. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  336. package/neural-interface/public/shared/ui-sessions.js +9 -3
  337. package/neural-interface/public/shared/ui-settings.js +509 -490
  338. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  339. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  340. package/neural-interface/public/shared/ui-sync.js +22 -0
  341. package/neural-interface/public/shared/ui-terminal.js +40 -5
  342. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  343. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  344. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  345. package/neural-interface/public/shared/ui-update.js +594 -5
  346. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  347. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  348. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  349. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  350. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  351. package/neural-interface/public/shared/worker-end.js +253 -0
  352. package/neural-interface/public/sw.js +2 -2
  353. package/neural-interface/public/variant/2d/main.js +10 -1
  354. package/neural-interface/public/variant/3d/main.js +10 -1
  355. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  356. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  357. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  358. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  359. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  360. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  361. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  362. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  363. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  364. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  365. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  366. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  367. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  368. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  369. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  370. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  371. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  372. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  373. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  374. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  375. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  376. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  377. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  378. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  379. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  380. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  381. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  382. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  383. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  384. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  385. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  386. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  387. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  388. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  389. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  390. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  391. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  392. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  393. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  394. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  395. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  396. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  397. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  398. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  399. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  400. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  401. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  402. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  403. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  404. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  405. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  406. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  407. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  408. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  409. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  410. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  411. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  412. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  413. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  414. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  415. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  416. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  417. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  418. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  419. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  420. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  421. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  422. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  423. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  424. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  425. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  426. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  427. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  428. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  429. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  430. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  431. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  432. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  433. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  434. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  435. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  436. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  437. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  438. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  439. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  440. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  441. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  442. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  443. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  444. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  445. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  446. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  447. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  448. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  449. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  450. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  451. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  452. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  453. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  454. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  455. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  456. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  457. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  458. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  459. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  460. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  461. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  462. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  463. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  464. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  465. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  466. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  467. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  468. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  469. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  470. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  471. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  472. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  473. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  474. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  475. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  476. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  477. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  478. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  479. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  480. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  481. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  482. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  483. package/neural-interface/scripts/build-vendor.mjs +78 -2
  484. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  485. package/neural-interface/server.js +2085 -1629
  486. package/neural-interface/templates/rulesets/README.md +1 -1
  487. package/package.json +5 -8
  488. package/setup.js +53 -15
  489. package/benchmarks/browser-live-results.json +0 -79
  490. package/benchmarks/browser-v2-results.json +0 -258
  491. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  492. package/neural-interface/public/category-logos/claude.svg +0 -1
  493. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  494. package/neural-interface/public/category-logos/openai.svg +0 -1
  495. package/neural-interface/public/category-logos/synabun.png +0 -0
  496. package/neural-interface/public/claude.svg +0 -1
  497. package/neural-interface/public/gallery.svg +0 -6
  498. package/neural-interface/public/leonardoai.svg +0 -1
  499. package/neural-interface/public/mcp.svg +0 -4
  500. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  501. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  502. package/neural-interface/public/sessionicon.svg +0 -8
  503. package/neural-interface/public/toolbar-album.svg +0 -8
  504. package/neural-interface/public/toolbar-question.svg +0 -6
  505. package/neural-interface/public/toolbar-sessions.svg +0 -6
  506. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  507. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,2099 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Code editor: the panel
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // The File Explorer's editor panel (#fe-editor-panel, markup in html-shell.js):
6
+ // the documents that are open in it, each in a tab of its own, and what is
7
+ // done with them: opening, saving, discarding and closing, the plan and
8
+ // changelog tabs other panels drive through the bus, the Markdown preview,
9
+ // unsaved work (the question on leaving, the copies, the check against the
10
+ // disk), and what the server can do for the editor (asked once per page: an
11
+ // older server still opens and saves files, without the newer checks).
12
+ //
13
+ // One CodeMirror view shows the tab that is selected; every other tab keeps
14
+ // its own state (text, history, selection, folds) and scroll position here.
15
+ // The decisions that need no page are in editor-tabs-logic.js,
16
+ // editor-panel-logic.js, editor-safety.js, editor-doc.js and
17
+ // editor-languages.js; the strip, the status bar and the menus are drawn by
18
+ // editor-tabs.js, editor-statusbar.js and editor-menu.js. What a document
19
+ // shows beyond its text is built by a module each and kept in its state: the
20
+ // lines that differ from the last commit (editor-git.js), the problems of a
21
+ // JSON file (editor-lint.js), the minimap (editor-minimap.js). The command
22
+ // palette and go-to-file are editor-palette.js.
23
+
24
+ import { emit, on } from '../state.js';
25
+ import { t } from '../i18n.js';
26
+ import { storage } from '../storage.js';
27
+ import { KEYS } from '../constants.js';
28
+ import { deleteBlob, fetchBlob, flushBlobs, putBlob } from '../blob-store.js';
29
+ import { loadCodeMirror } from './editor-loader.js';
30
+ import { createEditorView } from './editor-view.js';
31
+ import { editorSearch } from './editor-search.js';
32
+ import { editorAi } from './editor-ai.js';
33
+ import { editorCompare } from './editor-compare.js';
34
+ import { GIT_REFRESH_MS, editorGit } from './editor-git.js';
35
+ import { gitMarks, readHeadResponse } from './editor-git-logic.js';
36
+ import { editorLint, lintKind } from './editor-lint.js';
37
+ import { editorMinimap } from './editor-minimap.js';
38
+ import { createPalette } from './editor-palette.js';
39
+ import { projectFor } from './editor-palette-logic.js';
40
+ import { highlightSpecs } from './editor-theme.js';
41
+ import { closeEditorMenu, openEditorMenu } from './editor-menu.js';
42
+ import { createTabStrip } from './editor-tabs.js';
43
+ import { LANGUAGE_AUTO, LANGUAGE_PLAIN, createStatusBar } from './editor-statusbar.js';
44
+ import { EDITOR_COMMANDS, formatKey, runCommand, shortcutRows } from './editor-commands.js';
45
+ import { createEditorPrefs } from './editor-prefs.js';
46
+ import { pickNeighbors } from './editor-ai-logic.js';
47
+ import {
48
+ chooseIndentation, detectIndentation, detectLineEnding, isLargeDocument, looksLossyDecoded, minimalChange, saveFixes, serializeDocument, splitBom,
49
+ } from './editor-doc.js';
50
+ import { baseName, isMarkdownFile, languageLabel } from './editor-languages.js';
51
+ import { renderMarkdown } from './editor-markdown.js';
52
+ import {
53
+ closeConfirm, controlsState, describeOpenFailure, displayPath, isSamePlan, isSamePlanOwner,
54
+ matchesExpectedMarkdown, minimapShown, nextWhitespace, planEvent, planIdentityKey, planSourceLabel, viewMenuChange, viewMenuItems,
55
+ } from './editor-panel-logic.js';
56
+ import {
57
+ activateTab, closeTab, closeTargets, createTabs, findTab, getTab, moveTab, neighborTab, normalizePath, openTab, pinTab,
58
+ recallTabs, rememberTabs, repointPath, repointRemembered, restoreTabs, serializeTabs, tabKey, tabLabels, updateTab,
59
+ } from './editor-tabs-logic.js';
60
+ import {
61
+ BACKUP_MAX_CHARS, blobBackupStore, createBackups, createDiskWatcher, createUnloadGuard, describeBackup,
62
+ layeredBackupStore, localBackupStore, safetyBar, sameVersion, textFingerprint,
63
+ } from './editor-safety.js';
64
+
65
+ const $ = (id) => document.getElementById(id);
66
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iP(hone|[oa]d)/.test(navigator.platform || '');
67
+ /** The open tabs are written to storage this long after they last changed (a tab opened, closed, edited)... */
68
+ const PERSIST_DELAY_MS = 400;
69
+ /** ...and at most this often while only a caret or a view moves. */
70
+ const POSITION_PERSIST_MS = 2000;
71
+
72
+ // What the File Explorer lends the panel (initEditorPanel).
73
+ let _host = { toast: () => {}, projectRoot: () => null, beforeOpen: () => {}, openSettings: () => {}, revealInTree: null, fileIcon: () => null };
74
+
75
+ let _cm = null; // the vendored CodeMirror bundle, once loaded
76
+ let _editor = null; // the CodeMirror view (editor-view.js)
77
+ let _search = null;
78
+ let _ai = null; // AI autocomplete (editor-ai.js), built with the editor
79
+ let _compare = null; // the comparison with another text (editor-compare.js)
80
+ let _git = null; // the git markers (editor-git.js)
81
+ let _lint = null; // the problem check (editor-lint.js)
82
+ let _minimap = null; // the minimap (editor-minimap.js)
83
+ let _palette = null; // the command palette and go-to-file (editor-palette.js)
84
+ let _width = 0; // the editor's width in pixels: under 760 there is no room for a minimap
85
+ let _capabilities = null; // Set of what the server offers the editor, once asked
86
+ const _prefs = createEditorPrefs();
87
+
88
+ let _open = false; // the panel is showing
89
+ let _model = createTabs(); // which tabs, in which order, which one is selected (editor-tabs-logic.js)
90
+ const _docs = new Map(); // tab id → the document of that tab
91
+ let _shownId = null; // the tab whose state is in the view
92
+ let _session = false; // the tabs of this opening of the panel are being kept for a project
93
+ let _project = ''; // that project's folder ('' when none is selected)
94
+ let _queue = Promise.resolve(); // opens run one after another, in the order they were asked for
95
+ let _epoch = 0; // an open asked for before the panel was closed is given up on
96
+ let _pendingPlans = 0;
97
+ let _quiet = 0; // the panel itself is changing a document: not an edit
98
+ let _statusFrame = 0;
99
+ let _persistTimer = 0;
100
+ let _persisted = ''; // what was last written of the tabs, to write nothing when nothing changed
101
+ let _strip = null;
102
+ let _status = null;
103
+ let _backups = null;
104
+ // Tabs whose copy of unsaved text is still on its way to the name their file has now (the file was renamed while
105
+ // the text was only in the store): { at, now, steps }. `at`: the path the copy is under, which is where the tab
106
+ // stays remembered; `now`: where the file is. A tab is remembered at a new path only once its copy is under it,
107
+ // so a page that goes at any moment finds the two together (editor-safety.js: move, `commit`).
108
+ const _onTheWay = [];
109
+ let _watcher = null;
110
+
111
+ const toast = (message) => _host.toast(message);
112
+
113
+ // ── The documents ──
114
+ //
115
+ // One per tab: { id, kind, key, path, name } as the tab has them, and
116
+ // file { path, name, size, version, eol, mixed, diskEol, diskMixed, bom, detectedIndent, indentChoice, indent, markdown }
117
+ // state its CodeMirror state while it is not the one in the view (the view holds the shown one's)
118
+ // scroll, top where it was scrolled to when it left the view
119
+ // savedDoc the document as it was last saved (a CodeMirror Text); `dirty` is "the text differs from it"
120
+ // loaded false for a tab that came back from storage and was not read yet (`place`: where it was)
121
+ // plan { contract, filePath, source, tabId } for a plan tab
122
+ // refused { key } for a file the editor does not open; failed: the editor itself did not load
123
+ // saving, reloading a save, or a "Reload", is under way: the text is read-only until it ends
124
+ // bars id → kind (editor-safety.js: safetyBar)
125
+ // large over 2 MB: no minimap, no git markers, no problem check
126
+ // head { text, lines } the file as the last commit has it; null when git does not know it (or was not asked)
127
+ // gitColumn, lintKind, minimap what its state has of the three; diff: it is shown against the last commit
128
+
129
+ function createDoc(tab, extra = {}) {
130
+ const doc = {
131
+ id: tab.id, kind: tab.kind, key: tab.key, path: tab.path, name: tab.name,
132
+ file: null, state: null, scroll: null, top: 0, savedDoc: null, loaded: false, loading: false, place: null,
133
+ dirty: false, saving: false, reloading: false, eolChanged: false, preview: false,
134
+ plan: null, banner: null, refused: null, failed: false,
135
+ language: null, languageChoice: null, languageName: '', languageSeq: 0,
136
+ bars: new Map(), comparing: false, pendingVersion: null, deleted: false, base: null, prefs: null,
137
+ large: false, head: null, headSeq: 0, gitTimer: 0, gitColumn: false, diff: false, lintKind: null, lintSeq: 0, minimap: false,
138
+ ...extra,
139
+ };
140
+ _docs.set(doc.id, doc);
141
+ return doc;
142
+ }
143
+
144
+ const active = () => _docs.get(_model.activeId) || null;
145
+ const isActive = (doc) => !!doc && doc.id === _model.activeId;
146
+ const isShown = (doc) => !!doc && doc.id === _shownId;
147
+ const isOpen = (doc) => !!doc && _docs.get(doc.id) === doc;
148
+ /** A document's state: the view's when it is the one showing. */
149
+ const stateOf = (doc) => (isShown(doc) && _editor ? _editor.view.state : doc.state);
150
+ /** Unsaved text: in the buffer, or (a tab that was not read yet) in the copy it came back with. */
151
+ const isDirty = (doc) => !!doc && (doc.loaded ? doc.dirty : !!doc.place?.dirty);
152
+ const canCompare = () => !!_capabilities?.has('file-stat');
153
+ /** The features this server has, as commands name them: a command of another feature is not listed anywhere. */
154
+ const features = () => [...(_capabilities?.has('complete') ? ['ai'] : []), ...(_capabilities?.has('git-head') ? ['git'] : [])];
155
+
156
+ /** Change a document, wherever its state is. */
157
+ function applyTo(doc, spec) {
158
+ const next = _editor.apply(isShown(doc) ? null : doc.state, spec);
159
+ if (!isShown(doc)) doc.state = next;
160
+ return next;
161
+ }
162
+
163
+ /** A change the panel makes itself (a reload, a restored copy): the view's listener does not take it for an edit. */
164
+ function quietly(work) {
165
+ _quiet++;
166
+ try { return work(); } finally { _quiet--; }
167
+ }
168
+
169
+ // ── Plan tabs ──
170
+
171
+ const planIdentity = (doc) => ({ ...doc.plan.contract, source: doc.plan.source, tabId: doc.plan.tabId });
172
+ const planRequest = (doc) => ({ ...doc.plan.contract, filePath: doc.plan.filePath, source: doc.plan.source, tabId: doc.plan.tabId });
173
+ const planDocs = () => [..._docs.values()].filter((doc) => doc.plan);
174
+
175
+ function emitPlanState(doc) {
176
+ if (doc?.plan) emit('plan-editor-state', planEvent(planIdentity(doc), { dirty: doc.dirty, saving: doc.saving, open: _open }));
177
+ }
178
+
179
+ // ── What the server offers ──
180
+
181
+ let _capabilitiesAsked = null;
182
+
183
+ /** Ask the server once per page what it offers the editor. A server without the route offers nothing new. */
184
+ function askCapabilities() {
185
+ if (_capabilities) return Promise.resolve(_capabilities);
186
+ if (!_capabilitiesAsked) {
187
+ _capabilitiesAsked = (async () => {
188
+ let known = false;
189
+ let list = [];
190
+ try {
191
+ const res = await fetch('/api/editor/capabilities');
192
+ if (res.ok) {
193
+ const body = await res.json().catch(() => null);
194
+ if (Array.isArray(body?.capabilities)) list = body.capabilities.filter((c) => typeof c === 'string');
195
+ known = true;
196
+ } else if (res.status !== 0 && res.status < 500) {
197
+ known = true; // no such route (or not for this user): an answer, and it will not change
198
+ }
199
+ } catch { /* the server did not answer: ask again with the next file */ }
200
+ const found = new Set(list);
201
+ if (known) _capabilities = found;
202
+ else _capabilitiesAsked = null;
203
+ if (found.has('config')) await _prefs.load();
204
+ return found;
205
+ })();
206
+ }
207
+ return _capabilitiesAsked;
208
+ }
209
+
210
+ // ── Unsaved work ──
211
+
212
+ /**
213
+ * The copies of unsaved buffers. On a server whose blob store has a place only the owner reaches, they are
214
+ * kept there (and looked for in this browser too, where they were kept before that server was restarted);
215
+ * until then, in this browser.
216
+ */
217
+ function backups() {
218
+ if (!_backups) {
219
+ const local = localBackupStore();
220
+ const store = _capabilities?.has('backups') ? layeredBackupStore(blobBackupStore({ fetchBlob, putBlob, deleteBlob, flushBlobs }), local) : local;
221
+ _backups = createBackups({ store });
222
+ }
223
+ return _backups;
224
+ }
225
+
226
+ /** What a copy of a document holds, read when the copy is made. */
227
+ function backupReader(doc) {
228
+ return () => {
229
+ if (!isOpen(doc) || !doc.loaded || !doc.dirty || !doc.file) return null;
230
+ const state = stateOf(doc);
231
+ if (state.doc.length > BACKUP_MAX_CHARS) return null;
232
+ // What the text was written over: marked once per saved version.
233
+ if (doc.base?.of !== doc.savedDoc) doc.base = { of: doc.savedDoc, mark: textFingerprint(doc.savedDoc.toString()) };
234
+ return { path: doc.file.path, text: state.doc.toString(), base: doc.base.mark, eol: doc.file.eol };
235
+ };
236
+ }
237
+
238
+ function syncBackup(doc) {
239
+ if (!doc.file) return;
240
+ if (doc.dirty) backups().touch(doc.key, backupReader(doc));
241
+ else backups().drop(doc.key);
242
+ }
243
+
244
+ function startWatching() {
245
+ if (!_open || !_capabilities?.has('file-stat')) return;
246
+ if (!_watcher) {
247
+ _watcher = createDiskWatcher({
248
+ stat: async (paths) => {
249
+ const res = await fetch('/api/file-stat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) });
250
+ if (!res.ok) return null;
251
+ const body = await res.json().catch(() => null);
252
+ return body?.ok ? body.files : null;
253
+ },
254
+ // Files only: a plan is one revision of a document its own panel keeps, and a changelog draft is that panel's too.
255
+ files: () => [..._docs.values()]
256
+ .filter((doc) => doc.kind === 'file' && doc.loaded && doc.file && !doc.saving)
257
+ .map((doc) => ({ id: doc.id, path: doc.file.path, version: doc.deleted ? null : doc.file.version })),
258
+ onChange: onDiskChange,
259
+ isActive: () => _open && document.visibilityState === 'visible',
260
+ });
261
+ }
262
+ _watcher.start();
263
+ }
264
+
265
+ function onDiskChange(id, { exists, version, asked, path }) {
266
+ const doc = _docs.get(id);
267
+ if (!doc || !doc.loaded || !doc.file || doc.saving) return;
268
+ // Renamed or moved while the answer was on its way: it is about a path this tab no longer has.
269
+ if (path && path !== doc.file.path) return;
270
+ // Saved or reloaded while the answer was on its way: it is about a version this tab no longer has.
271
+ const known = doc.deleted ? null : doc.file.version;
272
+ if (known !== asked && !sameVersion(known, asked)) return;
273
+ if (!exists) {
274
+ doc.deleted = true;
275
+ setBar(doc, 'deleted');
276
+ return;
277
+ }
278
+ if (!doc.dirty) { reloadClean(doc); return; }
279
+ // The check runs every two seconds for as long as the tab and the disk differ. What it found the time before
280
+ // is already on screen: drawing the bar and the comparison again would take the keyboard from their buttons.
281
+ const told = !doc.deleted && doc.bars.has('disk') && sameVersion(doc.pendingVersion, version);
282
+ doc.pendingVersion = version;
283
+ doc.deleted = false;
284
+ if (told) return;
285
+ if (doc.bars.get('disk') !== 'conflict') setBar(doc, 'changed');
286
+ if (doc.comparing) showCompare(doc);
287
+ }
288
+
289
+ // ── The CodeMirror view ──
290
+
291
+ let _editorLoading = null;
292
+
293
+ function ensureEditor() {
294
+ if (_editor) return Promise.resolve(_editor);
295
+ if (!_editorLoading) {
296
+ _editorLoading = loadCodeMirror().then(({ cm }) => {
297
+ _cm = cm;
298
+ _search = editorSearch(cm, { t });
299
+ _compare = editorCompare(cm, { t });
300
+ _git = editorGit(cm);
301
+ _lint = editorLint(cm);
302
+ _minimap = editorMinimap(cm, { highlight: highlightSpecs(cm.tags), git: _git, problems: _lint });
303
+ _ai = editorAi(cm, {
304
+ t,
305
+ prefs: _prefs,
306
+ getDocument: () => {
307
+ const doc = active();
308
+ return doc?.loaded && doc.file ? { path: doc.file.path, language: doc.languageName, indentUnit: doc.file.indent?.unit || ' ' } : null;
309
+ },
310
+ getNeighbors: neighborSnippets,
311
+ openSettings: () => _host.openSettings({ tab: 'code-editor', scrollTo: 'stg-sec-editor-ai' }),
312
+ });
313
+ _editor = createEditorView(cm, {
314
+ parent: $('fe-editor-host'),
315
+ search: _search,
316
+ ai: _ai,
317
+ actions: {
318
+ save: () => saveEditorPanel(), toggleWordWrap, nextTab: () => cycleTab(1), previousTab: () => cycleTab(-1), closeTab: closeActiveTab,
319
+ toggleMinimap: () => changeEditing({ minimap: !_prefs.get().editing.minimap }),
320
+ toggleIndentGuides: () => changeEditing({ indentGuides: !_prefs.get().editing.indentGuides }),
321
+ cycleWhitespace: () => changeEditing({ renderWhitespace: nextWhitespace(_prefs.get().editing.renderWhitespace) }),
322
+ fontLarger: () => changeEditing(viewMenuChange('font:larger', _prefs.get().editing)),
323
+ fontSmaller: () => changeEditing(viewMenuChange('font:smaller', _prefs.get().editing)),
324
+ fontReset: () => changeEditing(viewMenuChange('font:reset', _prefs.get().editing)),
325
+ openPalette: () => _palette?.openCommands(),
326
+ openGoToFile: () => _palette?.openFiles(),
327
+ nextProblem: (view) => _lint.next(view) === true,
328
+ previousProblem: (view) => _lint.previous(view) === true,
329
+ toggleDiff,
330
+ },
331
+ t,
332
+ onUpdate: onEditorUpdate,
333
+ });
334
+ _editor.view.scrollDOM.addEventListener('scroll', () => schedulePersist({ soon: false }), { passive: true });
335
+ watchWidth();
336
+ return _editor;
337
+ }).finally(() => { _editorLoading = null; });
338
+ }
339
+ return _editorLoading;
340
+ }
341
+
342
+ function onEditorUpdate(update) {
343
+ const doc = _docs.get(_shownId);
344
+ if (!doc || !doc.loaded) return;
345
+ if (update.docChanged && !_quiet) {
346
+ refreshDoc(doc);
347
+ if (doc.dirty) backups().touch(doc.key, backupReader(doc));
348
+ }
349
+ if (update.docChanged && doc.head) scheduleGit(doc);
350
+ if (update.docChanged || update.selectionSet) {
351
+ scheduleStatus();
352
+ schedulePersist({ soon: false });
353
+ }
354
+ }
355
+
356
+ // AI autocomplete exists on this page only when the server has it: its chip is added to the status bar and its
357
+ // keys to the shortcuts list then, and not before.
358
+ function syncAi() {
359
+ if (!_ai || !_capabilities) return;
360
+ const capable = _capabilities.has('complete');
361
+ _ai.setCapable(capable);
362
+ if (!capable || $('fe-editor-ai')) return;
363
+ _ai.mountChip($('fe-editor-statusbar'));
364
+ buildShortcuts();
365
+ }
366
+
367
+ /** What the other open tabs of the document's language hold, for a completion request: the tabs used last first. */
368
+ function neighborSnippets() {
369
+ const current = active();
370
+ if (!current?.loaded || !current.file) return [];
371
+ const tabs = _model.recent
372
+ .filter((id) => id !== current.id)
373
+ .map((id) => _docs.get(id))
374
+ .filter((doc) => doc?.loaded && doc.file && doc.state)
375
+ .map((doc) => ({
376
+ path: doc.file.path, language: doc.languageName, length: doc.state.doc.length, head: doc.state.selection.main.head,
377
+ slice: (from, to) => doc.state.sliceDoc(from, to),
378
+ }));
379
+ return pickNeighbors({ current: { path: current.file.path, language: current.languageName }, tabs, exclude: _prefs.get().ai.exclude });
380
+ }
381
+
382
+ // ── What the panel shows ──
383
+
384
+ /** Which of the three bodies is visible: the editor, the Markdown preview, or a message. */
385
+ function showBody(which) {
386
+ const hostEl = $('fe-editor-host');
387
+ const previewEl = $('fe-editor-preview');
388
+ const messageEl = $('fe-editor-message');
389
+ if (hostEl) hostEl.hidden = which !== 'editor';
390
+ if (previewEl) previewEl.classList.toggle('visible', which === 'preview');
391
+ if (messageEl) messageEl.hidden = which !== 'message';
392
+ }
393
+
394
+ function showMessage(text, { retry = null } = {}) {
395
+ const textEl = $('fe-editor-message-text');
396
+ const retryBtn = $('fe-editor-message-retry');
397
+ if (textEl) textEl.textContent = text;
398
+ if (retryBtn) {
399
+ retryBtn.hidden = !retry;
400
+ retryBtn.onclick = retry;
401
+ }
402
+ showBody('message');
403
+ }
404
+
405
+ /** The body of the selected tab: a message, its preview, or the editor. */
406
+ function renderBody(doc) {
407
+ if (doc?.refused) { showMessage(t(doc.refused.key, { name: doc.name })); return; }
408
+ if (doc?.failed) { showMessage(t('editor.error.bundle'), { retry: () => loadLater(doc) }); return; }
409
+ if (doc?.preview && doc.file) {
410
+ const previewEl = $('fe-editor-preview');
411
+ if (previewEl) previewEl.innerHTML = renderMarkdown(stateOf(doc)?.doc.toString() || '');
412
+ showBody('preview');
413
+ return;
414
+ }
415
+ showBody('editor');
416
+ _editor?.remeasure();
417
+ }
418
+
419
+ function renderHeader(doc) {
420
+ const pathEl = $('fe-editor-filepath');
421
+ if (!pathEl) return;
422
+ const path = doc ? (doc.file?.path || doc.path) : '';
423
+ pathEl.textContent = displayPath(path, _host.projectRoot());
424
+ pathEl.title = String(path || '');
425
+ }
426
+
427
+ /** The header's dot and buttons, for the selected tab. */
428
+ function renderControls(doc) {
429
+ const dirty = !!doc?.loaded && doc.dirty;
430
+ $('fe-editor-dot')?.classList.toggle('dirty', dirty);
431
+ const controls = controlsState({ ready: !!doc?.loaded && !!doc.file, dirty, saving: !!doc?.saving, plan: !!doc?.plan, changelog: doc?.kind === 'changelog' });
432
+ const saveBtn = $('fe-editor-save');
433
+ if (saveBtn) {
434
+ saveBtn.disabled = controls.saveDisabled;
435
+ saveBtn.classList.toggle('dirty', dirty);
436
+ }
437
+ const discardBtn = $('fe-editor-discard');
438
+ if (discardBtn) discardBtn.disabled = controls.discardDisabled;
439
+ }
440
+
441
+ function renderBanner(doc) {
442
+ const panel = $('fe-editor-panel');
443
+ panel?.querySelector('.fe-plan-banner')?.remove();
444
+ let text = '';
445
+ if (doc?.banner) text = doc.banner;
446
+ else if (doc?.plan) {
447
+ text = doc.plan.source === 'codex'
448
+ ? t('editor.banner.planReview')
449
+ : t('editor.banner.planSend', { name: planSourceLabel(doc.plan.source) });
450
+ } else if (doc?.kind === 'changelog') {
451
+ text = t('editor.banner.changelog');
452
+ }
453
+ if (!text || !panel) return;
454
+ const banner = document.createElement('div');
455
+ banner.className = 'fe-plan-banner';
456
+ banner.textContent = text;
457
+ panel.prepend(banner);
458
+ }
459
+
460
+ // What each action of a bar does (editor-safety.js names the actions).
461
+ const BAR_ACTIONS = {
462
+ compare: (doc) => showCompare(doc),
463
+ closeCompare: (doc) => closeCompare(doc),
464
+ closeDiff: (doc) => closeDiff(doc),
465
+ reload: (doc) => reloadFromDisk(doc),
466
+ overwrite: (doc) => saveDocument(doc, { force: true }),
467
+ keepMine: (doc) => keepMine(doc),
468
+ closeTab: (doc) => closeTabs([doc.id]),
469
+ keep: (doc) => clearBar(doc, 'restored'),
470
+ discard: (doc) => revertToSaved(doc),
471
+ };
472
+
473
+ /** The neutral bars under the header, for the selected tab: a sentence and the actions that answer it. */
474
+ function renderBars(doc) {
475
+ const bars = $('fe-editor-bars');
476
+ if (!bars) return;
477
+ bars.textContent = '';
478
+ for (const kind of doc?.bars.values() || []) {
479
+ const described = safetyBar(kind, { canCompare: canCompare() && !doc.comparing });
480
+ const bar = document.createElement('div');
481
+ bar.className = 'fe-editor-bar';
482
+ bar.dataset.bar = described.id;
483
+ bar.setAttribute('role', 'status');
484
+ const label = document.createElement('span');
485
+ label.className = 'fe-editor-bar-text';
486
+ label.textContent = t(described.textKey);
487
+ bar.append(label);
488
+ for (const action of described.actions) {
489
+ const button = document.createElement('button');
490
+ button.type = 'button';
491
+ button.className = 'fe-editor-bar-action';
492
+ button.dataset.action = action.id;
493
+ button.textContent = t(action.labelKey);
494
+ // The bar may be gone once its action ran, and the button with it: the keyboard goes back to the text.
495
+ button.addEventListener('click', () => {
496
+ if (isOpen(doc)) Promise.resolve(BAR_ACTIONS[action.id](doc)).catch(() => {}).then(focusEditor);
497
+ });
498
+ bar.append(button);
499
+ }
500
+ bars.append(bar);
501
+ }
502
+ }
503
+
504
+ /** Give the keyboard to the text of the selected tab, when it is showing. */
505
+ function focusEditor() {
506
+ const doc = active();
507
+ if (_editor && doc?.loaded && !doc.preview && isShown(doc)) _editor.focus();
508
+ }
509
+
510
+ function setBar(doc, kind) {
511
+ doc.bars.set(safetyBar(kind).id, kind);
512
+ if (isActive(doc)) renderBars(doc);
513
+ }
514
+
515
+ function clearBar(doc, id) {
516
+ if (doc.bars.delete(id) && isActive(doc)) renderBars(doc);
517
+ }
518
+
519
+ function updateViewToggle(doc) {
520
+ const markdown = !!doc?.file?.markdown;
521
+ const toggle = $('fe-editor-view-toggle');
522
+ const sep = $('fe-editor-sep-preview');
523
+ if (toggle) toggle.style.display = markdown ? 'flex' : 'none';
524
+ if (sep) sep.style.display = markdown ? '' : 'none';
525
+ for (const button of document.querySelectorAll('#fe-editor-panel .fe-editor-toggle-btn')) {
526
+ const on = (button.dataset.mode === 'preview') === !!doc?.preview;
527
+ button.classList.toggle('active', on);
528
+ button.setAttribute('aria-pressed', String(on));
529
+ }
530
+ }
531
+
532
+ function setPreview(on) {
533
+ const doc = active();
534
+ if (!doc) return;
535
+ doc.preview = !!on && !!doc.file?.markdown;
536
+ updateViewToggle(doc);
537
+ renderBody(doc);
538
+ }
539
+
540
+ /** The name of the language a document is shown in, as the status bar and a completion request have it. */
541
+ function languageNameOf(doc) {
542
+ if (doc.languageChoice === LANGUAGE_PLAIN) return '';
543
+ if (doc.languageChoice) return doc.languageChoice;
544
+ return languageLabel(doc.name, doc.language?.name || null) || '';
545
+ }
546
+
547
+ function renderStatus(doc) {
548
+ if (!_status) return;
549
+ const file = doc?.file || null;
550
+ _status.showFile(file ? {
551
+ indent: file.indent, indentChoice: file.indentChoice, detected: chooseIndentation(file.detectedIndent, _prefs.get().editing, null),
552
+ eol: file.eol, mixed: file.mixed, bom: file.bom, size: file.size,
553
+ } : null);
554
+ if (!file) { _status.showPosition(null); return; }
555
+ const detected = _editor ? languageLabel(doc.name, _editor.describeLanguage(doc.name)?.name || null) : '';
556
+ _status.showLanguage({ label: doc.languageName, choice: doc.languageChoice, detected: detected || t('editor.status.plainText') });
557
+ showPosition();
558
+ }
559
+
560
+ function showPosition() {
561
+ _statusFrame = 0;
562
+ const doc = active();
563
+ if (!_status) return;
564
+ if (!_editor || !doc?.loaded || !isShown(doc)) { _status.showPosition(null); return; }
565
+ const { state } = _editor.view;
566
+ const head = state.selection.main.head;
567
+ const line = state.doc.lineAt(head);
568
+ const selected = state.selection.ranges.reduce((sum, range) => sum + (range.to - range.from), 0);
569
+ _status.showPosition({ line: line.number, column: head - line.from + 1, selected, cursors: state.selection.ranges.length });
570
+ }
571
+
572
+ // The position is written once per frame, however many updates a frame holds.
573
+ function scheduleStatus() {
574
+ if (!_statusFrame) _statusFrame = requestAnimationFrame(showPosition);
575
+ }
576
+
577
+ function renderStrip() {
578
+ announceTabs();
579
+ if (!_strip) return;
580
+ _strip.render({
581
+ tabs: _model.tabs,
582
+ activeId: _model.activeId,
583
+ labels: tabLabels(_model.tabs),
584
+ isDirty: (id) => isDirty(_docs.get(id)),
585
+ titleOf: (id) => { const doc = _docs.get(id); return doc ? displayPath(doc.file?.path || doc.path, _host.projectRoot()) : ''; },
586
+ });
587
+ }
588
+
589
+ /** Everything the panel shows of the selected tab. */
590
+ function renderActive() {
591
+ const doc = active();
592
+ renderHeader(doc);
593
+ renderBanner(doc);
594
+ renderBars(doc);
595
+ updateViewToggle(doc);
596
+ renderControls(doc);
597
+ renderDiffButton(doc);
598
+ renderStatus(doc);
599
+ renderBody(doc);
600
+ renderStrip();
601
+ }
602
+
603
+ /**
604
+ * A document's dirty state, and what follows from a change of it: the header, the strip, the copy of the buffer,
605
+ * and for a plan the state event its panel follows. A tab that has unsaved text is the user's to keep.
606
+ */
607
+ function refreshDoc(doc, { force = false } = {}) {
608
+ const state = stateOf(doc);
609
+ const dirty = !!(doc.loaded && doc.file && state && doc.savedDoc && (!state.doc.eq(doc.savedDoc) || doc.eolChanged));
610
+ const changed = dirty !== doc.dirty;
611
+ doc.dirty = dirty;
612
+ if (changed) {
613
+ if (dirty) _model = pinTab(_model, doc.id);
614
+ syncBackup(doc);
615
+ renderStrip();
616
+ schedulePersist();
617
+ }
618
+ if (!changed && !force) return;
619
+ if (isActive(doc)) renderControls(doc);
620
+ emitPlanState(doc);
621
+ }
622
+
623
+ // ── Preferences ──
624
+
625
+ /** Bring the shown document's state up to the preferences (a tab that was in the background when they changed). */
626
+ function syncPreferences(doc) {
627
+ const { editing } = _prefs.get();
628
+ if (!_editor || !doc?.loaded || !doc.file || !isShown(doc) || doc.prefs === editing) return;
629
+ doc.prefs = editing;
630
+ doc.file.indent = chooseIndentation(doc.file.detectedIndent, editing, doc.file.indentChoice);
631
+ _editor.apply(null, { effects: [_editor.effects.wrap(editing.wordWrap), _editor.effects.editing(editing), _editor.effects.indent(doc.file.indent)] });
632
+ }
633
+
634
+ /** Store a change to the `editing` preferences (a View menu entry, a command). It applies at once, here and in Settings. */
635
+ function changeEditing(change) {
636
+ if (change) _prefs.set({ editing: change });
637
+ }
638
+
639
+ // ── The minimap ──
640
+
641
+ const wantsMinimap = (doc) => minimapShown({ preference: _prefs.get().editing.minimap, width: _width, large: doc.large });
642
+
643
+ /** Bring a document's minimap up to the preference, the editor's width and its own size. */
644
+ function syncMinimap(doc) {
645
+ if (!_editor || !_minimap || !doc?.loaded || !doc.file) return;
646
+ const wanted = wantsMinimap(doc);
647
+ if (wanted === doc.minimap) return;
648
+ doc.minimap = wanted;
649
+ applyTo(doc, { effects: _editor.effects.minimap(wanted ? _minimap.extension : null) });
650
+ }
651
+
652
+ /** The editor's width decides whether there is room for a minimap: follow it. */
653
+ function watchWidth() {
654
+ const body = document.querySelector('#fe-editor-panel .fe-editor-body');
655
+ if (!body) return;
656
+ const measure = () => {
657
+ const width = body.clientWidth;
658
+ if (!width || width === _width) return;
659
+ _width = width;
660
+ syncMinimap(_docs.get(_shownId));
661
+ };
662
+ measure();
663
+ if (typeof ResizeObserver === 'function') new ResizeObserver(measure).observe(body);
664
+ }
665
+
666
+ // ── Git: the file against the last commit ──
667
+
668
+ /** Whether a document gets the git column: a file that is not large, on a server that reads the last commit. */
669
+ const wantsGit = (doc) => !!_capabilities?.has('git-head') && doc.kind === 'file' && !doc.large;
670
+
671
+ /** Ask for the file as the last commit has it, then mark the lines that differ. No answer is no markers. */
672
+ async function loadHead(doc) {
673
+ if (!doc.loaded || !doc.file) return;
674
+ if (!wantsGit(doc)) {
675
+ // No longer a document git is asked about (it grew past 2 MB): nothing of the last commit is kept for it.
676
+ if (doc.head) { doc.head = null; closeDiff(doc); if (isActive(doc)) renderDiffButton(doc); }
677
+ return;
678
+ }
679
+ const mine = ++doc.headSeq;
680
+ let reading = { tracked: false, text: null };
681
+ try {
682
+ const res = await fetch(`/api/file-head?path=${encodeURIComponent(doc.file.path)}`);
683
+ reading = readHeadResponse({ status: res.status, body: await res.json().catch(() => null) });
684
+ } catch { /* the server did not answer */ }
685
+ if (!isOpen(doc) || !doc.loaded || mine !== doc.headSeq) return;
686
+ const before = doc.head?.text ?? null;
687
+ doc.head = reading.text === null ? null : { text: reading.text, lines: reading.text.split('\n') };
688
+ refreshGit(doc);
689
+ // A comparison that is showing follows the commit it compares with.
690
+ if (doc.diff && (doc.head?.text ?? null) !== before) { if (doc.head) showDiff(doc); else closeDiff(doc); }
691
+ if (isActive(doc)) renderDiffButton(doc);
692
+ }
693
+
694
+ /** Compare the document with the committed lines and show the result in its git column. */
695
+ function refreshGit(doc) {
696
+ clearTimeout(doc.gitTimer);
697
+ doc.gitTimer = 0;
698
+ if (!_git || !isOpen(doc) || !doc.loaded || !doc.gitColumn) return;
699
+ const state = stateOf(doc);
700
+ if (!_git.has(state)) return;
701
+ applyTo(doc, { effects: _git.marks(doc.head ? gitMarks(doc.head.lines, state.doc.toJSON()) : []) });
702
+ }
703
+
704
+ /** After an edit: compare again once typing pauses. Until then the marks move with the text. */
705
+ function scheduleGit(doc) {
706
+ clearTimeout(doc.gitTimer);
707
+ doc.gitTimer = setTimeout(() => refreshGit(doc), GIT_REFRESH_MS);
708
+ }
709
+
710
+ function renderDiffButton(doc) {
711
+ const button = $('fe-editor-diff-btn');
712
+ if (!button) return;
713
+ const can = !!doc?.loaded && !!doc.head;
714
+ button.hidden = !can;
715
+ button.classList.toggle('active', can && doc.diff);
716
+ button.setAttribute('aria-pressed', String(can && doc.diff));
717
+ }
718
+
719
+ /** Show the document against the last commit, inline; each difference can be put back to what was committed. */
720
+ async function showDiff(doc) {
721
+ if (!doc?.loaded || !doc.head || !_compare) return;
722
+ let extension;
723
+ try {
724
+ extension = await _compare.extension({ original: doc.head.text, revertLabel: t('editor.diff.revert'), gutter: false });
725
+ } catch {
726
+ if (isOpen(doc)) toast(t('editor.diff.failed'));
727
+ return;
728
+ }
729
+ if (!isOpen(doc) || !doc.loaded || !doc.head) return;
730
+ applyTo(doc, { effects: _editor.effects.compare(extension) });
731
+ doc.comparing = false;
732
+ doc.diff = true;
733
+ doc.bars.set('compare', 'diffing');
734
+ if (!isActive(doc)) return;
735
+ if (doc.preview) setPreview(false);
736
+ renderBars(doc);
737
+ renderDiffButton(doc);
738
+ }
739
+
740
+ function closeDiff(doc) {
741
+ if (!doc.diff) return;
742
+ doc.diff = false;
743
+ doc.bars.delete('compare');
744
+ if (doc.loaded) applyTo(doc, { effects: _editor.effects.compare(null) });
745
+ if (!isActive(doc)) return;
746
+ renderBars(doc);
747
+ renderDiffButton(doc);
748
+ }
749
+
750
+ function toggleDiff() {
751
+ const doc = active();
752
+ if (!doc?.loaded || !doc.head) return;
753
+ if (doc.diff) closeDiff(doc); else showDiff(doc);
754
+ }
755
+
756
+ // ── Go to file ──
757
+
758
+ let _registered = null; // the registered projects, asked for once when go-to-file needs them
759
+
760
+ /**
761
+ * The folder go-to-file searches: the project selected in the File Explorer; without one (a file opened from a
762
+ * link in a chat), the registered project the open file is in.
763
+ */
764
+ async function searchRoot() {
765
+ const selected = _host.projectRoot() || _project;
766
+ if (selected) return selected;
767
+ const doc = active();
768
+ const path = doc?.file?.path || doc?.path;
769
+ if (!path) return null;
770
+ if (!_registered) {
771
+ try {
772
+ const body = await (await fetch('/api/projects')).json();
773
+ if (Array.isArray(body?.projects)) _registered = body.projects;
774
+ } catch { /* asked again the next time */ }
775
+ }
776
+ return projectFor(path, _registered);
777
+ }
778
+
779
+ // ── Problems ──
780
+
781
+ /** The check a document gets: none for a large one, or for a plan or a changelog. */
782
+ const lintKindOf = (doc) => (doc.large || doc.kind !== 'file' ? null : lintKind(doc.file?.path || doc.path, doc.languageName));
783
+
784
+ /** Bring a document's problem check up to its language (it may have been picked in the status bar). */
785
+ function syncLint(doc) {
786
+ if (!_lint || !doc.loaded || !doc.file) return;
787
+ const kind = lintKindOf(doc);
788
+ if (kind === doc.lintKind) return;
789
+ doc.lintKind = kind;
790
+ const mine = ++doc.lintSeq;
791
+ if (!kind) { applyTo(doc, { effects: _editor.effects.lint(null) }); return; }
792
+ _lint.extension(kind).then((extension) => {
793
+ if (isOpen(doc) && doc.loaded && mine === doc.lintSeq) applyTo(doc, { effects: _editor.effects.lint(extension) });
794
+ }).catch(() => { if (mine === doc.lintSeq) doc.lintKind = null; });
795
+ }
796
+
797
+ /**
798
+ * A CodeMirror state for a document's text, with what the document has beyond it from the start: the git column
799
+ * (so the text does not move when the marks arrive), the problem check when its chunk is there, the minimap.
800
+ */
801
+ function newState(doc, text, size) {
802
+ const editing = _prefs.get().editing;
803
+ doc.large = isLargeDocument({ size, length: text.length });
804
+ doc.prefs = editing;
805
+ doc.gitColumn = wantsGit(doc);
806
+ doc.minimap = wantsMinimap(doc);
807
+ const kind = lintKindOf(doc);
808
+ const lint = _lint.ready(kind);
809
+ doc.lintKind = lint ? kind : null;
810
+ return _editor.createState({
811
+ text, indent: doc.file.indent, wrap: editing.wordWrap, editing,
812
+ git: doc.gitColumn ? _git.extension : null, lint, minimap: doc.minimap ? _minimap.extension : null,
813
+ });
814
+ }
815
+
816
+ function applyPreferences() {
817
+ const { editing } = _prefs.get();
818
+ const panel = $('fe-editor-panel');
819
+ if (panel) {
820
+ panel.style.setProperty('--ed-font-size', `${editing.fontSize}px`);
821
+ panel.style.setProperty('--ed-ligatures', editing.ligatures ? 'normal' : 'none');
822
+ }
823
+ const wrapBtn = $('fe-editor-word-wrap');
824
+ if (wrapBtn) {
825
+ wrapBtn.classList.toggle('active', editing.wordWrap);
826
+ wrapBtn.setAttribute('aria-pressed', String(editing.wordWrap));
827
+ }
828
+ if (!_editor) return;
829
+ const doc = _docs.get(_shownId);
830
+ if (doc) {
831
+ syncPreferences(doc);
832
+ syncMinimap(doc);
833
+ if (isActive(doc)) renderStatus(doc);
834
+ }
835
+ _editor.remeasure();
836
+ }
837
+
838
+ function toggleWordWrap() {
839
+ _prefs.set({ editing: { wordWrap: !_prefs.get().editing.wordWrap } });
840
+ }
841
+
842
+ // ── Tabs ──
843
+
844
+ function openPanel() {
845
+ _host.beforeOpen();
846
+ const panel = $('fe-editor-panel');
847
+ if (panel) {
848
+ panel.inert = false;
849
+ panel.classList.add('open');
850
+ if (document.querySelector('.claude-panel.open')) panel.classList.add('panel-adjacent');
851
+ }
852
+ document.body.classList.add('fe-editor-open');
853
+ _open = true;
854
+ startWatching();
855
+ }
856
+
857
+ /** The panel leaves: no tab is open. */
858
+ function hidePanel() {
859
+ persistTabs();
860
+ _session = false;
861
+ _open = false;
862
+ _shownId = null;
863
+ _watcher?.stop();
864
+ closeEditorMenu();
865
+ _ai?.documentChanged();
866
+ if (_editor) {
867
+ _search?.dismiss(_editor.view);
868
+ _editor.clear();
869
+ }
870
+ renderActive();
871
+ $('fe-editor-keybinds')?.classList.remove('open');
872
+
873
+ // The panel stays in the page while it is closed: nothing in it keeps the keyboard or can be tabbed to.
874
+ const panel = $('fe-editor-panel');
875
+ if (panel) {
876
+ if (panel.contains(document.activeElement)) document.activeElement.blur();
877
+ panel.classList.remove('open');
878
+ panel.inert = true;
879
+ }
880
+ document.body.classList.remove('fe-editor-open');
881
+ }
882
+
883
+ /** The shown document leaves the view: its state and where it was scrolled to are kept with it. */
884
+ function stash(doc) {
885
+ if (!doc || !_editor || !doc.loaded || !doc.file) return;
886
+ doc.state = _editor.view.state;
887
+ doc.scroll = _editor.snapshotScroll();
888
+ doc.top = _editor.topPosition();
889
+ }
890
+
891
+ /** Put a document in the view. */
892
+ function mount(doc) {
893
+ _shownId = doc.id;
894
+ if (!_editor) return;
895
+ if (doc.loaded && doc.state) {
896
+ _editor.show(doc.state, { scroll: doc.scroll, top: doc.top });
897
+ doc.state = null;
898
+ doc.scroll = null;
899
+ syncPreferences(doc);
900
+ syncMinimap(doc);
901
+ } else if (!doc.loaded) {
902
+ _editor.clear();
903
+ }
904
+ }
905
+
906
+ /** Select a tab and show its document. */
907
+ function showTab(id, { focus = false } = {}) {
908
+ const doc = _docs.get(id);
909
+ if (!doc) return;
910
+ if (_shownId !== id) {
911
+ stash(_docs.get(_shownId));
912
+ closeEditorMenu();
913
+ _model = activateTab(_model, id);
914
+ mount(doc);
915
+ _ai?.documentChanged();
916
+ } else {
917
+ _model = activateTab(_model, id);
918
+ }
919
+ renderActive();
920
+ schedulePersist();
921
+ if (focus && doc.loaded && !doc.preview) _editor?.focus();
922
+ }
923
+
924
+ /** A tab the user went to: read it now when it came back from storage and was not read yet. */
925
+ function visitTab(id, { focus = true } = {}) {
926
+ const doc = _docs.get(id);
927
+ if (!doc) return;
928
+ showTab(id, { focus });
929
+ if (!doc.loaded && !doc.refused && !doc.failed) loadLater(doc);
930
+ }
931
+
932
+ function loadLater(doc) {
933
+ if (doc.loading) return;
934
+ doc.loading = true;
935
+ enqueue(async (alive) => {
936
+ try { if (alive() && isOpen(doc) && !doc.loaded) await loadDocument(doc); } finally { doc.loading = false; }
937
+ });
938
+ }
939
+
940
+ function cycleTab(delta) {
941
+ const id = neighborTab(_model, _model.activeId, delta);
942
+ if (id) visitTab(id);
943
+ }
944
+
945
+ function closeActiveTab() {
946
+ const doc = active();
947
+ if (doc) closeTabs([doc.id]);
948
+ }
949
+
950
+ /**
951
+ * Close tabs. One question is asked for all that have unsaved changes; a plan or a changelog that is closed
952
+ * without being saved tells its panel.
953
+ * @param {string[]} ids
954
+ * @param {{ saved?: boolean }} [o] `saved`: the tab closes because its plan or changelog was saved
955
+ * @returns {boolean} false when they stay open: a save is under way, or the user kept their changes
956
+ */
957
+ function closeTabs(ids, { saved = false } = {}) {
958
+ const docs = ids.map((id) => _docs.get(id)).filter(Boolean);
959
+ if (!docs.length) return true;
960
+ if (docs.some((doc) => doc.saving)) return false;
961
+ if (!saved) {
962
+ const question = closeConfirm(docs.filter(isDirty).map((doc) => doc.name), { only: _model.tabs.length === 1 });
963
+ if (question && !confirm(t(question.key, question.params))) return false;
964
+ }
965
+ for (const doc of docs) {
966
+ if (!saved) {
967
+ // The panel that asked for the plan or the changelog hears that it was not saved.
968
+ if (doc.plan) emit('plan-edit-cancelled', planEvent(planIdentity(doc)));
969
+ if (doc.kind === 'changelog') emit('changelog-edit-cancelled');
970
+ }
971
+ // The unsaved text was given up, or saved: its copy goes with the tab.
972
+ backups().drop(doc.key, { always: isDirty(doc) });
973
+ _model = closeTab(_model, doc.id).model;
974
+ _docs.delete(doc.id);
975
+ if (_shownId === doc.id) _shownId = null;
976
+ }
977
+ if (!_model.tabs.length) { hidePanel(); return true; }
978
+ if (_shownId !== _model.activeId) visitTab(_model.activeId, { focus: true });
979
+ else renderActive();
980
+ schedulePersist();
981
+ return true;
982
+ }
983
+
984
+ /** What a command of a tab's menu does. */
985
+ function tabCommand(id, command) {
986
+ const doc = _docs.get(id);
987
+ if (!doc) return;
988
+ if (command === 'close') closeTabs([id]);
989
+ else if (command === 'closeOthers') closeTabs(closeTargets(_model, 'others', id));
990
+ else if (command === 'closeSaved') closeTabs(closeTargets(_model, 'saved', id, (tab) => { const other = _docs.get(tab.id); return isDirty(other) || !!other?.saving; }));
991
+ else if (command === 'copyPath') {
992
+ // Said only when it happened: a page that is not allowed the clipboard copies nothing.
993
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(doc.file?.path || doc.path).then(() => toast(t('editor.toast.pathCopied'))).catch(() => {});
994
+ } else if (command === 'reveal') {
995
+ Promise.resolve(_host.revealInTree ? _host.revealInTree(doc.file?.path || doc.path) : false)
996
+ .then((found) => { if (!found) toast(t('editor.toast.notInTree')); })
997
+ .catch(() => toast(t('editor.toast.notInTree')));
998
+ }
999
+ }
1000
+
1001
+ // ── What is kept of the tabs, per project ──
1002
+
1003
+ function readRemembered() {
1004
+ try { return JSON.parse(storage.getItem(KEYS.EDITOR_TABS) || 'null'); } catch { return null; }
1005
+ }
1006
+
1007
+ /** What is to be remembered, with each tab whose copy is still on its way at the path that copy is under. */
1008
+ function heldBack(snapshot) {
1009
+ if (!_onTheWay.length) return snapshot;
1010
+ const held = (path) => (typeof path === 'string' ? _onTheWay.find((entry) => entry.now === normalizePath(path))?.at ?? path : path);
1011
+ return { active: held(snapshot.active), tabs: snapshot.tabs.map((tab) => ({ ...tab, path: held(tab.path) })) };
1012
+ }
1013
+
1014
+ /** Write the open tabs, the selected one and where each is, for the project the panel was opened in. */
1015
+ function persistTabs() {
1016
+ clearTimeout(_persistTimer);
1017
+ _persistTimer = 0;
1018
+ if (!_session) return;
1019
+ const snapshot = heldBack(serializeTabs(_model, (tab) => {
1020
+ const doc = _docs.get(tab.id);
1021
+ if (!doc || doc.refused) return null;
1022
+ if (!doc.loaded) return doc.place || {};
1023
+ const state = stateOf(doc);
1024
+ const main = state.selection.main;
1025
+ return { anchor: main.anchor, head: main.head, top: isShown(doc) ? _editor.topPosition() : doc.top, dirty: doc.dirty };
1026
+ }));
1027
+ const written = `${_project}\n${JSON.stringify(snapshot)}`;
1028
+ if (written === _persisted) return;
1029
+ _persisted = written;
1030
+ try { storage.setItem(KEYS.EDITOR_TABS, JSON.stringify(rememberTabs(readRemembered(), _project, snapshot, Date.now()))); } catch { /* storage is not there: nothing is kept */ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Write the tabs soon. `soon: false` is for a caret or a view that moved: that is written at most every two
1035
+ * seconds, and never puts off a write that is already due.
1036
+ */
1037
+ function schedulePersist({ soon = true } = {}) {
1038
+ if (!_session) return;
1039
+ if (!soon) {
1040
+ if (!_persistTimer) _persistTimer = setTimeout(persistTabs, POSITION_PERSIST_MS);
1041
+ return;
1042
+ }
1043
+ clearTimeout(_persistTimer);
1044
+ _persistTimer = setTimeout(persistTabs, PERSIST_DELAY_MS);
1045
+ }
1046
+
1047
+ /** The panel is being opened: the tabs that were open in this project the last time come back, not read yet. */
1048
+ function startSession() {
1049
+ if (_session) return;
1050
+ _session = true;
1051
+ _persisted = '';
1052
+ _project = String(_host.projectRoot() || '');
1053
+ const snapshot = recallTabs(readRemembered(), _project);
1054
+ if (!snapshot) return;
1055
+ // A tab that is still remembered at the path its copy is under opens where its file is by now.
1056
+ const ahead = (path) => (typeof path === 'string' ? _onTheWay.find((entry) => entry.at === normalizePath(path))?.now ?? path : path);
1057
+ const recalled = _onTheWay.length
1058
+ ? { active: ahead(snapshot.active), tabs: snapshot.tabs.map((tab) => (tab && typeof tab === 'object' ? { ...tab, path: ahead(tab.path) } : tab)) }
1059
+ : snapshot;
1060
+ const { model, places } = restoreTabs(recalled);
1061
+ _model = model;
1062
+ for (const tab of model.tabs) createDoc(tab, { place: places.get(tab.id) });
1063
+ }
1064
+
1065
+ /** An open that showed nothing: when the panel is not up, the tabs that came back for it wait for the next one. */
1066
+ function settleSession() {
1067
+ if (_open) return;
1068
+ _session = false;
1069
+ _model = createTabs();
1070
+ _docs.clear();
1071
+ _shownId = null;
1072
+ }
1073
+
1074
+ // ── Opening ──
1075
+
1076
+ /** Opens run one at a time, in the order they were asked for. `alive()` is false once the panel was closed since. */
1077
+ function enqueue(job) {
1078
+ const epoch = _epoch;
1079
+ const run = () => job(() => epoch === _epoch);
1080
+ _queue = _queue.then(run, run).catch((error) => console.error('[editor] could not open', error));
1081
+ return _queue;
1082
+ }
1083
+
1084
+ async function readFile(path) {
1085
+ const [res] = await Promise.all([fetch(`/api/file-content?path=${encodeURIComponent(path)}`), askCapabilities()]);
1086
+ return { res, data: await res.json() };
1087
+ }
1088
+
1089
+ /**
1090
+ * Open something in a tab: a file, a plan or a changelog. A document that is already open is shown again.
1091
+ * @param {{ kind?: 'file' | 'plan' | 'changelog', path: string, at?: { line?: number, column?: number } | null, pin?: boolean, plan?: object | null }} request
1092
+ * @param {() => boolean} alive
1093
+ */
1094
+ async function openDocument({ kind = 'file', path, at = null, pin = false, plan = null }, alive) {
1095
+ const cancelled = () => {
1096
+ if (kind === 'plan') emit('plan-edit-cancelled', planEvent({ ...plan.contract, source: plan.source, tabId: plan.tabId }));
1097
+ if (kind === 'changelog') emit('changelog-edit-cancelled');
1098
+ };
1099
+ const giveUp = (message) => { toast(message); cancelled(); settleSession(); };
1100
+ const keyOf = (where) => tabKey({ kind, path: where, identity: plan ? planIdentityKey({ ...plan.contract, source: plan.source, tabId: plan.tabId }) : '' });
1101
+
1102
+ startSession();
1103
+ let tab = findTab(_model, keyOf(path));
1104
+ if (tab) { await revisit(_docs.get(tab.id), { at, pin }); return; }
1105
+
1106
+ let res; let data;
1107
+ try {
1108
+ ({ res, data } = await readFile(path));
1109
+ } catch {
1110
+ if (alive()) giveUp(t('editor.toast.openFailed')); else cancelled();
1111
+ return;
1112
+ }
1113
+ if (!alive()) { cancelled(); return; }
1114
+ if (!res.ok) {
1115
+ const failure = describeOpenFailure({ status: res.status, body: data, path });
1116
+ if (failure.kind === 'refused' && kind === 'file') { showRefused(String(path).replace(/\\/g, '/'), failure.key); return; }
1117
+ giveUp(failure.text || t(failure.key, { name: baseName(path), ...failure.params }));
1118
+ return;
1119
+ }
1120
+ // A server from before the encoding check decodes anything: a file that was not UTF-8 is not opened either.
1121
+ if (data.version === undefined && looksLossyDecoded(data.content, data.size)) {
1122
+ if (kind === 'file') showRefused(data.path, 'editor.refused.encoding');
1123
+ else giveUp(t('editor.refused.encoding', { name: data.name }));
1124
+ return;
1125
+ }
1126
+ const split = splitBom(data.content, data.bom);
1127
+ if (kind === 'plan' && !matchesExpectedMarkdown(split.text, plan.contract.expectedMarkdown)) {
1128
+ giveUp(t('editor.toast.planChanged'));
1129
+ return;
1130
+ }
1131
+
1132
+ // The path as the server resolved it may be open under another spelling.
1133
+ tab = findTab(_model, keyOf(data.path));
1134
+ if (tab) { await revisit(_docs.get(tab.id), { at, pin }); return; }
1135
+
1136
+ const doc = addDoc({ key: keyOf(data.path), kind, path: data.path, name: data.name || baseName(data.path), pin }, { plan: plan ? { ...plan, filePath: data.path } : null });
1137
+ await fillDocument(doc, data, split, { at });
1138
+ }
1139
+
1140
+ /** A tab for a new document: the one nobody kept gives its place, or one is added. */
1141
+ function addDoc(request, extra) {
1142
+ const opened = openTab(_model, request, { canReuse: (other) => { const doc = _docs.get(other.id); return !doc || (!isDirty(doc) && !doc.saving); } });
1143
+ _model = opened.model;
1144
+ if (opened.replacedId) {
1145
+ _docs.delete(opened.replacedId);
1146
+ if (_shownId === opened.replacedId) _shownId = null;
1147
+ }
1148
+ return createDoc(getTab(_model, opened.id), extra);
1149
+ }
1150
+
1151
+ /** An open asked for a document that has a tab: show it, read it when it was not read yet, and bring it up to date. */
1152
+ async function revisit(doc, { at = null, pin = false } = {}) {
1153
+ if (pin) _model = pinTab(_model, doc.id);
1154
+ openPanel();
1155
+ showTab(doc.id);
1156
+ if (!doc.loaded) { await loadDocument(doc, { at }); return; }
1157
+ if (at?.line && isShown(doc)) _editor.reveal(at.line, at.column || 1);
1158
+ if (!doc.preview) _editor?.focus();
1159
+ // Opened again by name: when nothing is unsaved, it shows what the disk holds now.
1160
+ if (doc.kind === 'file' && !doc.dirty && !doc.saving) await reloadClean(doc);
1161
+ }
1162
+
1163
+ /**
1164
+ * A tab whose file could not be read. It goes, unless it came back with unsaved text: that tab stays, so the
1165
+ * text is not given up without a question, and its copy is there when the file can be read again.
1166
+ */
1167
+ function dropUnreadable(doc, message) {
1168
+ if (!isOpen(doc)) return;
1169
+ toast(message);
1170
+ if (doc.place?.dirty) return;
1171
+ _model = closeTab(_model, doc.id).model;
1172
+ _docs.delete(doc.id);
1173
+ if (_shownId === doc.id) _shownId = null;
1174
+ if (!_model.tabs.length) { hidePanel(); return; }
1175
+ if (_shownId !== _model.activeId) visitTab(_model.activeId, { focus: true });
1176
+ else renderActive();
1177
+ schedulePersist();
1178
+ }
1179
+
1180
+ /** Read the file of a tab that has none yet (it came back from storage, or the editor did not load) and fill it. */
1181
+ async function loadDocument(doc, { at = null } = {}) {
1182
+ const asked = doc.path;
1183
+ // Renamed or moved while it was being read (repointEditorTab): it is read where it is now.
1184
+ const elsewhere = () => isOpen(doc) && !doc.loaded && doc.path !== asked;
1185
+ let res; let data;
1186
+ try {
1187
+ ({ res, data } = await readFile(asked));
1188
+ } catch {
1189
+ if (elsewhere()) { await loadDocument(doc, { at }); return; }
1190
+ dropUnreadable(doc, t('editor.toast.openFailed'));
1191
+ return;
1192
+ }
1193
+ if (!isOpen(doc)) return;
1194
+ if (elsewhere()) { await loadDocument(doc, { at }); return; }
1195
+ if (!res.ok) {
1196
+ const failure = describeOpenFailure({ status: res.status, body: data, path: doc.path });
1197
+ if (failure.kind === 'refused') { markRefused(doc, failure.key); return; }
1198
+ dropUnreadable(doc, failure.text || t(failure.key, { name: doc.name, ...failure.params }));
1199
+ return;
1200
+ }
1201
+ if (data.version === undefined && looksLossyDecoded(data.content, data.size)) { markRefused(doc, 'editor.refused.encoding'); return; }
1202
+ await fillDocument(doc, data, splitBom(data.content, data.bom), { at });
1203
+ }
1204
+
1205
+ /** Give a tab its document: the file's facts, a CodeMirror state, the unsaved text a copy holds, the language. */
1206
+ async function fillDocument(doc, data, { text, bom }, { at = null } = {}) {
1207
+ const ending = detectLineEnding(text);
1208
+ const detectedIndent = detectIndentation(text);
1209
+ const name = data.name || baseName(data.path);
1210
+ doc.path = data.path;
1211
+ doc.name = name;
1212
+ doc.file = {
1213
+ path: data.path, name, size: data.size || 0, version: data.version || null,
1214
+ eol: ending.eol, mixed: ending.mixed, diskEol: ending.eol, diskMixed: ending.mixed, bom,
1215
+ detectedIndent, indentChoice: null, indent: chooseIndentation(detectedIndent, _prefs.get().editing, null),
1216
+ markdown: isMarkdownFile(name),
1217
+ };
1218
+ doc.refused = null;
1219
+ doc.failed = false;
1220
+ doc.languageName = languageLabel(name, null) || '';
1221
+ _model = updateTab(_model, doc.id, { path: data.path, name });
1222
+
1223
+ // A tab that is read while the user has gone on to another one stays in the background.
1224
+ if (isActive(doc)) {
1225
+ openPanel();
1226
+ showTab(doc.id);
1227
+ } else {
1228
+ renderStrip();
1229
+ }
1230
+ if (doc.plan) emitPlanState(doc);
1231
+
1232
+ let editor; let backup;
1233
+ try {
1234
+ [editor, backup] = await Promise.all([ensureEditor(), backups().read(doc.key)]);
1235
+ } catch (error) {
1236
+ console.error('[editor] CodeMirror did not load', error);
1237
+ if (!isOpen(doc)) return;
1238
+ doc.file = null;
1239
+ doc.failed = true;
1240
+ if (isActive(doc)) renderActive();
1241
+ return;
1242
+ }
1243
+ if (!isOpen(doc)) return;
1244
+
1245
+ doc.file.indent = chooseIndentation(detectedIndent, _prefs.get().editing, null);
1246
+ let state = newState(doc, text, data.size || 0);
1247
+ doc.savedDoc = state.doc;
1248
+ state = restoreBackup(doc, state, backup);
1249
+ // Where the caret and the view were, for a tab that came back: places in the text as it was left, unsaved
1250
+ // text included, so they are put back after that text is.
1251
+ if (doc.place) {
1252
+ const inside = (n) => Math.min(n, state.doc.length);
1253
+ state = state.update({ selection: { anchor: inside(doc.place.anchor), head: inside(doc.place.head) } }).state;
1254
+ doc.top = inside(doc.place.top);
1255
+ }
1256
+ doc.place = null;
1257
+ doc.state = state;
1258
+ doc.scroll = null;
1259
+ doc.loaded = true;
1260
+ if (isActive(doc)) mount(doc);
1261
+
1262
+ useLanguage(doc, editor.describeLanguage(name));
1263
+ syncAi();
1264
+ startWatching();
1265
+ loadHead(doc);
1266
+ refreshDoc(doc, { force: true });
1267
+ schedulePersist();
1268
+ if (!isActive(doc)) { renderStrip(); return; }
1269
+ renderActive();
1270
+ if (at?.line) editor.reveal(at.line, at.column || 1);
1271
+ editor.focus();
1272
+ }
1273
+
1274
+ /**
1275
+ * Unsaved text that was copied before the page went away comes back as the buffer, with a bar that says so.
1276
+ * The line ending the user had chosen comes back with it (it is unsaved too, even when the text is not).
1277
+ * @returns the state with the unsaved text in it; an undo takes it out again
1278
+ */
1279
+ function restoreBackup(doc, state, backup) {
1280
+ if (!backup) return state;
1281
+ const disk = state.doc.toString();
1282
+ const verdict = describeBackup(backup, disk);
1283
+ const ending = backup.eol !== doc.file.eol;
1284
+ if (verdict === 'same' && !ending) { backups().drop(doc.key); return state; }
1285
+ if (ending) {
1286
+ doc.file.eol = backup.eol;
1287
+ doc.file.mixed = false;
1288
+ doc.eolChanged = true;
1289
+ }
1290
+ doc.bars.set('restored', verdict === 'restore-changed' ? 'restored-changed' : 'restored');
1291
+ const change = minimalChange(disk, backup.text);
1292
+ return change ? state.update({ changes: change }).state : state;
1293
+ }
1294
+
1295
+ /** Show a document in a language (its chunk may still have to arrive), or in none. */
1296
+ async function useLanguage(doc, description) {
1297
+ doc.language = description || null;
1298
+ doc.languageName = languageNameOf(doc);
1299
+ if (isActive(doc)) renderStatus(doc);
1300
+ syncLint(doc);
1301
+ const mine = ++doc.languageSeq;
1302
+ const support = await _editor.loadLanguage(doc.language);
1303
+ // A language that arrives late lands in its own tab, wherever that tab is by then.
1304
+ if (!isOpen(doc) || !doc.loaded || mine !== doc.languageSeq) return;
1305
+ applyTo(doc, { effects: _editor.effects.language(support) });
1306
+ }
1307
+
1308
+ function markRefused(doc, key) {
1309
+ doc.file = null;
1310
+ doc.loaded = false;
1311
+ doc.failed = false;
1312
+ doc.refused = { key };
1313
+ doc.place = null;
1314
+ if (isActive(doc)) renderActive();
1315
+ renderStrip();
1316
+ }
1317
+
1318
+ // A file that is not text the editor can hold: its tab says so in place of an editor.
1319
+ function showRefused(path, key) {
1320
+ const where = String(path || '');
1321
+ const doc = addDoc({ key: tabKey({ path: where }), kind: 'file', path: where, name: baseName(where) }, { refused: { key } });
1322
+ openPanel();
1323
+ showTab(doc.id);
1324
+ }
1325
+
1326
+ // ── The file on disk ──
1327
+
1328
+ /** The document now holds what the disk holds: its facts, and nothing unsaved. */
1329
+ function adoptDisk(doc, data, { text, bom }) {
1330
+ const ending = detectLineEnding(text);
1331
+ const { file } = doc;
1332
+ file.size = data.size ?? file.size;
1333
+ file.version = data.version || null;
1334
+ file.eol = ending.eol;
1335
+ file.mixed = ending.mixed;
1336
+ file.diskEol = ending.eol;
1337
+ file.diskMixed = ending.mixed;
1338
+ file.bom = bom;
1339
+ file.detectedIndent = detectIndentation(text);
1340
+ doc.savedDoc = stateOf(doc).doc;
1341
+ doc.eolChanged = false;
1342
+ doc.deleted = false;
1343
+ doc.pendingVersion = null;
1344
+ doc.bars.delete('disk');
1345
+ doc.bars.delete('restored');
1346
+ backups().drop(doc.key);
1347
+ if (!file.indentChoice) {
1348
+ file.indent = chooseIndentation(file.detectedIndent, _prefs.get().editing, null);
1349
+ applyTo(doc, { effects: _editor.effects.indent(file.indent) });
1350
+ }
1351
+ refreshDoc(doc, { force: true });
1352
+ // What changed the file may have moved the last commit too (a checkout, a pull).
1353
+ loadHead(doc);
1354
+ if (isActive(doc)) renderActive();
1355
+ else renderStrip();
1356
+ }
1357
+
1358
+ /**
1359
+ * A file with nothing unsaved changed on disk: the tab takes the new text without a word. The change is the
1360
+ * smallest one that does it, so the caret, the view and the undo history stay where they are.
1361
+ */
1362
+ async function reloadClean(doc) {
1363
+ let res; let data;
1364
+ try { ({ res, data } = await readFile(doc.file.path)); } catch { return; }
1365
+ if (!isOpen(doc) || !doc.loaded || !doc.file || doc.dirty || doc.saving || !res.ok) return;
1366
+ if (data.version && sameVersion(data.version, doc.file.version) && !doc.deleted) return;
1367
+ if (data.version === undefined && looksLossyDecoded(data.content, data.size)) return;
1368
+ const split = splitBom(data.content, data.bom);
1369
+ const change = minimalChange(stateOf(doc).doc.toString(), split.text.replace(/\r\n?/g, '\n'));
1370
+ if (change) {
1371
+ quietly(() => {
1372
+ applyTo(doc, { changes: change, annotations: _cm.Transaction.addToHistory.of(false) });
1373
+ applyTo(doc, { effects: _editor.effects.saved() });
1374
+ });
1375
+ if (!isShown(doc)) {
1376
+ // Its place in the view moves with the text above it.
1377
+ doc.scroll = null;
1378
+ if (doc.top >= change.to) doc.top += change.insert.length - (change.to - change.from);
1379
+ else if (doc.top > change.from) doc.top = change.from;
1380
+ }
1381
+ if (doc.comparing) closeCompare(doc);
1382
+ }
1383
+ adoptDisk(doc, data, split);
1384
+ }
1385
+
1386
+ /**
1387
+ * "Reload": the buffer is given up for what the disk holds (the user is asked when it has unsaved text).
1388
+ * From that answer until the file's text is in, the buffer is read-only: an edit made while the file is being
1389
+ * read would be replaced with the rest, and nobody was asked about that one.
1390
+ */
1391
+ async function reloadFromDisk(doc) {
1392
+ if (doc.saving || doc.reloading || !doc.file) return;
1393
+ if (doc.dirty && !confirm(t('editor.confirm.reload'))) return;
1394
+ doc.reloading = true;
1395
+ // The lock is taken below. Whatever happens after it, a document that is still open ends as one of two things:
1396
+ // replaced by the file's text (`replaced`), or the buffer it was, to be typed into again. Never locked.
1397
+ let replaced = false;
1398
+ let failure = null;
1399
+ try {
1400
+ applyTo(doc, { effects: _editor.effects.readOnly(true) });
1401
+ let res; let data;
1402
+ try { ({ res, data } = await readFile(doc.file.path)); } catch { failure = t('editor.toast.openFailed'); }
1403
+ doc.reloading = false;
1404
+ if (!isOpen(doc) || !doc.file) return;
1405
+ if (!failure && !res.ok) failure = (typeof data?.error === 'string' && data.error) || t('editor.toast.cannotOpen');
1406
+ if (failure) return;
1407
+ // The new state is built whole before anything of the old one is given up.
1408
+ const split = splitBom(data.content, data.bom);
1409
+ const head = stateOf(doc).selection.main.head;
1410
+ let state = newState(doc, split.text, data.size ?? doc.file.size);
1411
+ state = state.update({ selection: { anchor: Math.min(head, state.doc.length) } }).state;
1412
+ if (isShown(doc)) {
1413
+ quietly(() => _editor.show(state, { top: Math.min(_editor.topPosition(), state.doc.length) }));
1414
+ } else {
1415
+ doc.state = state;
1416
+ doc.scroll = null;
1417
+ doc.top = Math.min(doc.top, state.doc.length);
1418
+ }
1419
+ replaced = true;
1420
+ doc.comparing = false;
1421
+ doc.diff = false;
1422
+ doc.bars.delete('compare');
1423
+ adoptDisk(doc, data, split);
1424
+ useLanguage(doc, doc.language);
1425
+ } catch (error) {
1426
+ // What was read could not be made into a document, or putting it in place failed.
1427
+ console.error('[editor] reload failed:', error);
1428
+ if (!replaced && !failure) failure = t('editor.toast.openFailed');
1429
+ } finally {
1430
+ doc.reloading = false;
1431
+ if (isOpen(doc) && !replaced) {
1432
+ // Nothing took the buffer's place: it is the user's as it was, and can be typed into again.
1433
+ try { applyTo(doc, { effects: _editor.effects.readOnly(false) }); } catch (error) { console.error('[editor] could not unlock the document:', error); }
1434
+ if (failure) toast(failure);
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ /** "Keep mine": the buffer stays, and the version on disk becomes the one the next save is checked against. */
1440
+ function keepMine(doc) {
1441
+ if (!doc.file) return;
1442
+ if (doc.deleted) doc.file.version = null;
1443
+ else if (doc.pendingVersion) doc.file.version = doc.pendingVersion;
1444
+ doc.pendingVersion = null;
1445
+ clearBar(doc, 'disk');
1446
+ }
1447
+
1448
+ /** Show the buffer against the file on disk, inline (or bring a comparison that is showing up to date). */
1449
+ async function showCompare(doc) {
1450
+ if (!doc.loaded || !doc.file || !_compare) return;
1451
+ let extension;
1452
+ try {
1453
+ const { res, data } = await readFile(doc.file.path);
1454
+ if (!res.ok) throw new Error(data?.error || 'not read');
1455
+ const disk = splitBom(data.content, data.bom).text.replace(/\r\n?/g, '\n');
1456
+ extension = await _compare.extension({ original: disk, revertLabel: t('editor.compare.useDisk') });
1457
+ } catch {
1458
+ if (isOpen(doc)) toast(t('editor.compare.failed'));
1459
+ return;
1460
+ }
1461
+ if (!isOpen(doc) || !doc.loaded) return;
1462
+ applyTo(doc, { effects: _editor.effects.compare(extension) });
1463
+ doc.comparing = true;
1464
+ doc.diff = false;
1465
+ doc.bars.set('compare', 'comparing');
1466
+ if (!isActive(doc)) return;
1467
+ if (doc.preview) setPreview(false);
1468
+ renderBars(doc);
1469
+ renderDiffButton(doc);
1470
+ }
1471
+
1472
+ function closeCompare(doc) {
1473
+ if (!doc.comparing) return;
1474
+ doc.comparing = false;
1475
+ doc.bars.delete('compare');
1476
+ if (doc.loaded) applyTo(doc, { effects: _editor.effects.compare(null) });
1477
+ if (isActive(doc)) renderBars(doc);
1478
+ }
1479
+
1480
+ // ── Saving, discarding, closing ──
1481
+
1482
+ /**
1483
+ * Save a document. A plan with a save callback of its own is saved through it and nothing is written here;
1484
+ * anything else goes to /api/file-content in the file's own line ending and encoding.
1485
+ */
1486
+ async function saveDocument(doc, { force = false } = {}) {
1487
+ if (!doc?.loaded || !doc.file || !_editor || doc.saving || doc.reloading) return;
1488
+ const changelog = doc.kind === 'changelog';
1489
+ // Nothing to write for an unchanged file. A plan or a changelog is still "saved": that is how it is approved.
1490
+ if (!doc.dirty && !doc.plan && !changelog && !force) return;
1491
+ if (doc.kind === 'file') applySaveFixes(doc);
1492
+ const savedState = stateOf(doc);
1493
+ const savedDoc = savedState.doc;
1494
+ const savedContent = savedDoc.toString();
1495
+ const savedPath = doc.file.path;
1496
+ const plan = doc.plan;
1497
+ const planIds = plan ? planEvent(planIdentity(doc)) : null;
1498
+ const contract = plan?.contract;
1499
+ doc.saving = true;
1500
+ applyTo(doc, { effects: _editor.effects.readOnly(true) });
1501
+ refreshDoc(doc, { force: true });
1502
+ let saved = false;
1503
+ let result = null;
1504
+ try {
1505
+ if (planIds?.source === 'codex' && typeof contract?.save !== 'function') {
1506
+ throw new Error(t('editor.error.planMissingRevision'));
1507
+ }
1508
+ if (typeof contract?.save === 'function') {
1509
+ result = await contract.save({ ...planIds, content: savedContent });
1510
+ if (!result?.ok) throw new Error(result?.reason || result?.error || t('editor.error.planChanged'));
1511
+ } else {
1512
+ const body = { path: savedPath, content: serializeDocument(savedContent, doc.file) };
1513
+ if (doc.deleted) {
1514
+ // The file is gone from the disk: the user kept the tab, a save puts it back. Unless it is there again by
1515
+ // now (made by someone else since the last look at the disk): the server then refuses, and the bar asks.
1516
+ body.create = true;
1517
+ if (!force) body.expectAbsent = true;
1518
+ } else if (_capabilities?.has('file-version') && doc.file.version) {
1519
+ body.expect = doc.file.version;
1520
+ if (force) body.force = true;
1521
+ }
1522
+ const res = await fetch('/api/file-content', {
1523
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1524
+ body: JSON.stringify(body),
1525
+ });
1526
+ result = await res.json();
1527
+ if (res.status === 409 && result?.code === 'changed') {
1528
+ if (isOpen(doc)) {
1529
+ doc.pendingVersion = result.current || doc.pendingVersion;
1530
+ doc.deleted = false; // whatever the tab took the file for: it is there, and it is not the tab's text
1531
+ setBar(doc, 'conflict');
1532
+ }
1533
+ throw new Error(t('editor.toast.changedOnDisk'));
1534
+ }
1535
+ if (!res.ok) throw new Error(result.error || t('editor.toast.saveFailed'));
1536
+ if (result.version) {
1537
+ doc.file.version = result.version;
1538
+ doc.file.size = result.version.size ?? doc.file.size;
1539
+ }
1540
+ }
1541
+ saved = true;
1542
+ } catch (error) {
1543
+ const message = error?.message || t('editor.toast.saveFailed');
1544
+ toast(message);
1545
+ if (plan?.source === 'codex' && isOpen(doc)) {
1546
+ doc.banner = t('editor.banner.planSaveFailed', { message });
1547
+ if (isActive(doc)) renderBanner(doc);
1548
+ }
1549
+ }
1550
+ doc.saving = false;
1551
+ if (!isOpen(doc)) return;
1552
+ if (saved) {
1553
+ doc.savedDoc = savedDoc;
1554
+ doc.file.diskEol = doc.file.eol;
1555
+ doc.file.diskMixed = false;
1556
+ doc.file.mixed = false;
1557
+ doc.eolChanged = false;
1558
+ doc.deleted = false;
1559
+ doc.pendingVersion = null;
1560
+ doc.banner = null;
1561
+ doc.bars.delete('disk');
1562
+ doc.bars.delete('restored');
1563
+ if (doc.comparing) closeCompare(doc);
1564
+ }
1565
+ quietly(() => applyTo(doc, { effects: saved ? [_editor.effects.readOnly(false), _editor.effects.saved()] : _editor.effects.readOnly(false) }));
1566
+ refreshDoc(doc, { force: true });
1567
+ if (!saved) return;
1568
+ // What was saved needs no copy any more. A buffer that holds more than was saved (it changed while the save was
1569
+ // on its way) is unsaved text like any other: its copy is made again, not dropped.
1570
+ syncBackup(doc);
1571
+ // The commit the markers compare with may have moved since the file was opened.
1572
+ if (doc.kind === 'file') loadHead(doc);
1573
+ if (isActive(doc)) renderActive();
1574
+
1575
+ if (plan && doc.plan === plan && savedPath === plan.filePath) {
1576
+ toast(t('editor.toast.planSaved'));
1577
+ emit('plan-saved', {
1578
+ ...planIds, filePath: result?.path || savedPath, content: savedContent,
1579
+ persisted: typeof contract?.save === 'function', savedRevision: result?.document?.revision,
1580
+ });
1581
+ closeTabs([doc.id], { saved: true });
1582
+ return;
1583
+ }
1584
+ if (changelog) {
1585
+ toast(t('editor.toast.changelogSaved'));
1586
+ emit('changelog-saved', { content: savedContent });
1587
+ closeTabs([doc.id], { saved: true });
1588
+ return;
1589
+ }
1590
+ toast(t('editor.toast.saved'));
1591
+ }
1592
+
1593
+ /**
1594
+ * What the on-save preferences change in a file's text, as one edit the user can undo: spaces and tabs at the end
1595
+ * of lines go, and the text ends with one line break. Two spaces at the end of a line are a line break in
1596
+ * Markdown, so its lines are not trimmed.
1597
+ */
1598
+ function applySaveFixes(doc) {
1599
+ const { trimTrailingWhitespace, insertFinalNewline } = _prefs.get().editing;
1600
+ if (!trimTrailingWhitespace && !insertFinalNewline) return;
1601
+ const changes = saveFixes(stateOf(doc).doc.toString(), { trimTrailingWhitespace: trimTrailingWhitespace && !doc.file.markdown, insertFinalNewline });
1602
+ if (changes.length) applyTo(doc, { changes, userEvent: 'synabun.save' });
1603
+ }
1604
+
1605
+ /** Put a document back to what was last saved. One undo brings the edits back. */
1606
+ function revertToSaved(doc) {
1607
+ if (!doc?.loaded || doc.saving || doc.reloading || !doc.savedDoc || !_editor) return;
1608
+ const state = stateOf(doc);
1609
+ const head = Math.min(state.selection.main.head, doc.savedDoc.length);
1610
+ doc.file.eol = doc.file.diskEol;
1611
+ doc.file.mixed = doc.file.diskMixed;
1612
+ doc.eolChanged = false;
1613
+ doc.bars.delete('restored');
1614
+ applyTo(doc, { changes: { from: 0, to: state.doc.length, insert: doc.savedDoc }, selection: { anchor: head } });
1615
+ refreshDoc(doc, { force: true });
1616
+ if (isActive(doc)) renderActive();
1617
+ }
1618
+
1619
+ /**
1620
+ * Save the document of the selected tab.
1621
+ * @param {{ force?: boolean }} [o] `force`: write even though the file changed on disk since it was read
1622
+ */
1623
+ export function saveEditorPanel({ force = false } = {}) {
1624
+ return saveDocument(active(), { force });
1625
+ }
1626
+
1627
+ /** Put the selected tab's document back to what was last saved (the user is asked first). */
1628
+ export function discardEditorPanel() {
1629
+ const doc = active();
1630
+ if (!doc?.loaded || doc.saving || !doc.savedDoc || !_editor) return;
1631
+ if (!confirm(t('editor.confirm.discard'))) return;
1632
+ revertToSaved(doc);
1633
+ }
1634
+
1635
+ /**
1636
+ * Close the panel: every tab. One question lists the files that have unsaved changes.
1637
+ * @returns {boolean} false when it stays open: a save is under way, or the user kept their changes
1638
+ */
1639
+ export function closeEditorPanel() {
1640
+ if (!closeTabs(closeTargets(_model, 'all', null))) return false;
1641
+ // An open that has not answered yet is given up on.
1642
+ ++_epoch;
1643
+ if (_open) hidePanel();
1644
+ return true;
1645
+ }
1646
+
1647
+ // ── What the File Explorer and the bus ask for ──
1648
+
1649
+ /** Whether the panel is showing. */
1650
+ export function isEditorPanelOpen() {
1651
+ return _open;
1652
+ }
1653
+
1654
+ /** Whether the editor is open, or about to be (a plan being fetched). */
1655
+ export function isEditorPanelBusy() {
1656
+ return _open || _pendingPlans > 0;
1657
+ }
1658
+
1659
+ /**
1660
+ * Open a file in a tab. A file that is already open is shown again; a tab nobody edited or kept gives its place.
1661
+ * @param {string} filePath
1662
+ * @param {{ line?: number, column?: number, pin?: boolean }} [at] where to put the caret (1-based); `pin`: the
1663
+ * tab is the user's to keep (a double click)
1664
+ */
1665
+ export function openFileInEditor(filePath, { line, column, pin = false } = {}) {
1666
+ if (!filePath) return;
1667
+ enqueue((alive) => (alive() ? openDocument({ kind: 'file', path: filePath, at: line ? { line, column } : null, pin }, alive) : undefined));
1668
+ }
1669
+
1670
+ /**
1671
+ * Open a plan in a tab of its own (`open-plan-editor`). Asking for the revision that is already open only
1672
+ * focuses it. The panel and tab that ask follow one plan at a time: an earlier plan of theirs gives its tab up.
1673
+ * @param {object} contract the event's payload: filePath, source, tabId, the plan's identity, and for a
1674
+ * versioned plan `expectedMarkdown` and `save`
1675
+ */
1676
+ export function openPlanInEditor(contract = {}) {
1677
+ const { filePath, source = 'claude', tabId = null } = contract;
1678
+ if (!filePath) return;
1679
+ const same = planDocs().find((doc) => isSamePlan(planRequest(doc), contract));
1680
+ if (same) {
1681
+ showTab(same.id);
1682
+ _editor?.focus();
1683
+ return;
1684
+ }
1685
+ _pendingPlans++;
1686
+ enqueue(async (alive) => {
1687
+ try {
1688
+ if (!alive()) { emit('plan-edit-cancelled', planEvent(contract)); return; }
1689
+ const earlier = planDocs().filter((doc) => isSamePlanOwner(planRequest(doc), contract));
1690
+ if (earlier.length && !closeTabs(earlier.map((doc) => doc.id))) {
1691
+ emit('plan-edit-cancelled', planEvent(contract));
1692
+ return;
1693
+ }
1694
+ await openDocument({ kind: 'plan', path: filePath, plan: { contract, filePath, source, tabId } }, alive);
1695
+ } finally {
1696
+ _pendingPlans--;
1697
+ }
1698
+ });
1699
+ }
1700
+
1701
+ /** Open a changelog draft in a tab of its own (`open-changelog-editor`). There is one at a time. */
1702
+ export function openChangelogInEditor({ filePath } = {}) {
1703
+ if (!filePath) return;
1704
+ enqueue(async (alive) => {
1705
+ if (!alive()) return;
1706
+ const key = tabKey({ kind: 'changelog', path: filePath });
1707
+ const others = [..._docs.values()].filter((doc) => doc.kind === 'changelog' && doc.key !== key);
1708
+ if (others.length && !closeTabs(others.map((doc) => doc.id))) return;
1709
+ await openDocument({ kind: 'changelog', path: filePath }, alive);
1710
+ });
1711
+ }
1712
+
1713
+ // ── What other panels may know of the tabs ──
1714
+
1715
+ let _tabsSaid = '';
1716
+
1717
+ /** The tabs as another panel may read them: in strip order, and none while the panel is closed. */
1718
+ function tabsSnapshot() {
1719
+ if (!_open) return { open: [], active: null };
1720
+ const pathOf = (doc) => doc.file?.path || doc.path;
1721
+ const open = [];
1722
+ for (const tab of _model.tabs) {
1723
+ const doc = _docs.get(tab.id);
1724
+ if (doc) open.push({ path: pathOf(doc), kind: doc.kind, dirty: isDirty(doc), pinned: !!tab.pinned });
1725
+ }
1726
+ const shown = active();
1727
+ return { open, active: shown ? pathOf(shown) : null };
1728
+ }
1729
+
1730
+ /** Say on the bus (`editor-tabs-changed`) when the open tabs, what is unsaved in them or the one showing changed. */
1731
+ function announceTabs() {
1732
+ const snapshot = tabsSnapshot();
1733
+ const said = JSON.stringify(snapshot);
1734
+ if (said === _tabsSaid) return;
1735
+ _tabsSaid = said;
1736
+ emit('editor-tabs-changed', snapshot);
1737
+ }
1738
+
1739
+ /**
1740
+ * The open tabs, as `editor-tabs-changed` last said them (for a listener that starts after the tabs were opened).
1741
+ * @returns {{ open: { path: string, kind: 'file' | 'plan' | 'changelog', dirty: boolean, pinned: boolean }[], active: string | null }}
1742
+ * `open`: in strip order, empty while the panel is closed; `dirty`: it has unsaved text; `pinned`: it is the
1743
+ * user's to keep; `active`: the path of the tab that is showing
1744
+ */
1745
+ export function getEditorTabs() {
1746
+ return tabsSnapshot();
1747
+ }
1748
+
1749
+ /**
1750
+ * A file, or a folder of files, was renamed or moved on disk (the File Explorer did it): the tabs of those files
1751
+ * follow. Each keeps its document as it is: the text and what is unsaved of it, the caret, where it is scrolled to,
1752
+ * the undo history, whether it is the user's to keep. From here on it is read, saved, checked against the disk,
1753
+ * copied and remembered at its new path. Plan and changelog tabs stay: each belongs to the panel that asked for it.
1754
+ *
1755
+ * A tab that was open for the new path showed a file that was replaced. It goes; unless it has unsaved text: then
1756
+ * it stays, and the tab that would take its path stays at the old one (`kept`), where the check against the disk
1757
+ * says the file is gone. The tabs that are remembered while the panel is closed follow too, with their copies.
1758
+ * @param {string} fromPath the file or the folder as it was
1759
+ * @param {string} toPath as it is now
1760
+ * @returns {{ moved: { from: string, to: string }[], kept: string[] }} the open tabs that follow, and the paths of
1761
+ * those that could not
1762
+ */
1763
+ export function repointEditorTab(fromPath, toPath) {
1764
+ const moved = [];
1765
+ const kept = [];
1766
+ if (!fromPath || !toPath || normalizePath(fromPath) === normalizePath(toPath)) return { moved, kept };
1767
+ const since = Date.now();
1768
+ // One kept tab, of whichever project, is at its new path from now on.
1769
+ const remember = (pairs) => {
1770
+ try {
1771
+ let stored = readRemembered(); let changed = false;
1772
+ for (const [from, to] of pairs) {
1773
+ const next = repointRemembered(stored, from, to);
1774
+ if (next.moved.length) { stored = next.stored; changed = true; }
1775
+ }
1776
+ if (changed) storage.setItem(KEYS.EDITOR_TABS, JSON.stringify(stored));
1777
+ } catch { /* storage is not there: nothing is kept */ }
1778
+ if (_session) {
1779
+ _persisted = '';
1780
+ persistTabs();
1781
+ }
1782
+ };
1783
+ // A copy of unsaved text is stored under its document's key: it follows the document to the new one, and what is
1784
+ // remembered of the tab follows the copy (`commit`), never the other way round. The text of an open document is
1785
+ // the newest there is and goes under the new key in this very call. A copy that is only in the store (a kept tab
1786
+ // that is not open) is brought by the store's queue: until it has arrived the tab stays remembered where its
1787
+ // copy is (`_onTheWay`), also while the store is not chosen yet, which it is once the server has said what it
1788
+ // offers. `hint`: `read`, the buffer of an open document; `expected`, it is known to have unsaved text.
1789
+ let settled = []; // what could be remembered at once: written together when this call is done
1790
+ const follow = (was, now, hint) => {
1791
+ const from = normalizePath(was); const to = normalizePath(now);
1792
+ let entry = _onTheWay.find((held) => held.now === from);
1793
+ if (entry) entry.now = to;
1794
+ else { entry = { at: from, now: to, steps: 0 }; _onTheWay.push(entry); }
1795
+ entry.steps++;
1796
+ const commit = () => {
1797
+ entry.at = to;
1798
+ if (--entry.steps <= 0 && _onTheWay.includes(entry)) _onTheWay.splice(_onTheWay.indexOf(entry), 1);
1799
+ if (settled) settled.push([from, to]); else remember([[from, to]]);
1800
+ };
1801
+ const move = () => backups().move(tabKey({ path: from }), tabKey({ path: to }), to, { ...hint, since, commit }).catch(() => {});
1802
+ if (_backups) move(); else askCapabilities().then(move).catch(() => {});
1803
+ };
1804
+
1805
+ for (const doc of [..._docs.values()]) {
1806
+ if (doc.kind !== 'file' || !isOpen(doc)) continue;
1807
+ const was = doc.file?.path || doc.path;
1808
+ const now = repointPath(was, fromPath, toPath);
1809
+ if (!now || now === normalizePath(was)) continue;
1810
+ const key = tabKey({ path: now });
1811
+ const other = findTab(_model, key);
1812
+ if (other && other.id !== doc.id) {
1813
+ const there = _docs.get(other.id);
1814
+ if (there && (isDirty(there) || there.saving)) { kept.push(was); continue; }
1815
+ _model = closeTab(_model, other.id).model;
1816
+ _docs.delete(other.id);
1817
+ if (_shownId === other.id) _shownId = null;
1818
+ }
1819
+ const before = { key: doc.key, name: doc.name };
1820
+ const name = baseName(now);
1821
+ doc.key = key;
1822
+ doc.path = now;
1823
+ doc.name = name;
1824
+ if (doc.file) {
1825
+ doc.file.path = now;
1826
+ doc.file.name = name;
1827
+ doc.file.markdown = isMarkdownFile(name);
1828
+ if (!doc.file.markdown) doc.preview = false;
1829
+ }
1830
+ _model = updateTab(_model, doc.id, { key, path: now, name });
1831
+ follow(was, now, { read: backupReader(doc), expected: isDirty(doc) });
1832
+ // A check of the disk that ran while the file was on its way found nothing at the old path.
1833
+ if (doc.deleted) {
1834
+ doc.deleted = false;
1835
+ if (doc.bars.get('disk') === 'deleted') doc.bars.delete('disk');
1836
+ }
1837
+ if (doc.loaded && doc.file && _editor) {
1838
+ // Its language follows its name, unless the user picked one; the last commit is asked about the new path.
1839
+ if (name !== before.name && !doc.languageChoice) useLanguage(doc, _editor.describeLanguage(name));
1840
+ else syncLint(doc);
1841
+ loadHead(doc);
1842
+ }
1843
+ moved.push({ from: was, to: now });
1844
+ }
1845
+
1846
+ // What is kept of the tabs: of this project while the panel is closed, and of every other one.
1847
+ try {
1848
+ // A tab that was already on its way (its file renamed a moment ago) and is not open: its copy goes on from
1849
+ // where it will be. The path it is still remembered at is no longer where its file is, so that path is not
1850
+ // taken for one of this rename.
1851
+ const open = new Set([...moved.map((pair) => pair.from), ...kept].map(normalizePath));
1852
+ const stale = new Set(_onTheWay.map((entry) => entry.at));
1853
+ for (const entry of [..._onTheWay]) {
1854
+ const again = repointPath(entry.now, fromPath, toPath);
1855
+ if (again && again !== entry.now) follow(entry.now, again, { expected: true });
1856
+ }
1857
+ for (const pair of repointRemembered(readRemembered(), fromPath, toPath).moved) {
1858
+ if (open.has(pair.from) || stale.has(pair.from)) continue;
1859
+ // A kept tab with unsaved text has that text in its copy only: it is remembered anew when the copy has arrived.
1860
+ if (pair.dirty) follow(pair.from, pair.to, { expected: true });
1861
+ else settled.push([pair.from, pair.to]);
1862
+ }
1863
+ } catch { /* storage is not there: nothing is kept */ }
1864
+ const now = settled;
1865
+ settled = null;
1866
+ remember(now);
1867
+ if (_open && (moved.length || kept.length)) {
1868
+ if (_model.activeId && _shownId !== _model.activeId) visitTab(_model.activeId, { focus: false });
1869
+ else renderActive();
1870
+ _ai?.documentChanged();
1871
+ }
1872
+ return { moved, kept };
1873
+ }
1874
+
1875
+ // ── What the status bar's lists do ──
1876
+
1877
+ const statusActions = {
1878
+ setLanguage(id) {
1879
+ const doc = active();
1880
+ if (!doc?.loaded || !_editor) return;
1881
+ if (id === LANGUAGE_AUTO) { doc.languageChoice = null; useLanguage(doc, _editor.describeLanguage(doc.name)); return; }
1882
+ if (id === LANGUAGE_PLAIN) { doc.languageChoice = LANGUAGE_PLAIN; useLanguage(doc, null); return; }
1883
+ const description = _editor.languages().find((entry) => entry.name === id);
1884
+ if (!description) return;
1885
+ doc.languageChoice = description.name;
1886
+ useLanguage(doc, description);
1887
+ },
1888
+ setIndent(choice) {
1889
+ const doc = active();
1890
+ if (!doc?.loaded || !_editor) return;
1891
+ doc.file.indentChoice = choice;
1892
+ doc.file.indent = chooseIndentation(doc.file.detectedIndent, _prefs.get().editing, choice);
1893
+ applyTo(doc, { effects: _editor.effects.indent(doc.file.indent) });
1894
+ renderStatus(doc);
1895
+ _editor.focus();
1896
+ },
1897
+ // Applied by the next save: until then the document differs from the file by its line endings.
1898
+ setEol(eol) {
1899
+ const doc = active();
1900
+ if (!doc?.loaded || (eol !== 'LF' && eol !== 'CRLF')) return;
1901
+ doc.file.eol = eol;
1902
+ doc.file.mixed = false;
1903
+ doc.eolChanged = eol !== doc.file.diskEol || doc.file.diskMixed;
1904
+ refreshDoc(doc, { force: true });
1905
+ syncBackup(doc);
1906
+ renderStatus(doc);
1907
+ _editor?.focus();
1908
+ },
1909
+ goToLine() {
1910
+ const doc = active();
1911
+ if (!doc?.loaded || !_editor) return;
1912
+ if (doc.preview) setPreview(false);
1913
+ _search.openGoToLine(_editor.view);
1914
+ },
1915
+ };
1916
+
1917
+ // ── Wiring ──
1918
+
1919
+ function buildShortcuts() {
1920
+ const list = $('fe-editor-keybinds');
1921
+ if (!list) return;
1922
+ list.textContent = '';
1923
+ const title = document.createElement('div');
1924
+ title.className = 'fe-editor-keybinds-title';
1925
+ title.textContent = t('editor.shortcuts.title');
1926
+ list.append(title);
1927
+ const word = (name) => t(`editor.shortcuts.${name.toLowerCase()}`);
1928
+ for (const row of shortcutRows(undefined, { features: features() })) {
1929
+ const line = document.createElement('div');
1930
+ line.className = 'fe-editor-kb';
1931
+ const key = document.createElement('kbd');
1932
+ key.textContent = formatKey(row.key, IS_MAC, row.gesture ? word : undefined);
1933
+ const name = document.createElement('span');
1934
+ name.textContent = t(row.titleKey);
1935
+ line.append(key, ' ', name);
1936
+ list.append(line);
1937
+ }
1938
+ }
1939
+
1940
+ /**
1941
+ * Wire the panel. Called once, by the File Explorer.
1942
+ * @param {{ toast?: (message: string) => void, projectRoot?: () => string | null, beforeOpen?: () => void,
1943
+ * openSettings?: (target: object) => void, revealInTree?: (path: string) => boolean | Promise<boolean>,
1944
+ * fileIcon?: (fileName: string) => object | null }} [host]
1945
+ * `toast` shows a short message; `projectRoot` is the selected project's folder (the header shows paths
1946
+ * relative to it, and the open tabs are kept for it); `beforeOpen` runs as the panel opens (the File Explorer
1947
+ * closes its menus); `openSettings` opens Settings on a page and section (AI autocomplete's popover links
1948
+ * there); `revealInTree` shows a file in the tree and says whether it is there; `fileIcon` is the tree's
1949
+ * icon for a file name.
1950
+ */
1951
+ export function initEditorPanel(host = {}) {
1952
+ _host = { ..._host, ...host };
1953
+
1954
+ $('fe-editor-back')?.addEventListener('click', () => closeEditorPanel());
1955
+ $('fe-editor-close')?.addEventListener('click', () => closeActiveTab());
1956
+ $('fe-editor-save')?.addEventListener('click', () => saveEditorPanel());
1957
+ $('fe-editor-discard')?.addEventListener('click', () => discardEditorPanel());
1958
+
1959
+ const panel = $('fe-editor-panel');
1960
+ if (panel && !panel.classList.contains('open')) panel.inert = true;
1961
+ const openMenu = (menu) => openEditorMenu({ host: panel, ...menu });
1962
+
1963
+ const tabsRoot = $('fe-editor-tabs');
1964
+ if (tabsRoot) {
1965
+ _strip = createTabStrip({
1966
+ root: tabsRoot,
1967
+ t,
1968
+ fileIcon: (name) => _host.fileIcon?.(name) || null,
1969
+ openMenu,
1970
+ on: {
1971
+ activate: (id, { keepFocus = false } = {}) => visitTab(id, { focus: !keepFocus }),
1972
+ close: (id) => closeTabs([id]),
1973
+ pin: (id) => { _model = pinTab(_model, id); renderStrip(); schedulePersist(); },
1974
+ move: (id, index) => { _model = moveTab(_model, id, index); renderStrip(); schedulePersist(); },
1975
+ command: tabCommand,
1976
+ // A tab was closed from the keyboard and the strip left with it (one file is open now). The keyboard
1977
+ // stays in the panel, on the control that does what was being done: not in the text, where a key still
1978
+ // held down would delete, and not on the page, where it would be the whiteboard's shortcut.
1979
+ keyboardLeft: () => { if (isEditorPanelOpen()) $('fe-editor-close')?.focus({ preventScroll: true }); },
1980
+ },
1981
+ });
1982
+ }
1983
+ if ($('fe-editor-statusbar')) {
1984
+ _status = createStatusBar({ t, openMenu, languages: () => (_editor ? _editor.languages() : []), actions: statusActions });
1985
+ }
1986
+
1987
+ const withKey = (id, labelKey, key) => {
1988
+ const button = $(id);
1989
+ if (!button) return null;
1990
+ const label = t(labelKey);
1991
+ button.dataset.tooltip = `${label} (${formatKey(key, IS_MAC)})`;
1992
+ button.setAttribute('aria-label', label);
1993
+ return button;
1994
+ };
1995
+ withKey('fe-editor-find-btn', 'editor.header.find', 'Mod-f')?.addEventListener('click', () => {
1996
+ const doc = active();
1997
+ if (!_editor || !doc?.loaded) return;
1998
+ if (doc.preview) setPreview(false);
1999
+ _search.toggleFind(_editor.view);
2000
+ });
2001
+ withKey('fe-editor-word-wrap', 'editor.header.wordWrap', 'Alt-z')?.addEventListener('click', toggleWordWrap);
2002
+ $('fe-editor-diff-btn')?.addEventListener('click', toggleDiff);
2003
+ // The header is outside the editor's own element: a key pressed on one of its buttons (or let go of there,
2004
+ // after it closed a menu) stays in the panel.
2005
+ const header = panel?.querySelector('.fe-editor-header');
2006
+ for (const type of ['keydown', 'keyup', 'keypress']) header?.addEventListener(type, (event) => event.stopPropagation());
2007
+
2008
+ // The View menu: what is drawn around the text. Every entry is stored with the preferences as it is chosen.
2009
+ const viewBtn = $('fe-editor-view-btn');
2010
+ const openViewMenu = (activeId = null) => {
2011
+ const items = viewMenuItems(_prefs.get().editing).map((entry) => (entry.separator ? entry : {
2012
+ id: entry.id, label: t(entry.labelKey), checked: entry.checked, toggle: entry.toggle, disabled: entry.disabled,
2013
+ hint: entry.hintKey ? t(entry.hintKey, entry.hintParams) : (entry.id === 'wordWrap' ? formatKey('Alt-z', IS_MAC) : ''),
2014
+ }));
2015
+ openMenu({
2016
+ anchor: viewBtn,
2017
+ label: t('editor.header.view'),
2018
+ items,
2019
+ activeId: activeId || items[0].id,
2020
+ onSelect: (id) => {
2021
+ changeEditing(viewMenuChange(id, _prefs.get().editing));
2022
+ // The text size is changed a step at a time: the menu stays for the next step.
2023
+ if (id.startsWith('font:')) openViewMenu(id);
2024
+ },
2025
+ });
2026
+ };
2027
+ viewBtn?.addEventListener('click', () => openViewMenu());
2028
+
2029
+ // The command palette and go-to-file: at the top of the panel, under the header.
2030
+ _palette = createPalette({
2031
+ t,
2032
+ openMenu,
2033
+ position: () => {
2034
+ const frame = panel.getBoundingClientRect();
2035
+ const header = panel.querySelector('.fe-editor-header')?.getBoundingClientRect() || frame;
2036
+ return { x: frame.left + (frame.width - Math.min(460, frame.width - 16)) / 2, y: header.bottom + 6 };
2037
+ },
2038
+ mac: IS_MAC,
2039
+ commands: EDITOR_COMMANDS,
2040
+ features,
2041
+ run: (id) => { if (_editor) runCommand(id, { cm: _cm, view: _editor.view, actions: _editor.actions }); },
2042
+ project: searchRoot,
2043
+ // The files that are open, the one used last first: with nothing typed, Enter goes back to the file before this one.
2044
+ tabs: () => _model.recent.filter((id) => id !== _model.activeId).map((id) => _docs.get(id))
2045
+ .filter((doc) => doc && doc.kind === 'file' && !doc.refused).map((doc) => ({ path: doc.file?.path || doc.path, name: doc.name })),
2046
+ openFile: (path) => openFileInEditor(path, { pin: true }),
2047
+ onClose: focusEditor,
2048
+ });
2049
+
2050
+ // Shortcuts popover
2051
+ const helpBtn = $('fe-editor-help-btn');
2052
+ const shortcuts = $('fe-editor-keybinds');
2053
+ if (helpBtn && shortcuts) {
2054
+ buildShortcuts();
2055
+ helpBtn.addEventListener('click', (event) => {
2056
+ event.stopPropagation();
2057
+ const open = shortcuts.classList.toggle('open');
2058
+ helpBtn.setAttribute('aria-expanded', String(open));
2059
+ });
2060
+ document.addEventListener('click', () => {
2061
+ shortcuts.classList.remove('open');
2062
+ helpBtn.setAttribute('aria-expanded', 'false');
2063
+ });
2064
+ shortcuts.addEventListener('click', (event) => event.stopPropagation());
2065
+ }
2066
+
2067
+ // Raw / Preview, for Markdown
2068
+ for (const button of document.querySelectorAll('#fe-editor-panel .fe-editor-toggle-btn')) {
2069
+ button.addEventListener('click', () => setPreview(button.dataset.mode === 'preview'));
2070
+ }
2071
+
2072
+ // Leaving the page with unsaved text asks first; where the tabs are, and the copies that are due, are written
2073
+ // before the page goes whatever the answer.
2074
+ const leaving = () => {
2075
+ const kept = _session;
2076
+ persistTabs();
2077
+ // storage.js has sent the page's state off by now (its listener is older than this one, and a window's
2078
+ // listeners run in the order they were added): what was just written follows by itself.
2079
+ try {
2080
+ const value = kept ? storage.getItem(KEYS.EDITOR_TABS) : null;
2081
+ if (value && storage.persists !== false) navigator.sendBeacon?.('/api/ui-state', new Blob([JSON.stringify({ [KEYS.EDITOR_TABS]: JSON.parse(value) })], { type: 'application/json' }));
2082
+ } catch { /* the debounced write is what is left */ }
2083
+ _backups?.flush({ leaving: true });
2084
+ };
2085
+ createUnloadGuard({ target: window, isDirty: () => [..._docs.values()].some((doc) => doc.loaded && doc.dirty), beforeLeave: leaving }).install();
2086
+ window.addEventListener('pagehide', () => { persistTabs(); _backups?.flush({ leaving: true }); });
2087
+ document.addEventListener('visibilitychange', () => {
2088
+ if (document.visibilityState === 'hidden') { persistTabs(); _backups?.flush(); }
2089
+ });
2090
+ // Back in this window: the files may have changed meanwhile.
2091
+ window.addEventListener('focus', () => { if (_open) _watcher?.check().catch(() => {}); });
2092
+
2093
+ _prefs.subscribe(applyPreferences);
2094
+ applyPreferences();
2095
+ // Preferences saved in another window.
2096
+ on('sync:editor:config-changed', (message) => { if (message?.config) _prefs.applyRemote(message.config); });
2097
+
2098
+ renderActive();
2099
+ }