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,1039 @@
1
+ // ── The File Explorer's routes ──
2
+ // Everything the explorer panel asks the server for, mounted at /api/explorer:
3
+ //
4
+ // GET /info what this server and its system can do
5
+ // GET /list?path= one folder: its entries, nothing of git
6
+ // GET /search?root=&q= entries of a project by name
7
+ // GET /git?root= the git state of a project: branch, changed paths, ignored paths
8
+ // GET /watch?root= an event stream: which shown folders changed, and when git may have
9
+ // POST /watch/:id { dirs } the folders the page shows now
10
+ // POST /create { parent, name, type }
11
+ // POST /rename { path, name }
12
+ // POST /move { paths, into, replace?, permanent? }
13
+ // POST /copy { paths, into, replace?, permanent? } into its own folder: a copy next to the original
14
+ // POST /delete { paths, permanent? } to the system's Trash, unless `permanent`
15
+ // POST /upload?into=&name=&rel=&replace=&permanent= the request body is the file
16
+ // POST /reveal { path } show it in the system's file manager
17
+ // GET /raw?path= the bytes of an image, for the preview
18
+ //
19
+ // A path is allowed when it is inside one of the folders the server registers
20
+ // (the projects and the data home), judged as written and then as the disk
21
+ // resolves it (lib/confined-fs.js): a link that leads out of its folder is
22
+ // refused, never followed. A registered folder itself, and a folder that holds
23
+ // one, is never renamed, moved, deleted or replaced from here: judged by what
24
+ // the entry is on disk (device and inode), because a spelling says nothing on
25
+ // a disk that does not tell case apart. What a replace clears goes to the
26
+ // Trash; it is removed for good only by a request that says `permanent`, as a
27
+ // delete is. Names are checked here, whatever the page checked. A failure
28
+ // answers `{ ok: false, code, error }`: the page words `code` in the user's
29
+ // language, `error` is for a log. A batch that stopped midway also says what
30
+ // it had done (`moved`, `copied` or `deleted`) and the entry it stopped at
31
+ // (`failed`).
32
+ //
33
+ // Nothing that was checked is trusted across a wait. A request that waits (for
34
+ // the Trash, a copy, an upload's body) checks again before each thing it then
35
+ // does that both ends are where they were checked to be: the same folders, by
36
+ // device and inode, inside their root. One swapped for a link meanwhile
37
+ // answers `changed` and nothing more is touched. A copy holds to that all the
38
+ // way down: every folder it makes, and every folder it reads, is remembered by
39
+ // device and inode and looked at again before the next thing is written into
40
+ // it or read from it; a file is read and written through handles opened
41
+ // without following a link. And a name is given only while it is free: an
42
+ // entry is placed exclusively (a rename onto a placeholder that was just made
43
+ // exclusively, an exclusive copy, a folder made exclusively), so a file that
44
+ // turns up at a destination nobody was asked about is a conflict (`exists`),
45
+ // never something that was replaced. (What stays open: the instant between a
46
+ // placeholder and the rename onto it, which renameExclusive in
47
+ // lib/file-explorer-system.js describes; and what lib/confined-fs.js says of
48
+ // itself: Node has no openat(), so a swap timed inside one synchronous
49
+ // check-and-use is not excluded.)
50
+ //
51
+ // A move is a rename and nothing else. To another disk a rename cannot be
52
+ // made, and the move is not made up of a copy and a removal instead (a copy
53
+ // that missed a file, or was overtaken by a save, would lose it with the
54
+ // original): the answer is `other-disk`, and nothing is touched.
55
+ //
56
+ // Who may ask is the `guard` (server.js: lib/http-guards.js ownerRouteGuard):
57
+ // the owner, on this server's own Host, and for a write from this server's page.
58
+ // The guard looks at the Origin of a write only, and a read here is not
59
+ // harmless (a watch is a session, git is a process, a search walks a project):
60
+ // a read from another site's page is refused here as well, before anything is
61
+ // set up (`isForeignRead`).
62
+ // Dependencies are injected: the router is tested on a bare app.
63
+
64
+ import { Router } from 'express';
65
+ import { randomBytes } from 'node:crypto';
66
+ import {
67
+ closeSync, constants, createWriteStream, fchmodSync, fstatSync, futimesSync, lstatSync, mkdirSync, openSync, read as readHandle,
68
+ readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, write as writeHandle,
69
+ } from 'node:fs';
70
+ import { lstat, readdir, realpath, stat } from 'node:fs/promises';
71
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
72
+ import { promisify } from 'node:util';
73
+ import { PathConfineError, isInside } from './path-confine.js';
74
+ import { canonicalInside, openConfinedStream, removeConfinedEntry } from './confined-fs.js';
75
+ import { matchFileRoots, useFileRoot } from './editor-files-api.js';
76
+ import { runGit } from './editor-git-api.js';
77
+ import { TrashUnavailable, createRevealer, createTrash, renameExclusive } from './file-explorer-system.js';
78
+ import { isAllowedWebSocketOrigin } from './http-guards.js';
79
+ import { createTreeWatcher, watchStrategy } from './file-explorer-watch.js';
80
+ import { compareNames, copyName, validateName } from '../public/shared/explorer/fx-names.js';
81
+
82
+ /** What `GET /api/editor/capabilities` lists when these routes are mounted. */
83
+ export const EXPLORER_CAPABILITIES = Object.freeze(['explorer']);
84
+ export const LISTING_CAP = 5000;
85
+ export const SEARCH_LIMIT = 300;
86
+ export const SEARCH_BUDGET_MS = 1500;
87
+ export const SEARCH_MAX_VISITED = 200000;
88
+ export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
89
+ export const MAX_RAW_BYTES = 30 * 1024 * 1024;
90
+ export const MAX_BATCH = 500;
91
+ export const MAX_WATCH_SESSIONS = 16;
92
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
93
+ const GIT_STATUS_TIMEOUT_MS = 15000;
94
+ const STAT_PARALLEL = 64;
95
+ const COPY_CHUNK_BYTES = 1024 * 1024;
96
+ const O_NOFOLLOW = constants.O_NOFOLLOW || 0;
97
+ const O_NONBLOCK = constants.O_NONBLOCK || 0;
98
+ const O_DIRECTORY = constants.O_DIRECTORY || 0;
99
+ const readFrom = promisify(readHandle);
100
+ const writeTo = promisify(writeHandle);
101
+
102
+ /** The images the preview shows, and the type each is served as. */
103
+ export const RAW_TYPES = Object.freeze({
104
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
105
+ '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', '.svg': 'image/svg+xml',
106
+ });
107
+
108
+ const ERRORS = {
109
+ 'bad-request': 'The request is missing something.',
110
+ outside: 'Path outside registered project roots',
111
+ gone: 'That file or folder is no longer there.',
112
+ denied: 'The system did not allow that (no permission).',
113
+ 'not-dir': 'That is not a folder.',
114
+ exists: 'Something with that name is already there.',
115
+ 'invalid-name': 'That name cannot be used on this system.',
116
+ root: 'A project folder is not renamed, moved, deleted or replaced from here.',
117
+ 'into-self': 'A folder cannot be moved or copied into itself.',
118
+ 'no-trash': 'There is no Trash for that path. Nothing was removed.',
119
+ changed: 'A folder changed while this was under way. Nothing more was done.',
120
+ 'other-disk': 'That is on another disk. It is not moved from here: copy it there, then delete the original.',
121
+ 'no-reveal': 'This system has no file manager to show it in.',
122
+ 'too-large': 'That file is too large.',
123
+ 'not-image': 'That is not an image the preview shows.',
124
+ busy: 'The file is in use by another program.',
125
+ 'no-space': 'The disk is full.',
126
+ limit: 'Too many at once.',
127
+ failed: 'That did not work.',
128
+ };
129
+
130
+ const slashes = (path) => String(path).replace(/\\/g, '/');
131
+ const trimSlash = (path) => { const p = slashes(path); return p.length > 1 ? p.replace(/\/+$/, '') : p; };
132
+ const isText = (value) => typeof value === 'string' && value.length > 0 && !value.includes('\0');
133
+
134
+ /**
135
+ * What an entry is on disk, whatever it is called: its device and inode (the entry itself: a link is the link).
136
+ * As whole numbers of any size, because a Windows file id does not fit a double. Null when it is not there, or
137
+ * on a disk that gives no inode.
138
+ */
139
+ export function entryId(path) {
140
+ try { const st = lstatSync(path, { bigint: true }); return st.ino ? `${st.dev}:${st.ino}` : null; } catch { return null; }
141
+ }
142
+
143
+ /** What open file a stat describes: its device and inode, as `entryId()` says them. Null on a disk that gives none. */
144
+ const idOf = (st) => (st.ino ? `${st.dev}:${st.ino}` : null);
145
+
146
+ /**
147
+ * The bytes of one file, from one open handle to another.
148
+ * @param {number} input @param {number} output file descriptors
149
+ */
150
+ export async function pumpFile(input, output) {
151
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
152
+ for (;;) {
153
+ const { bytesRead } = await readFrom(input, buffer, 0, buffer.length, null);
154
+ if (!bytesRead) return;
155
+ for (let at = 0; at < bytesRead;) at += (await writeTo(output, buffer, at, bytesRead - at, null)).bytesWritten;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Whether a read (GET, HEAD) comes from the page of another site. A browser says so in one of two ways: the
161
+ * Origin of a request it sends across origins (an EventSource, a fetch), judged by the rule of every WebSocket
162
+ * (isAllowedWebSocketOrigin: this server under one of its own names, or one of `allowedOrigins`); and, where it
163
+ * sends no Origin (an image, a link), `Sec-Fetch-Site`, which must then be `same-origin` or `none` (an address
164
+ * that was typed). A request that carries neither is not a browser's (a test, a tool) and passes.
165
+ * @param {{ method: string, headers: object }} req
166
+ * @param {{ port?: number | string, allowedOrigins?: string[] | (() => string[]), machineName?: any }} [origins]
167
+ */
168
+ export function isForeignRead(req, origins = {}) {
169
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
170
+ if (req.headers?.origin !== undefined) return !isAllowedWebSocketOrigin(req, origins || {});
171
+ const site = req.headers?.['sec-fetch-site'];
172
+ return site !== undefined && site !== 'same-origin' && site !== 'none';
173
+ }
174
+
175
+ /** A failure as the routes answer it. */
176
+ class ExplorerError extends Error {
177
+ constructor(status, code, extra = {}) {
178
+ super(ERRORS[code] || code);
179
+ this.status = status;
180
+ this.code = code;
181
+ this.extra = extra;
182
+ }
183
+ }
184
+
185
+ /** What an errno means to the user. */
186
+ export function codeOfErrno(error) {
187
+ switch (error?.code) {
188
+ case 'ENOENT': return [404, 'gone'];
189
+ case 'EACCES': case 'EPERM': case 'EROFS': return [403, 'denied'];
190
+ case 'EEXIST': case 'ENOTEMPTY': case 'ERR_FS_CP_EEXIST': return [409, 'exists'];
191
+ case 'ENOTDIR': return [400, 'not-dir'];
192
+ case 'EBUSY': case 'ETXTBSY': return [423, 'busy'];
193
+ case 'ENOSPC': case 'EDQUOT': return [507, 'no-space'];
194
+ case 'ENAMETOOLONG': case 'EINVAL': case 'EILSEQ': return [400, 'invalid-name'];
195
+ default: return [500, 'failed'];
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Read what `git status --porcelain=v1 -z --ignored` printed.
201
+ * @param {string} text @param {string} prefix the project's folder below the repository's, '' or `a/b/`
202
+ * @returns {{ changes: Record<string, string>, ignored: string[] }} paths below the project; a folder ends in `/`
203
+ */
204
+ export function parseGitStatus(text, prefix = '') {
205
+ const changes = {};
206
+ const ignored = [];
207
+ const fields = String(text).split('\0');
208
+ for (let i = 0; i < fields.length; i++) {
209
+ const entry = fields[i];
210
+ if (entry.length < 4) continue;
211
+ const x = entry[0]; const y = entry[1];
212
+ let path = entry.slice(3);
213
+ // A rename or a copy is followed by the path it came from.
214
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') i++;
215
+ if (prefix) {
216
+ if (!path.startsWith(prefix)) continue;
217
+ path = path.slice(prefix.length);
218
+ }
219
+ if (!path) continue;
220
+ const xy = x + y;
221
+ if (xy === '!!') { ignored.push(path); continue; }
222
+ let status;
223
+ if (xy === '??') status = 'untracked';
224
+ else if (x === 'U' || y === 'U' || xy === 'DD' || xy === 'AA') status = 'conflict';
225
+ else if (x === 'A') status = 'added';
226
+ else if (x === 'D' || y === 'D') status = 'deleted';
227
+ else if (x === 'R') status = 'renamed';
228
+ else if (x !== ' ' && y !== ' ') status = 'mixed';
229
+ else if (x !== ' ') status = 'staged';
230
+ else if (y !== ' ') status = 'modified';
231
+ else continue;
232
+ changes[path] = status;
233
+ }
234
+ return { changes, ignored };
235
+ }
236
+
237
+ /**
238
+ * The folder a request names, where it really is inside one of the registered folders (a registered folder
239
+ * itself included). Throws PathConfineError (403) for a path outside them, or out of one through a link. For
240
+ * the routes of server.js that run git in a folder they are given (as lib/git-diff-api.js judges its own).
241
+ * @param {string} path @param {{ dirs?: string[] }} roots
242
+ */
243
+ export function confinedFolder(path, roots) {
244
+ return useFileRoot(String(path), { dirs: (roots?.dirs || []).filter(Boolean), files: [] }, (match) => canonicalInside(match.base, match.path, { allowBase: true }));
245
+ }
246
+
247
+ /** Run `work` over `items`, at most `limit` at a time, keeping their order. */
248
+ async function mapLimit(items, limit, work) {
249
+ const out = new Array(items.length);
250
+ let next = 0;
251
+ const lane = async () => { for (;;) { const i = next++; if (i >= items.length) return; out[i] = await work(items[i], i); } };
252
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * @param {object} deps
258
+ * @param {() => { dirs: string[] }} deps.fileRoots the folders validateProjectPath() allows
259
+ * @param {(req, res, next) => void} [deps.guard] who may use these routes
260
+ * @param {{ port?: number | string, allowedOrigins?: string[] | (() => string[]), machineName?: any }} [deps.origins]
261
+ * whose pages may read (isForeignRead): the server's port and the public addresses it is reached at
262
+ * @param {typeof runGit} [deps.git]
263
+ * @param {ReturnType<typeof createTrash>} [deps.trash] @param {ReturnType<typeof createRevealer>} [deps.revealer]
264
+ * @param {string} [deps.platform] @param {'recursive' | 'folders'} [deps.watch] how folders are watched
265
+ * @param {Function} [deps.watchImpl] the system's watch (node:fs `watch`), for a test that has it fail
266
+ * @param {{ rename?: typeof renameSync, pump?: typeof pumpFile, device?: (path: string) => any }} [deps.io]
267
+ * how a name is given (`rename`), how the bytes of one file are copied (`pump`) and which disk an entry is on
268
+ * (`device`), for a test that needs another disk or a moment in the middle of a request
269
+ * @param {(line: string) => void} [deps.log] what the server's log is told (something that was left behind)
270
+ * @param {{ now?: () => number, setTimer?: Function, clearTimer?: Function }} [deps.watchClock] the clock of the
271
+ * watcher's waits (lib/file-explorer-watch.js), for a test that moves it by hand
272
+ * @param {number} [deps.maxUploadBytes] @param {number} [deps.listingCap]
273
+ * @returns {import('express').Router & { close: () => void, sessions: () => number }}
274
+ */
275
+ export function createFileExplorerRouter({
276
+ fileRoots, guard = null, origins = {}, git = runGit, trash = createTrash(), revealer = createRevealer(),
277
+ platform = process.platform, watch = watchStrategy(), watchImpl = undefined, watchClock = {}, io = {}, log = (line) => console.warn(line),
278
+ maxUploadBytes = MAX_UPLOAD_BYTES, listingCap = LISTING_CAP,
279
+ } = {}) {
280
+ if (typeof fileRoots !== 'function') throw new Error('createFileExplorerRouter requires fileRoots');
281
+ const rename = io.rename || renameSync;
282
+ const pump = io.pump || pumpFile;
283
+ const device = io.device || ((path) => lstatSync(path).dev);
284
+ const router = Router();
285
+ if (typeof guard === 'function') router.use(guard);
286
+ // Before any route, so before anything is set up: a stream is a watch session, of which there are sixteen.
287
+ router.use((req, res, next) => {
288
+ if (!isForeignRead(req, origins)) return next();
289
+ return res.status(403).json({ ok: false, code: 'BAD_ORIGIN', error: 'Unexpected Origin header.' });
290
+ });
291
+
292
+ // The folders only: a single file the editor is allowed by name is not something the explorer manages.
293
+ const roots = () => ({ dirs: (fileRoots()?.dirs || []).filter(Boolean), files: [] });
294
+ const sessions = new Map(); // watch id → { watcher, root, real, res, written: Map<real dir, written dir> }
295
+ const gitKnown = new Map(); // canonical project folder → { dirs: string[], files: Set<string> } git ignores
296
+ const gitFlights = new Map(); // canonical project folder → the status being read
297
+
298
+ function answer(res, error) {
299
+ const done = error?.midway || {};
300
+ if (error instanceof ExplorerError) return res.status(error.status).json({ ok: false, code: error.code, error: error.message, ...error.extra, ...done });
301
+ if (error instanceof PathConfineError) {
302
+ const code = error.status === 403 ? 'outside' : error.status === 404 ? 'gone' : 'failed';
303
+ return res.status(error.status).json({ ok: false, code, error: error.message, ...done });
304
+ }
305
+ if (error instanceof TrashUnavailable) return res.status(409).json({ ok: false, code: 'no-trash', error: ERRORS['no-trash'], ...done });
306
+ const [status, code] = codeOfErrno(error);
307
+ return res.status(status).json({ ok: false, code, error: code === 'failed' ? (error?.message || ERRORS.failed) : ERRORS[code], ...done });
308
+ }
309
+ // A batch that stopped midway: its failure carries what had been done by then and the entry it stopped at.
310
+ const midway = (error, done) => Object.assign(error, { midway: done });
311
+ const route = (handler) => async (req, res) => { try { await handler(req, res); } catch (error) { if (!res.headersSent) answer(res, error); else res.end(); } };
312
+ const need = (ok, code = 'bad-request', status = 400, extra) => { if (!ok) throw new ExplorerError(status, code, extra); };
313
+ const goodName = (name) => { const why = validateName(name, { platform }); if (why) throw new ExplorerError(400, 'invalid-name', { reason: why }); };
314
+
315
+ /** A folder a request names: where it really is, inside the root that holds it, and which folder that is (`id`). */
316
+ function folderOf(path) {
317
+ need(isText(path));
318
+ return useFileRoot(path, roots(), (match) => {
319
+ const real = canonicalInside(match.base, match.path, { allowBase: true });
320
+ return { base: match.base, written: trimSlash(match.target), real, id: entryId(real) };
321
+ });
322
+ }
323
+
324
+ /**
325
+ * The entries on disk that are a registered folder or lead to one, by device and inode: each registered folder
326
+ * as it is written (the entry itself: a registered link is the link) and as the disk resolves it, and every
327
+ * folder above either. A spelling says nothing where the disk does not tell case apart, or keeps a name in
328
+ * another Unicode form: there `/p/linked` is `/p/Linked`.
329
+ */
330
+ function rootMarks(known) {
331
+ const marks = new Set();
332
+ const climb = (from) => {
333
+ for (let at = from; ;) {
334
+ const id = entryId(at);
335
+ if (id) marks.add(id);
336
+ const up = dirname(at);
337
+ if (up === at) return;
338
+ at = up;
339
+ }
340
+ };
341
+ for (const dir of known.dirs) {
342
+ climb(resolve(dir));
343
+ try { climb(realpathSync(dir)); } catch { /* not there: nothing of it is on disk */ }
344
+ }
345
+ return marks;
346
+ }
347
+ // The marks as they are now, worked out once for everything that is checked without a wait in between (a
348
+ // batch of five hundred renames asks once); after any wait they are read from the disk again.
349
+ let marksMemo = null;
350
+ function marksNow(known) {
351
+ if (!marksMemo) {
352
+ marksMemo = rootMarks(known);
353
+ queueMicrotask(() => { marksMemo = null; });
354
+ }
355
+ return marksMemo;
356
+ }
357
+
358
+ /**
359
+ * Whether an entry is a registered folder or holds one: by the path it is written as, and by what it is on disk.
360
+ * A link that merely points at a registered folder is neither (removing it removes nothing of what it points
361
+ * to); the link a registered folder is written as, or one on the way to it, is.
362
+ */
363
+ function holdsRoot(known, written, entry, st) {
364
+ const at = resolve(written);
365
+ if (known.dirs.some((dir) => { const root = resolve(dir); return root === at || isInside(at, root); })) return true;
366
+ if (!st) return false;
367
+ const id = entryId(entry);
368
+ if (id) return marksNow(known).has(id);
369
+ // A disk that gives no inode: by where the path resolves.
370
+ if (st.isSymbolicLink()) return false;
371
+ let real = entry;
372
+ try { real = realpathSync(entry); } catch { /* judged as written */ }
373
+ return known.dirs.some((dir) => { try { const root = realpathSync(dir); return root === real || isInside(real, root); } catch { return false; } });
374
+ }
375
+
376
+ /**
377
+ * An entry a request names, as it is itself: the folder that holds it is resolved, its own name is kept, so
378
+ * a link is the link and never what it points to. `protect` refuses a registered folder and one that holds one.
379
+ * `id` and `parentId` are what the entry and its folder are on disk, for the checks that follow a wait.
380
+ */
381
+ function entryOf(path, { protect = false } = {}) {
382
+ need(isText(path));
383
+ const known = roots();
384
+ const { target, matches } = matchFileRoots(path, known);
385
+ if (!matches.length) throw new ExplorerError(403, 'outside');
386
+ if (protect && (matches.some((match) => match.isBase) || known.dirs.some((dir) => isInside(target, dir)))) throw new ExplorerError(403, 'root');
387
+ return useFileRoot(path, known, (match) => {
388
+ if (match.isBase) throw new ExplorerError(403, 'root');
389
+ const parent = canonicalInside(match.base, dirname(match.path), { allowBase: true });
390
+ const name = basename(match.path);
391
+ const entry = join(parent, name);
392
+ let st = null;
393
+ try { st = lstatSync(entry); } catch { st = null; }
394
+ // What it really is must not be a registered folder either (a link's own entry is fine: it is a link).
395
+ if (protect && holdsRoot(known, target, entry, st)) throw new ExplorerError(403, 'root');
396
+ return { base: match.base, parent, parentId: entryId(parent), name, entry, st, id: st ? entryId(entry) : null, written: trimSlash(match.target) };
397
+ });
398
+ }
399
+
400
+ const present = (found) => { if (!found.st) throw new ExplorerError(404, 'gone'); return found; };
401
+ const exists = (path) => { try { lstatSync(path); return true; } catch { return false; } };
402
+ const sameEntry = (a, b) => { const id = entryId(a); return !!id && id === entryId(b); };
403
+ const changed = (extra) => new ExplorerError(409, 'changed', extra);
404
+
405
+ /**
406
+ * A folder that was checked is still that folder: where the disk resolves it, inside its root, and the same one
407
+ * (device and inode). For after anything a request waited for, and right before it uses the folder: one that
408
+ * was swapped for a link in the meantime would take a write, a move or a removal somewhere else.
409
+ */
410
+ function sameFolder(base, real, id) {
411
+ let now = null;
412
+ try { now = canonicalInside(base, real, { allowBase: true }); } catch { now = null; }
413
+ if (now !== real || (id && entryId(real) !== id)) throw changed();
414
+ }
415
+ /** An entry that was checked is still that entry, in the folder it was in. Throws `gone` when it is not there. */
416
+ function sameEntryStill(found) {
417
+ sameFolder(found.base, found.parent, found.parentId);
418
+ const id = entryId(found.entry);
419
+ if (!id && !exists(found.entry)) throw new ExplorerError(404, 'gone', { name: found.name });
420
+ if (found.id && id !== found.id) throw changed({ name: found.name });
421
+ }
422
+
423
+ // ── What the server can do ──
424
+ router.get('/info', (req, res) => {
425
+ res.json({
426
+ ok: true, version: 1, platform, trash: !!trash?.available?.(), reveal: !!revealer?.available?.(), watch: true,
427
+ maxUploadBytes, listingCap, images: Object.keys(RAW_TYPES),
428
+ });
429
+ });
430
+
431
+ // ── One folder ──
432
+ router.get('/list', route(async (req, res) => {
433
+ const dir = folderOf(req.query.path);
434
+ let dirents = await readdir(dir.real, { withFileTypes: true });
435
+ const total = dirents.length;
436
+ const truncated = total > listingCap;
437
+ if (truncated) {
438
+ dirents.sort((a, b) => (a.isDirectory() !== b.isDirectory() ? (a.isDirectory() ? -1 : 1) : compareNames(a.name, b.name)));
439
+ dirents = dirents.slice(0, listingCap);
440
+ }
441
+ const realBase = realpathSync(dir.base);
442
+ const items = await mapLimit(dirents, STAT_PARALLEL, async (entry) => {
443
+ const full = join(dir.real, entry.name);
444
+ if (entry.isDirectory()) return { name: entry.name, type: 'dir' };
445
+ if (entry.isSymbolicLink()) {
446
+ try {
447
+ const real = await realpath(full);
448
+ // A link that leaves its project is shown, and is not followed.
449
+ if (real !== realBase && !isInside(realBase, real)) return { name: entry.name, type: 'file', link: true, outside: true };
450
+ const st = await stat(real);
451
+ return st.isDirectory() ? { name: entry.name, type: 'dir', link: true } : { name: entry.name, type: 'file', link: true, size: st.size, mtime: st.mtimeMs };
452
+ } catch { return { name: entry.name, type: 'file', link: true, broken: true }; }
453
+ }
454
+ if (!entry.isFile()) return { name: entry.name, type: 'file', special: true };
455
+ try { const st = await lstat(full); return { name: entry.name, type: 'file', size: st.size, mtime: st.mtimeMs }; }
456
+ catch { return { name: entry.name, type: 'file' }; }
457
+ });
458
+ items.sort((a, b) => (a.type !== b.type ? (a.type === 'dir' ? -1 : 1) : compareNames(a.name, b.name)));
459
+ res.json({ ok: true, path: dir.written, items, total, truncated });
460
+ }));
461
+
462
+ // ── The git state of a project ──
463
+ async function readGit(real) {
464
+ const top = await git(['rev-parse', '--show-toplevel'], { cwd: real, maxBuffer: 1024 * 1024 });
465
+ if (!top.ok) return { isGit: false };
466
+ let repo = top.stdout.toString('utf8').trim();
467
+ try { repo = realpathSync(repo); } catch { /* as git printed it */ }
468
+ const below = slashes(relative(repo, real));
469
+ if (below.startsWith('..')) return { isGit: false };
470
+ const prefix = below ? `${below}/` : '';
471
+ let branch = '';
472
+ const named = await git(['symbolic-ref', '--short', '-q', 'HEAD'], { cwd: real, maxBuffer: 1024 * 1024 });
473
+ if (named.ok) branch = named.stdout.toString('utf8').trim();
474
+ if (!branch) {
475
+ const short = await git(['rev-parse', '--short', 'HEAD'], { cwd: real, maxBuffer: 1024 * 1024 });
476
+ if (short.ok) branch = short.stdout.toString('utf8').trim();
477
+ }
478
+ const status = await git(['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=normal'], { cwd: real, maxBuffer: GIT_MAX_BUFFER, timeoutMs: GIT_STATUS_TIMEOUT_MS });
479
+ if (!status.ok) return { isGit: true, branch, changes: {}, ignored: [], partial: true };
480
+ const { changes, ignored } = parseGitStatus(status.stdout.toString('utf8'), prefix);
481
+ gitKnown.set(real, { dirs: ignored.filter((path) => path.endsWith('/')), files: new Set(ignored.filter((path) => !path.endsWith('/'))) });
482
+ return { isGit: true, branch, changes, ignored };
483
+ }
484
+ router.get('/git', route(async (req, res) => {
485
+ const dir = folderOf(req.query.root);
486
+ // Two windows that ask at once share one run of git.
487
+ let flight = gitFlights.get(dir.real);
488
+ if (!flight) {
489
+ flight = readGit(dir.real).finally(() => gitFlights.delete(dir.real));
490
+ gitFlights.set(dir.real, flight);
491
+ }
492
+ res.json({ ok: true, root: dir.written, ...(await flight) });
493
+ }));
494
+ const ignoredByGit = (real) => (path) => {
495
+ const known = gitKnown.get(real);
496
+ return !!known && (known.files.has(path) || known.dirs.some((dir) => path.startsWith(dir) || `${path}/` === dir));
497
+ };
498
+
499
+ // ── Entries by name ──
500
+ router.get('/search', route(async (req, res) => {
501
+ const dir = folderOf(req.query.root);
502
+ const query = String(req.query.q || '').trim().toLowerCase();
503
+ need(query.length > 0 && query.length <= 200);
504
+ const limit = Math.max(1, Math.min(1000, Number(req.query.limit) || SEARCH_LIMIT));
505
+ const words = query.split(/\s+/);
506
+ const byPath = query.includes('/');
507
+ // What git ignores as a whole folder (node_modules, build output) is not walked; without git, the usual two.
508
+ const known = gitKnown.get(dir.real) || (await readGit(dir.real).catch(() => null), gitKnown.get(dir.real));
509
+ const skipped = (rel, name) => name === '.git' || (known ? known.dirs.includes(`${rel}/`) : name === 'node_modules');
510
+ const items = [];
511
+ const queue = [''];
512
+ const started = Date.now();
513
+ let visited = 0; let partial = false; let truncated = false;
514
+ walk: for (let at = 0; at < queue.length; at++) {
515
+ const rel = queue[at];
516
+ let dirents;
517
+ try { dirents = await readdir(rel ? join(dir.real, rel) : dir.real, { withFileTypes: true }); } catch { continue; }
518
+ dirents.sort((a, b) => compareNames(a.name, b.name));
519
+ for (const entry of dirents) {
520
+ if (++visited > SEARCH_MAX_VISITED || Date.now() - started > SEARCH_BUDGET_MS) { partial = true; break walk; }
521
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
522
+ const isDir = entry.isDirectory();
523
+ const hay = (byPath ? path : entry.name).toLowerCase();
524
+ if (words.every((word) => hay.includes(word))) {
525
+ if (items.length >= limit) { truncated = true; break walk; }
526
+ items.push({ path, type: isDir ? 'dir' : 'file' });
527
+ }
528
+ // A link is listed and not entered: it could lead anywhere, or back here.
529
+ if (isDir && !skipped(path, entry.name)) queue.push(path);
530
+ }
531
+ }
532
+ res.json({ ok: true, root: dir.written, items, truncated, partial });
533
+ }));
534
+
535
+ // ── Watching ──
536
+ router.get('/watch', route(async (req, res) => {
537
+ const dir = folderOf(req.query.root);
538
+ need(statSync(dir.real).isDirectory(), 'not-dir');
539
+ need(sessions.size < MAX_WATCH_SESSIONS, 'limit', 429);
540
+ const id = randomBytes(12).toString('hex');
541
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
542
+ const send = (event, data) => { try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* the page is gone */ } };
543
+ const session = { root: dir.written, real: dir.real, res, written: new Map([[dir.real, dir.written]]), watcher: null };
544
+ session.watcher = createTreeWatcher({
545
+ root: dir.real,
546
+ strategy: watch,
547
+ ...(watchImpl ? { watch: watchImpl } : {}),
548
+ ...watchClock,
549
+ ignoresGit: ignoredByGit(dir.real),
550
+ onChange: ({ dirs, git: gitChanged }) => send('change', { dirs: dirs.map((real) => session.written.get(real)).filter(Boolean), git: gitChanged }),
551
+ onError: () => send('resync', {}),
552
+ // A stream is not a watch that works: what is really watched is said, and said again when it changes.
553
+ onState: (state) => send('state', state),
554
+ });
555
+ sessions.set(id, session);
556
+ send('ready', { id, ...session.watcher.state() });
557
+ const beat = setInterval(() => { try { res.write(': still here\n\n'); } catch { /* closing */ } }, 25000);
558
+ beat.unref?.();
559
+ // The response's end is the page leaving (a request's own `close` only says its body was read).
560
+ res.on('close', () => { clearInterval(beat); session.watcher.close(); sessions.delete(id); });
561
+ }));
562
+
563
+ router.post('/watch/:id', route(async (req, res) => {
564
+ const session = sessions.get(String(req.params.id));
565
+ need(session, 'gone', 404);
566
+ const dirs = req.body?.dirs;
567
+ need(Array.isArray(dirs) && dirs.length <= 2000 && dirs.every(isText));
568
+ const written = new Map([[session.real, session.root]]);
569
+ for (const path of dirs) {
570
+ try {
571
+ const dir = folderOf(path);
572
+ if (dir.real === session.real || isInside(session.real, dir.real)) written.set(dir.real, dir.written);
573
+ } catch { /* a folder that is gone or not allowed is simply not watched */ }
574
+ }
575
+ session.written = written;
576
+ session.watcher.setDirs([...written.keys()]);
577
+ res.json({ ok: true, watching: written.size, live: session.watcher.state().live });
578
+ }));
579
+
580
+ // ── Creating ──
581
+ router.post('/create', route(async (req, res) => {
582
+ const { parent, name, type } = req.body || {};
583
+ need(type === 'file' || type === 'dir');
584
+ need(isText(name));
585
+ goodName(name);
586
+ const dir = folderOf(parent);
587
+ need(statSync(dir.real).isDirectory(), 'not-dir');
588
+ const dest = join(dir.real, name);
589
+ if (type === 'dir') mkdirSync(dest);
590
+ else closeSync(openSync(dest, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o666));
591
+ res.json({ ok: true, path: `${dir.written}/${name}`, type });
592
+ }));
593
+
594
+ // ── Renaming ──
595
+ router.post('/rename', route(async (req, res) => {
596
+ const { path, name } = req.body || {};
597
+ need(isText(name));
598
+ goodName(name);
599
+ const found = present(entryOf(path, { protect: true }));
600
+ const dest = join(found.parent, name);
601
+ if (found.name !== name) {
602
+ // A change of case only is the same entry on a disk that does not tell case apart: its name is simply changed.
603
+ if (sameEntry(found.entry, dest)) rename(found.entry, dest);
604
+ else {
605
+ if (exists(dest)) throw new ExplorerError(409, 'exists', { name });
606
+ // The name is taken only while it is free: something that turns up under it at this moment is not replaced.
607
+ try { renameExclusive(found.entry, dest, { isDir: found.st.isDirectory(), platform, rename }); }
608
+ catch (error) { throw error?.code === 'EEXIST' ? new ExplorerError(409, 'exists', { name }) : error; }
609
+ }
610
+ }
611
+ res.json({ ok: true, from: found.written, path: `${trimSlash(dirname(found.written))}/${name}` });
612
+ }));
613
+
614
+ // What a move or a copy is about to do, checked as a whole before anything is touched.
615
+ function plan(paths, into, { copy }) {
616
+ need(Array.isArray(paths) && paths.length > 0 && paths.every(isText));
617
+ need(paths.length <= MAX_BATCH, 'limit', 413);
618
+ const target = folderOf(into);
619
+ need(statSync(target.real).isDirectory(), 'not-dir');
620
+ const known = roots();
621
+ const steps = [];
622
+ const conflicts = [];
623
+ const taken = new Set();
624
+ for (const path of paths) {
625
+ const found = present(entryOf(path, { protect: !copy }));
626
+ const isDir = found.st.isDirectory();
627
+ if (isDir && (found.entry === target.real || isInside(found.entry, target.real))) throw new ExplorerError(400, 'into-self', { name: found.name });
628
+ const inPlace = found.parent === target.real;
629
+ if (inPlace && !copy) continue; // it is there already
630
+ let name = found.name;
631
+ // A copy into its own folder sits next to the original under a name of its own.
632
+ if (inPlace) name = copyName(found.name, (candidate) => taken.has(candidate) || exists(join(target.real, candidate)), { isDir });
633
+ const dest = join(target.real, name);
634
+ if (taken.has(name)) throw new ExplorerError(409, 'exists', { conflicts: [name] });
635
+ taken.add(name);
636
+ const to = `${target.written}/${name}`;
637
+ const replaces = !inPlace && exists(dest) && !sameEntry(found.entry, dest);
638
+ if (replaces) {
639
+ // A replace would clear what is there: never a project folder, or a folder that holds one.
640
+ let there = null;
641
+ try { there = lstatSync(dest); } catch { there = null; }
642
+ if (holdsRoot(known, to, dest, there)) throw new ExplorerError(403, 'root', { name });
643
+ conflicts.push(name);
644
+ }
645
+ steps.push({ found, name, dest, from: found.written, to, replaces, isDir });
646
+ }
647
+ return { target, steps, conflicts };
648
+ }
649
+
650
+ /**
651
+ * Which names a request allows to be replaced. `replace: true` is every name the plan found taken;
652
+ * `replace: [names]` only those, which is what the page sends: the names it asked about. A name that is taken
653
+ * and not among them is a conflict before anything is touched, so the page can ask about it too.
654
+ */
655
+ function refuseUnasked(conflicts, replace) {
656
+ const asked = replace === true ? null : new Set(Array.isArray(replace) ? replace.filter(isText) : []);
657
+ const unasked = asked ? conflicts.filter((name) => !asked.has(name)) : [];
658
+ if (unasked.length) throw new ExplorerError(409, 'exists', { conflicts: unasked });
659
+ }
660
+
661
+ /**
662
+ * Right before a step touches the disk, and again after anything it waited for: both ends are where they were
663
+ * checked to be, the entry is the one that was asked about, and neither it (for a move) nor what is in its way
664
+ * has become a registered folder.
665
+ * @param {boolean} o.free nothing may be in the way: nobody was asked about replacing anything there (or what
666
+ * was there has just been cleared), so something there now is a conflict
667
+ * @returns {boolean} whether something is in the way that the request allows to be replaced
668
+ */
669
+ function recheck(target, step, { move, free }) {
670
+ sameFolder(target.base, target.real, target.id);
671
+ sameEntryStill(step.found);
672
+ const known = roots();
673
+ if (move && holdsRoot(known, step.from, step.found.entry, step.found.st)) throw new ExplorerError(403, 'root', { name: step.found.name });
674
+ let there = null;
675
+ try { there = lstatSync(step.dest); } catch { there = null; }
676
+ if (!there || sameEntry(step.found.entry, step.dest)) return false;
677
+ if (holdsRoot(known, step.to, step.dest, there)) throw new ExplorerError(403, 'root', { name: step.name });
678
+ if (free) throw new ExplorerError(409, 'exists', { conflicts: [step.name] });
679
+ return true;
680
+ }
681
+ // A name that was taken at the very moment of placing: a conflict the page can ask about, like any other.
682
+ const takenMeanwhile = (error, step) => (error?.code === 'EEXIST' ? new ExplorerError(409, 'exists', { conflicts: [step.name] }) : error);
683
+
684
+ // The folders a copy came down through, each as it was when it was listed or made (device and inode): one that
685
+ // was swapped for a link since, or for another folder, is no longer the folder that was checked.
686
+ const stillThere = (places) => { for (const place of places) if (entryId(place.path) !== place.id) throw changed(); };
687
+
688
+ // A folder's mode and times are set through a handle on the folder itself, opened without following a link: by
689
+ // its path they would reach whatever a link put under its name since.
690
+ function finishFolder(dest, id, st) {
691
+ if (entryId(dest) !== id) throw changed();
692
+ let fd = null;
693
+ try {
694
+ fd = openSync(dest, constants.O_RDONLY | O_NOFOLLOW | O_DIRECTORY);
695
+ if (idOf(fstatSync(fd, { bigint: true })) !== id) return;
696
+ fchmodSync(fd, Number(st.mode) & 0o7777);
697
+ futimesSync(fd, st.atime, st.mtime);
698
+ } catch { /* the copy is whole all the same (Windows opens no folder this way) */ }
699
+ finally { if (fd !== null) { try { closeSync(fd); } catch { /* closed */ } } }
700
+ }
701
+
702
+ /**
703
+ * Copy one entry to a name that must be free, never over or into something that is there, and never through
704
+ * something that was not checked. A folder is made exclusively and then filled; a file is read through a handle
705
+ * opened without following a link, which must be the file that was listed, and written through a handle made
706
+ * exclusively, and its mode and times are set through that handle; a link is made again as the link it is
707
+ * (nothing is read through it).
708
+ *
709
+ * `verify` (both ends of the request are still where they were checked to be) runs before each of those, and
710
+ * with it every folder on the way down is looked at again: the ones this copy made before anything is written
711
+ * into them, the ones it reads before anything is taken from them. A change answers `changed`.
712
+ *
713
+ * A child that cannot be read stops the copy: nothing is skipped, because a copy that says it is whole and is
714
+ * not is how a file gets lost. (An entry that left its folder while the folder was being copied is no child any
715
+ * more.) A file that was not copied whole is taken away again.
716
+ * @param {{ id?: string | null }} made says what was made under `dest`, also when the copy then failed
717
+ */
718
+ async function copyEntry(src, dest, verify, made = {}) {
719
+ const down = async (from, to, st, read, written) => {
720
+ const top = !written.length;
721
+ const ends = () => { verify(); stillThere(read); stillThere(written); };
722
+ // Inside a folder this copy made nobody was asked about anything: a name that is taken there is a change.
723
+ const place = (work) => { try { return work(); } catch (error) { throw !top && error?.code === 'EEXIST' ? changed() : error; } };
724
+ ends();
725
+ if (st.isSymbolicLink()) {
726
+ let target;
727
+ try { target = readlinkSync(from); } catch { throw changed(); } // no longer the link that was listed
728
+ place(() => symlinkSync(target, to));
729
+ if (top) made.id = entryId(to);
730
+ return;
731
+ }
732
+ if (st.isDirectory()) {
733
+ place(() => mkdirSync(to));
734
+ const mine = lstatSync(to, { bigint: true });
735
+ if (!mine.isDirectory()) throw changed();
736
+ if (top) made.id = idOf(mine);
737
+ const deeper = [[...read, { path: from, id: idOf(st) }], [...written, { path: to, id: idOf(mine) }]];
738
+ for (const name of await readdir(from)) {
739
+ verify(); stillThere(deeper[0]); stillThere(deeper[1]);
740
+ const child = join(from, name);
741
+ let inner;
742
+ try { inner = lstatSync(child, { bigint: true }); }
743
+ catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
744
+ await down(child, join(to, name), inner, ...deeper);
745
+ }
746
+ verify(); stillThere(deeper[0]); stillThere(deeper[1]);
747
+ finishFolder(to, idOf(mine), st);
748
+ return;
749
+ }
750
+ if (!st.isFile()) throw new Error('Only files, folders and links are copied.');
751
+ let input = null; let output = null; let mine = null;
752
+ // What was made is the file under that name, in the folder that was checked.
753
+ const placed = () => { ends(); if (entryId(to) !== mine) throw changed(); };
754
+ try {
755
+ try { input = openSync(from, constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK); }
756
+ catch (error) { throw ['ELOOP', 'EMLINK'].includes(error?.code) ? changed() : error; }
757
+ // What was opened is the file that was listed: not a link that took its name, not what a swapped folder leads to.
758
+ const opened = fstatSync(input, { bigint: true });
759
+ if (!opened.isFile() || idOf(opened) !== idOf(st)) throw changed();
760
+ output = place(() => openSync(to, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600));
761
+ mine = idOf(fstatSync(output, { bigint: true }));
762
+ if (top) made.id = mine;
763
+ placed();
764
+ await pump(input, output, { from, to });
765
+ placed();
766
+ try { fchmodSync(output, Number(st.mode) & 0o7777); futimesSync(output, st.atime, st.mtime); } catch { /* the copy is whole all the same */ }
767
+ } catch (error) {
768
+ // Half a file is not left to be taken for a whole one: removed, where it is still the file that was made here.
769
+ if (mine) { try { placed(); unlinkSync(to); } catch { /* it cannot be told where it is: left */ } }
770
+ throw error;
771
+ } finally {
772
+ for (const fd of [input, output]) if (fd !== null) { try { closeSync(fd); } catch { /* closed */ } }
773
+ }
774
+ };
775
+ verify();
776
+ await down(src, dest, lstatSync(src, { bigint: true }), [], []);
777
+ }
778
+
779
+ /**
780
+ * Send what is in the way of a replace to the Trash: a replace can then be undone too. What the Trash cannot
781
+ * take stays where it is and the answer is `no-trash`, unless the request said `permanent`.
782
+ * @returns {Promise<boolean>} false when it is still there and the request allows removing it for good
783
+ */
784
+ async function toTrash(dest, { permanent }) {
785
+ if (!exists(dest)) return true;
786
+ if (trash?.available?.()) {
787
+ try { await trash.trash(dest); } catch (error) { if (!(error instanceof TrashUnavailable)) throw error; }
788
+ if (!exists(dest)) return true;
789
+ }
790
+ if (!permanent) throw new ExplorerError(409, 'no-trash');
791
+ return false;
792
+ }
793
+ /**
794
+ * Clear what is in the way of a replace: to the Trash, and for good only when the request said so. The Trash is
795
+ * waited for: `verify` runs after it, before anything is removed here.
796
+ */
797
+ async function makeRoom(base, dest, { permanent, verify }) {
798
+ if (await toTrash(dest, { permanent })) return;
799
+ verify();
800
+ removeConfinedEntry(base, dest);
801
+ }
802
+ // No Trash at all, and a replace that did not say "for good": refused before anything of the batch is touched.
803
+ function needTrashFor(steps, permanent, done) {
804
+ const first = steps.find((step) => step.replaces);
805
+ if (first && !permanent && !trash?.available?.()) throw midway(new ExplorerError(409, 'no-trash'), { ...done, failed: first.from });
806
+ }
807
+
808
+ // Whether an entry is on another disk than a folder: a move there would be a copy and a removal, which is not
809
+ // made here. Asked before anything is cleared for the move; where it cannot be told, the rename itself says so.
810
+ const otherDisk = (entry, folder) => { try { return device(entry) !== device(folder); } catch { return false; } };
811
+ const notMovedThere = (step) => new ExplorerError(409, 'other-disk', { name: step.name });
812
+
813
+ // ── Moving ──
814
+ router.post('/move', route(async (req, res) => {
815
+ const { paths, into, replace, permanent } = req.body || {};
816
+ const { target, steps, conflicts } = plan(paths, into, { copy: false });
817
+ refuseUnasked(conflicts, replace);
818
+ const forGood = permanent === true;
819
+ const moved = [];
820
+ needTrashFor(steps, forGood, { moved });
821
+ for (const step of steps) {
822
+ try {
823
+ if (otherDisk(step.found.entry, target.real)) throw notMovedThere(step);
824
+ // Earlier steps may have waited (the Trash): nothing of the plan is taken on trust.
825
+ const ends = () => recheck(target, step, { move: true, free: true });
826
+ if (recheck(target, step, { move: true, free: !step.replaces })) {
827
+ await makeRoom(target.base, step.dest, { permanent: forGood, verify: () => recheck(target, step, { move: true, free: false }) });
828
+ ends();
829
+ }
830
+ try {
831
+ renameExclusive(step.found.entry, step.dest, { isDir: step.isDir, platform, rename });
832
+ } catch (error) {
833
+ // Another disk after all (the system says so where the devices did not): nothing of the entry was touched.
834
+ throw error?.code === 'EXDEV' ? notMovedThere(step) : takenMeanwhile(error, step);
835
+ }
836
+ } catch (error) { throw midway(error, { moved, failed: step.from }); }
837
+ moved.push({ from: step.from, to: step.to });
838
+ }
839
+ res.json({ ok: true, moved });
840
+ }));
841
+
842
+ // ── Copying ──
843
+ router.post('/copy', route(async (req, res) => {
844
+ const { paths, into, replace, permanent } = req.body || {};
845
+ const { target, steps, conflicts } = plan(paths, into, { copy: true });
846
+ refuseUnasked(conflicts, replace);
847
+ const forGood = permanent === true;
848
+ const copied = [];
849
+ needTrashFor(steps, forGood, { copied });
850
+ for (const step of steps) {
851
+ const made = {};
852
+ try {
853
+ if (recheck(target, step, { move: false, free: !step.replaces })) {
854
+ await makeRoom(target.base, step.dest, { permanent: forGood, verify: () => recheck(target, step, { move: false, free: false }) });
855
+ recheck(target, step, { move: false, free: true });
856
+ }
857
+ // Links inside a copied folder stay links: nothing is read through them.
858
+ try { await copyEntry(step.found.entry, step.dest, () => { sameFolder(target.base, target.real, target.id); sameEntryStill(step.found); }, made); }
859
+ catch (error) { throw takenMeanwhile(error, step); }
860
+ } catch (error) {
861
+ // What the copy had begun under the name stays, and is said to be there in part.
862
+ const partial = !!made.id && entryId(step.dest) === made.id;
863
+ throw midway(error, { copied, failed: step.from, ...(partial ? { partial: step.to } : {}) });
864
+ }
865
+ copied.push({ from: step.from, to: step.to });
866
+ }
867
+ res.json({ ok: true, copied });
868
+ }));
869
+
870
+ // ── Deleting ──
871
+ router.post('/delete', route(async (req, res) => {
872
+ const { paths, permanent } = req.body || {};
873
+ need(Array.isArray(paths) && paths.length > 0 && paths.every(isText));
874
+ need(paths.length <= MAX_BATCH, 'limit', 413);
875
+ const found = paths.map((path) => entryOf(path, { protect: true }));
876
+ const forGood = permanent === true;
877
+ const deleted = [];
878
+ if (!forGood && !trash?.available?.()) throw midway(new ExplorerError(409, 'no-trash'), { deleted, failed: found.find((item) => item.st)?.written });
879
+ let waited = false;
880
+ for (const item of found) {
881
+ if (!item.st) continue; // gone in the meantime: that is what was asked for
882
+ try {
883
+ if (waited) {
884
+ // The Trash was waited for since the check: it is still that entry, in that folder, and no project folder.
885
+ if (!exists(item.entry) && entryId(item.parent) === item.parentId) continue;
886
+ sameEntryStill(item);
887
+ if (holdsRoot(roots(), item.written, item.entry, item.st)) throw new ExplorerError(403, 'root', { name: item.name });
888
+ }
889
+ waited = !forGood;
890
+ if (forGood) removeConfinedEntry(item.base, item.entry);
891
+ else await trash.trash(item.entry);
892
+ } catch (error) {
893
+ // Nothing more is touched. Without a Trash for it, the page asks before anything is removed for good.
894
+ throw midway(error instanceof TrashUnavailable ? new ExplorerError(409, 'no-trash') : error, { deleted, failed: item.written });
895
+ }
896
+ deleted.push(item.written);
897
+ }
898
+ res.json({ ok: true, deleted, trashed: !forGood });
899
+ }));
900
+
901
+ // Put a finished file under its name, never over something: as every file is placed (renameExclusive). Not by a
902
+ // hard link either, as it once was: the system would not report that file's later deletion (see there).
903
+ function placeNew(temp, dest) {
904
+ try { renameExclusive(temp, dest, { platform, rename }); }
905
+ catch (error) { throw error?.code === 'EEXIST' ? new ExplorerError(409, 'exists') : error; }
906
+ }
907
+
908
+ // ── A file dropped from the computer ──
909
+ router.post('/upload', route(async (req, res) => {
910
+ const name = String(req.query.name || '');
911
+ const rel = String(req.query.rel || '');
912
+ need(isText(name));
913
+ goodName(name);
914
+ const parts = rel ? slashes(rel).split('/').filter(Boolean) : [];
915
+ need(parts.length <= 32, 'limit', 413);
916
+ parts.forEach(goodName);
917
+ const declared = Number(req.headers['content-length']);
918
+ if (Number.isFinite(declared) && declared > maxUploadBytes) throw new ExplorerError(413, 'too-large', { maxBytes: maxUploadBytes });
919
+ const target = folderOf(req.query.into);
920
+ need(statSync(target.real).isDirectory(), 'not-dir');
921
+ // The folders of a dropped folder are made one by one, each where the check says it is.
922
+ let folder = target.real;
923
+ for (const part of parts) {
924
+ folder = join(folder, part);
925
+ try { mkdirSync(folder); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
926
+ folder = canonicalInside(target.base, folder);
927
+ need(statSync(folder).isDirectory(), 'not-dir');
928
+ }
929
+ const dest = join(folder, name);
930
+ const written = [target.written, ...parts, name].join('/');
931
+ const replace = req.query.replace === '1';
932
+ const forGood = req.query.permanent === '1';
933
+ // The folder the file goes into, as it is now: the body is waited for, and so is the Trash. After each, it must
934
+ // still be this folder, where it was (one swapped for a link would take the file, or a replace, somewhere else).
935
+ const folderId = entryId(folder);
936
+ const still = () => sameFolder(target.base, folder, folderId);
937
+ // What is under that name, judged before a byte is read and again when the file is whole: a project folder is
938
+ // never replaced, a name that is taken only when the page said so, and without a Trash only "for good".
939
+ const inTheWay = () => {
940
+ let there = null;
941
+ try { there = lstatSync(dest); } catch { return false; }
942
+ if (holdsRoot(roots(), written, dest, there)) throw new ExplorerError(403, 'root', { name });
943
+ if (!replace || !there.isFile()) throw new ExplorerError(409, 'exists', { name });
944
+ if (!forGood && !trash?.available?.()) throw new ExplorerError(409, 'no-trash', { name });
945
+ return true;
946
+ };
947
+ inTheWay();
948
+ // The body goes to a file of its own in the same folder (made exclusively, never through a link). What is
949
+ // under the name stays as it is until the new file is whole: an upload that fails or is given up loses nothing.
950
+ const temp = join(folder, `.synabun-upload-${randomBytes(12).toString('hex')}.tmp`);
951
+ const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o666);
952
+ // Which file that is, for the end: it is removed as the file that was made here, not as whatever has its name.
953
+ let tempId = null;
954
+ try { tempId = idOf(fstatSync(fd, { bigint: true })); } catch { tempId = null; }
955
+ try {
956
+ await new Promise((done, failed) => {
957
+ let size = 0; let failure = null; let whole = false;
958
+ const out = createWriteStream(null, { fd, autoClose: true });
959
+ const giveUp = (error) => { if (failure) return; failure = error || new ExplorerError(400, 'failed'); req.unpipe(out); out.destroy(); };
960
+ req.on('data', (chunk) => {
961
+ size += chunk.length;
962
+ if (size > maxUploadBytes && !failure) { giveUp(new ExplorerError(413, 'too-large', { maxBytes: maxUploadBytes })); req.resume(); }
963
+ });
964
+ req.on('close', () => { if (!req.complete) giveUp(new ExplorerError(400, 'failed')); });
965
+ req.on('error', giveUp);
966
+ out.on('error', giveUp);
967
+ out.on('finish', () => { whole = true; });
968
+ // The file is closed by now, whichever way it ended.
969
+ out.on('close', () => (whole && !failure ? done() : failed(failure || new ExplorerError(400, 'failed'))));
970
+ req.pipe(out);
971
+ });
972
+ still();
973
+ if (inTheWay()) {
974
+ const cleared = await toTrash(dest, { permanent: forGood });
975
+ still();
976
+ // For good, as the request said: the new file takes the name of the one that is (still) there in one step.
977
+ if (!cleared && inTheWay()) rename(temp, dest);
978
+ else placeNew(temp, dest);
979
+ } else {
980
+ placeNew(temp, dest);
981
+ }
982
+ } catch (error) {
983
+ throw error instanceof ExplorerError && !error.extra?.name ? Object.assign(error, { extra: { ...error.extra, name } }) : error;
984
+ } finally {
985
+ // Whichever way it ended, nothing is left under the temporary name (it is gone already when it was placed).
986
+ // Removed only where it was made: when the folder is no longer the one it was made in, its path leads
987
+ // somewhere else by now, and what has that name there is somebody else's. Then it is left, and logged.
988
+ let there = 'unknown';
989
+ try {
990
+ still();
991
+ // By what it is on disk; by its name alone only on a disk that tells no file from another.
992
+ if (tempId) there = entryId(temp) === tempId ? 'mine' : exists(temp) ? 'unknown' : 'gone';
993
+ else there = exists(temp) ? 'mine' : 'gone';
994
+ } catch { there = 'unknown'; }
995
+ if (there === 'mine') { try { unlinkSync(temp); } catch { /* not there */ } }
996
+ else if (there === 'unknown') log(`[explorer] an upload left its temporary file ${basename(temp)} behind: the folder it was made in (${folder}) changed while the upload was under way.`);
997
+ }
998
+ res.json({ ok: true, path: written });
999
+ }));
1000
+
1001
+ // ── Showing an entry in the system's file manager ──
1002
+ router.post('/reveal', route(async (req, res) => {
1003
+ const path = req.body?.path;
1004
+ need(isText(path));
1005
+ // A project folder itself can be shown: nothing about it changes.
1006
+ const { matches } = matchFileRoots(path, roots());
1007
+ const real = matches.some((match) => match.isBase) ? folderOf(path).real : present(entryOf(path)).entry;
1008
+ if (!revealer?.available?.() || !(await revealer.reveal(real))) throw new ExplorerError(501, 'no-reveal');
1009
+ res.json({ ok: true });
1010
+ }));
1011
+
1012
+ // ── The bytes of an image ──
1013
+ router.get('/raw', route(async (req, res) => {
1014
+ const path = req.query.path;
1015
+ need(isText(path));
1016
+ const type = RAW_TYPES[extname(path).toLowerCase()];
1017
+ if (!type) throw new ExplorerError(415, 'not-image');
1018
+ const file = useFileRoot(path, roots(), (match) => ({ base: match.base, real: canonicalInside(match.base, match.path) }));
1019
+ const st = statSync(file.real);
1020
+ need(st.isFile(), 'gone', 404);
1021
+ if (st.size > MAX_RAW_BYTES) throw new ExplorerError(413, 'too-large', { maxBytes: MAX_RAW_BYTES });
1022
+ const stream = openConfinedStream(file.base, file.real);
1023
+ res.writeHead(200, {
1024
+ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
1025
+ 'Content-Disposition': 'inline',
1026
+ // An SVG opened by its address is a document: it gets no scripts, no network and no way out of its frame.
1027
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
1028
+ });
1029
+ stream.on('error', () => res.destroy());
1030
+ stream.pipe(res);
1031
+ }));
1032
+
1033
+ router.use((req, res) => res.status(404).json({ ok: false, code: 'bad-request', error: 'Unknown explorer route.' }));
1034
+
1035
+ /** Release every watch (the server is going down). */
1036
+ router.close = () => { for (const session of sessions.values()) { session.watcher.close(); try { session.res.end(); } catch { /* gone */ } } sessions.clear(); };
1037
+ router.sessions = () => sessions.size;
1038
+ return router;
1039
+ }