synabun 2.0.0-beta → 2.0.5
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.
- package/CHANGELOG.md +65 -0
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/THIRD-PARTY-LICENSES.md +51 -0
- package/benchmarks/fixtures/browser/README.md +1 -1
- package/benchmarks/fixtures/desktop/README.md +1 -1
- package/docs/whatsapp.md +2 -2
- package/hooks/claude-code/pre-compact.mjs +11 -0
- package/hooks/claude-code/prompt-submit.mjs +60 -184
- package/hooks/claude-code/session-start.mjs +73 -16
- package/hooks/claude-code/shared.mjs +20 -0
- package/hooks/claude-code/stop.mjs +5 -5
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +61 -8
- package/lib/client-config-repair.js +4 -3
- package/lib/data-home-migration.js +4 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +5 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/start-launcher.js +1396 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -3
- package/neural-interface/i18n/en.json +1540 -154
- package/neural-interface/i18n/pt-BR.json +1538 -153
- package/neural-interface/lib/assistant-api.js +62 -6
- package/neural-interface/lib/assistant-brains/claude.js +4 -0
- package/neural-interface/lib/assistant-catalog.js +179 -27
- package/neural-interface/lib/assistant-config.js +31 -2
- package/neural-interface/lib/assistant-dispatch.js +62 -13
- package/neural-interface/lib/assistant-envelope.js +9 -6
- package/neural-interface/lib/assistant-hidden-models.js +393 -66
- package/neural-interface/lib/assistant-memory.js +24 -10
- package/neural-interface/lib/assistant-persona.js +6 -2
- package/neural-interface/lib/assistant-pricing.js +21 -6
- package/neural-interface/lib/assistant-runtime.js +181 -12
- package/neural-interface/lib/assistant-task-prompt.js +13 -3
- package/neural-interface/lib/assistant-test-density.js +142 -0
- package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
- package/neural-interface/lib/assistant-user-context.js +908 -0
- package/neural-interface/lib/assistant-user-knowledge.js +328 -0
- package/neural-interface/lib/assistant-user-learn.js +404 -0
- package/neural-interface/lib/assistant-user-threads.js +226 -0
- package/neural-interface/lib/assistant-user-writer.js +181 -0
- package/neural-interface/lib/augmented-path.js +23 -3
- package/neural-interface/lib/browser-background-tab.js +134 -0
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/browser-screenshot.js +678 -61
- package/neural-interface/lib/claude-agent-bridge.js +87 -153
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-model-catalog.js +82 -93
- package/neural-interface/lib/claude-temporary.js +46 -7
- package/neural-interface/lib/cloudflared/install.js +533 -0
- package/neural-interface/lib/cloudflared/manager.js +545 -0
- package/neural-interface/lib/cloudflared/pins.js +115 -0
- package/neural-interface/lib/cloudflared/pins.json +63 -0
- package/neural-interface/lib/cloudflared/tunnel.js +265 -0
- package/neural-interface/lib/editor-completion-endpoint.js +264 -0
- package/neural-interface/lib/editor-completion-opencode.js +436 -0
- package/neural-interface/lib/editor-completion.js +714 -0
- package/neural-interface/lib/editor-config.js +149 -0
- package/neural-interface/lib/editor-files-api.js +268 -0
- package/neural-interface/lib/editor-git-api.js +134 -0
- package/neural-interface/lib/file-explorer-api.js +1039 -0
- package/neural-interface/lib/file-explorer-system.js +355 -0
- package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
- package/neural-interface/lib/file-explorer-watch.js +239 -0
- package/neural-interface/lib/git-diff-api.js +103 -0
- package/neural-interface/lib/git-read.js +202 -0
- package/neural-interface/lib/greeting/api.js +89 -0
- package/neural-interface/lib/greeting/budgets.js +90 -0
- package/neural-interface/lib/greeting/engine.js +461 -0
- package/neural-interface/lib/greeting/facts.js +320 -0
- package/neural-interface/lib/greeting/sources.js +252 -0
- package/neural-interface/lib/greeting/writer.js +139 -0
- package/neural-interface/lib/http-guards.js +189 -13
- package/neural-interface/lib/machine-names.js +88 -0
- package/neural-interface/lib/memory-api.js +31 -7
- package/neural-interface/lib/native-binary-runtime.js +69 -337
- package/neural-interface/lib/native-loop-providers.js +40 -49
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/plugin-installer.js +4 -2
- package/neural-interface/lib/project-storage.js +5 -7
- package/neural-interface/lib/request-gate.js +589 -0
- package/neural-interface/lib/session-title-generator.js +5 -0
- package/neural-interface/lib/synabun-update-plan.js +19 -0
- package/neural-interface/lib/ui-blob-api.js +108 -0
- package/neural-interface/lib/ui-blob-store.js +68 -0
- package/neural-interface/package-lock.json +701 -253
- package/neural-interface/package.json +13 -2
- package/neural-interface/public/install-app.html +34 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/offline.html +402 -66
- package/neural-interface/public/onboarding.html +264 -147
- package/neural-interface/public/shared/agent-runtime-options.js +22 -6
- package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
- package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
- package/neural-interface/public/shared/assistant/asst-mascot.js +26 -64
- package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
- package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
- package/neural-interface/public/shared/assistant/asst-render.js +219 -67
- package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
- package/neural-interface/public/shared/assistant/asst-route.js +23 -329
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
- package/neural-interface/public/shared/assistant/asst-state.js +185 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +162 -6
- package/neural-interface/public/shared/blob-store.js +111 -22
- package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
- package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
- package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/constants.js +6 -1
- package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +15 -0
- package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
- package/neural-interface/public/shared/editor/editor-ai.js +619 -0
- package/neural-interface/public/shared/editor/editor-commands.js +212 -0
- package/neural-interface/public/shared/editor/editor-compare.js +103 -0
- package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
- package/neural-interface/public/shared/editor/editor-doc.js +257 -0
- package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
- package/neural-interface/public/shared/editor/editor-git.js +95 -0
- package/neural-interface/public/shared/editor/editor-languages.js +156 -0
- package/neural-interface/public/shared/editor/editor-lint.js +229 -0
- package/neural-interface/public/shared/editor/editor-loader.js +51 -0
- package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
- package/neural-interface/public/shared/editor/editor-menu.js +233 -0
- package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
- package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
- package/neural-interface/public/shared/editor/editor-palette.js +125 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
- package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
- package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
- package/neural-interface/public/shared/editor/editor-safety.js +500 -0
- package/neural-interface/public/shared/editor/editor-search.js +352 -0
- package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
- package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
- package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
- package/neural-interface/public/shared/editor/editor-theme.js +244 -0
- package/neural-interface/public/shared/editor/editor-view.js +332 -0
- package/neural-interface/public/shared/explorer/fx-api.js +216 -0
- package/neural-interface/public/shared/explorer/fx-links.js +204 -0
- package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
- package/neural-interface/public/shared/explorer/fx-main.js +865 -0
- package/neural-interface/public/shared/explorer/fx-names.js +87 -0
- package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
- package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
- package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
- package/neural-interface/public/shared/file-links.js +339 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
- package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
- package/neural-interface/public/shared/html-shell.js +169 -170
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
- package/neural-interface/public/shared/pwa-install-page.js +62 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +127 -0
- package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
- package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
- package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
- package/neural-interface/public/shared/settings/settings-ia.js +18 -4
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
- package/neural-interface/public/shared/start-bridge.js +802 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
- package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
- package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
- package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
- package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
- package/neural-interface/public/shared/styles.css +2219 -1117
- package/neural-interface/public/shared/synabun-mascot.js +20 -56
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/tunnel-view.js +167 -0
- package/neural-interface/public/shared/ui-claude-panel.js +160 -19
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-detail.js +15 -0
- package/neural-interface/public/shared/ui-explorer.js +22 -0
- package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
- package/neural-interface/public/shared/ui-file-links.js +103 -0
- package/neural-interface/public/shared/ui-help.js +26 -0
- package/neural-interface/public/shared/ui-invite.js +126 -116
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-keybinds.js +4 -0
- package/neural-interface/public/shared/ui-loading.js +367 -12
- package/neural-interface/public/shared/ui-menubar.js +21 -55
- package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
- package/neural-interface/public/shared/ui-navbar.js +45 -21
- package/neural-interface/public/shared/ui-settings.js +491 -547
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
- package/neural-interface/public/shared/ui-sync.js +16 -0
- package/neural-interface/public/shared/ui-terminal.js +153 -149
- package/neural-interface/public/shared/ui-tunnel.js +130 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
- package/neural-interface/public/shared/ui-whiteboard.js +12 -0
- package/neural-interface/public/shared/ui-worker-end.js +245 -0
- package/neural-interface/public/shared/ui-workspaces.js +41 -27
- package/neural-interface/public/shared/worker-end.js +251 -0
- package/neural-interface/public/sw.js +3 -1
- package/neural-interface/public/variant/2d/main.js +2 -1
- package/neural-interface/public/variant/3d/main.js +2 -1
- package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
- package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
- package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
- package/neural-interface/scripts/build-vendor.mjs +78 -2
- package/neural-interface/scripts/rebuild-pty.js +15 -12
- package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
- package/neural-interface/server.js +1092 -1212
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/package.json +13 -7
- package/postinstall.js +11 -81
- package/setup.js +111 -6
- package/benchmarks/browser-live-results.json +0 -79
- package/benchmarks/browser-v2-results.json +0 -258
- package/docs/browser-v2.md +0 -109
- package/docs/hook-integrity.md +0 -89
- package/docs/judgments.md +0 -182
- package/docs/style-guide.md +0 -246
- package/neural-interface/lib/codex-capabilities.d.ts +0 -41
- package/neural-interface/public/category-logos/claude.svg +0 -1
- package/neural-interface/public/category-logos/gemini-color.svg +0 -1
- package/neural-interface/public/category-logos/openai.svg +0 -1
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +0 -1
- package/neural-interface/public/gallery.svg +0 -6
- package/neural-interface/public/leonardoai.svg +0 -1
- package/neural-interface/public/mcp.svg +0 -4
- package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
- package/neural-interface/public/pin-svgrepo-com.svg +0 -25
- package/neural-interface/public/sessionicon.svg +0 -8
- package/neural-interface/public/toolbar-album.svg +0 -8
- package/neural-interface/public/toolbar-question.svg +0 -6
- package/neural-interface/public/toolbar-sessions.svg +0 -6
- package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
- package/scripts/audit-claude-hook-usage.mjs +0 -12
- package/scripts/audit-memory-duplicates.mjs +0 -44
- package/scripts/measure-claude-hook-overhead.mjs +0 -77
- package/scripts/recover-user-config.mjs +0 -254
- 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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
64
|
-
let
|
|
65
|
-
let
|
|
66
|
-
let
|
|
67
|
-
let
|
|
68
|
-
let
|
|
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
|
-
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (summary)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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(' · ');
|
|
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('
|
|
437
|
+
showToast(tr('fileExplorer.gitPanel.switched', { branch: data.branch || branch }));
|
|
462
438
|
closeGitPopover();
|
|
463
|
-
|
|
464
|
-
_rootLoaded = false;
|
|
465
|
-
loadDirectory(_selectedProject.path).then(() => {
|
|
466
|
-
_rootLoaded = true;
|
|
467
|
-
buildFileTree();
|
|
468
|
-
});
|
|
439
|
+
_fx?.refresh();
|
|
469
440
|
} else {
|
|
470
|
-
showToast(data.error || '
|
|
471
|
-
refreshGitStatus();
|
|
441
|
+
showToast(data.error || tr('fileExplorer.gitPanel.checkoutFailed'));
|
|
472
442
|
loadGitBranches();
|
|
473
443
|
}
|
|
474
444
|
} catch {
|
|
475
|
-
showToast('
|
|
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
|
-
|
|
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 || '
|
|
470
|
+
showCommitOutput(data.error || tr('fileExplorer.gitPanel.commitFailed'), false);
|
|
507
471
|
}
|
|
508
472
|
} catch {
|
|
509
|
-
showCommitOutput('
|
|
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"
|
|
525
|
-
: '<svg viewBox="0 0 24 24"
|
|
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 ? '
|
|
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 || '
|
|
525
|
+
showToast(data.summary || tr('fileExplorer.gitPanel.noChanges'));
|
|
562
526
|
}
|
|
563
527
|
} catch {
|
|
564
|
-
showToast('
|
|
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"
|
|
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"
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
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, '"'); }
|
|
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: '
|
|
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
|
|
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
|
|
705
|
+
else showToast(tr('fileExplorer.project.addFailed'));
|
|
721
706
|
} catch (err) {
|
|
722
|
-
showToast('
|
|
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"
|
|
726
|
+
<div class="fe-modal-title" id="fe-trust-title"></div>
|
|
739
727
|
<div class="fe-modal-body">
|
|
740
|
-
|
|
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"
|
|
745
|
-
<button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm"
|
|
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
|
-
|
|
750
|
+
window.removeEventListener('keydown', onKey, true);
|
|
758
751
|
resolve();
|
|
759
752
|
};
|
|
760
|
-
|
|
761
|
-
|
|
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 = '
|
|
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('
|
|
778
|
-
else showToast(data.error || '
|
|
771
|
+
if (data.ok) showToast(tr('fileExplorer.trust.done'));
|
|
772
|
+
else showToast(data.error || tr('fileExplorer.trust.failed'));
|
|
779
773
|
} catch {
|
|
780
|
-
showToast('
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
//
|
|
861
|
+
// RESIZE HANDLE
|
|
850
862
|
// ═══════════════════════════════════════════
|
|
851
863
|
|
|
852
|
-
function
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
958
|
+
_visible = !_visible;
|
|
959
|
+
panel.classList.toggle('open', _visible);
|
|
960
|
+
panel.inert = !_visible;
|
|
961
|
+
saveVisible();
|
|
1009
962
|
|
|
1010
|
-
//
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
await loadDirectory();
|
|
1055
|
-
buildFileTree();
|
|
1056
|
-
}
|
|
970
|
+
// Resize after transition
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
window.dispatchEvent(new Event('resize'));
|
|
973
|
+
}, 320);
|
|
1057
974
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
981
|
+
if (!_visible) closeGitPopover();
|
|
982
|
+
syncTree();
|
|
983
|
+
}
|
|
1065
984
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
996
|
+
// ═══════════════════════════════════════════
|
|
997
|
+
// INIT
|
|
998
|
+
// ═══════════════════════════════════════════
|
|
1099
999
|
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
};
|
|
1007
|
+
export function initFileExplorer() {
|
|
1008
|
+
loadPersistedState();
|
|
1009
|
+
loadCustomIcons(); // non-blocking — icons load in background
|
|
1107
1010
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1136
|
-
if (
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
dismissCtxMenu();
|
|
1057
|
+
// Resize handle
|
|
1058
|
+
initResizeHandle();
|
|
1175
1059
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1060
|
+
// Keybind
|
|
1061
|
+
registerAction('toggle-file-explorer', toggleFileExplorer);
|
|
1178
1062
|
|
|
1179
|
-
|
|
1063
|
+
// Menu item
|
|
1064
|
+
const menuEl = $('menu-toggle-file-explorer');
|
|
1065
|
+
if (menuEl) {
|
|
1066
|
+
menuEl.addEventListener('click', toggleFileExplorer);
|
|
1067
|
+
}
|
|
1180
1068
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
//
|
|
1195
|
-
if (
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/^>\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
|