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
@@ -5,6 +5,13 @@ import { sendToFocusedSession } from './ui-sidepanel-windows.js';
5
5
  //
6
6
  // Docked sidebar that shows project files in a tree view.
7
7
  // Sits adjacent to the Memory Explorer (to its right).
8
+ //
9
+ // This file is the panel's shell: it opens and closes it, sizes it, selects the
10
+ // project, holds the branch button's popover and the file icons, and wires the
11
+ // code editor. The tree and everything it does (keyboard, menu, operations,
12
+ // following the disk, git marks) is shared/explorer/, created here and loaded by
13
+ // its address: the editor's browser tests run this file without its imports.
14
+ // docs/file-explorer.md says what the panel does.
8
15
 
9
16
  import { KEYS } from './constants.js';
10
17
  import { registerAction } from './ui-keybinds.js';
@@ -14,24 +21,29 @@ import { attachPathToOpencode } from './ui-opencode-panel-v2.js';
14
21
  import { isCodexPanelOpen, toggleCodexPanel } from './cdx/cdx-panel.js';
15
22
  import { addPathChip as addCodexPathChip } from './cdx/cdx-tabs.js';
16
23
  import { on, emit, state } from './state.js';
17
- import { debounce, rafThrottle } from './utils.js';
18
-
19
- // Route to the most recently focused provider, even when several panels
20
- // are visible. The provider opens if minimized and attaches its native path chip.
21
- async function sendPathToActivePanel(filePath) {
22
- if (sendToFocusedSession('path', filePath)) return;
23
- const target = state.lastActivePanel || 'claude';
24
-
25
- // The Assistant sidepanel: through the bus (a static import would close an import cycle).
26
- if (target === 'assistant') { emit('assistant:attach-path', { path: filePath }); return; }
27
- if (target === 'opencode') {
28
- await attachPathToOpencode(filePath);
29
- } else if (target === 'codex') {
30
- if (!isCodexPanelOpen()) await toggleCodexPanel();
31
- addCodexPathChip(filePath);
32
- } else {
33
- await sendToClaudePanel(filePath);
34
- }
24
+ import {
25
+ closeEditorPanel, initEditorPanel, isEditorPanelOpen,
26
+ openChangelogInEditor, openFileInEditor, openPlanInEditor, saveEditorPanel,
27
+ } from './editor/editor-panel.js';
28
+
29
+ // What each side panel offers for a path. Every entry names its function only when it is called: a page that
30
+ // runs this file without its imports (the editor's tests) has none of them.
31
+ const SEND = {
32
+ sendToFocusedSession: (kind, data) => sendToFocusedSession(kind, data),
33
+ lastActive: () => state.lastActivePanel,
34
+ // As a file reference, whatever is in the path (a space would otherwise make it text of the prompt).
35
+ claude: (path) => sendToClaudePanel(path, { asFile: true }),
36
+ codex: async (path) => { if (!isCodexPanelOpen()) await toggleCodexPanel(); addCodexPathChip(path); },
37
+ opencode: (path) => attachPathToOpencode(path),
38
+ assistant: (path) => attachToAssistant('assistant', path),
39
+ };
40
+ function attachToAssistant(target, filePath) {
41
+ // The Assistant sidepanel: through the bus (a static import would close an import cycle). It marks the request
42
+ // it took; one it did not take (the Assistant is switched off, or is not there) is not said to be sent.
43
+ if (target !== 'assistant') return false;
44
+ const request = { path: filePath };
45
+ emit('assistant:attach-path', request);
46
+ return request.taken === true;
35
47
  }
36
48
 
37
49
  const $ = (id) => document.getElementById(id);
@@ -39,65 +51,22 @@ const $ = (id) => document.getElementById(id);
39
51
  // ─── Local state ────────────────────────
40
52
  let _visible = false;
41
53
  let _width = 260;
42
- let _collapsed = new Set(); // set of dir paths that are collapsed
43
- let _filterText = '';
44
- let _sortMode = 'name'; // 'name' | 'size' | 'date'
45
- let _cache = new Map(); // path → items array
46
54
  let _rootLoaded = false;
47
55
  let _projects = []; // [{ path, label }] from API
48
56
  let _selectedProject = null; // { path, label } of current project
49
- let _editorOpen = false;
50
- let _editorFilePath = null;
51
- let _editorOriginal = '';
52
- let _editorDirty = false;
53
- let _editorPreviewMode = false; // false = raw, true = rendered MD
54
- let _editorIsMarkdown = false;
55
- let _findOpen = false;
56
- let _findMatches = []; // [{ start, end }]
57
- let _findIndex = -1;
58
- let _undoStack = []; // [{ value, selStart, selEnd }]
59
- let _redoStack = [];
60
- let _undoBurstOpen = false; // true while user is in a typing burst
61
57
  let _folderColors = {}; // { "/path/to/folder": "#hex" }
62
58
  let _customIcons = null; // { extensions: {}, filenames: {} } from server
63
- let _planEditMode = false; // true when editing a plan file from Claude panel
64
- let _planEditFilePath = null; // path of the plan file being edited
65
- let _planEditSource = null; // panel that opened the plan editor
66
- let _planEditTabId = null; // tab id to route plan editor events back
67
- let _planEditContract = null; // optional versioned document identity and atomic save callback
68
- let _editorSaving = false;
69
- let _editorOpenSequence = 0;
70
- let _changelogEditMode = false; // true when editing a changelog draft from Claude panel
71
- let _changelogEditFilePath = null; // path of the changelog draft being edited
59
+ let _fx = null; // shared/explorer/fx-main.js createExplorer(): the tree and what it does
60
+ let _fxLoading = null;
61
+ let _guest = false; // an invited guest's page: the File Explorer is the owner's, and is not offered
62
+ let _showing = null; // the first tree being put up (the projects are read, then the selected one is shown)
63
+ let _tr = (key) => key; // the translator, once shared/explorer/ is loaded
64
+ let _git = { isGit: false, branch: '', count: 0 };
72
65
 
73
66
  const MIN_WIDTH = 240;
74
67
  const MAX_WIDTH = 500;
75
-
76
- function planEditorSourceLabel(source) {
77
- if (source === 'codex') return 'Codex';
78
- if (source === 'opencode') return 'OpenCode';
79
- return 'Claude';
80
- }
81
-
82
- function planEditorEvent(extra = {}) {
83
- const { planId, revision, accountId, threadId, sidepanelWindowId = null } = _planEditContract || {};
84
- return { source: _planEditSource, tabId: _planEditTabId, planId, revision, accountId, threadId, sidepanelWindowId, ...extra };
85
- }
86
-
87
- function emitPlanEditorState() {
88
- if (_planEditMode) emit('plan-editor-state', planEditorEvent({ dirty: _editorDirty, saving: _editorSaving, open: _editorOpen }));
89
- }
90
-
91
- function cancelPlanEditMode() {
92
- if (!_planEditMode) return;
93
- const event = planEditorEvent();
94
- _planEditMode = false;
95
- _planEditFilePath = null;
96
- _planEditSource = null;
97
- _planEditTabId = null;
98
- _planEditContract = null;
99
- emit('plan-edit-cancelled', event);
100
- }
68
+ const tr = (key, params) => _tr(key, params);
69
+ const samePath = (a, b) => String(a || '').replace(/\\/g, '/').replace(/\/+$/, '') === String(b || '').replace(/\\/g, '/').replace(/\/+$/, '');
101
70
 
102
71
  // ─── SVG icons ────────────────────────
103
72
  const FOLDER_SVG = '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
@@ -177,17 +146,17 @@ function getFileIcon(filename) {
177
146
  return { svg: FI.file, color: null };
178
147
  }
179
148
 
180
- // Git status → badge text + class
181
- const GIT_BADGE = {
182
- modified: ['M', 'fe-git-modified'],
183
- staged: ['S', 'fe-git-staged'],
184
- added: ['A', 'fe-git-added'],
185
- deleted: ['D', 'fe-git-deleted'],
186
- untracked:['?', 'fe-git-untracked'],
187
- conflict: ['!', 'fe-git-conflict'],
188
- renamed: ['R', 'fe-git-renamed'],
189
- mixed: ['M', 'fe-git-mixed'],
190
- };
149
+ // The tree and what it does, loaded when the panel is there.
150
+ function loadTree() {
151
+ if (!_fxLoading) {
152
+ _fxLoading = import('/shared/explorer/fx-main.js').catch((error) => {
153
+ console.error('[file-explorer] could not load the tree', error);
154
+ return null;
155
+ });
156
+ }
157
+ return _fxLoading;
158
+ }
159
+ const whenTree = () => loadTree().then(() => _fx);
191
160
 
192
161
 
193
162
  // ═══════════════════════════════════════════
@@ -195,7 +164,7 @@ const GIT_BADGE = {
195
164
  // ═══════════════════════════════════════════
196
165
 
197
166
  function applyFileExplorerWidth() {
198
- const w = _visible ? `${_width}px` : '0px';
167
+ const w = _visible && !_guest ? `${_width}px` : '0px';
199
168
  document.documentElement.style.setProperty('--file-explorer-width', w);
200
169
  }
201
170
 
@@ -219,8 +188,6 @@ function calcDefaultWidth() {
219
188
 
220
189
  function saveVisible() { try { storage.setItem(KEYS.FILE_EXPLORER_VISIBLE, String(_visible)); } catch {} }
221
190
  function saveWidth() { try { storage.setItem(KEYS.FILE_EXPLORER_WIDTH, String(_width)); } catch {} }
222
- function saveCollapsed() { try { storage.setItem(KEYS.FILE_EXPLORER_COLLAPSED, JSON.stringify([..._collapsed])); } catch {} }
223
- function saveSort() { try { storage.setItem(KEYS.FILE_EXPLORER_SORT, _sortMode); } catch {} }
224
191
  function saveFolderColors() { try { storage.setItem(KEYS.FILE_EXPLORER_FOLDER_COLORS, JSON.stringify(_folderColors)); } catch {} }
225
192
 
226
193
  function loadPersistedState() {
@@ -230,67 +197,112 @@ function loadPersistedState() {
230
197
  const w = parseInt(storage.getItem(KEYS.FILE_EXPLORER_WIDTH), 10);
231
198
  if (w >= MIN_WIDTH && w <= MAX_WIDTH) _width = w;
232
199
 
233
- try {
234
- const col = JSON.parse(storage.getItem(KEYS.FILE_EXPLORER_COLLAPSED) || '[]');
235
- if (Array.isArray(col)) _collapsed = new Set(col);
236
- } catch {}
237
-
238
- const sort = storage.getItem(KEYS.FILE_EXPLORER_SORT);
239
- if (sort === 'name' || sort === 'size' || sort === 'date') _sortMode = sort;
240
-
241
200
  try {
242
201
  const fc = JSON.parse(storage.getItem(KEYS.FILE_EXPLORER_FOLDER_COLORS) || '{}');
243
202
  if (fc && typeof fc === 'object') _folderColors = fc;
244
203
  } catch {}
245
204
  }
246
205
 
206
+ // Folder colours: the first is the folder's own, the one a folder has when none is set.
207
+ const FOLDER_COLORS = [
208
+ { hex: '#ffb74d', key: 'amber' },
209
+ { hex: '#64b5f6', key: 'blue' },
210
+ { hex: '#81c784', key: 'green' },
211
+ { hex: '#e57373', key: 'red' },
212
+ { hex: '#ba68c8', key: 'purple' },
213
+ { hex: '#4dd0e1', key: 'cyan' },
214
+ { hex: '#fff176', key: 'yellow' },
215
+ { hex: '#a1887f', key: 'brown' },
216
+ { hex: '#90a4ae', key: 'grey' },
217
+ ];
218
+ const folderColors = {
219
+ all: FOLDER_COLORS,
220
+ of: (path) => _folderColors[path],
221
+ set(path, color) {
222
+ if (color) _folderColors[path] = color; else delete _folderColors[path];
223
+ saveFolderColors();
224
+ },
225
+ // A folder that was renamed or moved keeps its colour, and so do the folders inside it.
226
+ move(from, to) {
227
+ let changed = false;
228
+ for (const path of Object.keys(_folderColors)) {
229
+ if (path !== from && !path.startsWith(`${from}/`)) continue;
230
+ _folderColors[to + path.slice(from.length)] = _folderColors[path];
231
+ delete _folderColors[path];
232
+ changed = true;
233
+ }
234
+ if (changed) saveFolderColors();
235
+ },
236
+ };
237
+
247
238
 
248
239
  // ═══════════════════════════════════════════
249
240
  // PROJECT SELECTOR
250
241
  // ═══════════════════════════════════════════
251
242
 
243
+ let _projectsFailed = false;
244
+
252
245
  async function fetchProjects() {
253
246
  try {
254
247
  const res = await fetch('/api/projects');
255
248
  const data = await res.json();
256
- if (data.ok && Array.isArray(data.projects)) {
249
+ _projectsFailed = !(data.ok && Array.isArray(data.projects));
250
+ if (!_projectsFailed) {
257
251
  _projects = data.projects;
252
+ // What this server has for the tree (an older one lists nothing: the tree then asks it for nothing new).
253
+ _fx?.api.serverSays(Array.isArray(data.capabilities) ? data.capabilities : []);
258
254
  }
259
- } catch {}
255
+ } catch { _projectsFailed = true; }
260
256
  return _projects;
261
257
  }
262
258
 
259
+ function showProjectLabel() {
260
+ const label = $('fe-project-label');
261
+ const btn = $('fe-project-btn');
262
+ const name = _selectedProject ? _selectedProject.label : tr('fileExplorer.project.none');
263
+ if (label) label.textContent = name;
264
+ if (btn) {
265
+ btn.title = _selectedProject ? _selectedProject.path : '';
266
+ btn.setAttribute('aria-label', tr('fileExplorer.project.label', { name }));
267
+ }
268
+ }
269
+
263
270
  function selectProject(proj) {
264
271
  _selectedProject = proj;
265
272
  try { storage.setItem(KEYS.FILE_EXPLORER_PROJECT, proj.path); } catch {}
266
-
267
- // Update label
268
- const label = $('fe-project-label');
269
- if (label) label.textContent = proj.label;
270
-
271
- // Close dropdown
273
+ showProjectLabel();
272
274
  closeProjectDropdown();
275
+ buildProjectDropdown();
276
+ // The tree of that project comes back as it was left: what was open, selected and scrolled to.
277
+ return whenTree().then((tree) => tree?.setProject(proj));
278
+ }
273
279
 
274
- // Reset tree state
275
- _cache.clear();
276
- _collapsed.clear();
277
- saveCollapsed();
278
- _rootLoaded = false;
279
- loadDirectory(_selectedProject.path).then(() => {
280
- _rootLoaded = true;
281
- buildFileTree();
282
- });
280
+ // The tree of the selected project, or what there is to say when there is none.
281
+ async function showTree() {
282
+ const tree = await whenTree();
283
+ if (!tree) return;
284
+ await fetchProjects();
285
+ initProjectSelector();
286
+ await tree.setProject(_selectedProject);
287
+ if (_projectsFailed) {
288
+ tree.notice.show(tr('fileExplorer.error.offline'), { tone: 'error', action: { label: tr('fileExplorer.state.retry'), run: showTree } });
289
+ }
283
290
  }
284
291
 
285
- function updateBranchLabel(branch) {
292
+ function updateBranchLabel(git) {
293
+ _git = git;
286
294
  const btn = $('fe-branch-btn');
287
295
  const nameEl = $('fe-branch-btn-name');
296
+ const badge = $('fe-branch-btn-badge');
288
297
  if (!btn) return;
289
298
 
290
- if (branch) {
299
+ if (git.isGit && git.branch) {
291
300
  btn.style.display = '';
292
- if (nameEl) nameEl.textContent = branch;
293
- refreshGitStatus();
301
+ if (nameEl) nameEl.textContent = git.branch;
302
+ if (badge) badge.textContent = git.count > 0 ? git.count : '';
303
+ btn.setAttribute('aria-label', tr('fileExplorer.branch.label', { branch: git.branch, count: git.count }));
304
+ btn.dataset.tooltip = tr('fileExplorer.branch.label', { branch: git.branch, count: git.count });
305
+ if (_gitPopoverOpen) renderGitSummary();
294
306
  } else {
295
307
  btn.style.display = 'none';
296
308
  closeGitPopover();
@@ -302,7 +314,6 @@ function updateBranchLabel(branch) {
302
314
  // GIT POPOVER
303
315
  // ═══════════════════════════════════════════
304
316
 
305
- let _gitChanges = [];
306
317
  let _gitPopoverOpen = false;
307
318
 
308
319
  function positionGitPopover() {
@@ -320,22 +331,10 @@ function positionGitPopover() {
320
331
  let top = r.top + (r.height / 2) - 18; // align arrow with button center
321
332
 
322
333
  // If it would overflow the right edge, flip to the left
323
- let flipped = false;
324
334
  const popWidth = pop.offsetWidth || 248;
325
- if (left + popWidth > window.innerWidth) {
326
- left = r.left - popWidth - 8;
327
- flipped = true;
328
- }
329
-
330
- // Arrow positioning
331
- if (arrow) {
332
- arrow.style.left = flipped ? '' : '-5px';
333
- arrow.style.right = flipped ? '-5px' : '';
334
- arrow.style.borderLeft = flipped ? 'none' : '1px solid rgba(255,255,255,0.1)';
335
- arrow.style.borderBottom = flipped ? 'none' : '1px solid rgba(255,255,255,0.1)';
336
- arrow.style.borderRight = flipped ? '1px solid rgba(255,255,255,0.1)' : 'none';
337
- arrow.style.borderTop = flipped ? '1px solid rgba(255,255,255,0.1)' : 'none';
338
- }
335
+ const flipped = left + popWidth > window.innerWidth;
336
+ if (flipped) left = Math.max(8, r.left - popWidth - 8);
337
+ if (arrow) arrow.classList.toggle('flipped', flipped);
339
338
 
340
339
  // Clamp to viewport — respect navbar at top
341
340
  if (top + popHeight > window.innerHeight - 8) top = window.innerHeight - popHeight - 8;
@@ -353,22 +352,13 @@ function openGitPopover() {
353
352
  resetCommitOutput();
354
353
  _gitPopoverOpen = true;
355
354
  btn?.classList.add('active');
355
+ btn?.setAttribute('aria-expanded', 'true');
356
356
  positionGitPopover();
357
357
 
358
- // Reset arrow for default (right-side) positioning
359
- const arrow = pop.querySelector('.fe-git-popover-arrow');
360
- if (arrow) {
361
- arrow.style.left = '-5px';
362
- arrow.style.right = '';
363
- arrow.style.borderLeft = '1px solid rgba(255,255,255,0.1)';
364
- arrow.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
365
- arrow.style.borderRight = 'none';
366
- arrow.style.borderTop = 'none';
367
- }
368
-
369
358
  requestAnimationFrame(() => pop.classList.add('visible'));
370
359
  loadGitBranches();
371
- refreshGitStatus();
360
+ renderGitSummary();
361
+ _fx?.refresh();
372
362
  }
373
363
 
374
364
  function closeGitPopover() {
@@ -379,6 +369,7 @@ function closeGitPopover() {
379
369
  _gitPopoverOpen = false;
380
370
  pop.classList.remove('visible');
381
371
  btn?.classList.remove('active');
372
+ btn?.setAttribute('aria-expanded', 'false');
382
373
  }
383
374
 
384
375
  function toggleGitPopover() {
@@ -386,47 +377,32 @@ function toggleGitPopover() {
386
377
  else openGitPopover();
387
378
  }
388
379
 
389
- async function refreshGitStatus() {
390
- if (!_selectedProject) return;
391
- try {
392
- const res = await fetch('/api/git/status?path=' + encodeURIComponent(_selectedProject.path));
393
- const data = await res.json();
394
- if (!data.ok || !data.isGit) return;
395
-
396
- _gitChanges = data.changes || [];
397
-
398
- // Update button badge
399
- const badge = $('fe-branch-btn-badge');
400
- if (badge) badge.textContent = _gitChanges.length > 0 ? _gitChanges.length : '';
401
-
402
- // Update branch name on button
403
- const nameEl = $('fe-branch-btn-name');
404
- if (nameEl && data.branch) nameEl.textContent = data.branch;
405
-
406
- // Update summary in popover
407
- const summary = $('fe-git-summary');
408
- if (summary) {
409
- if (_gitChanges.length === 0) {
410
- summary.innerHTML = 'Clean working tree';
411
- } else {
412
- const counts = { modified: 0, added: 0, deleted: 0, untracked: 0, other: 0 };
413
- for (const c of _gitChanges) {
414
- if (c.status === 'modified' || c.status === 'mixed') counts.modified++;
415
- else if (c.status === 'added' || c.status === 'staged') counts.added++;
416
- else if (c.status === 'deleted') counts.deleted++;
417
- else if (c.status === 'untracked') counts.untracked++;
418
- else counts.other++;
419
- }
420
- const parts = [];
421
- if (counts.modified) parts.push('<span class="gs-m">' + counts.modified + ' modified</span>');
422
- if (counts.added) parts.push('<span class="gs-a">' + counts.added + ' added</span>');
423
- if (counts.deleted) parts.push('<span class="gs-d">' + counts.deleted + ' deleted</span>');
424
- if (counts.untracked) parts.push('<span class="gs-u">' + counts.untracked + ' untracked</span>');
425
- if (counts.other) parts.push(counts.other + ' other');
426
- summary.innerHTML = parts.join(' &middot; ');
427
- }
428
- }
429
- } catch {}
380
+ // What changed, in words and counts: from the git state the tree already holds.
381
+ function renderGitSummary() {
382
+ const summary = $('fe-git-summary');
383
+ if (!summary) return;
384
+ const changes = _fx?.state.git.changed() || [];
385
+ summary.textContent = '';
386
+ if (changes.length === 0) { summary.textContent = tr('fileExplorer.gitPanel.clean'); return; }
387
+ const counts = { modified: 0, added: 0, deleted: 0, untracked: 0, other: 0 };
388
+ for (const c of changes) {
389
+ if (c.status === 'modified' || c.status === 'mixed') counts.modified++;
390
+ else if (c.status === 'added' || c.status === 'staged') counts.added++;
391
+ else if (c.status === 'deleted') counts.deleted++;
392
+ else if (c.status === 'untracked') counts.untracked++;
393
+ else counts.other++;
394
+ }
395
+ const classes = { modified: 'gs-m', added: 'gs-a', deleted: 'gs-d', untracked: 'gs-u', other: '' };
396
+ let first = true;
397
+ for (const kind of Object.keys(counts)) {
398
+ if (!counts[kind]) continue;
399
+ if (!first) summary.append(' · ');
400
+ first = false;
401
+ const part = document.createElement('span');
402
+ if (classes[kind]) part.className = classes[kind];
403
+ part.textContent = tr(`fileExplorer.gitPanel.count.${kind}`, { count: counts[kind] });
404
+ summary.append(part);
405
+ }
430
406
  }
431
407
 
432
408
  async function loadGitBranches() {
@@ -458,21 +434,15 @@ async function switchBranch(branch) {
458
434
  });
459
435
  const data = await res.json();
460
436
  if (data.ok) {
461
- showToast('Switched to ' + (data.branch || branch));
437
+ showToast(tr('fileExplorer.gitPanel.switched', { branch: data.branch || branch }));
462
438
  closeGitPopover();
463
- _cache.clear();
464
- _rootLoaded = false;
465
- loadDirectory(_selectedProject.path).then(() => {
466
- _rootLoaded = true;
467
- buildFileTree();
468
- });
439
+ _fx?.refresh();
469
440
  } else {
470
- showToast(data.error || 'Checkout failed');
471
- refreshGitStatus();
441
+ showToast(data.error || tr('fileExplorer.gitPanel.checkoutFailed'));
472
442
  loadGitBranches();
473
443
  }
474
444
  } catch {
475
- showToast('Checkout failed');
445
+ showToast(tr('fileExplorer.gitPanel.checkoutFailed'));
476
446
  }
477
447
  }
478
448
 
@@ -495,18 +465,12 @@ async function doGitCommit() {
495
465
  if (data.ok) {
496
466
  input.value = '';
497
467
  showCommitOutput(data.output, true);
498
- _cache.clear();
499
- _rootLoaded = false;
500
- loadDirectory(_selectedProject.path).then(() => {
501
- _rootLoaded = true;
502
- buildFileTree();
503
- });
504
- refreshGitStatus();
468
+ _fx?.refresh();
505
469
  } else {
506
- showCommitOutput(data.error || 'Commit failed', false);
470
+ showCommitOutput(data.error || tr('fileExplorer.gitPanel.commitFailed'), false);
507
471
  }
508
472
  } catch {
509
- showCommitOutput('Commit failed', false);
473
+ showCommitOutput(tr('fileExplorer.gitPanel.commitFailed'), false);
510
474
  } finally {
511
475
  btn?.classList.remove('loading');
512
476
  }
@@ -521,11 +485,11 @@ function showCommitOutput(text, isSuccess) {
521
485
  controls.style.display = 'none';
522
486
 
523
487
  const icon = isSuccess
524
- ? '<svg viewBox="0 0 24 24" style="width:11px;height:11px;stroke:#66bb6a;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>'
525
- : '<svg viewBox="0 0 24 24" style="width:11px;height:11px;stroke:#ef5350;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-1px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
488
+ ? '<svg viewBox="0 0 24 24" class="fe-git-output-icon ok" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'
489
+ : '<svg viewBox="0 0 24 24" class="fe-git-output-icon" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
526
490
 
527
491
  output.innerHTML =
528
- `<div class="fe-git-output-header">${icon} ${isSuccess ? 'Committed' : 'Failed'}</div>` +
492
+ `<div class="fe-git-output-header">${icon} ${escHtml(tr(isSuccess ? 'fileExplorer.gitPanel.committed' : 'fileExplorer.gitPanel.failed'))}</div>` +
529
493
  `<pre class="fe-git-output-pre${isSuccess ? '' : ' error'}">${escHtml(text)}</pre>`;
530
494
  output.style.display = 'block';
531
495
  pop.classList.add('fe-git-output-mode');
@@ -558,10 +522,10 @@ async function generateCommitMessage() {
558
522
  input.value = data.message;
559
523
  input.focus();
560
524
  } else {
561
- showToast(data.summary || 'No changes');
525
+ showToast(data.summary || tr('fileExplorer.gitPanel.noChanges'));
562
526
  }
563
527
  } catch {
564
- showToast('Failed to generate message');
528
+ showToast(tr('fileExplorer.gitPanel.generateFailed'));
565
529
  } finally {
566
530
  genBtn?.classList.remove('loading');
567
531
  }
@@ -573,23 +537,25 @@ function createGitPopover() {
573
537
  const pop = document.createElement('div');
574
538
  pop.className = 'fe-git-popover';
575
539
  pop.id = 'fe-git-popover';
540
+ pop.setAttribute('role', 'dialog');
541
+ pop.setAttribute('aria-label', tr('fileExplorer.gitPanel.label'));
576
542
  pop.innerHTML = `
577
543
  <div class="fe-git-popover-arrow"></div>
578
544
  <div class="fe-git-controls" id="fe-git-controls">
579
545
  <div class="fe-git-pop-section">
580
- <label class="fe-git-pop-label">Branch</label>
546
+ <label class="fe-git-pop-label" for="fe-git-branch-select">${escHtml(tr('fileExplorer.gitPanel.branch'))}</label>
581
547
  <select class="fe-git-branch-select" id="fe-git-branch-select"></select>
582
548
  </div>
583
549
  <div class="fe-git-pop-sep"></div>
584
550
  <div class="fe-git-pop-section">
585
- <label class="fe-git-pop-label">Commit</label>
551
+ <label class="fe-git-pop-label" for="fe-git-commit-input">${escHtml(tr('fileExplorer.gitPanel.commit'))}</label>
586
552
  <div class="fe-git-commit-area">
587
- <button class="fe-git-gen-btn" id="fe-git-gen-btn" data-tooltip="Generate message">
588
- <svg viewBox="0 0 24 24"><path d="M12 2L9.5 8.5 3 10l5 4-1.5 7L12 17.5 17.5 21 16 14l5-4-6.5-1.5z"/></svg>
553
+ <button class="fe-git-gen-btn" id="fe-git-gen-btn" data-tooltip="${escHtml(tr('fileExplorer.gitPanel.generate'))}" aria-label="${escHtml(tr('fileExplorer.gitPanel.generate'))}">
554
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2L9.5 8.5 3 10l5 4-1.5 7L12 17.5 17.5 21 16 14l5-4-6.5-1.5z"/></svg>
589
555
  </button>
590
- <input type="text" class="fe-git-commit-input" id="fe-git-commit-input" placeholder="Message..." autocomplete="off" spellcheck="false" maxlength="200">
591
- <button class="fe-git-commit-btn" id="fe-git-commit-btn">
592
- <svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
556
+ <input type="text" class="fe-git-commit-input" id="fe-git-commit-input" placeholder="${escHtml(tr('fileExplorer.gitPanel.message'))}" autocomplete="off" spellcheck="false" maxlength="200">
557
+ <button class="fe-git-commit-btn" id="fe-git-commit-btn" data-tooltip="${escHtml(tr('fileExplorer.gitPanel.commitAction'))}" aria-label="${escHtml(tr('fileExplorer.gitPanel.commitAction'))}">
558
+ <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
593
559
  </button>
594
560
  </div>
595
561
  </div>
@@ -611,6 +577,8 @@ function initGitPanel() {
611
577
  const commitBtn = $('fe-git-commit-btn');
612
578
 
613
579
  if (branchBtn) {
580
+ branchBtn.setAttribute('aria-haspopup', 'dialog');
581
+ branchBtn.setAttribute('aria-expanded', 'false');
614
582
  branchBtn.addEventListener('click', (e) => {
615
583
  e.stopPropagation();
616
584
  toggleGitPopover();
@@ -624,6 +592,11 @@ function initGitPanel() {
624
592
  closeGitPopover();
625
593
  }
626
594
  });
595
+ // Its keys stay in it, and Escape closes it.
596
+ popover.addEventListener('keydown', (e) => {
597
+ e.stopPropagation();
598
+ if (e.key === 'Escape') { e.preventDefault(); closeGitPopover(); branchBtn?.focus(); }
599
+ });
627
600
 
628
601
  // Reposition on scroll/resize
629
602
  window.addEventListener('resize', () => { if (_gitPopoverOpen) positionGitPopover(); });
@@ -644,7 +617,6 @@ function initGitPanel() {
644
617
  if (commitInput) {
645
618
  commitInput.addEventListener('keydown', (e) => {
646
619
  if (e.key === 'Enter') { e.preventDefault(); doGitCommit(); }
647
- e.stopPropagation();
648
620
  });
649
621
  commitInput.addEventListener('click', (e) => e.stopPropagation());
650
622
  }
@@ -653,27 +625,37 @@ function initGitPanel() {
653
625
  function closeProjectDropdown() {
654
626
  const wrap = $('fe-project-selector');
655
627
  if (wrap) wrap.classList.remove('open');
628
+ $('fe-project-btn')?.setAttribute('aria-expanded', 'false');
656
629
  }
657
630
 
658
631
  function toggleProjectDropdown() {
659
632
  const wrap = $('fe-project-selector');
660
633
  if (!wrap) return;
661
- wrap.classList.toggle('open');
634
+ const open = wrap.classList.toggle('open');
635
+ $('fe-project-btn')?.setAttribute('aria-expanded', String(open));
636
+ if (open) ($('fe-project-dropdown')?.querySelector('.fe-project-option.active') || $('fe-project-dropdown')?.querySelector('.fe-project-option'))?.focus();
662
637
  }
663
638
 
664
639
  function buildProjectDropdown() {
665
640
  const dd = $('fe-project-dropdown');
666
641
  if (!dd) return;
667
642
  dd.innerHTML = '';
643
+ dd.setAttribute('role', 'listbox');
644
+ dd.setAttribute('aria-label', tr('fileExplorer.project.choose'));
668
645
 
669
- const folderSvg = '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
670
- const plusSvg = '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
646
+ const folderSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
647
+ const plusSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
671
648
 
672
649
  for (const p of _projects) {
650
+ const active = !!_selectedProject && _selectedProject.path === p.path;
673
651
  const opt = document.createElement('button');
674
- opt.className = 'fe-project-option' + (_selectedProject && _selectedProject.path === p.path ? ' active' : '');
652
+ opt.type = 'button';
653
+ opt.className = 'fe-project-option' + (active ? ' active' : '');
654
+ opt.setAttribute('role', 'option');
655
+ opt.setAttribute('aria-selected', String(active));
656
+ opt.title = p.path;
675
657
  opt.innerHTML = folderSvg + '<span class="fe-opt-label">' + escHtml(p.label) + '</span>';
676
- opt.addEventListener('click', (e) => { e.stopPropagation(); selectProject(p); });
658
+ opt.addEventListener('click', (e) => { e.stopPropagation(); selectProject(p); $('fe-project-btn')?.focus(); });
677
659
  dd.appendChild(opt);
678
660
  }
679
661
 
@@ -683,23 +665,26 @@ function buildProjectDropdown() {
683
665
  dd.appendChild(sep);
684
666
 
685
667
  const addBtn = document.createElement('button');
668
+ addBtn.type = 'button';
686
669
  addBtn.className = 'fe-project-option fe-add-project';
687
- addBtn.innerHTML = plusSvg + '<span class="fe-opt-label">Add project...</span>';
670
+ addBtn.setAttribute('role', 'option');
671
+ addBtn.setAttribute('aria-selected', 'false');
672
+ addBtn.innerHTML = plusSvg + '<span class="fe-opt-label">' + escHtml(tr('fileExplorer.project.add')) + '</span>';
688
673
  addBtn.addEventListener('click', (e) => { e.stopPropagation(); addProjectFlow(); });
689
674
  dd.appendChild(addBtn);
690
675
  }
691
676
 
692
- function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
677
+ function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g, '&quot;'); }
693
678
 
694
679
  async function addProjectFlow() {
695
680
  closeProjectDropdown();
696
681
  try {
697
- const res = await fetch('/api/browse-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'Select project folder' }) });
682
+ const res = await fetch('/api/browse-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: tr('fileExplorer.project.pickFolder') }) });
698
683
  const data = await res.json();
699
684
  if (!data.path) return; // User cancelled
700
685
 
701
686
  // Register as a project
702
- const label = data.path.split(/[\\/]/).pop() || data.path;
687
+ const label = data.path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || data.path;
703
688
  const integRes = await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
704
689
  const integData = await integRes.json().catch(() => ({}));
705
690
 
@@ -710,16 +695,16 @@ async function addProjectFlow() {
710
695
 
711
696
  // Refresh projects list and select the new one
712
697
  await fetchProjects();
713
- const newProj = _projects.find(p => p.path.replace(/\\/g, '/') === data.path.replace(/\\/g, '/'));
698
+ const newProj = _projects.find(p => samePath(p.path, data.path));
714
699
  if (newProj) {
715
700
  _selectedProject = newProj;
716
701
  try { storage.setItem(KEYS.FILE_EXPLORER_PROJECT, newProj.path); } catch {}
717
702
  }
718
703
  initProjectSelector();
719
704
  if (newProj) selectProject(newProj);
720
- else { _cache.clear(); buildFileTree(); }
705
+ else showToast(tr('fileExplorer.project.addFailed'));
721
706
  } catch (err) {
722
- showToast('Failed to add project');
707
+ showToast(tr('fileExplorer.project.addFailed'));
723
708
  }
724
709
  }
725
710
 
@@ -734,31 +719,40 @@ function promptTrustWorkspace(trustPath) {
734
719
 
735
720
  const modal = document.createElement('div');
736
721
  modal.className = 'fe-modal fe-trust-modal';
722
+ modal.setAttribute('role', 'alertdialog');
723
+ modal.setAttribute('aria-modal', 'true');
724
+ modal.setAttribute('aria-labelledby', 'fe-trust-title');
737
725
  modal.innerHTML = `
738
- <div class="fe-modal-title">Git ownership mismatch</div>
726
+ <div class="fe-modal-title" id="fe-trust-title"></div>
739
727
  <div class="fe-modal-body">
740
- Git is refusing to read this workspace because it's owned by a different user account. Trusting it adds the directory to your global <code>safe.directory</code> config so Synabun's git UI can show the branch and changes.
728
+ <span id="fe-trust-body"></span>
741
729
  <div class="fe-trust-path" id="fe-trust-path"></div>
742
730
  </div>
743
731
  <div class="fe-modal-actions">
744
- <button class="fe-modal-btn fe-modal-cancel" id="fe-trust-skip">Skip</button>
745
- <button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm">Trust workspace</button>
732
+ <button class="fe-modal-btn fe-modal-cancel" id="fe-trust-skip"></button>
733
+ <button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm"></button>
746
734
  </div>
747
735
  `;
748
736
  overlay.appendChild(modal);
749
737
  document.body.appendChild(overlay);
738
+ modal.querySelector('#fe-trust-title').textContent = tr('fileExplorer.trust.title');
739
+ modal.querySelector('#fe-trust-body').textContent = tr('fileExplorer.trust.body');
750
740
  modal.querySelector('#fe-trust-path').textContent = trustPath;
741
+ modal.querySelector('#fe-trust-skip').textContent = tr('fileExplorer.trust.skip');
742
+ modal.querySelector('#fe-trust-confirm').textContent = tr('fileExplorer.trust.confirm');
751
743
 
752
744
  requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
745
+ modal.querySelector('#fe-trust-skip').focus();
753
746
 
754
747
  const done = () => {
755
748
  overlay.classList.remove('fe-modal-overlay--in');
756
749
  setTimeout(() => overlay.remove(), 150);
757
- document.removeEventListener('keydown', onKey);
750
+ window.removeEventListener('keydown', onKey, true);
758
751
  resolve();
759
752
  };
760
- const onKey = (ev) => { if (ev.key === 'Escape') done(); };
761
- document.addEventListener('keydown', onKey);
753
+ // Taken at the window: the page's own shortcuts listen before a dialog's buttons would.
754
+ const onKey = (ev) => { ev.stopPropagation(); if (ev.key === 'Escape') { ev.preventDefault(); done(); } };
755
+ window.addEventListener('keydown', onKey, true);
762
756
 
763
757
  modal.querySelector('#fe-trust-skip').addEventListener('click', done);
764
758
  overlay.addEventListener('click', (ev) => { if (ev.target === overlay) done(); });
@@ -766,7 +760,7 @@ function promptTrustWorkspace(trustPath) {
766
760
  modal.querySelector('#fe-trust-confirm').addEventListener('click', async () => {
767
761
  const btn = modal.querySelector('#fe-trust-confirm');
768
762
  btn.disabled = true;
769
- btn.textContent = 'Trusting...';
763
+ btn.textContent = tr('fileExplorer.trust.trusting');
770
764
  try {
771
765
  const res = await fetch('/api/terminal/trust-workspace', {
772
766
  method: 'POST',
@@ -774,10 +768,10 @@ function promptTrustWorkspace(trustPath) {
774
768
  body: JSON.stringify({ path: trustPath }),
775
769
  });
776
770
  const data = await res.json();
777
- if (data.ok) showToast('Workspace trusted');
778
- else showToast(data.error || 'Trust failed');
771
+ if (data.ok) showToast(tr('fileExplorer.trust.done'));
772
+ else showToast(data.error || tr('fileExplorer.trust.failed'));
779
773
  } catch {
780
- showToast('Trust failed');
774
+ showToast(tr('fileExplorer.trust.failed'));
781
775
  }
782
776
  done();
783
777
  });
@@ -787,21 +781,21 @@ function promptTrustWorkspace(trustPath) {
787
781
  function initProjectSelector() {
788
782
  const btn = $('fe-project-btn');
789
783
  const wrap = $('fe-project-selector');
790
- if (!btn || !wrap || _projects.length === 0) return;
784
+ if (!btn || !wrap) return;
791
785
 
792
786
  // Restore persisted selection
793
787
  const saved = storage.getItem(KEYS.FILE_EXPLORER_PROJECT);
794
788
  const match = _projects.find(p => p.path === saved);
795
- _selectedProject = match || _projects[0];
789
+ _selectedProject = match || _projects[0] || null;
796
790
 
797
- // Update label
798
- const label = $('fe-project-label');
799
- if (label) label.textContent = _selectedProject.label;
791
+ showProjectLabel();
800
792
 
801
793
  // Build dropdown options
802
794
  buildProjectDropdown();
803
795
 
804
796
  // Wire toggle (safe to reassign)
797
+ btn.setAttribute('aria-haspopup', 'listbox');
798
+ btn.setAttribute('aria-expanded', 'false');
805
799
  btn.onclick = (e) => { e.stopPropagation(); toggleProjectDropdown(); };
806
800
 
807
801
  // Close on outside click
@@ -810,2318 +804,309 @@ function initProjectSelector() {
810
804
  if (!wrap.contains(e.target)) closeProjectDropdown();
811
805
  };
812
806
  document.addEventListener('click', wrap._outsideHandler);
807
+ // Arrows move along the list, Escape closes it; none of its keys reach the page's shortcuts.
808
+ wrap.addEventListener('keydown', (e) => {
809
+ if (!wrap.classList.contains('open')) { if (e.key === 'ArrowDown' && e.target === btn) { e.preventDefault(); e.stopPropagation(); toggleProjectDropdown(); } return; }
810
+ e.stopPropagation();
811
+ const options = [...wrap.querySelectorAll('.fe-project-option')];
812
+ const at = options.indexOf(document.activeElement);
813
+ if (e.key === 'Escape') { e.preventDefault(); closeProjectDropdown(); btn.focus(); }
814
+ else if (e.key === 'ArrowDown') { e.preventDefault(); options[(at + 1) % options.length]?.focus(); }
815
+ else if (e.key === 'ArrowUp') { e.preventDefault(); options[(at - 1 + options.length) % options.length]?.focus(); }
816
+ else if (e.key === 'Home') { e.preventDefault(); options[0]?.focus(); }
817
+ else if (e.key === 'End') { e.preventDefault(); options[options.length - 1]?.focus(); }
818
+ });
813
819
  }
814
820
  }
815
821
 
816
822
 
817
- // ═══════════════════════════════════════════
818
- // API
819
- // ═══════════════════════════════════════════
820
-
821
- async function loadDirectory(dirPath) {
822
- const params = new URLSearchParams();
823
- // Use selected project root when no specific path given
824
- const target = dirPath || (_selectedProject ? _selectedProject.path : null);
825
- if (target) params.set('path', target);
826
- const res = await fetch(`/api/project-files?${params}`);
827
- const data = await res.json();
828
- if (data.items) {
829
- const key = data.path || '';
830
- _cache.set(key, data);
831
- // Update branch label when loading the project root
832
- if (target && _selectedProject && target.replace(/\\/g, '/') === _selectedProject.path.replace(/\\/g, '/')) {
833
- updateBranchLabel(data.branch || null);
834
- }
835
- }
836
- return data;
823
+ // Show a file in the tree (the editor's "Reveal in tree"): the explorer is opened, the file's project is
824
+ // selected when it is another one, its folders are expanded, and its row is selected and marked for a
825
+ // moment. Answers whether the file was found.
826
+ async function revealFileInTree(filePath) {
827
+ const slashes = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '');
828
+ const target = slashes(filePath);
829
+ if (!target) return false;
830
+ if (!_visible) toggleFileExplorer();
831
+ const tree = await whenTree();
832
+ if (!tree) return false;
833
+ // A panel that was just opened is still reading its projects: the file's project is chosen after that.
834
+ syncTree();
835
+ await _showing;
836
+ await tree.whenLoaded();
837
+ if (!_projects.length) await fetchProjects();
838
+ const project = [..._projects].sort((a, b) => b.path.length - a.path.length).find((p) => target.startsWith(slashes(p.path) + '/'));
839
+ if (!project) return false;
840
+ if (!_selectedProject || slashes(_selectedProject.path) !== slashes(project.path)) await selectProject(project);
841
+ return tree.reveal(target);
837
842
  }
838
843
 
839
- async function searchFiles(term, signal) {
840
- const params = new URLSearchParams({ search: term });
841
- // Scope search to selected project
842
- if (_selectedProject) params.set('path', _selectedProject.path);
843
- const res = await fetch(`/api/project-files?${params}`, signal ? { signal } : undefined);
844
- return res.json();
844
+ function showToast(msg) {
845
+ let toast = document.getElementById('fe-toast');
846
+ if (!toast) {
847
+ toast = document.createElement('div');
848
+ toast.id = 'fe-toast';
849
+ toast.setAttribute('role', 'status');
850
+ toast.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:rgba(30,30,34,0.95);color:#e0e0e0;padding:6px 16px;border-radius:6px;font-size:12px;font-family:var(--ff-mono);z-index:2147483647;pointer-events:none;opacity:0;transition:opacity 0.2s;border:1px solid rgba(255,255,255,0.1);';
851
+ document.body.appendChild(toast);
852
+ }
853
+ toast.textContent = msg;
854
+ toast.style.opacity = '1';
855
+ clearTimeout(toast._timer);
856
+ toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 3500);
845
857
  }
846
858
 
847
859
 
848
860
  // ═══════════════════════════════════════════
849
- // TREE RENDERING
861
+ // RESIZE HANDLE
850
862
  // ═══════════════════════════════════════════
851
863
 
852
- function sortItems(items) {
853
- const sorted = [...items];
854
- // Always dirs first
855
- sorted.sort((a, b) => {
856
- if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
857
- if (_sortMode === 'size' && a.type === 'file' && b.type === 'file') {
858
- return (b.size || 0) - (a.size || 0);
859
- }
860
- if (_sortMode === 'date' && a.mtime && b.mtime) {
861
- return b.mtime - a.mtime;
862
- }
863
- return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
864
- });
865
- return sorted;
866
- }
864
+ function initResizeHandle() {
865
+ const handle = $('fe-resize-handle');
866
+ const panel = $('file-explorer-panel');
867
+ if (!handle || !panel) return;
867
868
 
868
- function createRow(item, depth) {
869
- const node = document.createElement('div');
870
- node.className = 'fe-node';
871
- node.dataset.path = item.fullPath || item.name;
872
- node.dataset.type = item.type;
873
- node.dataset.expanded = 'false';
874
- node.dataset.depth = depth;
875
- node._item = item; // stashed so the delegated #fe-tree listener can recover context
876
-
877
- const row = document.createElement('div');
878
- row.className = 'fe-row';
879
- row.style.paddingLeft = `${depth * 16 + 8}px`;
880
-
881
- if (item.type === 'dir') {
882
- const chevron = document.createElement('span');
883
- chevron.className = 'fe-chevron';
884
- chevron.textContent = '\u25B6'; // ▶
885
- row.appendChild(chevron);
886
- } else {
887
- // Spacer for alignment
888
- const spacer = document.createElement('span');
889
- spacer.style.width = '12px';
890
- spacer.style.flexShrink = '0';
891
- row.appendChild(spacer);
892
- }
869
+ let dragging = false;
870
+ let resizeRaf = 0;
871
+ let pendingWidth = _width;
872
+ let explorerWidth = 0;
873
+ const clamp = (width) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 48), width));
893
874
 
894
- const icon = document.createElement('span');
895
- if (item.type === 'dir') {
896
- icon.className = 'fe-icon fe-icon--dir';
897
- icon.innerHTML = FOLDER_SVG;
898
- if (_folderColors[item.fullPath]) {
899
- const svg = icon.querySelector('svg');
900
- if (svg) { svg.style.fill = _folderColors[item.fullPath]; svg.style.stroke = _folderColors[item.fullPath]; }
901
- }
902
- } else {
903
- const fi = getFileIcon(item.name);
904
- icon.className = 'fe-icon fe-icon--file';
905
- if (fi.img) {
906
- icon.innerHTML = `<img src="${fi.img}" class="fe-icon-img">`;
907
- } else {
908
- icon.innerHTML = fi.svg;
909
- if (fi.color) icon.style.color = fi.color;
875
+ const applyResize = () => {
876
+ resizeRaf = 0;
877
+ _width = pendingWidth;
878
+ panel.style.width = `${_width}px`;
879
+ handle.setAttribute('aria-valuenow', String(_width));
880
+ applyFileExplorerWidth();
881
+ };
882
+ const scheduleResize = (width) => {
883
+ pendingWidth = width;
884
+ if (!resizeRaf) resizeRaf = requestAnimationFrame(applyResize);
885
+ };
886
+ const flushResize = () => {
887
+ if (resizeRaf) {
888
+ cancelAnimationFrame(resizeRaf);
889
+ resizeRaf = 0;
910
890
  }
911
- }
912
- row.appendChild(icon);
913
-
914
- const name = document.createElement('span');
915
- name.className = 'fe-name';
916
- name.textContent = item.name;
917
- row.appendChild(name);
918
-
919
- // Git badge
920
- if (item.git && GIT_BADGE[item.git]) {
921
- const [text, cls] = GIT_BADGE[item.git];
922
- const badge = document.createElement('span');
923
- badge.className = `fe-git-badge ${cls}`;
924
- badge.textContent = text;
925
- row.appendChild(badge);
926
- }
891
+ applyResize();
892
+ };
893
+ const settle = () => {
894
+ saveWidth();
895
+ window.dispatchEvent(new Event('resize'));
896
+ };
927
897
 
928
- node.appendChild(row);
898
+ handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
899
+ handle.setAttribute('aria-valuemax', String(MAX_WIDTH));
900
+ handle.setAttribute('aria-valuenow', String(_width));
929
901
 
930
- // Children container for dirs
931
- if (item.type === 'dir') {
932
- const children = document.createElement('div');
933
- children.className = 'fe-children';
934
- node.appendChild(children);
935
- }
902
+ // A mouse, a finger or a pen: the pointer is held by the handle until it is let go.
903
+ handle.addEventListener('pointerdown', (e) => {
904
+ if (e.button !== 0 && e.pointerType === 'mouse') return;
905
+ e.preventDefault();
906
+ dragging = true;
907
+ explorerWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--explorer-width')) || 0;
908
+ try { handle.setPointerCapture(e.pointerId); } catch {}
909
+ handle.classList.add('active');
910
+ document.body.style.cursor = 'col-resize';
911
+ document.body.style.userSelect = 'none';
912
+ });
936
913
 
937
- // Row click (dir → expand/collapse; file → context menu) and right-click are
938
- // handled by the delegated listeners on #fe-tree (see onFeTreeClick /
939
- // onFeTreeContextMenu). The node carries data-path/data-type/data-depth and a
940
- // stashed _item so the handler can recover the same context createRow had.
914
+ handle.addEventListener('pointermove', (e) => {
915
+ if (!dragging) return;
916
+ // The panel left edge starts at --explorer-width
917
+ scheduleResize(clamp(e.clientX - explorerWidth));
918
+ });
941
919
 
942
- return node;
943
- }
920
+ const release = () => {
921
+ if (!dragging) return;
922
+ dragging = false;
923
+ flushResize();
924
+ handle.classList.remove('active');
925
+ document.body.style.cursor = '';
926
+ document.body.style.userSelect = '';
927
+ settle();
928
+ };
929
+ handle.addEventListener('pointerup', release);
930
+ handle.addEventListener('pointercancel', release);
944
931
 
945
- // Delegated tree interaction — one click + one contextmenu listener on #fe-tree,
946
- // instead of two listeners per row re-created on every expand. Recovers the
947
- // server item / depth / row / icon from the clicked .fe-node.
948
- function onFeTreeClick(e) {
949
- const nodeEl = e.target.closest('.fe-node');
950
- if (!nodeEl) return;
951
- const item = nodeEl._item;
952
- if (!item) return;
953
- const depth = Number(nodeEl.dataset.depth) || 0;
954
- if (item.type === 'dir') {
955
- if (nodeEl.dataset.expanded === 'true') {
956
- collapseDir(nodeEl, item.fullPath);
957
- } else {
958
- expandDir(nodeEl, item.fullPath, depth + 1);
959
- }
960
- } else {
932
+ // The arrow keys resize it too.
933
+ handle.addEventListener('keydown', (e) => {
934
+ const step = e.key === 'ArrowRight' ? 16 : e.key === 'ArrowLeft' ? -16 : 0;
935
+ if (!step) return;
961
936
  e.preventDefault();
962
937
  e.stopPropagation();
963
- showContextMenu(e, item, nodeEl, nodeEl.querySelector('.fe-row'), nodeEl.querySelector('.fe-icon'), depth);
964
- }
965
- }
966
-
967
- function onFeTreeContextMenu(e) {
968
- const nodeEl = e.target.closest('.fe-node');
969
- if (!nodeEl || !nodeEl._item) return;
970
- e.preventDefault();
971
- e.stopPropagation();
972
- const depth = Number(nodeEl.dataset.depth) || 0;
973
- showContextMenu(e, nodeEl._item, nodeEl, nodeEl.querySelector('.fe-row'), nodeEl.querySelector('.fe-icon'), depth);
938
+ pendingWidth = clamp(_width + step);
939
+ flushResize();
940
+ settle();
941
+ });
974
942
  }
975
943
 
976
- // ─── Delete undo/redo stacks ─────────────
977
- let _deleteUndoStack = []; // [{ path, content, type }] — deleted items
978
- let _deleteRedoStack = []; // [{ path, content, type }] — undone deletes
979
944
 
980
- function updateDeleteButtons() {
981
- const undoBtn = $('fe-undo-btn');
982
- const redoBtn = $('fe-redo-btn');
983
- if (undoBtn) undoBtn.classList.toggle('fe-disabled', _deleteUndoStack.length === 0);
984
- if (redoBtn) redoBtn.classList.toggle('fe-disabled', _deleteRedoStack.length === 0);
985
- }
945
+ // ═══════════════════════════════════════════
946
+ // TOGGLE
947
+ // ═══════════════════════════════════════════
986
948
 
987
- async function deleteFileOrDir(filePath, itemName, type) {
988
- // Read content before deleting (for undo) — only for files
989
- let content = null;
990
- if (type === 'file') {
991
- try {
992
- const r = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
993
- const d = await r.json();
994
- if (d.ok) content = d.content;
995
- } catch {}
996
- }
949
+ export function toggleFileExplorer() {
950
+ const panel = $('file-explorer-panel');
951
+ if (!panel || _guest) return;
997
952
 
998
- const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
999
- const data = await res.json();
1000
- if (!data.ok) {
1001
- showToast(data.error || 'Delete failed');
1002
- return false;
953
+ // If closing while editor is open + dirty, prompt first
954
+ if (_visible && isEditorPanelOpen()) {
955
+ if (!closeEditorPanel()) return; // User cancelled discard
1003
956
  }
1004
957
 
1005
- _deleteUndoStack.push({ path: filePath, content, type });
1006
- _deleteRedoStack = [];
1007
- updateDeleteButtons();
1008
- showToast(`Deleted ${itemName}`);
958
+ _visible = !_visible;
959
+ panel.classList.toggle('open', _visible);
960
+ panel.inert = !_visible;
961
+ saveVisible();
1009
962
 
1010
- // Refresh tree
1011
- _cache.clear();
1012
- await loadDirectory();
1013
- buildFileTree();
1014
- return true;
1015
- }
963
+ // Toggle unified title bar style (seamless with sidebar, slides under title bar)
964
+ const titleBar = document.getElementById('title-bar');
965
+ if (titleBar) titleBar.classList.toggle('file-explorer-active', _visible);
966
+ document.body.classList.toggle('file-explorer-open', _visible);
1016
967
 
1017
- async function undoDelete() {
1018
- if (_deleteUndoStack.length === 0) return;
1019
- const entry = _deleteUndoStack.pop();
1020
-
1021
- if (entry.type === 'file' && entry.content !== null) {
1022
- // Recreate file via POST — need to ensure parent dir exists
1023
- try {
1024
- const res = await fetch('/api/file-content', {
1025
- method: 'POST',
1026
- headers: { 'Content-Type': 'application/json' },
1027
- body: JSON.stringify({ path: entry.path, content: entry.content, create: true }),
1028
- });
1029
- const data = await res.json();
1030
- if (!data.ok) { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
1031
- } catch { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
1032
- } else if (entry.type === 'dir') {
1033
- // Recreate empty dir
1034
- try {
1035
- const res = await fetch('/api/file-mkdir', {
1036
- method: 'POST',
1037
- headers: { 'Content-Type': 'application/json' },
1038
- body: JSON.stringify({ path: entry.path }),
1039
- });
1040
- const data = await res.json();
1041
- if (!data.ok) { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
1042
- } catch { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
1043
- } else {
1044
- showToast('Cannot undo — file content was not saved');
1045
- _deleteUndoStack.push(entry);
1046
- updateDeleteButtons();
1047
- return;
1048
- }
968
+ applyFileExplorerWidth();
1049
969
 
1050
- _deleteRedoStack.push(entry);
1051
- updateDeleteButtons();
1052
- showToast(`Restored ${entry.path.split('/').pop()}`);
1053
- _cache.clear();
1054
- await loadDirectory();
1055
- buildFileTree();
1056
- }
970
+ // Resize after transition
971
+ setTimeout(() => {
972
+ window.dispatchEvent(new Event('resize'));
973
+ }, 320);
1057
974
 
1058
- async function redoDelete() {
1059
- if (_deleteRedoStack.length === 0) return;
1060
- const entry = _deleteRedoStack.pop();
975
+ // Sync menu checkmark
976
+ const menuEl = $('menu-toggle-file-explorer');
977
+ if (menuEl) menuEl.classList.toggle('active', _visible);
978
+ const tbBtn = $('topright-file-explorer-btn');
979
+ if (tbBtn) tbBtn.classList.toggle('active', _visible);
1061
980
 
1062
- const res = await fetch(`/api/file-content?path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
1063
- const data = await res.json();
1064
- if (!data.ok) { showToast('Redo failed'); _deleteRedoStack.push(entry); updateDeleteButtons(); return; }
981
+ if (!_visible) closeGitPopover();
982
+ syncTree();
983
+ }
1065
984
 
1066
- _deleteUndoStack.push(entry);
1067
- updateDeleteButtons();
1068
- showToast(`Deleted ${entry.path.split('/').pop()}`);
1069
- _cache.clear();
1070
- await loadDirectory();
1071
- buildFileTree();
985
+ // The tree follows the panel: built when it is first shown, and following the disk only while it is shown.
986
+ function syncTree() {
987
+ if (!_fx) return;
988
+ _fx.setVisible(_visible && !_guest);
989
+ if (_visible && !_guest && !_rootLoaded) {
990
+ _rootLoaded = true;
991
+ _showing = showTree();
992
+ }
1072
993
  }
1073
994
 
1074
- // ─── Confirmation modal ──────────────────
1075
- function showDeleteConfirm(itemName, itemType, onConfirm) {
1076
- // Remove any existing modal
1077
- const existing = document.getElementById('fe-delete-modal');
1078
- if (existing) existing.remove();
1079
-
1080
- const overlay = document.createElement('div');
1081
- overlay.id = 'fe-delete-modal';
1082
- overlay.className = 'fe-modal-overlay';
1083
-
1084
- const modal = document.createElement('div');
1085
- modal.className = 'fe-modal';
1086
-
1087
- const typeLabel = itemType === 'dir' ? 'folder' : 'file';
1088
- modal.innerHTML = `
1089
- <div class="fe-modal-title">Delete ${typeLabel}</div>
1090
- <div class="fe-modal-body">Are you sure you want to delete <strong>${itemName}</strong>?${itemType === 'dir' ? '<br><span style="color:rgba(255,255,255,0.35);font-size:11px">Only empty folders can be deleted.</span>' : ''}</div>
1091
- <div class="fe-modal-actions">
1092
- <button class="fe-modal-btn fe-modal-cancel">Cancel</button>
1093
- <button class="fe-modal-btn fe-modal-confirm">Delete</button>
1094
- </div>
1095
- `;
1096
995
 
1097
- overlay.appendChild(modal);
1098
- document.body.appendChild(overlay);
996
+ // ═══════════════════════════════════════════
997
+ // INIT
998
+ // ═══════════════════════════════════════════
1099
999
 
1100
- // Animate in
1101
- requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
1000
+ async function loadCustomIcons() {
1001
+ try {
1002
+ const r = await fetch('/api/file-icons').then(r => r.json());
1003
+ if (r.ok) _customIcons = r.custom;
1004
+ } catch {}
1005
+ }
1102
1006
 
1103
- const dismiss = () => {
1104
- overlay.classList.remove('fe-modal-overlay--in');
1105
- setTimeout(() => overlay.remove(), 150);
1106
- };
1007
+ export function initFileExplorer() {
1008
+ loadPersistedState();
1009
+ loadCustomIcons(); // non-blocking — icons load in background
1107
1010
 
1108
- modal.querySelector('.fe-modal-cancel').addEventListener('click', dismiss);
1109
- modal.querySelector('.fe-modal-confirm').addEventListener('click', () => {
1110
- dismiss();
1111
- onConfirm();
1011
+ // Re-render tree when custom icons change
1012
+ on('sync:icons:changed', () => {
1013
+ loadCustomIcons().then(() => { _fx?.redraw(); });
1112
1014
  });
1113
- overlay.addEventListener('click', (ev) => { if (ev.target === overlay) dismiss(); });
1114
- // Esc to cancel
1115
- const onKey = (ev) => { if (ev.key === 'Escape') { dismiss(); document.removeEventListener('keydown', onKey); } };
1116
- document.addEventListener('keydown', onKey);
1117
- }
1118
1015
 
1119
- // ─── Context menu ────────────────────────
1120
- let _activeCtxMenu = null;
1121
- let _activeColorPalette = null;
1122
-
1123
- const FOLDER_COLORS = [
1124
- { hex: '#ffb74d', label: 'Amber' },
1125
- { hex: '#64b5f6', label: 'Blue' },
1126
- { hex: '#81c784', label: 'Green' },
1127
- { hex: '#e57373', label: 'Red' },
1128
- { hex: '#ba68c8', label: 'Purple' },
1129
- { hex: '#4dd0e1', label: 'Cyan' },
1130
- { hex: '#fff176', label: 'Yellow' },
1131
- { hex: '#a1887f', label: 'Brown' },
1132
- { hex: '#90a4ae', label: 'Grey' },
1133
- ];
1016
+ // ── Code editor (shared/editor/) — opened from the tree, and by other panels through the bus ──
1017
+ initEditorPanel({
1018
+ toast: showToast,
1019
+ projectRoot: () => _selectedProject?.path || null,
1020
+ beforeOpen: () => _fx?.closeMenus(),
1021
+ revealInTree: revealFileInTree,
1022
+ fileIcon: getFileIcon,
1023
+ // ui-settings.js imports this module: it is loaded when it is asked for, not at the top.
1024
+ openSettings: (target) => import('./ui-settings.js').then((settings) => settings.openSettingsModal(target)).catch((error) => console.warn('[editor] could not open Settings', error)),
1025
+ });
1026
+ // Plan editor — a panel opens its plan file for direct editing
1027
+ on('open-plan-editor', (contract = {}) => openPlanInEditor(contract));
1028
+ // Changelog editor — the Claude panel opens a changelog draft for direct editing
1029
+ on('open-changelog-editor', ({ filePath } = {}) => openChangelogInEditor({ filePath }));
1030
+ on('open-file-editor', ({ filePath, line, column } = {}) => openFileInEditor(filePath, { line, column }));
1134
1031
 
1135
- function dismissCtxMenu() {
1136
- if (_activeCtxMenu) {
1137
- if (_activeCtxMenu._row) _activeCtxMenu._row.classList.remove('fe-row--ctx-active');
1138
- _activeCtxMenu.classList.remove('fe-ctx-flyout--in');
1139
- _activeCtxMenu.classList.add('fe-ctx-flyout--out');
1140
- const el = _activeCtxMenu;
1141
- setTimeout(() => el.remove(), 150);
1142
- _activeCtxMenu = null;
1143
- }
1144
- }
1032
+ const panel = $('file-explorer-panel');
1033
+ if (!panel) return;
1145
1034
 
1146
- function dismissColorPalette() {
1147
- if (_activeColorPalette) { _activeColorPalette.remove(); _activeColorPalette = null; }
1148
- }
1035
+ // The File Explorer is the owner's: its routes refuse an invited guest. A guest's page does not offer it (as it
1036
+ // does not offer Share): its button and menu entry are hidden, and so is the panel if the owner left it open.
1037
+ // Nothing is stored for that: the state on the server is the owner's.
1038
+ on('session:info', ({ isGuest: guest } = {}) => {
1039
+ _guest = !!guest;
1040
+ for (const id of ['menu-toggle-file-explorer', 'topright-file-explorer-btn']) { const el = $(id); if (el) el.style.display = _guest ? 'none' : ''; }
1041
+ panel.hidden = _guest;
1042
+ const shown = _visible && !_guest;
1043
+ document.getElementById('title-bar')?.classList.toggle('file-explorer-active', shown);
1044
+ document.body.classList.toggle('file-explorer-open', shown);
1045
+ applyFileExplorerWidth();
1046
+ if (_guest) closeGitPopover();
1047
+ syncTree();
1048
+ });
1149
1049
 
1150
- function setFolderColor(folderPath, color, iconEl, dotEl) {
1151
- if (color) {
1152
- _folderColors[folderPath] = color;
1153
- } else {
1154
- delete _folderColors[folderPath];
1155
- }
1156
- saveFolderColors();
1157
-
1158
- // Update icon in DOM
1159
- if (iconEl) {
1160
- const svg = iconEl.querySelector('svg');
1161
- if (svg) {
1162
- svg.style.fill = color || '';
1163
- svg.style.stroke = color || '';
1164
- }
1165
- }
1166
- // Update color dot
1167
- if (dotEl) {
1168
- dotEl.style.color = color || '#ffb74d';
1050
+ // Set width — use navbar-aligned default if no persisted value
1051
+ if (!storage.getItem(KEYS.FILE_EXPLORER_WIDTH)) {
1052
+ _width = calcDefaultWidth();
1169
1053
  }
1170
- }
1054
+ panel.style.width = `${_width}px`;
1055
+ panel.inert = !_visible;
1171
1056
 
1172
- function showColorPalette(e, folderPath, iconEl, dotEl) {
1173
- dismissColorPalette();
1174
- dismissCtxMenu();
1057
+ // Resize handle
1058
+ initResizeHandle();
1175
1059
 
1176
- const palette = document.createElement('div');
1177
- palette.className = 'fe-color-palette';
1060
+ // Keybind
1061
+ registerAction('toggle-file-explorer', toggleFileExplorer);
1178
1062
 
1179
- const currentColor = _folderColors[folderPath] || '#ffb74d';
1063
+ // Menu item
1064
+ const menuEl = $('menu-toggle-file-explorer');
1065
+ if (menuEl) {
1066
+ menuEl.addEventListener('click', toggleFileExplorer);
1067
+ }
1180
1068
 
1181
- for (const c of FOLDER_COLORS) {
1182
- const swatch = document.createElement('button');
1183
- swatch.className = 'fe-color-swatch' + (c.hex === currentColor ? ' fe-color-swatch--active' : '');
1184
- swatch.style.background = c.hex;
1185
- swatch.title = c.label;
1186
- swatch.addEventListener('click', (ev) => {
1187
- ev.stopPropagation();
1188
- setFolderColor(folderPath, c.hex === '#ffb74d' ? null : c.hex, iconEl, dotEl);
1189
- dismissColorPalette();
1190
- });
1191
- palette.appendChild(swatch);
1069
+ // Toolbar button
1070
+ const toolbarBtn = $('topright-file-explorer-btn');
1071
+ if (toolbarBtn) {
1072
+ toolbarBtn.addEventListener('click', toggleFileExplorer);
1192
1073
  }
1193
1074
 
1194
- // Reset button
1195
- if (_folderColors[folderPath]) {
1196
- const resetBtn = document.createElement('button');
1197
- resetBtn.className = 'fe-ctx-item fe-color-reset';
1198
- resetBtn.textContent = 'Reset';
1199
- resetBtn.addEventListener('click', (ev) => {
1200
- ev.stopPropagation();
1201
- setFolderColor(folderPath, null, iconEl, dotEl);
1202
- dismissColorPalette();
1203
- });
1204
- palette.appendChild(resetBtn);
1075
+ // Restore visibility
1076
+ if (_visible) {
1077
+ panel.classList.add('open');
1078
+ const titleBar = document.getElementById('title-bar');
1079
+ if (titleBar) titleBar.classList.add('file-explorer-active');
1080
+ document.body.classList.add('file-explorer-open');
1081
+ applyFileExplorerWidth();
1082
+ if (menuEl) menuEl.classList.add('active');
1083
+ if (toolbarBtn) toolbarBtn.classList.add('active');
1205
1084
  }
1206
1085
 
1207
- const panel = $('file-explorer-panel');
1208
- if (panel) panel.appendChild(palette);
1209
-
1210
- // Position near the click
1211
- const panelRect = panel.getBoundingClientRect();
1212
- palette.style.top = `${e.clientY - panelRect.top + 4}px`;
1213
- palette.style.left = `${e.clientX - panelRect.left}px`;
1214
-
1215
- // Clamp
1216
- requestAnimationFrame(() => {
1217
- const palRect = palette.getBoundingClientRect();
1218
- if (palRect.right > panelRect.right - 8) {
1219
- palette.style.left = `${panelRect.width - palRect.width - 8}px`;
1220
- }
1221
- if (palRect.bottom > panelRect.bottom - 8) {
1222
- palette.style.top = `${e.clientY - panelRect.top - palRect.height - 4}px`;
1086
+ // The tree, and what the panel says in the user's language.
1087
+ loadTree().then((mod) => {
1088
+ if (!mod) {
1089
+ // Its own words could not be loaded with it: they are asked for by themselves.
1090
+ import('/shared/i18n.js').then((i18n) => { $('fe-tree')?.append(Object.assign(document.createElement('div'), { className: 'fe-note fe-note--error', textContent: i18n.t('fileExplorer.loadFailed') })); }).catch(() => {});
1091
+ return;
1223
1092
  }
1093
+ _tr = mod.t;
1094
+ initGitPanel();
1095
+ _fx = mod.createExplorer({
1096
+ panel,
1097
+ storage,
1098
+ KEYS,
1099
+ editor: { openFile: (path, at) => openFileInEditor(path, at), save: () => saveEditorPanel() },
1100
+ send: SEND,
1101
+ fileIcon: getFileIcon,
1102
+ folderSvg: FOLDER_SVG,
1103
+ colors: folderColors,
1104
+ onGit: updateBranchLabel,
1105
+ addProject: addProjectFlow,
1106
+ readServer: fetchProjects,
1107
+ });
1108
+ syncTree();
1224
1109
  });
1225
-
1226
- _activeColorPalette = palette;
1227
-
1228
- const onOutside = (ev) => {
1229
- if (!palette.contains(ev.target)) {
1230
- dismissColorPalette();
1231
- document.removeEventListener('mousedown', onOutside, true);
1232
- }
1233
- };
1234
- setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
1235
- }
1236
-
1237
- function _ctxItem(iconSvg, label, onClick) {
1238
- const btn = document.createElement('button');
1239
- btn.className = 'fe-ctx-item';
1240
- btn.innerHTML = iconSvg + `<span>${label}</span>`;
1241
- btn.addEventListener('click', onClick);
1242
- return btn;
1243
- }
1244
-
1245
- function _ctxSeparator() {
1246
- const sep = document.createElement('div');
1247
- sep.className = 'fe-ctx-separator';
1248
- return sep;
1249
- }
1250
-
1251
- function showContextMenu(e, item, nodeEl, rowEl, iconEl, depth) {
1252
- dismissCtxMenu();
1253
- dismissColorPalette();
1254
-
1255
- const panel = $('file-explorer-panel');
1256
- if (!panel) return;
1257
-
1258
- // ── Flyout container (positioned outside panel, to its right)
1259
- const flyout = document.createElement('div');
1260
- flyout.className = 'fe-ctx-flyout';
1261
-
1262
- // ── Arrow element
1263
- const arrow = document.createElement('div');
1264
- arrow.className = 'fe-ctx-arrow';
1265
- flyout.appendChild(arrow);
1266
-
1267
- // ── Menu body
1268
- const menu = document.createElement('div');
1269
- menu.className = 'fe-ctx-menu';
1270
-
1271
- const copyIcon = '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1272
- const nameIcon = '<svg viewBox="0 0 24 24"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
1273
- const aiIcon = '<svg viewBox="0 0 24 24"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"/><path d="M18 14l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9L18 14z"/></svg>';
1274
- const editIcon = '<svg viewBox="0 0 24 24"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
1275
- const paletteIcon = '<svg viewBox="0 0 24 24"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="10.5" r="2.5"/><circle cx="8.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="12.5" r="2.5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.14-.74-.39-1.04-.24-.28-.37-.59-.37-.96 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.5-4.5-10-10-10z"/></svg>';
1276
- const collapseIcon = '<svg viewBox="0 0 24 24"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>';
1277
- const expandIcon = '<svg viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>';
1278
-
1279
- // ── Copy Path (both)
1280
- menu.appendChild(_ctxItem(copyIcon, 'Copy Path', () => {
1281
- navigator.clipboard.writeText(item.fullPath).then(() => showToast('Path copied'));
1282
- dismissCtxMenu();
1283
- }));
1284
-
1285
- // ── Copy Name (both)
1286
- menu.appendChild(_ctxItem(nameIcon, 'Copy Name', () => {
1287
- navigator.clipboard.writeText(item.name).then(() => showToast('Name copied'));
1288
- dismissCtxMenu();
1289
- }));
1290
-
1291
- // ── Send to AI (both)
1292
- menu.appendChild(_ctxItem(aiIcon, 'Send to AI', () => {
1293
- sendPathToActivePanel(item.fullPath);
1294
- dismissCtxMenu();
1295
- }));
1296
-
1297
- if (item.type === 'file') {
1298
- menu.appendChild(_ctxSeparator());
1299
-
1300
- // ── Edit File
1301
- menu.appendChild(_ctxItem(editIcon, 'Edit File', () => {
1302
- dismissCtxMenu();
1303
- if ((_editorOpen || _planEditMode) && !closeFileEditor()) return;
1304
- openFileEditor(item.fullPath);
1305
- }));
1306
- }
1307
-
1308
- if (item.type === 'dir') {
1309
- menu.appendChild(_ctxSeparator());
1310
-
1311
- // ── Change Color
1312
- menu.appendChild(_ctxItem(paletteIcon, 'Change Color', (ev) => {
1313
- dismissCtxMenu();
1314
- showColorPalette(ev, item.fullPath, iconEl, null);
1315
- }));
1316
-
1317
- menu.appendChild(_ctxSeparator());
1318
-
1319
- // ── Expand / Collapse
1320
- const isExpanded = nodeEl.dataset.expanded === 'true';
1321
- if (isExpanded) {
1322
- menu.appendChild(_ctxItem(collapseIcon, 'Collapse', () => {
1323
- collapseDir(nodeEl, item.fullPath);
1324
- dismissCtxMenu();
1325
- }));
1326
-
1327
- // ── Collapse All Children
1328
- menu.appendChild(_ctxItem(collapseIcon, 'Collapse Children', () => {
1329
- nodeEl.querySelectorAll('.fe-node[data-type="dir"][data-expanded="true"]').forEach(n => {
1330
- n.dataset.expanded = 'false';
1331
- _collapsed.add(n.dataset.path);
1332
- });
1333
- saveCollapsed();
1334
- dismissCtxMenu();
1335
- }));
1336
- } else {
1337
- menu.appendChild(_ctxItem(expandIcon, 'Expand', () => {
1338
- expandDir(nodeEl, item.fullPath, depth + 1);
1339
- dismissCtxMenu();
1340
- }));
1341
- }
1342
- }
1343
-
1344
- // ── Delete (both files and dirs)
1345
- menu.appendChild(_ctxSeparator());
1346
- const deleteIcon = '<svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
1347
- const deleteBtn = _ctxItem(deleteIcon, 'Delete', () => {
1348
- dismissCtxMenu();
1349
- showDeleteConfirm(item.name, item.type, () => {
1350
- deleteFileOrDir(item.fullPath, item.name, item.type);
1351
- });
1352
- });
1353
- deleteBtn.classList.add('fe-ctx-item--danger');
1354
- menu.appendChild(deleteBtn);
1355
-
1356
- flyout.appendChild(menu);
1357
-
1358
- // ── Position: flyout docks to the right edge of the panel, arrow points at the row
1359
- document.body.appendChild(flyout);
1360
-
1361
- const panelRect = panel.getBoundingClientRect();
1362
- const rowRect = rowEl.getBoundingClientRect();
1363
- const rowCenterY = rowRect.top + rowRect.height / 2;
1364
-
1365
- // Flyout left edge sits at the panel's right edge + small gap for arrow
1366
- flyout.style.left = `${panelRect.right + 6}px`;
1367
- flyout.style.opacity = '0';
1368
-
1369
- requestAnimationFrame(() => {
1370
- const flyRect = flyout.getBoundingClientRect();
1371
- // Vertically center on the row, but clamp to viewport
1372
- let top = rowCenterY - flyRect.height / 2;
1373
- if (top < 8) top = 8;
1374
- if (top + flyRect.height > window.innerHeight - 8) top = window.innerHeight - flyRect.height - 8;
1375
- flyout.style.top = `${top}px`;
1376
-
1377
- // Arrow points at the row center
1378
- const arrowY = rowCenterY - top;
1379
- arrow.style.top = `${Math.max(12, Math.min(flyRect.height - 12, arrowY))}px`;
1380
-
1381
- // Animate in
1382
- flyout.style.opacity = '';
1383
- flyout.classList.add('fe-ctx-flyout--in');
1384
- });
1385
-
1386
- // Highlight the source row
1387
- rowEl.classList.add('fe-row--ctx-active');
1388
-
1389
- _activeCtxMenu = flyout;
1390
- _activeCtxMenu._row = rowEl;
1391
-
1392
- // Close on outside click
1393
- const onOutside = (ev) => {
1394
- if (!flyout.contains(ev.target)) {
1395
- dismissCtxMenu();
1396
- document.removeEventListener('mousedown', onOutside, true);
1397
- }
1398
- };
1399
- setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
1400
- }
1401
-
1402
- function showToast(msg) {
1403
- let toast = document.getElementById('fe-toast');
1404
- if (!toast) {
1405
- toast = document.createElement('div');
1406
- toast.id = 'fe-toast';
1407
- toast.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:rgba(30,30,34,0.95);color:#e0e0e0;padding:6px 16px;border-radius:6px;font-size:12px;font-family:var(--ff-mono);z-index:2147483647;pointer-events:none;opacity:0;transition:opacity 0.2s;border:1px solid rgba(255,255,255,0.1);';
1408
- document.body.appendChild(toast);
1409
- }
1410
- toast.textContent = msg;
1411
- toast.style.opacity = '1';
1412
- clearTimeout(toast._timer);
1413
- toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 3500);
1414
- }
1415
-
1416
- // ═══════════════════════════════════════════
1417
- // INLINE FILE EDITOR
1418
- // ═══════════════════════════════════════════
1419
-
1420
- // File extension → language label
1421
- const EXT_LANG = {
1422
- js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
1423
- ts: 'TypeScript', tsx: 'TypeScript',
1424
- json: 'JSON', html: 'HTML', css: 'CSS',
1425
- md: 'Markdown', py: 'Python', sh: 'Shell', bash: 'Shell',
1426
- yml: 'YAML', yaml: 'YAML', toml: 'TOML',
1427
- sql: 'SQL', rs: 'Rust', go: 'Go', rb: 'Ruby',
1428
- env: 'ENV', gitignore: 'Git', dockerignore: 'Docker',
1429
- };
1430
-
1431
- const COMMENT_PREFIX = {
1432
- JavaScript: '//', TypeScript: '//', JSON: '//', CSS: '//',
1433
- HTML: '<!--', Python: '#', Shell: '#', YAML: '#', TOML: '#',
1434
- SQL: '--', Rust: '//', Go: '//', Ruby: '#', ENV: '#', Git: '#', Docker: '#',
1435
- };
1436
-
1437
- // ─── Syntax Highlighting ────────────────────────
1438
-
1439
- const SH_KEYWORDS = {
1440
- JavaScript: 'async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield|true|false|null|undefined',
1441
- TypeScript: 'abstract|as|async|await|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|keyof|let|new|of|return|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield|true|false|null|undefined',
1442
- Python: 'and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield|True|False|None',
1443
- JSON: 'true|false|null',
1444
- Shell: 'if|then|else|elif|fi|for|while|do|done|case|esac|in|function|return|exit|local|export|source|set|unset|shift|read|echo|printf|test|eval',
1445
- SQL: 'SELECT|FROM|WHERE|AND|OR|NOT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|DROP|ALTER|ADD|INDEX|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|COUNT|SUM|AVG|MIN|MAX|IS|NULL|LIKE|BETWEEN|IN|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|DEFAULT|CASCADE|VIEW|BEGIN|END|IF|ELSE|THEN|WHILE|DECLARE|COMMIT|ROLLBACK',
1446
- Rust: 'as|async|await|break|const|continue|crate|dyn|else|enum|extern|false|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|true|type|unsafe|use|where|while|yield',
1447
- Go: 'break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var|true|false|nil',
1448
- Ruby: 'alias|and|begin|break|case|class|def|do|else|elsif|end|ensure|false|for|if|in|module|next|nil|not|or|redo|rescue|retry|return|self|super|then|true|undef|unless|until|when|while|yield',
1449
- TOML: 'true|false',
1450
- YAML: 'true|false|null|yes|no|on|off',
1451
- };
1452
-
1453
- const SH_MAX_SIZE = 80000;
1454
-
1455
- function _shEsc(s) {
1456
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1457
- }
1458
-
1459
- function _shBuildRules(lang) {
1460
- const rules = [];
1461
- const cStyle = ['JavaScript', 'TypeScript', 'CSS', 'Go', 'Rust'];
1462
- const hashStyle = ['Python', 'Shell', 'YAML', 'TOML', 'Ruby', 'ENV', 'Git', 'Docker'];
1463
-
1464
- // Comments (highest priority)
1465
- if (cStyle.includes(lang)) rules.push({ p: '\\/\\*[\\s\\S]*?\\*\\/', c: 'sh-cm' });
1466
- if (lang === 'HTML') rules.push({ p: '<!--[\\s\\S]*?-->', c: 'sh-cm' });
1467
- if (cStyle.includes(lang) && lang !== 'CSS') rules.push({ p: '\\/\\/[^\\n]*', c: 'sh-cm' });
1468
- if (hashStyle.includes(lang)) rules.push({ p: '#[^\\n]*', c: 'sh-cm' });
1469
- if (lang === 'SQL') rules.push({ p: '--[^\\n]*', c: 'sh-cm' });
1470
-
1471
- // JSON property keys (before generic strings so they match first)
1472
- if (lang === 'JSON') {
1473
- rules.push({ p: '"(?:[^"\\\\\\n]|\\\\.)*"(?=\\s*:)', c: 'sh-prop' });
1474
- }
1475
-
1476
- // Strings
1477
- rules.push({ p: '"(?:[^"\\\\\\n]|\\\\.)*"', c: 'sh-str' });
1478
- rules.push({ p: "'(?:[^'\\\\\\n]|\\\\.)*'", c: 'sh-str' });
1479
- if (['JavaScript', 'TypeScript'].includes(lang)) {
1480
- rules.push({ p: '`(?:[^`\\\\]|\\\\.)*`', c: 'sh-str' });
1481
- }
1482
- if (lang === 'Python') {
1483
- // Triple-quoted strings
1484
- rules.push({ p: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'', c: 'sh-str' });
1485
- }
1486
-
1487
- // Numbers
1488
- rules.push({ p: '\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?)\\b', c: 'sh-num' });
1489
-
1490
- // CSS-specific: at-rules, selectors, properties
1491
- if (lang === 'CSS') {
1492
- rules.push({ p: '@[a-zA-Z][a-zA-Z-]*', c: 'sh-kw' });
1493
- rules.push({ p: '!important', c: 'sh-kw' });
1494
- rules.push({ p: '[.#][a-zA-Z_-][a-zA-Z0-9_-]*', c: 'sh-fn' });
1495
- rules.push({ p: ':[a-zA-Z][a-zA-Z-]*', c: 'sh-attr' });
1496
- }
1497
-
1498
- // HTML-specific: tags and attributes
1499
- if (lang === 'HTML') {
1500
- rules.push({ p: '<\\/?[a-zA-Z][a-zA-Z0-9-]*', c: 'sh-tag' });
1501
- rules.push({ p: '\\/?>', c: 'sh-op' });
1502
- rules.push({ p: '\\b[a-zA-Z][a-zA-Z-]*(?=\\s*=)', c: 'sh-attr' });
1503
- }
1504
-
1505
- // Keywords
1506
- const kw = SH_KEYWORDS[lang];
1507
- if (kw) {
1508
- const flags = lang === 'SQL' ? 'i' : '';
1509
- rules.push({ p: '\\b(?:' + kw + ')\\b', c: 'sh-kw', flags });
1510
- }
1511
-
1512
- // Function calls (identifier followed by paren)
1513
- if (!['JSON', 'CSS', 'HTML', 'YAML', 'TOML', 'ENV', 'Markdown'].includes(lang)) {
1514
- rules.push({ p: '\\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\\s*\\()', c: 'sh-fn' });
1515
- }
1516
-
1517
- // YAML keys (word at line start followed by colon)
1518
- if (lang === 'YAML' || lang === 'TOML') {
1519
- rules.push({ p: '^[ \\t]*[a-zA-Z_][a-zA-Z0-9_.\\-]*(?=\\s*[:=])', c: 'sh-prop' });
1520
- }
1521
-
1522
- // Shell variables
1523
- if (lang === 'Shell' || lang === 'ENV') {
1524
- rules.push({ p: '\\$[a-zA-Z_][a-zA-Z0-9_]*|\\$\\{[^}]+\\}', c: 'sh-prop' });
1525
- }
1526
-
1527
- // Brackets
1528
- rules.push({ p: '[{}\\[\\]()]', c: 'sh-br' });
1529
-
1530
- return rules;
1531
- }
1532
-
1533
- // Cache compiled regexes per language
1534
- const _shCache = {};
1535
-
1536
- function _shGetRegex(lang) {
1537
- if (_shCache[lang]) return _shCache[lang];
1538
- const rules = _shBuildRules(lang);
1539
- if (rules.length === 0) return null;
1540
- // Combine all rule patterns into one alternation
1541
- const combined = rules.map(r => '(' + r.p + ')').join('|');
1542
- const flags = 'gm' + (rules.some(r => r.flags?.includes('i')) ? 'i' : '');
1543
- _shCache[lang] = { regex: new RegExp(combined, flags), rules };
1544
- return _shCache[lang];
1545
- }
1546
-
1547
- function highlightCode(text, lang) {
1548
- if (!text) return '';
1549
- if (text.length > SH_MAX_SIZE || !lang || lang === 'Plain Text' || lang === 'Markdown') {
1550
- return _shEsc(text);
1551
- }
1552
-
1553
- const compiled = _shGetRegex(lang);
1554
- if (!compiled) return _shEsc(text);
1555
-
1556
- const { regex, rules } = compiled;
1557
- regex.lastIndex = 0;
1558
- let result = '';
1559
- let lastIndex = 0;
1560
- let match;
1561
-
1562
- while ((match = regex.exec(text)) !== null) {
1563
- // Unmatched text before this token
1564
- if (match.index > lastIndex) {
1565
- result += _shEsc(text.slice(lastIndex, match.index));
1566
- }
1567
- // Find which capturing group matched
1568
- for (let i = 0; i < rules.length; i++) {
1569
- if (match[i + 1] !== undefined) {
1570
- result += '<span class="' + rules[i].c + '">' + _shEsc(match[0]) + '</span>';
1571
- break;
1572
- }
1573
- }
1574
- lastIndex = match.index + match[0].length;
1575
- // Guard against zero-length matches
1576
- if (match[0].length === 0) { regex.lastIndex++; lastIndex++; }
1577
- }
1578
- // Remaining text
1579
- if (lastIndex < text.length) result += _shEsc(text.slice(lastIndex));
1580
-
1581
- return result;
1582
- }
1583
-
1584
- let _shLang = '';
1585
- let _shLines = null; // cached line split for large files
1586
- let _shViewStart = 0; // first buffered line
1587
- let _shViewEnd = 0; // last buffered line
1588
- const SH_LINE_H = 20;
1589
- const SH_BUFFER = 80; // lines of buffer above/below viewport
1590
- const SH_REBUFFER = 30; // re-render when within this many lines of buffer edge
1591
-
1592
- function updateHighlight() {
1593
- const textarea = $('fe-editor-textarea');
1594
- const code = $('fe-editor-highlight-code');
1595
- if (!textarea || !code) return;
1596
-
1597
- const text = textarea.value;
1598
- if (!text || !_shLang || _shLang === 'Plain Text' || _shLang === 'Markdown') {
1599
- code.innerHTML = _shEsc(text || '') + '\n';
1600
- _shLines = null;
1601
- return;
1602
- }
1603
-
1604
- if (text.length <= SH_MAX_SIZE) {
1605
- // Small file: highlight everything
1606
- code.innerHTML = highlightCode(text, _shLang) + '\n';
1607
- _shLines = null;
1608
- return;
1609
- }
1610
-
1611
- // Large file: viewport-based highlighting
1612
- _shLines = text.split('\n');
1613
- _shRenderViewport(textarea, code, text);
1614
- }
1615
-
1616
- function _shRenderViewport(textarea, code, text) {
1617
- if (!_shLines) return;
1618
-
1619
- const scrollTop = textarea.scrollTop;
1620
- const viewH = textarea.clientHeight;
1621
- const firstVisLine = Math.max(0, Math.floor(scrollTop / SH_LINE_H));
1622
- const visLineCount = Math.ceil(viewH / SH_LINE_H);
1623
-
1624
- const startLine = Math.max(0, firstVisLine - SH_BUFFER);
1625
- const endLine = Math.min(_shLines.length, firstVisLine + visLineCount + SH_BUFFER);
1626
-
1627
- _shViewStart = startLine;
1628
- _shViewEnd = endLine;
1629
-
1630
- // Character offsets for the visible chunk
1631
- let charStart = 0;
1632
- for (let i = 0; i < startLine; i++) charStart += _shLines[i].length + 1;
1633
- let charEnd = charStart;
1634
- for (let i = startLine; i < endLine; i++) charEnd += _shLines[i].length + 1;
1635
- if (charEnd > text.length) charEnd = text.length;
1636
-
1637
- const chunk = text.slice(charStart, charEnd);
1638
- const highlighted = highlightCode(chunk, _shLang);
1639
-
1640
- // Use real newlines as spacers — guarantees identical line height to textarea
1641
- code.innerHTML =
1642
- '\n'.repeat(startLine) +
1643
- highlighted +
1644
- '\n'.repeat(Math.max(0, _shLines.length - endLine)) +
1645
- '\n';
1646
- }
1647
-
1648
- function syncHighlightScroll() {
1649
- const textarea = $('fe-editor-textarea');
1650
- const code = $('fe-editor-highlight-code');
1651
- if (!textarea || !code) return;
1652
- code.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
1653
-
1654
- // Large file: re-render viewport if scrolled near buffer edge
1655
- if (_shLines) {
1656
- const firstVisLine = Math.floor(textarea.scrollTop / SH_LINE_H);
1657
- const lastVisLine = firstVisLine + Math.ceil(textarea.clientHeight / SH_LINE_H);
1658
- if (firstVisLine < _shViewStart + SH_REBUFFER || lastVisLine > _shViewEnd - SH_REBUFFER) {
1659
- _shRenderViewport(textarea, code, textarea.value);
1660
- }
1661
- }
1662
- }
1663
-
1664
- function detectLang(filename) {
1665
- const parts = filename.split('.');
1666
- if (parts.length < 2) return 'Plain Text';
1667
- const ext = parts.pop().toLowerCase();
1668
- // Check the full dotfile name first (e.g. .env, .gitignore)
1669
- const dotName = filename.startsWith('.') ? filename.slice(1).toLowerCase() : null;
1670
- return EXT_LANG[dotName] || EXT_LANG[ext] || ext.toUpperCase();
1671
- }
1672
-
1673
- function formatSize(bytes) {
1674
- if (bytes < 1024) return `${bytes} B`;
1675
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1676
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1677
- }
1678
-
1679
- function updateGutter() {
1680
- const gutter = $('fe-editor-gutter');
1681
- const textarea = $('fe-editor-textarea');
1682
- if (!gutter || !textarea) return;
1683
- const lines = textarea.value.split('\n').length;
1684
- // Incremental: line numbers are just 1..N, so we only add/remove the tail delta.
1685
- // childElementCount is the source of truth (desync-proof, no layout read), and the
1686
- // common typing case (line count unchanged) does zero DOM work.
1687
- let have = gutter.childElementCount;
1688
- if (have === lines) return;
1689
- if (have < lines) {
1690
- const frag = document.createDocumentFragment();
1691
- for (let i = have + 1; i <= lines; i++) {
1692
- const d = document.createElement('div');
1693
- d.textContent = i;
1694
- frag.appendChild(d);
1695
- }
1696
- gutter.appendChild(frag);
1697
- } else {
1698
- while (have > lines) { gutter.removeChild(gutter.lastChild); have--; }
1699
- }
1700
- }
1701
-
1702
- // Debounced heavy editor render. Re-highlighting the whole file and reconciling
1703
- // the gutter shouldn't run on every keystroke; the colorized <pre> sits behind a
1704
- // transparent-text textarea, so the user reads live textarea glyphs while
1705
- // colorization catches up a beat later. Caret/line-highlight + dirty state stay
1706
- // immediate (see the input listener). Discrete actions (undo/redo/replace) keep
1707
- // calling updateHighlight()/updateGutter() directly for instant feedback.
1708
- const debouncedEditorRender = debounce(() => {
1709
- updateHighlight();
1710
- updateGutter();
1711
- }, 90);
1712
-
1713
- function updateCursorPos() {
1714
- const textarea = $('fe-editor-textarea');
1715
- const el = $('fe-editor-cursor');
1716
- if (!textarea || !el) return;
1717
- const val = textarea.value;
1718
- const pos = textarea.selectionStart;
1719
- const before = val.substring(0, pos);
1720
- const ln = before.split('\n').length;
1721
- const col = pos - before.lastIndexOf('\n');
1722
- el.textContent = `Ln ${ln}, Col ${col}`;
1723
- }
1724
-
1725
- function syncGutterScroll() {
1726
- const gutter = $('fe-editor-gutter');
1727
- const textarea = $('fe-editor-textarea');
1728
- if (gutter && textarea) gutter.scrollTop = textarea.scrollTop;
1729
- }
1730
-
1731
- let _hlMirror = null;
1732
- let _hlMirrorSynced = false; // false \u2192 re-copy textarea computed styles + width into the mirror
1733
- let _hlResizeObs = null;
1734
- const _hlMirrorProps = [
1735
- 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight',
1736
- 'letterSpacing', 'wordSpacing', 'whiteSpace', 'overflowWrap',
1737
- 'wordBreak', 'tabSize', 'paddingTop', 'paddingRight',
1738
- 'paddingBottom', 'paddingLeft', 'boxSizing'
1739
- ];
1740
-
1741
- // Force the next getLineVisualTop to re-copy the textarea's computed styles into
1742
- // the measuring mirror. Needed on word-wrap toggle, which changes wrapping without
1743
- // changing the textarea's box size and so isn't caught by the ResizeObserver.
1744
- function invalidateLineMirror() { _hlMirrorSynced = false; }
1745
-
1746
- function getLineVisualTop(textarea, pos) {
1747
- if (!_hlMirror) {
1748
- _hlMirror = document.createElement('div');
1749
- _hlMirror.style.position = 'absolute';
1750
- _hlMirror.style.visibility = 'hidden';
1751
- _hlMirror.style.pointerEvents = 'none';
1752
- _hlMirror.style.top = '-9999px';
1753
- _hlMirror.style.left = '0';
1754
- _hlMirror.style.height = 'auto';
1755
- _hlMirror.style.overflow = 'hidden';
1756
- _hlMirror.style.border = 'none';
1757
- document.body.appendChild(_hlMirror);
1758
- _hlMirrorSynced = false;
1759
- }
1760
- // Observe the textarea so any box-size change (panel/window resize, show/hide)
1761
- // invalidates the cached copy automatically. The textarea element is stable
1762
- // across file opens, so this observes exactly once.
1763
- if (typeof ResizeObserver === 'function' && _hlResizeObs?._target !== textarea) {
1764
- _hlResizeObs?.disconnect();
1765
- _hlResizeObs = new ResizeObserver(() => { _hlMirrorSynced = false; });
1766
- _hlResizeObs.observe(textarea);
1767
- _hlResizeObs._target = textarea;
1768
- _hlMirrorSynced = false;
1769
- }
1770
- // The getComputedStyle read + 16 style writes was the per-caret-move hot cost;
1771
- // copy only when stale. The marker measurement below is the one unavoidable read.
1772
- if (!_hlMirrorSynced) {
1773
- const cs = getComputedStyle(textarea);
1774
- for (const p of _hlMirrorProps) _hlMirror.style[p] = cs[p];
1775
- _hlMirror.style.width = textarea.clientWidth + 'px';
1776
- _hlMirrorSynced = true;
1777
- }
1778
-
1779
- const val = textarea.value;
1780
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
1781
- _hlMirror.textContent = '';
1782
- _hlMirror.appendChild(document.createTextNode(val.substring(0, lineStart)));
1783
- const marker = document.createElement('span');
1784
- marker.textContent = '\u200b';
1785
- _hlMirror.appendChild(marker);
1786
- return marker.offsetTop;
1787
- }
1788
-
1789
- function updateLineHighlight() {
1790
- const textarea = $('fe-editor-textarea');
1791
- const hl = $('fe-editor-line-highlight');
1792
- if (!textarea || !hl) return;
1793
- const yPos = getLineVisualTop(textarea, textarea.selectionStart);
1794
- hl.style.top = `${yPos - textarea.scrollTop}px`;
1795
- }
1796
-
1797
- function updateScrollmap() {
1798
- const textarea = $('fe-editor-textarea');
1799
- const thumb = $('fe-editor-scrollmap-thumb');
1800
- const map = $('fe-editor-scrollmap');
1801
- if (!textarea || !thumb || !map) return;
1802
- const sh = textarea.scrollHeight;
1803
- const ch = textarea.clientHeight;
1804
- if (sh <= ch) { thumb.style.display = 'none'; return; }
1805
- thumb.style.display = '';
1806
- const mapH = map.clientHeight;
1807
- const ratio = ch / sh;
1808
- const thumbH = Math.max(12, mapH * ratio);
1809
- const scrollRatio = textarea.scrollTop / (sh - ch);
1810
- thumb.style.height = `${thumbH}px`;
1811
- thumb.style.top = `${scrollRatio * (mapH - thumbH)}px`;
1812
- }
1813
-
1814
- // ─── Undo/Redo stack ───────────────────────
1815
- function editorPushUndo(ta) {
1816
- _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1817
- if (_undoStack.length > 200) _undoStack.shift();
1818
- _redoStack = [];
1819
- }
1820
-
1821
- function editorInsertText(ta, text) {
1822
- if (_editorSaving) return;
1823
- editorPushUndo(ta);
1824
- const start = ta.selectionStart;
1825
- const end = ta.selectionEnd;
1826
- ta.value = ta.value.substring(0, start) + text + ta.value.substring(end);
1827
- const newPos = start + text.length;
1828
- ta.selectionStart = ta.selectionEnd = newPos;
1829
- ta.dispatchEvent(new Event('input'));
1830
- }
1831
-
1832
- function editorReplaceRange(ta, from, to, text) {
1833
- if (_editorSaving) return;
1834
- editorPushUndo(ta);
1835
- ta.value = ta.value.substring(0, from) + text + ta.value.substring(to);
1836
- ta.dispatchEvent(new Event('input'));
1837
- }
1838
-
1839
- function editorUndo(ta) {
1840
- if (_editorSaving) return;
1841
- if (_undoStack.length === 0) return;
1842
- _redoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1843
- const state = _undoStack.pop();
1844
- ta.value = state.value;
1845
- ta.selectionStart = state.selStart;
1846
- ta.selectionEnd = state.selEnd;
1847
- updateEditorDirtyState();
1848
- updateGutter();
1849
- updateHighlight();
1850
- }
1851
-
1852
- function editorRedo(ta) {
1853
- if (_editorSaving) return;
1854
- if (_redoStack.length === 0) return;
1855
- _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1856
- const state = _redoStack.pop();
1857
- ta.value = state.value;
1858
- ta.selectionStart = state.selStart;
1859
- ta.selectionEnd = state.selEnd;
1860
- updateEditorDirtyState();
1861
- updateGutter();
1862
- updateHighlight();
1863
- }
1864
-
1865
- function getEditorSelectionOffset(text, lineNum, columnNum = 1) {
1866
- const content = String(text || '');
1867
- const lines = content.split('\n');
1868
- const targetLine = Math.min(Math.max(1, Number(lineNum) || 1), lines.length || 1);
1869
- const targetColumn = Math.max(1, Number(columnNum) || 1);
1870
- let pos = 0;
1871
- for (let i = 0; i < targetLine - 1; i++) pos += lines[i].length + 1;
1872
- pos += Math.min(targetColumn - 1, (lines[targetLine - 1] || '').length);
1873
- return { pos, line: targetLine };
1874
- }
1875
-
1876
- function focusEditorLocation(lineNum, columnNum = 1) {
1877
- const textarea = $('fe-editor-textarea');
1878
- if (!textarea || !lineNum || lineNum < 1) return;
1879
- const { pos, line } = getEditorSelectionOffset(textarea.value, lineNum, columnNum);
1880
- textarea.focus();
1881
- textarea.setSelectionRange(pos, pos);
1882
- const yPos = getLineVisualTop(textarea, pos);
1883
- const lineHeight = 20;
1884
- textarea.scrollTop = Math.max(0, yPos - 5 * lineHeight);
1885
- updateCursorPos();
1886
- updateLineHighlight();
1887
- syncGutterScroll();
1888
- }
1889
-
1890
- async function openFileEditor(filePath, opts = {}) {
1891
- const openSequence = ++_editorOpenSequence;
1892
- const contract = _planEditContract;
1893
- try {
1894
- const normalizedPath = String(filePath || '').replace(/\\/g, '/');
1895
- const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1896
- const data = await res.json();
1897
- if (openSequence !== _editorOpenSequence) return;
1898
- if (!res.ok) {
1899
- const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1900
- const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1901
- if (isOpencodeConfig && isAllowlistMiss) {
1902
- showToast('Restart Synabun to apply OpenCode config editor support');
1903
- cancelPlanEditMode();
1904
- return;
1905
- }
1906
- showToast(data.error || 'Cannot open file');
1907
- cancelPlanEditMode();
1908
- return;
1909
- }
1910
-
1911
- if (typeof contract?.expectedMarkdown === 'string'
1912
- && String(data.content).replace(/\r\n/g, '\n') !== contract.expectedMarkdown.replace(/\r\n/g, '\n')) {
1913
- showToast('The plan file changed. Reopen the current plan revision.');
1914
- cancelPlanEditMode();
1915
- return;
1916
- }
1917
- _editorOpen = true;
1918
- _editorFilePath = data.path;
1919
- if (_planEditMode) _planEditFilePath = data.path;
1920
- // Normalize line endings — textarea always returns \n, server may send \r\n on Windows
1921
- _editorOriginal = data.content.replace(/\r\n/g, '\n');
1922
- _editorDirty = false;
1923
- _undoStack = [];
1924
- _redoStack = [];
1925
- _undoBurstOpen = false;
1926
-
1927
- // Slide editor panel open
1928
- dismissCtxMenu();
1929
- const editorPanel = $('fe-editor-panel');
1930
- if (editorPanel) editorPanel.classList.add('open');
1931
- document.body.classList.add('fe-editor-open');
1932
- if (document.querySelector('.claude-panel.open')) editorPanel?.classList.add('panel-adjacent');
1933
-
1934
- const textarea = $('fe-editor-textarea');
1935
- if (textarea) {
1936
- textarea.value = _editorOriginal;
1937
- textarea.scrollTop = 0;
1938
- textarea.setSelectionRange(0, 0);
1939
- // Push initial state so first Ctrl+Z reverts to original
1940
- _undoStack.push({ value: _editorOriginal, selStart: 0, selEnd: 0 });
1941
- }
1942
-
1943
- // File path in header (show relative path from project root)
1944
- const fpEl = $('fe-editor-filepath');
1945
- if (fpEl) {
1946
- const rel = _selectedProject ? data.path.replace(_selectedProject.path.replace(/\\/g, '/'), '') : data.path;
1947
- fpEl.textContent = rel.startsWith('/') ? rel.slice(1) : rel;
1948
- }
1949
-
1950
- // Language detection + markdown check + syntax highlighting
1951
- const lang = detectLang(data.name);
1952
- _shLang = lang;
1953
- const langEl = $('fe-editor-lang');
1954
- if (langEl) langEl.textContent = lang;
1955
- _editorIsMarkdown = lang === 'Markdown';
1956
- _editorPreviewMode = false;
1957
- updateViewToggle();
1958
- // Reset toggle buttons
1959
- const toggleBtns = document.querySelectorAll('.fe-editor-toggle-btn');
1960
- toggleBtns.forEach(b => b.classList.toggle('active', b.dataset.mode === 'raw'));
1961
- setPreviewMode(false);
1962
-
1963
- // Close find bar + go-to-line on new file
1964
- closeFind();
1965
- closeGoToLine();
1966
-
1967
- // File size
1968
- const sizeEl = $('fe-editor-size');
1969
- if (sizeEl) sizeEl.textContent = formatSize(data.size || 0);
1970
-
1971
- // Plan/changelog edit banner
1972
- const edPanel = $('fe-editor-panel');
1973
- edPanel?.querySelector('.fe-plan-banner')?.remove();
1974
- if (_planEditMode) {
1975
- const banner = document.createElement('div');
1976
- banner.className = 'fe-plan-banner';
1977
- banner.textContent = _planEditSource === 'codex'
1978
- ? 'Editing plan — save to return to review'
1979
- : `Editing plan — save to send to ${planEditorSourceLabel(_planEditSource)}`;
1980
- edPanel?.prepend(banner);
1981
- } else if (_changelogEditMode) {
1982
- const banner = document.createElement('div');
1983
- banner.className = 'fe-plan-banner';
1984
- banner.textContent = 'Editing changelog — save to send to Claude';
1985
- edPanel?.prepend(banner);
1986
- }
1987
-
1988
- updateGutter();
1989
- updateHighlight();
1990
- updateCursorPos();
1991
- updateEditorDirtyState();
1992
- // Defer so the textarea has rendered its scroll dimensions
1993
- requestAnimationFrame(() => {
1994
- if (opts?.line) focusEditorLocation(opts.line, opts.column || 1);
1995
- updateLineHighlight();
1996
- updateScrollmap();
1997
- syncHighlightScroll();
1998
- });
1999
- } catch (err) {
2000
- cancelPlanEditMode();
2001
- showToast('Failed to open file');
2002
- }
2003
- }
2004
-
2005
- function closeFileEditor() {
2006
- if (_editorSaving) return false;
2007
- if (_editorDirty) {
2008
- if (!confirm('Discard unsaved changes?')) return false;
2009
- }
2010
-
2011
- // Emit cancel events before clearing flags so Claude panel can re-enable buttons
2012
- if (_planEditMode) cancelPlanEditMode();
2013
- if (_changelogEditMode) emit('changelog-edit-cancelled');
2014
-
2015
- ++_editorOpenSequence;
2016
- _editorOpen = false;
2017
- _editorFilePath = null;
2018
- _editorOriginal = '';
2019
- _editorDirty = false;
2020
- _editorIsMarkdown = false;
2021
- _editorPreviewMode = false;
2022
- _planEditMode = false;
2023
- _planEditFilePath = null;
2024
- _planEditSource = null;
2025
- _planEditTabId = null;
2026
- _changelogEditMode = false;
2027
- _changelogEditFilePath = null;
2028
- $('fe-editor-panel')?.querySelector('.fe-plan-banner')?.remove();
2029
- _shLang = '';
2030
- _shLines = null;
2031
- _shViewStart = 0;
2032
- _shViewEnd = 0;
2033
- if (_hlMirror) { _hlMirror.remove(); _hlMirror = null; }
2034
- const hlCode = $('fe-editor-highlight-code');
2035
- if (hlCode) hlCode.innerHTML = '';
2036
-
2037
- // Close find bar and preview
2038
- closeFind();
2039
- setPreviewMode(false);
2040
- updateViewToggle();
2041
-
2042
- // Slide editor panel closed
2043
- const editorPanel = $('fe-editor-panel');
2044
- if (editorPanel) editorPanel.classList.remove('open');
2045
- document.body.classList.remove('fe-editor-open');
2046
-
2047
- // Restore footer
2048
- const data = _cache.values().next().value;
2049
- updateCount(data?.items?.length || 0);
2050
-
2051
- return true;
2052
- }
2053
-
2054
- async function saveFileEditor() {
2055
- if (!_editorFilePath || _editorSaving) return;
2056
- const textarea = $('fe-editor-textarea');
2057
- if (!textarea) return;
2058
- const savedContent = textarea.value;
2059
- const savedPath = _editorFilePath;
2060
- const planEvent = _planEditMode ? planEditorEvent() : null;
2061
- const contract = _planEditContract;
2062
- _editorSaving = true;
2063
- textarea.readOnly = true;
2064
- updateEditorDirtyState();
2065
- let saved = false;
2066
- let result = null;
2067
- try {
2068
- if (planEvent?.source === 'codex' && typeof contract?.save !== 'function') {
2069
- throw new Error('This plan editor is missing its revision. Reopen the plan to save.');
2070
- }
2071
- if (typeof contract?.save === 'function') {
2072
- result = await contract.save({ ...planEvent, content: savedContent });
2073
- if (!result?.ok) throw new Error(result?.reason || result?.error || 'The plan changed. Reopen the latest revision before saving.');
2074
- } else {
2075
- const res = await fetch('/api/file-content', {
2076
- method: 'POST', headers: { 'Content-Type': 'application/json' },
2077
- body: JSON.stringify({ path: savedPath, content: savedContent }),
2078
- });
2079
- result = await res.json();
2080
- if (!res.ok) throw new Error(result.error || 'Save failed');
2081
- }
2082
- _editorOriginal = savedContent;
2083
- saved = true;
2084
- } catch (error) {
2085
- showToast(error?.message || 'Save failed');
2086
- if (_planEditSource === 'codex') {
2087
- const banner = $('fe-editor-panel')?.querySelector('.fe-plan-banner');
2088
- if (banner) banner.textContent = `${error?.message || 'Save failed'} Your edits are still open.`;
2089
- }
2090
- } finally {
2091
- _editorSaving = false;
2092
- textarea.readOnly = false;
2093
- updateEditorDirtyState();
2094
- }
2095
- if (!saved) return;
2096
-
2097
- if (planEvent && _planEditMode && savedPath === _planEditFilePath) {
2098
- _planEditMode = false;
2099
- _planEditFilePath = null;
2100
- _planEditSource = null;
2101
- _planEditTabId = null;
2102
- _planEditContract = null;
2103
- showToast('Plan saved');
2104
- emit('plan-saved', {
2105
- ...planEvent, filePath: result?.path || savedPath, content: savedContent,
2106
- persisted: typeof contract?.save === 'function', savedRevision: result?.document?.revision,
2107
- });
2108
- closeFileEditor();
2109
- return;
2110
- }
2111
- if (_changelogEditMode && savedPath === _changelogEditFilePath) {
2112
- _changelogEditMode = false;
2113
- _changelogEditFilePath = null;
2114
- showToast('Changelog saved — sending to Claude');
2115
- emit('changelog-saved', { content: savedContent });
2116
- closeFileEditor();
2117
- return;
2118
- }
2119
- showToast('Saved');
2120
- }
2121
-
2122
- function discardFileEditor() {
2123
- if (_editorSaving) return;
2124
- if (!confirm('Discard changes?')) return;
2125
- const textarea = $('fe-editor-textarea');
2126
- if (textarea) textarea.value = _editorOriginal;
2127
- updateEditorDirtyState();
2128
- }
2129
-
2130
- function updateEditorDirtyState() {
2131
- const textarea = $('fe-editor-textarea');
2132
- _editorDirty = textarea ? textarea.value !== _editorOriginal : false;
2133
-
2134
- const dot = $('fe-editor-dot');
2135
- if (dot) dot.classList.toggle('dirty', _editorDirty);
2136
-
2137
- const saveBtn = $('fe-editor-save');
2138
- if (saveBtn) {
2139
- saveBtn.disabled = _editorSaving || (!_editorDirty && !_planEditMode);
2140
- saveBtn.classList.toggle('dirty', _editorDirty);
2141
- }
2142
-
2143
- const discardBtn = $('fe-editor-discard');
2144
- if (discardBtn) discardBtn.disabled = _editorSaving || !_editorDirty;
2145
- emitPlanEditorState();
2146
- }
2147
-
2148
- // ─── Markdown rendering (same as Skills Studio) ────────────
2149
- function renderMarkdown(md) {
2150
- let text = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
2151
- text = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2152
- text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
2153
- return `<pre><code class="lang-${lang}">${code.trimEnd()}</code></pre>`;
2154
- });
2155
- text = text.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
2156
- text = text.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
2157
- text = text.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
2158
- text = text.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
2159
- text = text.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
2160
- text = text.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
2161
- text = text.replace(/^---+$/gm, '<hr>');
2162
- text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
2163
- text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2164
- text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
2165
- text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
2166
- text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
2167
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
2168
- text = text.replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>');
2169
- text = text.replace(/<\/blockquote>\n<blockquote>/g, '\n');
2170
- text = text.replace(/^(\|.+\|)\n(\|[-| :]+\|)\n((?:\|.+\|\n?)+)/gm, (_, header, _sep, body) => {
2171
- const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
2172
- const rows = body.trim().split('\n').map(row => {
2173
- const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
2174
- return `<tr>${tds}</tr>`;
2175
- }).join('');
2176
- return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
2177
- });
2178
- text = text.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>');
2179
- text = text.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
2180
- text = text.replace(/^\d+\.\s+(.+)$/gm, '<oli>$1</oli>');
2181
- text = text.replace(/((?:<oli>.*<\/oli>\n?)+)/g, (match) => {
2182
- return '<ol>' + match.replace(/<\/?oli>/g, (t) => t.replace('oli', 'li')) + '</ol>';
2183
- });
2184
- text = text.replace(/^(?!<[a-z/])((?!<).+)$/gm, '<p>$1</p>');
2185
- text = text.replace(/<p>\s*<\/p>/g, '');
2186
- return text;
2187
- }
2188
-
2189
- function setPreviewMode(preview) {
2190
- _editorPreviewMode = preview;
2191
- const textarea = $('fe-editor-textarea');
2192
- const gutter = $('fe-editor-gutter');
2193
- const previewEl = $('fe-editor-preview');
2194
- const highlight = $('fe-editor-highlight');
2195
- const lineHighlight = $('fe-editor-line-highlight');
2196
-
2197
- if (preview) {
2198
- if (textarea) textarea.style.display = 'none';
2199
- if (gutter) gutter.style.display = 'none';
2200
- if (highlight) highlight.style.display = 'none';
2201
- if (lineHighlight) lineHighlight.style.display = 'none';
2202
- if (previewEl) {
2203
- previewEl.innerHTML = renderMarkdown(textarea?.value || '');
2204
- previewEl.classList.add('visible');
2205
- }
2206
- } else {
2207
- if (textarea) textarea.style.display = '';
2208
- if (gutter) gutter.style.display = '';
2209
- if (highlight) highlight.style.display = '';
2210
- if (lineHighlight) lineHighlight.style.display = '';
2211
- if (previewEl) previewEl.classList.remove('visible');
2212
- }
2213
- }
2214
-
2215
- function updateViewToggle() {
2216
- const toggle = $('fe-editor-view-toggle');
2217
- const sep = $('fe-editor-sep-preview');
2218
- if (toggle) toggle.style.display = _editorIsMarkdown ? 'flex' : 'none';
2219
- if (sep) sep.style.display = _editorIsMarkdown ? '' : 'none';
2220
- }
2221
-
2222
- // ─── Find ─────────────────────────────────
2223
- function openFind() {
2224
- _findOpen = true;
2225
- const bar = $('fe-editor-find');
2226
- const input = $('fe-editor-find-input');
2227
- if (bar) bar.classList.add('open');
2228
- if (input) { input.value = ''; input.focus(); }
2229
- _findMatches = [];
2230
- _findIndex = -1;
2231
- updateFindCount();
2232
- }
2233
-
2234
- function closeFind() {
2235
- _findOpen = false;
2236
- const bar = $('fe-editor-find');
2237
- if (bar) bar.classList.remove('open');
2238
- _findMatches = [];
2239
- _findIndex = -1;
2240
- updateFindCount();
2241
- }
2242
-
2243
- function doFind(query) {
2244
- _findMatches = [];
2245
- _findIndex = -1;
2246
- if (!query) { updateFindCount(); return; }
2247
-
2248
- const textarea = $('fe-editor-textarea');
2249
- if (!textarea) return;
2250
- const text = textarea.value;
2251
- const lower = text.toLowerCase();
2252
- const q = query.toLowerCase();
2253
- let pos = 0;
2254
- while (true) {
2255
- const idx = lower.indexOf(q, pos);
2256
- if (idx === -1) break;
2257
- _findMatches.push({ start: idx, end: idx + q.length });
2258
- pos = idx + 1;
2259
- }
2260
- if (_findMatches.length > 0) {
2261
- _findIndex = 0;
2262
- selectFindMatch();
2263
- }
2264
- updateFindCount();
2265
- }
2266
-
2267
- function findNext() {
2268
- if (_findMatches.length === 0) return;
2269
- _findIndex = (_findIndex + 1) % _findMatches.length;
2270
- selectFindMatch(true);
2271
- updateFindCount();
2272
- }
2273
-
2274
- function findPrev() {
2275
- if (_findMatches.length === 0) return;
2276
- _findIndex = (_findIndex - 1 + _findMatches.length) % _findMatches.length;
2277
- selectFindMatch(true);
2278
- updateFindCount();
2279
- }
2280
-
2281
- function selectFindMatch(focusTextarea) {
2282
- const textarea = $('fe-editor-textarea');
2283
- if (!textarea || _findIndex < 0 || _findIndex >= _findMatches.length) return;
2284
- const m = _findMatches[_findIndex];
2285
- if (focusTextarea) {
2286
- textarea.focus();
2287
- textarea.setSelectionRange(m.start, m.end);
2288
- }
2289
- // Scroll match into view
2290
- const yPos = getLineVisualTop(textarea, m.start);
2291
- const lineHeight = 20;
2292
- textarea.scrollTop = Math.max(0, yPos - 5 * lineHeight);
2293
- }
2294
-
2295
- function updateFindCount() {
2296
- const el = $('fe-editor-find-count');
2297
- if (!el) return;
2298
- if (_findMatches.length === 0) {
2299
- const input = $('fe-editor-find-input');
2300
- el.textContent = input?.value ? 'No results' : '';
2301
- } else {
2302
- el.textContent = `${_findIndex + 1} of ${_findMatches.length}`;
2303
- }
2304
- }
2305
-
2306
- function doReplace() {
2307
- if (_editorSaving) return;
2308
- const textarea = $('fe-editor-textarea');
2309
- const replaceInput = $('fe-editor-replace-input');
2310
- if (!textarea || !replaceInput || _findMatches.length === 0 || _findIndex < 0) return;
2311
- const m = _findMatches[_findIndex];
2312
- const replacement = replaceInput.value;
2313
- const savedIndex = _findIndex;
2314
- editorReplaceRange(textarea, m.start, m.end, replacement);
2315
- textarea.selectionStart = textarea.selectionEnd = m.start + replacement.length;
2316
- updateEditorDirtyState();
2317
- updateGutter();
2318
- doFind($('fe-editor-find-input')?.value || '');
2319
- // Stay at same index (next match slid into this position)
2320
- if (_findMatches.length > 0) {
2321
- _findIndex = Math.min(savedIndex, _findMatches.length - 1);
2322
- selectFindMatch(true);
2323
- updateFindCount();
2324
- }
2325
- }
2326
-
2327
- function doReplaceAll() {
2328
- if (_editorSaving) return;
2329
- const textarea = $('fe-editor-textarea');
2330
- const findInput = $('fe-editor-find-input');
2331
- const replaceInput = $('fe-editor-replace-input');
2332
- if (!textarea || !findInput?.value || !replaceInput) return;
2333
- const query = findInput.value;
2334
- const replacement = replaceInput.value;
2335
- const text = textarea.value;
2336
- const lower = text.toLowerCase();
2337
- const q = query.toLowerCase();
2338
- const parts = [];
2339
- let pos = 0;
2340
- while (true) {
2341
- const idx = lower.indexOf(q, pos);
2342
- if (idx === -1) break;
2343
- parts.push(text.substring(pos, idx), replacement);
2344
- pos = idx + q.length;
2345
- }
2346
- parts.push(text.substring(pos));
2347
- editorPushUndo(textarea);
2348
- textarea.value = parts.join('');
2349
- updateEditorDirtyState();
2350
- updateGutter();
2351
- updateHighlight();
2352
- doFind(query);
2353
- }
2354
-
2355
- // ─── Go to Line ───────────────────────────
2356
- function openGoToLine() {
2357
- const bar = $('fe-editor-goto');
2358
- const input = $('fe-editor-goto-input');
2359
- if (bar) bar.classList.add('open');
2360
- if (input) { input.value = ''; input.focus(); }
2361
- }
2362
-
2363
- function closeGoToLine() {
2364
- const bar = $('fe-editor-goto');
2365
- if (bar) bar.classList.remove('open');
2366
- }
2367
-
2368
- function goToLine(lineNum) {
2369
- focusEditorLocation(lineNum, 1);
2370
- closeGoToLine();
2371
- }
2372
-
2373
-
2374
- // Append a muted "… N more (truncated)" row when the server capped a large dir,
2375
- // so files aren't silently dropped. Styled like the inline "Loading..." indicator.
2376
- function appendTruncationNotice(container, total, shown, depth) {
2377
- if (!total || total <= shown) return;
2378
- const note = document.createElement('div');
2379
- note.style.cssText = `padding:4px 0 4px ${depth * 16 + 24}px;font-size:11px;color:rgba(255,255,255,0.3);font-family:var(--ff-mono);`;
2380
- note.textContent = `… ${total - shown} more (truncated — narrow with search)`;
2381
- container.appendChild(note);
2382
- }
2383
-
2384
- async function buildFileTree() {
2385
- const tree = $('fe-tree');
2386
- if (!tree) return;
2387
- // Delegated listeners survive innerHTML clears, so wire them once on the container.
2388
- if (!tree._delegated) {
2389
- tree.addEventListener('click', onFeTreeClick);
2390
- tree.addEventListener('contextmenu', onFeTreeContextMenu);
2391
- tree._delegated = true;
2392
- }
2393
- tree.innerHTML = '';
2394
-
2395
- const data = _cache.values().next().value || await loadDirectory();
2396
- if (!data || !data.items) return;
2397
-
2398
- const items = sortItems(data.items);
2399
- const rootPath = data.path || '';
2400
-
2401
- for (const item of items) {
2402
- // Apply client-side filter
2403
- if (_filterText && !item.name.toLowerCase().includes(_filterText.toLowerCase())) continue;
2404
- item.fullPath = rootPath + '/' + item.name;
2405
- const node = createRow(item, 0);
2406
-
2407
- // Restore expanded state
2408
- if (item.type === 'dir' && !_collapsed.has(item.fullPath)) {
2409
- // Only auto-expand if previously expanded (not collapsed)
2410
- // By default dirs start collapsed (not in _collapsed means not explicitly toggled)
2411
- }
2412
-
2413
- tree.appendChild(node);
2414
- }
2415
-
2416
- if (data.truncated) appendTruncationNotice(tree, data.total, items.length, 0);
2417
- updateCount(items.length);
2418
- }
2419
-
2420
- async function expandDir(nodeEl, dirPath, depth) {
2421
- nodeEl.dataset.expanded = 'true';
2422
- _collapsed.delete(dirPath);
2423
- saveCollapsed();
2424
-
2425
- const childrenEl = nodeEl.querySelector('.fe-children');
2426
- if (!childrenEl) return;
2427
-
2428
- // If already loaded, just show
2429
- if (childrenEl.children.length > 0) return;
2430
-
2431
- // Loading indicator
2432
- childrenEl.innerHTML = '<div style="padding:4px 0 4px ' + (depth * 16 + 24) + 'px;font-size:11px;color:rgba(255,255,255,0.25);font-family:var(--ff-mono);">Loading...</div>';
2433
-
2434
- const data = await loadDirectory(dirPath);
2435
- childrenEl.innerHTML = '';
2436
-
2437
- if (!data || !data.items) return;
2438
-
2439
- const items = sortItems(data.items);
2440
- for (const item of items) {
2441
- if (_filterText && !item.name.toLowerCase().includes(_filterText.toLowerCase())) continue;
2442
- item.fullPath = dirPath + '/' + item.name;
2443
- const child = createRow(item, depth);
2444
- childrenEl.appendChild(child);
2445
- }
2446
-
2447
- if (data.truncated) appendTruncationNotice(childrenEl, data.total, items.length, depth);
2448
- }
2449
-
2450
- function collapseDir(nodeEl, dirPath) {
2451
- nodeEl.dataset.expanded = 'false';
2452
- if (dirPath) {
2453
- _collapsed.add(dirPath);
2454
- saveCollapsed();
2455
- }
2456
- }
2457
-
2458
- function collapseAll() {
2459
- const tree = $('fe-tree');
2460
- if (!tree) return;
2461
- tree.querySelectorAll('.fe-node[data-type="dir"][data-expanded="true"]').forEach(n => {
2462
- n.dataset.expanded = 'false';
2463
- _collapsed.add(n.dataset.path);
2464
- });
2465
- saveCollapsed();
2466
- }
2467
-
2468
- async function expandAll() {
2469
- const tree = $('fe-tree');
2470
- if (!tree) return;
2471
- const dirs = tree.querySelectorAll('.fe-node[data-type="dir"][data-expanded="false"]');
2472
- for (const n of dirs) {
2473
- const path = n.dataset.path;
2474
- // Determine depth by counting ancestor fe-children
2475
- let depth = 0;
2476
- let parent = n.parentElement;
2477
- while (parent && parent !== tree) {
2478
- if (parent.classList.contains('fe-children')) depth++;
2479
- parent = parent.parentElement;
2480
- }
2481
- await expandDir(n, path, depth + 1);
2482
- }
2483
- }
2484
-
2485
- function updateCount(count) {
2486
- const el = $('fe-count');
2487
- if (el) el.textContent = `${count} items`;
2488
- }
2489
-
2490
-
2491
- // ═══════════════════════════════════════════
2492
- // SEARCH (debounced server-side for >2 chars)
2493
- // ═══════════════════════════════════════════
2494
-
2495
- let _searchTimer = null;
2496
- let _searchAbort = null;
2497
-
2498
- function onFilterInput(e) {
2499
- _filterText = e.target.value.trim();
2500
-
2501
- clearTimeout(_searchTimer);
2502
- // Cancel any in-flight search — its response is for a now-stale query.
2503
- _searchAbort?.abort();
2504
-
2505
- if (_filterText.length > 2) {
2506
- _searchTimer = setTimeout(async () => {
2507
- const tree = $('fe-tree');
2508
- if (!tree) return;
2509
- tree.innerHTML = '<div style="padding:12px;font-size:11px;color:rgba(255,255,255,0.3);font-family:var(--ff-mono);text-align:center;">Searching...</div>';
2510
-
2511
- const controller = new AbortController();
2512
- _searchAbort = controller;
2513
- const queryAtRequest = _filterText;
2514
- let data;
2515
- try {
2516
- data = await searchFiles(_filterText, controller.signal);
2517
- } catch (err) {
2518
- if (err && err.name === 'AbortError') return; // superseded by a newer search
2519
- tree.innerHTML = '<div style="padding:12px;font-size:11px;color:rgba(255,120,120,0.6);font-family:var(--ff-mono);text-align:center;">Search failed</div>';
2520
- updateCount(0);
2521
- return;
2522
- }
2523
- // Guard against an out-of-order response landing after the query moved on.
2524
- if (queryAtRequest !== _filterText) return;
2525
- tree.innerHTML = '';
2526
-
2527
- if (!data || !data.items || data.items.length === 0) {
2528
- tree.innerHTML = '<div style="padding:12px;font-size:11px;color:rgba(255,255,255,0.25);font-family:var(--ff-mono);text-align:center;">No results</div>';
2529
- updateCount(0);
2530
- return;
2531
- }
2532
-
2533
- for (const item of data.items) {
2534
- item.fullPath = item.path;
2535
- const node = createRow(item, 0);
2536
- // Show relative path for search results
2537
- const nameEl = node.querySelector('.fe-name');
2538
- if (nameEl) nameEl.textContent = item.path;
2539
- tree.appendChild(node);
2540
- }
2541
- updateCount(data.items.length);
2542
- }, 300);
2543
- } else {
2544
- // Client-side filter on loaded tree
2545
- buildFileTree();
2546
- }
2547
- }
2548
-
2549
-
2550
- // ═══════════════════════════════════════════
2551
- // RESIZE HANDLE
2552
- // ═══════════════════════════════════════════
2553
-
2554
- function initResizeHandle() {
2555
- const handle = $('fe-resize-handle');
2556
- const panel = $('file-explorer-panel');
2557
- if (!handle || !panel) return;
2558
-
2559
- let dragging = false;
2560
- let resizeRaf = 0;
2561
- let pendingWidth = _width;
2562
- let explorerWidth = 0;
2563
-
2564
- const applyResize = () => {
2565
- resizeRaf = 0;
2566
- _width = pendingWidth;
2567
- panel.style.width = `${_width}px`;
2568
- applyFileExplorerWidth();
2569
- };
2570
- const scheduleResize = (width) => {
2571
- pendingWidth = width;
2572
- if (!resizeRaf) resizeRaf = requestAnimationFrame(applyResize);
2573
- };
2574
- const flushResize = () => {
2575
- if (resizeRaf) {
2576
- cancelAnimationFrame(resizeRaf);
2577
- resizeRaf = 0;
2578
- }
2579
- applyResize();
2580
- };
2581
-
2582
- handle.addEventListener('mousedown', (e) => {
2583
- e.preventDefault();
2584
- dragging = true;
2585
- explorerWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--explorer-width')) || 0;
2586
- handle.classList.add('active');
2587
- document.body.style.cursor = 'col-resize';
2588
- document.body.style.userSelect = 'none';
2589
- });
2590
-
2591
- document.addEventListener('mousemove', (e) => {
2592
- if (!dragging) return;
2593
- // The panel left edge starts at --explorer-width
2594
- const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, e.clientX - explorerWidth));
2595
- scheduleResize(newWidth);
2596
- });
2597
-
2598
- document.addEventListener('mouseup', () => {
2599
- if (!dragging) return;
2600
- dragging = false;
2601
- flushResize();
2602
- handle.classList.remove('active');
2603
- document.body.style.cursor = '';
2604
- document.body.style.userSelect = '';
2605
- saveWidth();
2606
- window.dispatchEvent(new Event('resize'));
2607
- });
2608
- }
2609
-
2610
-
2611
- // ═══════════════════════════════════════════
2612
- // TOGGLE
2613
- // ═══════════════════════════════════════════
2614
-
2615
- export function toggleFileExplorer() {
2616
- const panel = $('file-explorer-panel');
2617
- if (!panel) return;
2618
-
2619
- // If closing while editor is open + dirty, prompt first
2620
- if (_visible && _editorOpen) {
2621
- if (!closeFileEditor()) return; // User cancelled discard
2622
- }
2623
-
2624
- _visible = !_visible;
2625
- panel.classList.toggle('open', _visible);
2626
- saveVisible();
2627
-
2628
- // Toggle unified title bar style (seamless with sidebar, slides under title bar)
2629
- const titleBar = document.getElementById('title-bar');
2630
- if (titleBar) titleBar.classList.toggle('file-explorer-active', _visible);
2631
- document.body.classList.toggle('file-explorer-open', _visible);
2632
-
2633
- applyFileExplorerWidth();
2634
-
2635
- // Resize after transition
2636
- setTimeout(() => {
2637
- window.dispatchEvent(new Event('resize'));
2638
- }, 320);
2639
-
2640
- // Sync menu checkmark
2641
- const menuEl = $('menu-toggle-file-explorer');
2642
- if (menuEl) menuEl.classList.toggle('active', _visible);
2643
- const tbBtn = $('topright-file-explorer-btn');
2644
- if (tbBtn) tbBtn.classList.toggle('active', _visible);
2645
-
2646
- // Build tree on first show
2647
- if (_visible && !_rootLoaded) {
2648
- _rootLoaded = true;
2649
- fetchProjects().then(() => {
2650
- initProjectSelector();
2651
- return loadDirectory();
2652
- }).then(() => buildFileTree());
2653
- }
2654
- }
2655
-
2656
-
2657
- // ═══════════════════════════════════════════
2658
- // INIT
2659
- // ═══════════════════════════════════════════
2660
-
2661
- async function loadCustomIcons() {
2662
- try {
2663
- const r = await fetch('/api/file-icons').then(r => r.json());
2664
- if (r.ok) _customIcons = r.custom;
2665
- } catch {}
2666
- }
2667
-
2668
- export function initFileExplorer() {
2669
- loadPersistedState();
2670
- loadCustomIcons(); // non-blocking — icons load in background
2671
-
2672
- // Re-render tree when custom icons change
2673
- on('sync:icons:changed', () => {
2674
- loadCustomIcons().then(() => { if (_rootLoaded) buildFileTree(); });
2675
- });
2676
-
2677
- // ── Plan editor — open plan file from Claude panel for direct editing ──
2678
- on('open-plan-editor', (contract = {}) => {
2679
- const { filePath, source = 'claude', tabId = null } = contract;
2680
- if (!filePath) return;
2681
- if (_planEditMode && _planEditFilePath === filePath
2682
- && _planEditSource === source && _planEditTabId === tabId
2683
- && _planEditContract?.planId === contract.planId && _planEditContract?.revision === contract.revision) {
2684
- $('fe-editor-textarea')?.focus();
2685
- return;
2686
- }
2687
- if ((_editorOpen || _planEditMode) && !closeFileEditor()) {
2688
- emit('plan-edit-cancelled', { source, tabId, planId: contract.planId, revision: contract.revision, accountId: contract.accountId, threadId: contract.threadId, sidepanelWindowId: contract.sidepanelWindowId || null });
2689
- return;
2690
- }
2691
- _planEditMode = true;
2692
- _planEditFilePath = filePath;
2693
- _planEditSource = source;
2694
- _planEditTabId = tabId;
2695
- _planEditContract = contract;
2696
- openFileEditor(filePath);
2697
- });
2698
-
2699
- // ── Changelog editor — open changelog draft from Claude panel for direct editing ──
2700
- on('open-changelog-editor', ({ filePath }) => {
2701
- if ((_editorOpen || _planEditMode) && !closeFileEditor()) return;
2702
- _changelogEditMode = true;
2703
- _changelogEditFilePath = filePath;
2704
- openFileEditor(filePath);
2705
- });
2706
-
2707
- on('open-file-editor', ({ filePath, line, column } = {}) => {
2708
- if (!filePath) return;
2709
- if ((_editorOpen || _planEditMode) && !closeFileEditor()) return;
2710
- _planEditContract = null;
2711
- _planEditMode = false;
2712
- _planEditFilePath = null;
2713
- _planEditSource = null;
2714
- _planEditTabId = null;
2715
- _changelogEditMode = false;
2716
- _changelogEditFilePath = null;
2717
- openFileEditor(filePath, { line, column });
2718
- });
2719
-
2720
- const panel = $('file-explorer-panel');
2721
- if (!panel) return;
2722
-
2723
- // Set width — use navbar-aligned default if no persisted value
2724
- if (!storage.getItem(KEYS.FILE_EXPLORER_WIDTH)) {
2725
- _width = calcDefaultWidth();
2726
- }
2727
- panel.style.width = `${_width}px`;
2728
-
2729
- // Wire filter
2730
- const filterInput = $('fe-filter-input');
2731
- const filterClear = $('fe-filter-clear');
2732
- if (filterInput) filterInput.addEventListener('input', onFilterInput);
2733
- if (filterClear) filterClear.addEventListener('click', () => {
2734
- if (filterInput) filterInput.value = '';
2735
- _filterText = '';
2736
- buildFileTree();
2737
- });
2738
-
2739
- // Wire toolbar
2740
- const collapseBtn = $('fe-collapse-all');
2741
- const expandBtn = $('fe-expand-all');
2742
- const sortBtn = $('fe-sort-btn');
2743
- const refreshBtn = $('fe-refresh');
2744
-
2745
- if (collapseBtn) collapseBtn.addEventListener('click', collapseAll);
2746
- if (expandBtn) expandBtn.addEventListener('click', expandAll);
2747
-
2748
- if (sortBtn) {
2749
- const sortModes = ['name', 'size', 'date'];
2750
- const sortLabels = { name: 'Sort: name', size: 'Sort: size', date: 'Sort: date' };
2751
- sortBtn.addEventListener('click', () => {
2752
- const idx = sortModes.indexOf(_sortMode);
2753
- _sortMode = sortModes[(idx + 1) % sortModes.length];
2754
- sortBtn.dataset.tooltip = sortLabels[_sortMode];
2755
- saveSort();
2756
- buildFileTree();
2757
- });
2758
- sortBtn.dataset.tooltip = `Sort: ${_sortMode}`;
2759
- }
2760
-
2761
- const undoBtn = $('fe-undo-btn');
2762
- const redoBtn = $('fe-redo-btn');
2763
- if (undoBtn) undoBtn.addEventListener('click', undoDelete);
2764
- if (redoBtn) redoBtn.addEventListener('click', redoDelete);
2765
-
2766
- if (refreshBtn) refreshBtn.addEventListener('click', () => {
2767
- _cache.clear();
2768
- _rootLoaded = false;
2769
- fetchProjects().then(() => {
2770
- initProjectSelector();
2771
- return loadDirectory();
2772
- }).then(() => {
2773
- _rootLoaded = true;
2774
- buildFileTree();
2775
- });
2776
- });
2777
-
2778
- // Editor buttons + features
2779
- const editorBack = $('fe-editor-back');
2780
- const editorSave = $('fe-editor-save');
2781
- const editorDiscard = $('fe-editor-discard');
2782
- const editorClose = $('fe-editor-close');
2783
- const editorTextarea = $('fe-editor-textarea');
2784
- const wordWrapBtn = $('fe-editor-word-wrap');
2785
-
2786
- if (editorBack) editorBack.addEventListener('click', closeFileEditor);
2787
- if (editorSave) editorSave.addEventListener('click', saveFileEditor);
2788
- if (editorDiscard) editorDiscard.addEventListener('click', discardFileEditor);
2789
- if (editorClose) editorClose.addEventListener('click', closeFileEditor);
2790
-
2791
- // Word wrap toggle (enabled by default)
2792
- if (wordWrapBtn) wordWrapBtn.classList.add('active');
2793
- if (wordWrapBtn && editorTextarea) {
2794
- wordWrapBtn.addEventListener('click', () => {
2795
- const wrapped = editorTextarea.style.whiteSpace === 'pre-wrap';
2796
- const newWrap = wrapped ? 'pre' : 'pre-wrap';
2797
- const newOverflow = wrapped ? 'normal' : 'break-word';
2798
- editorTextarea.style.whiteSpace = newWrap;
2799
- editorTextarea.style.overflowWrap = newOverflow;
2800
- const hl = $('fe-editor-highlight');
2801
- if (hl) { hl.style.whiteSpace = newWrap; hl.style.overflowWrap = newOverflow; }
2802
- wordWrapBtn.classList.toggle('active', !wrapped);
2803
- // Wrapping changed without a box-size change → re-sync the measuring mirror
2804
- // and reposition the current-line highlight.
2805
- invalidateLineMirror();
2806
- updateLineHighlight();
2807
- });
2808
- }
2809
-
2810
- // Keybinds help popover
2811
- const helpBtn = $('fe-editor-help-btn');
2812
- const keybindsPanel = $('fe-editor-keybinds');
2813
- if (helpBtn && keybindsPanel) {
2814
- helpBtn.addEventListener('click', (e) => {
2815
- e.stopPropagation();
2816
- keybindsPanel.classList.toggle('open');
2817
- });
2818
- document.addEventListener('click', () => keybindsPanel.classList.remove('open'));
2819
- keybindsPanel.addEventListener('click', (e) => e.stopPropagation());
2820
- }
2821
-
2822
- // Raw/Preview toggle for markdown files
2823
- document.querySelectorAll('.fe-editor-toggle-btn').forEach(btn => {
2824
- btn.addEventListener('click', () => {
2825
- const mode = btn.dataset.mode;
2826
- _editorPreviewMode = mode === 'preview';
2827
- document.querySelectorAll('.fe-editor-toggle-btn').forEach(b => b.classList.toggle('active', b === btn));
2828
- setPreviewMode(_editorPreviewMode);
2829
- });
2830
- });
2831
-
2832
- // Find & Replace bar
2833
- const findBtn = $('fe-editor-find-btn');
2834
- const findInput = $('fe-editor-find-input');
2835
- const findPrevBtn = $('fe-editor-find-prev');
2836
- const findNextBtn = $('fe-editor-find-next');
2837
- const findCloseBtn = $('fe-editor-find-close');
2838
- const replaceBtn = $('fe-editor-replace');
2839
- const replaceAllBtn = $('fe-editor-replace-all');
2840
- const replaceInput = $('fe-editor-replace-input');
2841
-
2842
- if (findBtn) findBtn.addEventListener('click', () => {
2843
- if (_findOpen) closeFind(); else openFind();
2844
- });
2845
- if (findCloseBtn) findCloseBtn.addEventListener('click', closeFind);
2846
- if (findNextBtn) findNextBtn.addEventListener('click', findNext);
2847
- if (findPrevBtn) findPrevBtn.addEventListener('click', findPrev);
2848
- if (replaceBtn) replaceBtn.addEventListener('click', doReplace);
2849
- if (replaceAllBtn) replaceAllBtn.addEventListener('click', doReplaceAll);
2850
- if (findInput) {
2851
- findInput.addEventListener('input', () => doFind(findInput.value));
2852
- findInput.addEventListener('keydown', (e) => {
2853
- if (e.key === 'Enter') { e.shiftKey ? findPrev() : findNext(); }
2854
- if (e.key === 'Escape') closeFind();
2855
- });
2856
- }
2857
- if (replaceInput) {
2858
- replaceInput.addEventListener('keydown', (e) => {
2859
- if (e.key === 'Enter') doReplace();
2860
- if (e.key === 'Escape') closeFind();
2861
- });
2862
- }
2863
-
2864
- // Go to Line
2865
- const gotoInput = $('fe-editor-goto-input');
2866
- if (gotoInput) {
2867
- gotoInput.addEventListener('keydown', (e) => {
2868
- if (e.key === 'Enter') { goToLine(parseInt(gotoInput.value, 10)); }
2869
- if (e.key === 'Escape') closeGoToLine();
2870
- });
2871
- }
2872
-
2873
- // Auto-close bracket/quote pairs
2874
- const PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
2875
-
2876
- if (editorTextarea) {
2877
- editorTextarea.addEventListener('input', () => {
2878
- // Immediate: cheap feedback the user perceives directly.
2879
- updateEditorDirtyState();
2880
- updateLineHighlight();
2881
- // Debounced: full-file syntax highlight + gutter reconcile (heavy on big files).
2882
- debouncedEditorRender();
2883
- });
2884
- // Scroll sync: gutter follows textarea, update scrollmap + line highlight + syntax.
2885
- // Coalesced to one run per frame (vsync-aligned) so fast scrolling doesn't fire
2886
- // four DOM-measuring ops on every scroll pixel. Passive: the handler never
2887
- // calls preventDefault.
2888
- const onEditorScroll = rafThrottle(() => {
2889
- syncGutterScroll();
2890
- syncHighlightScroll();
2891
- updateScrollmap();
2892
- updateLineHighlight();
2893
- });
2894
- editorTextarea.addEventListener('scroll', onEditorScroll, { passive: true });
2895
- // Cursor position + line highlight tracking
2896
- editorTextarea.addEventListener('click', () => { updateCursorPos(); updateLineHighlight(); });
2897
- editorTextarea.addEventListener('keyup', () => { updateCursorPos(); updateLineHighlight(); });
2898
- editorTextarea.addEventListener('select', () => { updateCursorPos(); updateLineHighlight(); });
2899
-
2900
- let _undoBurstTimer = null;
2901
-
2902
- editorTextarea.addEventListener('keydown', (e) => {
2903
- if (_editorSaving) return;
2904
- const ta = e.target;
2905
- const mod = e.ctrlKey || e.metaKey;
2906
-
2907
- // Typing snapshot — capture pre-edit state on first key of each burst
2908
- const isTypingKey = e.key.length === 1 && !mod;
2909
- const isDeleteKey = (e.key === 'Backspace' || e.key === 'Delete') && !mod;
2910
- if ((isTypingKey || isDeleteKey) && !PAIRS[e.key]) {
2911
- if (!_undoBurstOpen) {
2912
- _undoBurstOpen = true;
2913
- const top = _undoStack.length ? _undoStack[_undoStack.length - 1] : null;
2914
- if (!top || top.value !== ta.value) {
2915
- _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
2916
- if (_undoStack.length > 200) _undoStack.shift();
2917
- _redoStack = [];
2918
- }
2919
- }
2920
- clearTimeout(_undoBurstTimer);
2921
- _undoBurstTimer = setTimeout(() => { _undoBurstOpen = false; }, 400);
2922
- }
2923
-
2924
- // Ctrl+Z → undo
2925
- if (e.key === 'z' && mod && !e.shiftKey) {
2926
- e.preventDefault();
2927
- editorUndo(ta);
2928
- return;
2929
- }
2930
-
2931
- // Ctrl+Y or Ctrl+Shift+Z → redo
2932
- if ((e.key === 'y' && mod) || (e.key === 'z' && mod && e.shiftKey)) {
2933
- e.preventDefault();
2934
- editorRedo(ta);
2935
- return;
2936
- }
2937
-
2938
- // Tab → insert 2 spaces
2939
- if (e.key === 'Tab' && !e.shiftKey) {
2940
- e.preventDefault();
2941
- editorInsertText(ta, ' ');
2942
- return;
2943
- }
2944
-
2945
- // Shift+Tab → unindent selected lines
2946
- if (e.key === 'Tab' && e.shiftKey) {
2947
- e.preventDefault();
2948
- const start = ta.selectionStart;
2949
- const end = ta.selectionEnd;
2950
- const val = ta.value;
2951
- const lineStart = val.lastIndexOf('\n', start - 1) + 1;
2952
- const lineEnd = val.indexOf('\n', end);
2953
- const blockEnd = lineEnd === -1 ? val.length : lineEnd;
2954
- const block = val.substring(lineStart, blockEnd);
2955
- const unindented = block.split('\n').map(l => l.startsWith(' ') ? l.slice(2) : l.replace(/^\t/, '')).join('\n');
2956
- editorReplaceRange(ta, lineStart, blockEnd, unindented);
2957
- ta.setSelectionRange(lineStart, lineStart + unindented.length);
2958
- updateEditorDirtyState();
2959
- updateGutter();
2960
- return;
2961
- }
2962
-
2963
- // Enter → auto-indent
2964
- if (e.key === 'Enter' && !mod) {
2965
- e.preventDefault();
2966
- const pos = ta.selectionStart;
2967
- const val = ta.value;
2968
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
2969
- const line = val.substring(lineStart, pos);
2970
- const indent = line.match(/^(\s*)/)[1];
2971
- editorInsertText(ta, '\n' + indent);
2972
- return;
2973
- }
2974
-
2975
- // Ctrl+D → duplicate line
2976
- if (e.key === 'd' && mod) {
2977
- e.preventDefault();
2978
- const pos = ta.selectionStart;
2979
- const val = ta.value;
2980
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
2981
- let lineEnd = val.indexOf('\n', pos);
2982
- if (lineEnd === -1) lineEnd = val.length;
2983
- const line = val.substring(lineStart, lineEnd);
2984
- editorPushUndo(ta);
2985
- ta.value = val.substring(0, lineEnd) + '\n' + line + val.substring(lineEnd);
2986
- const newPos = lineEnd + 1 + line.length;
2987
- ta.selectionStart = ta.selectionEnd = newPos;
2988
- updateEditorDirtyState();
2989
- updateGutter();
2990
- return;
2991
- }
2992
-
2993
- // Ctrl+/ → toggle line comment
2994
- if (e.key === '/' && mod) {
2995
- e.preventDefault();
2996
- const lang = $('fe-editor-lang')?.textContent || '';
2997
- const prefix = COMMENT_PREFIX[lang] || '//';
2998
- const start = ta.selectionStart;
2999
- const end = ta.selectionEnd;
3000
- const val = ta.value;
3001
- const lineStart = val.lastIndexOf('\n', start - 1) + 1;
3002
- let lineEnd = val.indexOf('\n', end);
3003
- if (lineEnd === -1) lineEnd = val.length;
3004
- const block = val.substring(lineStart, lineEnd);
3005
- const lines = block.split('\n');
3006
- const allCommented = lines.every(l => l.trimStart().startsWith(prefix));
3007
- const toggled = lines.map(l => {
3008
- if (allCommented) {
3009
- const idx = l.indexOf(prefix);
3010
- return l.substring(0, idx) + l.substring(idx + prefix.length + (l[idx + prefix.length] === ' ' ? 1 : 0));
3011
- }
3012
- return prefix + ' ' + l;
3013
- }).join('\n');
3014
- editorReplaceRange(ta, lineStart, lineEnd, toggled);
3015
- ta.setSelectionRange(lineStart, lineStart + toggled.length);
3016
- updateEditorDirtyState();
3017
- updateGutter();
3018
- return;
3019
- }
3020
-
3021
- // Ctrl+S → save
3022
- if (e.key === 's' && mod) {
3023
- e.preventDefault();
3024
- saveFileEditor();
3025
- return;
3026
- }
3027
-
3028
- // Ctrl+F → find
3029
- if (e.key === 'f' && mod) {
3030
- e.preventDefault();
3031
- openFind();
3032
- return;
3033
- }
3034
-
3035
- // Ctrl+G → go to line
3036
- if (e.key === 'g' && mod) {
3037
- e.preventDefault();
3038
- openGoToLine();
3039
- return;
3040
- }
3041
-
3042
- // Ctrl+H → focus replace (open find first if needed)
3043
- if (e.key === 'h' && mod) {
3044
- e.preventDefault();
3045
- if (!_findOpen) openFind();
3046
- const ri = $('fe-editor-replace-input');
3047
- if (ri) ri.focus();
3048
- return;
3049
- }
3050
-
3051
- // Auto-close brackets and quotes
3052
- if (PAIRS[e.key]) {
3053
- const pos = ta.selectionStart;
3054
- const end = ta.selectionEnd;
3055
- editorPushUndo(ta);
3056
- if (pos !== end) {
3057
- const selected = ta.value.substring(pos, end);
3058
- ta.value = ta.value.substring(0, pos) + e.key + selected + PAIRS[e.key] + ta.value.substring(end);
3059
- ta.setSelectionRange(pos + 1, pos + 1 + selected.length);
3060
- } else {
3061
- ta.value = ta.value.substring(0, pos) + e.key + PAIRS[e.key] + ta.value.substring(pos);
3062
- ta.setSelectionRange(pos + 1, pos + 1);
3063
- }
3064
- e.preventDefault();
3065
- updateEditorDirtyState();
3066
- return;
3067
- }
3068
-
3069
- // Backspace → delete matching pair if cursor is between them
3070
- if (e.key === 'Backspace' && !mod) {
3071
- const pos = ta.selectionStart;
3072
- if (pos > 0 && pos === ta.selectionEnd) {
3073
- const before = ta.value[pos - 1];
3074
- const after = ta.value[pos];
3075
- if (PAIRS[before] === after) {
3076
- e.preventDefault();
3077
- editorPushUndo(ta);
3078
- ta.value = ta.value.substring(0, pos - 1) + ta.value.substring(pos + 1);
3079
- ta.selectionStart = ta.selectionEnd = pos - 1;
3080
- updateEditorDirtyState();
3081
- updateGutter();
3082
- return;
3083
- }
3084
- }
3085
- }
3086
- });
3087
- }
3088
-
3089
- // Git panel
3090
- initGitPanel();
3091
-
3092
- // Resize handle
3093
- initResizeHandle();
3094
-
3095
- // Keybind
3096
- registerAction('toggle-file-explorer', toggleFileExplorer);
3097
-
3098
- // Menu item
3099
- const menuEl = $('menu-toggle-file-explorer');
3100
- if (menuEl) {
3101
- menuEl.addEventListener('click', toggleFileExplorer);
3102
- }
3103
-
3104
- // Toolbar button
3105
- const toolbarBtn = $('topright-file-explorer-btn');
3106
- if (toolbarBtn) {
3107
- toolbarBtn.addEventListener('click', toggleFileExplorer);
3108
- }
3109
-
3110
- // Restore visibility
3111
- if (_visible) {
3112
- panel.classList.add('open');
3113
- const titleBar = document.getElementById('title-bar');
3114
- if (titleBar) titleBar.classList.add('file-explorer-active');
3115
- document.body.classList.add('file-explorer-open');
3116
- applyFileExplorerWidth();
3117
- if (menuEl) menuEl.classList.add('active');
3118
- if (toolbarBtn) toolbarBtn.classList.add('active');
3119
- _rootLoaded = true;
3120
- fetchProjects().then(() => {
3121
- initProjectSelector();
3122
- return loadDirectory();
3123
- }).then(() => buildFileTree());
3124
- }
3125
1110
  }
3126
1111
 
3127
1112
  // Exports for Settings → Icons tab preview