@nimbus-sh/worker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/dist/_shared/bytes.d.ts +18 -0
- package/dist/_shared/bytes.d.ts.map +1 -0
- package/dist/_shared/bytes.js +17 -0
- package/dist/_shared/cache-stats.d.ts +146 -0
- package/dist/_shared/cache-stats.d.ts.map +1 -0
- package/dist/_shared/cache-stats.js +162 -0
- package/dist/_shared/exports-resolver.d.ts +76 -0
- package/dist/_shared/exports-resolver.d.ts.map +1 -0
- package/dist/_shared/exports-resolver.js +284 -0
- package/dist/_shared/install-phase.d.ts +47 -0
- package/dist/_shared/install-phase.d.ts.map +1 -0
- package/dist/_shared/install-phase.js +1 -0
- package/dist/_shared/real-node-imports.d.ts +38 -0
- package/dist/_shared/real-node-imports.d.ts.map +1 -0
- package/dist/_shared/real-node-imports.js +46 -0
- package/dist/_shared/retry.d.ts +93 -0
- package/dist/_shared/retry.d.ts.map +1 -0
- package/dist/_shared/retry.js +161 -0
- package/dist/_shared/session-id.d.ts +39 -0
- package/dist/_shared/session-id.d.ts.map +1 -0
- package/dist/_shared/session-id.js +127 -0
- package/dist/_shared/session-router.d.ts +104 -0
- package/dist/_shared/session-router.d.ts.map +1 -0
- package/dist/_shared/session-router.js +165 -0
- package/dist/_shared/w7-frame.d.ts +93 -0
- package/dist/_shared/w7-frame.d.ts.map +1 -0
- package/dist/_shared/w7-frame.js +402 -0
- package/dist/auth/index.d.ts +16 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +15 -0
- package/dist/auth/middleware.d.ts +85 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +150 -0
- package/dist/auth/token.d.ts +96 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +226 -0
- package/dist/auth/types.d.ts +141 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +114 -0
- package/dist/bindings/d1.d.ts +102 -0
- package/dist/bindings/d1.d.ts.map +1 -0
- package/dist/bindings/d1.js +549 -0
- package/dist/bindings/kv.d.ts +82 -0
- package/dist/bindings/kv.d.ts.map +1 -0
- package/dist/bindings/kv.js +290 -0
- package/dist/bindings/r2.d.ts +137 -0
- package/dist/bindings/r2.d.ts.map +1 -0
- package/dist/bindings/r2.js +379 -0
- package/dist/cirrus-npm-cjs.generated.d.ts +17 -0
- package/dist/cirrus-npm-cjs.generated.d.ts.map +1 -0
- package/dist/cirrus-npm-cjs.generated.js +41 -0
- package/dist/cirrus-plugin-react.generated.d.ts +34 -0
- package/dist/cirrus-plugin-react.generated.d.ts.map +1 -0
- package/dist/cirrus-plugin-react.generated.js +33 -0
- package/dist/constants.d.ts +34 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +116 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts +41 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts.map +1 -0
- package/dist/esbuild-wasm-bundle.generated.js +40 -0
- package/dist/facets/cirrus-real.d.ts +147 -0
- package/dist/facets/cirrus-real.d.ts.map +1 -0
- package/dist/facets/cirrus-real.js +981 -0
- package/dist/facets/inner-do-registry.d.ts +41 -0
- package/dist/facets/inner-do-registry.d.ts.map +1 -0
- package/dist/facets/inner-do-registry.js +51 -0
- package/dist/facets/manager.d.ts +314 -0
- package/dist/facets/manager.d.ts.map +1 -0
- package/dist/facets/manager.js +2681 -0
- package/dist/facets/process.d.ts +318 -0
- package/dist/facets/process.d.ts.map +1 -0
- package/dist/facets/process.js +567 -0
- package/dist/facets/real-vite-fs-shim.d.ts +151 -0
- package/dist/facets/real-vite-fs-shim.d.ts.map +1 -0
- package/dist/facets/real-vite-fs-shim.js +1279 -0
- package/dist/facets/real-vite-hmr.d.ts +142 -0
- package/dist/facets/real-vite-hmr.d.ts.map +1 -0
- package/dist/facets/real-vite-hmr.js +476 -0
- package/dist/facets/vite-dev-server.d.ts +293 -0
- package/dist/facets/vite-dev-server.d.ts.map +1 -0
- package/dist/facets/vite-dev-server.js +2580 -0
- package/dist/facets/wasm-swap-registry.d.ts +261 -0
- package/dist/facets/wasm-swap-registry.d.ts.map +1 -0
- package/dist/facets/wasm-swap-registry.js +551 -0
- package/dist/facets/ws-terminal.d.ts +107 -0
- package/dist/facets/ws-terminal.d.ts.map +1 -0
- package/dist/facets/ws-terminal.js +207 -0
- package/dist/git/commands.d.ts +13 -0
- package/dist/git/commands.d.ts.map +1 -0
- package/dist/git/commands.js +633 -0
- package/dist/git/network-facet.d.ts +61 -0
- package/dist/git/network-facet.d.ts.map +1 -0
- package/dist/git/network-facet.js +700 -0
- package/dist/git-bundle.generated.d.ts +16 -0
- package/dist/git-bundle.generated.d.ts.map +1 -0
- package/dist/git-bundle.generated.js +15 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/loaders/child-process/spawn-facet.d.ts +90 -0
- package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-facet.js +103 -0
- package/dist/loaders/child-process/spawn-pool.d.ts +110 -0
- package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-pool.js +161 -0
- package/dist/loaders/fanout-pool.d.ts +195 -0
- package/dist/loaders/fanout-pool.d.ts.map +1 -0
- package/dist/loaders/fanout-pool.js +326 -0
- package/dist/loaders/generated-workers.d.ts +26 -0
- package/dist/loaders/generated-workers.d.ts.map +1 -0
- package/dist/loaders/generated-workers.js +25 -0
- package/dist/loaders/index.d.ts +15 -0
- package/dist/loaders/index.d.ts.map +1 -0
- package/dist/loaders/index.js +13 -0
- package/dist/loaders/loader-pool.d.ts +273 -0
- package/dist/loaders/loader-pool.d.ts.map +1 -0
- package/dist/loaders/loader-pool.js +624 -0
- package/dist/loaders/npm-resolve-preamble.d.ts +34 -0
- package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -0
- package/dist/loaders/npm-resolve-preamble.js +237 -0
- package/dist/loaders/pre-bundle-preamble.d.ts +41 -0
- package/dist/loaders/pre-bundle-preamble.d.ts.map +1 -0
- package/dist/loaders/pre-bundle-preamble.js +114 -0
- package/dist/loaders/vendor/codegen.d.ts +24 -0
- package/dist/loaders/vendor/codegen.d.ts.map +1 -0
- package/dist/loaders/vendor/codegen.js +44 -0
- package/dist/loaders/vendor/errors.d.ts +24 -0
- package/dist/loaders/vendor/errors.d.ts.map +1 -0
- package/dist/loaders/vendor/errors.js +46 -0
- package/dist/loaders/vendor/index.d.ts +16 -0
- package/dist/loaders/vendor/index.d.ts.map +1 -0
- package/dist/loaders/vendor/index.js +11 -0
- package/dist/loaders/vendor/pool.d.ts +88 -0
- package/dist/loaders/vendor/pool.d.ts.map +1 -0
- package/dist/loaders/vendor/pool.js +353 -0
- package/dist/loaders/vendor/primitives.d.ts +11 -0
- package/dist/loaders/vendor/primitives.d.ts.map +1 -0
- package/dist/loaders/vendor/primitives.js +33 -0
- package/dist/loaders/vendor/serialize.d.ts +3 -0
- package/dist/loaders/vendor/serialize.d.ts.map +1 -0
- package/dist/loaders/vendor/serialize.js +25 -0
- package/dist/loaders/vendor/types.d.ts +60 -0
- package/dist/loaders/vendor/types.d.ts.map +1 -0
- package/dist/loaders/vendor/types.js +4 -0
- package/dist/npm/cache.d.ts +136 -0
- package/dist/npm/cache.d.ts.map +1 -0
- package/dist/npm/cache.js +429 -0
- package/dist/npm/install-batch-facet.d.ts +129 -0
- package/dist/npm/install-batch-facet.d.ts.map +1 -0
- package/dist/npm/install-batch-facet.js +598 -0
- package/dist/npm/install-facet.d.ts +29 -0
- package/dist/npm/install-facet.d.ts.map +1 -0
- package/dist/npm/install-facet.js +9 -0
- package/dist/npm/installer.d.ts +239 -0
- package/dist/npm/installer.d.ts.map +1 -0
- package/dist/npm/installer.js +2022 -0
- package/dist/npm/npx-install.d.ts +74 -0
- package/dist/npm/npx-install.d.ts.map +1 -0
- package/dist/npm/npx-install.js +302 -0
- package/dist/npm/pre-bundle-facet.d.ts +168 -0
- package/dist/npm/pre-bundle-facet.d.ts.map +1 -0
- package/dist/npm/pre-bundle-facet.js +653 -0
- package/dist/npm/r2-cache.d.ts +286 -0
- package/dist/npm/r2-cache.d.ts.map +1 -0
- package/dist/npm/r2-cache.js +506 -0
- package/dist/npm/resolve-facet.d.ts +202 -0
- package/dist/npm/resolve-facet.d.ts.map +1 -0
- package/dist/npm/resolve-facet.js +833 -0
- package/dist/npm/resolve-one-facet.d.ts +204 -0
- package/dist/npm/resolve-one-facet.d.ts.map +1 -0
- package/dist/npm/resolve-one-facet.js +437 -0
- package/dist/npm/resolver.d.ts +109 -0
- package/dist/npm/resolver.d.ts.map +1 -0
- package/dist/npm/resolver.js +899 -0
- package/dist/npm/tarball-stream.d.ts +85 -0
- package/dist/npm/tarball-stream.d.ts.map +1 -0
- package/dist/npm/tarball-stream.js +205 -0
- package/dist/npm/tarball.d.ts +51 -0
- package/dist/npm/tarball.d.ts.map +1 -0
- package/dist/npm/tarball.js +185 -0
- package/dist/observability/diag-counters.d.ts +292 -0
- package/dist/observability/diag-counters.d.ts.map +1 -0
- package/dist/observability/diag-counters.js +302 -0
- package/dist/observability/heap-estimate.d.ts +166 -0
- package/dist/observability/heap-estimate.d.ts.map +1 -0
- package/dist/observability/heap-estimate.js +192 -0
- package/dist/observability/heavy-alloc-coord.d.ts +95 -0
- package/dist/observability/heavy-alloc-coord.d.ts.map +1 -0
- package/dist/observability/heavy-alloc-coord.js +149 -0
- package/dist/observability/oom-classify.d.ts +56 -0
- package/dist/observability/oom-classify.d.ts.map +1 -0
- package/dist/observability/oom-classify.js +118 -0
- package/dist/observability/oom-discriminator.d.ts +192 -0
- package/dist/observability/oom-discriminator.d.ts.map +1 -0
- package/dist/observability/oom-discriminator.js +233 -0
- package/dist/real-vite-bundle.generated.d.ts +46 -0
- package/dist/real-vite-bundle.generated.d.ts.map +1 -0
- package/dist/real-vite-bundle.generated.js +53 -0
- package/dist/replica/routing.d.ts +171 -0
- package/dist/replica/routing.d.ts.map +1 -0
- package/dist/replica/routing.js +288 -0
- package/dist/replica/suspension.d.ts +36 -0
- package/dist/replica/suspension.d.ts.map +1 -0
- package/dist/replica/suspension.js +49 -0
- package/dist/router/index.d.ts +156 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +220 -0
- package/dist/runtime/assets-loader.d.ts +71 -0
- package/dist/runtime/assets-loader.d.ts.map +1 -0
- package/dist/runtime/assets-loader.js +128 -0
- package/dist/runtime/barrel-detect.d.ts +46 -0
- package/dist/runtime/barrel-detect.d.ts.map +1 -0
- package/dist/runtime/barrel-detect.js +73 -0
- package/dist/runtime/barrel-synthesizer.d.ts +181 -0
- package/dist/runtime/barrel-synthesizer.d.ts.map +1 -0
- package/dist/runtime/barrel-synthesizer.js +541 -0
- package/dist/runtime/bun-repl.d.ts +46 -0
- package/dist/runtime/bun-repl.d.ts.map +1 -0
- package/dist/runtime/bun-repl.js +326 -0
- package/dist/runtime/bun-runner.d.ts +66 -0
- package/dist/runtime/bun-runner.d.ts.map +1 -0
- package/dist/runtime/bun-runner.js +219 -0
- package/dist/runtime/clang-runner.d.ts +38 -0
- package/dist/runtime/clang-runner.d.ts.map +1 -0
- package/dist/runtime/clang-runner.js +1033 -0
- package/dist/runtime/comment-strip.d.ts +37 -0
- package/dist/runtime/comment-strip.d.ts.map +1 -0
- package/dist/runtime/comment-strip.js +74 -0
- package/dist/runtime/esbuild-service.d.ts +201 -0
- package/dist/runtime/esbuild-service.d.ts.map +1 -0
- package/dist/runtime/esbuild-service.js +1444 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts +87 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -0
- package/dist/runtime/esbuild-wasm-bytes.js +131 -0
- package/dist/runtime/framework-detect.d.ts +71 -0
- package/dist/runtime/framework-detect.d.ts.map +1 -0
- package/dist/runtime/framework-detect.js +178 -0
- package/dist/runtime/long-running-handle.d.ts +133 -0
- package/dist/runtime/long-running-handle.d.ts.map +1 -0
- package/dist/runtime/long-running-handle.js +192 -0
- package/dist/runtime/node-repl.d.ts +36 -0
- package/dist/runtime/node-repl.d.ts.map +1 -0
- package/dist/runtime/node-repl.js +293 -0
- package/dist/runtime/node-runner.d.ts +111 -0
- package/dist/runtime/node-runner.d.ts.map +1 -0
- package/dist/runtime/node-runner.js +194 -0
- package/dist/runtime/node-shims.d.ts +24 -0
- package/dist/runtime/node-shims.d.ts.map +1 -0
- package/dist/runtime/node-shims.js +3019 -0
- package/dist/runtime/package-manager.d.ts +118 -0
- package/dist/runtime/package-manager.d.ts.map +1 -0
- package/dist/runtime/package-manager.js +420 -0
- package/dist/runtime/port-registry.d.ts +80 -0
- package/dist/runtime/port-registry.d.ts.map +1 -0
- package/dist/runtime/port-registry.js +166 -0
- package/dist/runtime/process-logs-api.d.ts +102 -0
- package/dist/runtime/process-logs-api.d.ts.map +1 -0
- package/dist/runtime/process-logs-api.js +253 -0
- package/dist/runtime/process-logs.d.ts +311 -0
- package/dist/runtime/process-logs.d.ts.map +1 -0
- package/dist/runtime/process-logs.js +656 -0
- package/dist/runtime/process-table.d.ts +65 -0
- package/dist/runtime/process-table.d.ts.map +1 -0
- package/dist/runtime/process-table.js +107 -0
- package/dist/runtime/project-detect.d.ts +21 -0
- package/dist/runtime/project-detect.d.ts.map +1 -0
- package/dist/runtime/project-detect.js +46 -0
- package/dist/runtime/python-repl.d.ts +66 -0
- package/dist/runtime/python-repl.d.ts.map +1 -0
- package/dist/runtime/python-repl.js +563 -0
- package/dist/runtime/python-runner.d.ts +50 -0
- package/dist/runtime/python-runner.d.ts.map +1 -0
- package/dist/runtime/python-runner.js +870 -0
- package/dist/runtime/repl-session.d.ts +160 -0
- package/dist/runtime/repl-session.d.ts.map +1 -0
- package/dist/runtime/repl-session.js +393 -0
- package/dist/runtime/require-resolver.d.ts +52 -0
- package/dist/runtime/require-resolver.d.ts.map +1 -0
- package/dist/runtime/require-resolver.js +690 -0
- package/dist/runtime/router-basename.d.ts +36 -0
- package/dist/runtime/router-basename.d.ts.map +1 -0
- package/dist/runtime/router-basename.js +310 -0
- package/dist/runtime/ruby-repl.d.ts +43 -0
- package/dist/runtime/ruby-repl.d.ts.map +1 -0
- package/dist/runtime/ruby-repl.js +229 -0
- package/dist/runtime/ruby-runner.d.ts +62 -0
- package/dist/runtime/ruby-runner.d.ts.map +1 -0
- package/dist/runtime/ruby-runner.js +737 -0
- package/dist/runtime/runtime-catalog.d.ts +99 -0
- package/dist/runtime/runtime-catalog.d.ts.map +1 -0
- package/dist/runtime/runtime-catalog.js +183 -0
- package/dist/runtime/runtime-registry.d.ts +136 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -0
- package/dist/runtime/runtime-registry.js +318 -0
- package/dist/runtime/streams.d.ts +13 -0
- package/dist/runtime/streams.d.ts.map +1 -0
- package/dist/runtime/streams.js +402 -0
- package/dist/runtime/wasi-instance.d.ts +190 -0
- package/dist/runtime/wasi-instance.d.ts.map +1 -0
- package/dist/runtime/wasi-instance.js +2044 -0
- package/dist/runtime/wasm-runner.d.ts +117 -0
- package/dist/runtime/wasm-runner.d.ts.map +1 -0
- package/dist/runtime/wasm-runner.js +705 -0
- package/dist/session/bindings.d.ts +168 -0
- package/dist/session/bindings.d.ts.map +1 -0
- package/dist/session/bindings.js +524 -0
- package/dist/session/ctx-exports.d.ts +16 -0
- package/dist/session/ctx-exports.d.ts.map +1 -0
- package/dist/session/ctx-exports.js +22 -0
- package/dist/session/diag.d.ts +62 -0
- package/dist/session/diag.d.ts.map +1 -0
- package/dist/session/diag.js +118 -0
- package/dist/session/fs-watch.d.ts +132 -0
- package/dist/session/fs-watch.d.ts.map +1 -0
- package/dist/session/fs-watch.js +255 -0
- package/dist/session/helpers.d.ts +150 -0
- package/dist/session/helpers.d.ts.map +1 -0
- package/dist/session/helpers.js +472 -0
- package/dist/session/hibernation.d.ts +145 -0
- package/dist/session/hibernation.d.ts.map +1 -0
- package/dist/session/hibernation.js +385 -0
- package/dist/session/init-phases.d.ts +87 -0
- package/dist/session/init-phases.d.ts.map +1 -0
- package/dist/session/init-phases.js +137 -0
- package/dist/session/init.d.ts +46 -0
- package/dist/session/init.d.ts.map +1 -0
- package/dist/session/init.js +3579 -0
- package/dist/session/keys.d.ts +56 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +55 -0
- package/dist/session/nimbus-session.d.ts +434 -0
- package/dist/session/nimbus-session.d.ts.map +1 -0
- package/dist/session/nimbus-session.js +1084 -0
- package/dist/session/programmatic.d.ts +105 -0
- package/dist/session/programmatic.d.ts.map +1 -0
- package/dist/session/programmatic.js +326 -0
- package/dist/session/replica-routes.d.ts +68 -0
- package/dist/session/replica-routes.d.ts.map +1 -0
- package/dist/session/replica-routes.js +77 -0
- package/dist/session/routes.d.ts +29 -0
- package/dist/session/routes.d.ts.map +1 -0
- package/dist/session/routes.js +1331 -0
- package/dist/session/rpc.d.ts +323 -0
- package/dist/session/rpc.d.ts.map +1 -0
- package/dist/session/rpc.js +808 -0
- package/dist/session/state-store.d.ts +210 -0
- package/dist/session/state-store.d.ts.map +1 -0
- package/dist/session/state-store.js +459 -0
- package/dist/session/supervisor-rpc.d.ts +269 -0
- package/dist/session/supervisor-rpc.d.ts.map +1 -0
- package/dist/session/supervisor-rpc.js +437 -0
- package/dist/session/ws-hibernation-config.d.ts +64 -0
- package/dist/session/ws-hibernation-config.d.ts.map +1 -0
- package/dist/session/ws-hibernation-config.js +89 -0
- package/dist/session/ws.d.ts +95 -0
- package/dist/session/ws.d.ts.map +1 -0
- package/dist/session/ws.js +395 -0
- package/dist/shell/features.d.ts +410 -0
- package/dist/shell/features.d.ts.map +1 -0
- package/dist/shell/features.js +1885 -0
- package/dist/shell/unix-commands.d.ts +15 -0
- package/dist/shell/unix-commands.d.ts.map +1 -0
- package/dist/shell/unix-commands.js +3703 -0
- package/dist/stubs/lifo-ui.d.ts +10 -0
- package/dist/stubs/lifo-ui.d.ts.map +1 -0
- package/dist/stubs/lifo-ui.js +1 -0
- package/dist/tailwind-play.generated.d.ts +19 -0
- package/dist/tailwind-play.generated.d.ts.map +1 -0
- package/dist/tailwind-play.generated.js +18 -0
- package/dist/vfs/dev-provider.d.ts +64 -0
- package/dist/vfs/dev-provider.d.ts.map +1 -0
- package/dist/vfs/dev-provider.js +191 -0
- package/dist/vfs/events.d.ts +51 -0
- package/dist/vfs/events.d.ts.map +1 -0
- package/dist/vfs/events.js +127 -0
- package/dist/vfs/path.d.ts +30 -0
- package/dist/vfs/path.d.ts.map +1 -0
- package/dist/vfs/path.js +41 -0
- package/dist/vfs/seed-project.d.ts +62 -0
- package/dist/vfs/seed-project.d.ts.map +1 -0
- package/dist/vfs/seed-project.js +1045 -0
- package/dist/vfs/sqlite-vfs.d.ts +435 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -0
- package/dist/vfs/sqlite-vfs.js +1670 -0
- package/dist/vfs/symlink-registry.d.ts +64 -0
- package/dist/vfs/symlink-registry.d.ts.map +1 -0
- package/dist/vfs/symlink-registry.js +137 -0
- package/dist/wrangler/nimbus-wrangler.d.ts +157 -0
- package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -0
- package/dist/wrangler/nimbus-wrangler.js +931 -0
- package/package.json +77 -0
- package/public/_assets/cirrus-plugin-react.bundle.js +70740 -0
- package/public/_assets/esbuild-0.24.2.wasm +0 -0
- package/public/_assets/real-vite-bundle.js +101478 -0
- package/public/_assets/rollup.wasm +0 -0
- package/public/favicon.svg +8 -0
- package/public/index.html +565 -0
- package/public/s/index.html +2379 -0
- package/scripts/bundle-esbuild-wasm.mjs +244 -0
- package/scripts/bundle-facet-workers.mjs +137 -0
- package/scripts/bundle-git.mjs +118 -0
- package/scripts/bundle-npm-cjs.mjs +363 -0
- package/scripts/bundle-plugin-react.mjs +480 -0
- package/scripts/bundle-real-vite.mjs +959 -0
- package/scripts/bundle-runtime.mjs +600 -0
- package/scripts/bundle-tailwind-play.mjs +130 -0
- package/scripts/patch-lifo-ui.mjs +131 -0
|
@@ -0,0 +1,2379 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Nimbus — Cloud Dev Environment</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
8
|
+
<style>
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
body { background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
|
11
|
+
|
|
12
|
+
/* Header */
|
|
13
|
+
.header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; min-height: 32px; }
|
|
14
|
+
.header h1 { font-size: 13px; font-weight: 600; color: #58a6ff; display: flex; gap: 8px; align-items: center; }
|
|
15
|
+
.header h1 span { color: #3fb950; font-weight: 400; font-family: ui-monospace, Menlo, monospace; font-size: 11px; cursor: pointer; padding: 1px 6px; border-radius: 3px; background: #161b22; border: 1px solid #30363d; transition: background 0.15s; }
|
|
16
|
+
.header h1 span:hover { background: #21262d; }
|
|
17
|
+
.header h1 span.copied { color: #3fb950; background: #0f2416; border-color: #238636; }
|
|
18
|
+
.status { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
|
19
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #f85149; transition: background 0.3s; }
|
|
20
|
+
.status-dot.connected { background: #3fb950; }
|
|
21
|
+
.status-dot.connecting { background: #d29922; animation: pulse 1s infinite; }
|
|
22
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
23
|
+
|
|
24
|
+
/* Toolbar */
|
|
25
|
+
.toolbar { display: flex; align-items: center; gap: 2px; padding: 2px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
|
|
26
|
+
.toolbar button { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 3px 10px; font-size: 11px; cursor: pointer; border-radius: 4px; }
|
|
27
|
+
.toolbar button:hover { background: #30363d; }
|
|
28
|
+
.toolbar button.active { background: #388bfd26; border-color: #58a6ff; color: #58a6ff; }
|
|
29
|
+
.toolbar .sep { width: 1px; height: 16px; background: #30363d; margin: 0 4px; }
|
|
30
|
+
.toolbar .vite-status { font-size: 11px; color: #8b949e; margin-left: auto; }
|
|
31
|
+
.toolbar .vite-status.running { color: #3fb950; }
|
|
32
|
+
/* Process count button. Sits next to the layout-mode buttons. Hidden
|
|
33
|
+
(display:none) until at least one process has been seen in this
|
|
34
|
+
session so it doesn't clutter a brand-new session's toolbar. */
|
|
35
|
+
.toolbar #btnProcs { display: none; gap: 6px; align-items: center; font-family: ui-monospace, Menlo, monospace; }
|
|
36
|
+
.toolbar #btnProcs.visible { display: inline-flex; }
|
|
37
|
+
.toolbar #btnProcs.has-running { border-color: #3fb950; color: #3fb950; }
|
|
38
|
+
.toolbar #btnProcs.active { background: #388bfd26; border-color: #58a6ff; color: #58a6ff; }
|
|
39
|
+
.toolbar #btnProcs .procs-dot { width: 6px; height: 6px; border-radius: 50%; background: #6e7681; display: inline-block; }
|
|
40
|
+
.toolbar #btnProcs.has-running .procs-dot { background: #3fb950; box-shadow: 0 0 4px #3fb950; animation: pulse 1.6s ease-in-out infinite; }
|
|
41
|
+
|
|
42
|
+
/* Main panel */
|
|
43
|
+
.main { flex: 1; display: flex; overflow: hidden; }
|
|
44
|
+
|
|
45
|
+
/* Terminal panel */
|
|
46
|
+
.panel-terminal { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
47
|
+
#terminal-container { flex: 1; padding: 4px; overflow: hidden; min-height: 0; }
|
|
48
|
+
|
|
49
|
+
/* Process log tabs panel — lives below the xterm inside .panel-terminal.
|
|
50
|
+
Hidden entirely when no tabs are open so the xterm fills the full panel. */
|
|
51
|
+
.logs-panel { display: none; flex-direction: column; border-top: 1px solid #30363d; background: #0d1117; flex-shrink: 0; max-height: 45%; min-height: 120px; }
|
|
52
|
+
.logs-panel.active { display: flex; }
|
|
53
|
+
.logs-tabbar { display: flex; align-items: stretch; background: #161b22; border-bottom: 1px solid #30363d; overflow-x: auto; scrollbar-width: thin; flex-shrink: 0; }
|
|
54
|
+
.logs-tabbar::-webkit-scrollbar { height: 4px; }
|
|
55
|
+
.logs-tabbar::-webkit-scrollbar-thumb { background: #30363d; }
|
|
56
|
+
.logs-tab { display: flex; align-items: center; gap: 6px; padding: 4px 8px 4px 10px; font-size: 11px; color: #8b949e; border-right: 1px solid #30363d; cursor: pointer; white-space: nowrap; background: #161b22; font-family: ui-monospace, Menlo, monospace; user-select: none; max-width: 240px; flex-shrink: 0; }
|
|
57
|
+
.logs-tab:hover { background: #1c2128; color: #c9d1d9; }
|
|
58
|
+
.logs-tab.active { background: #0d1117; color: #58a6ff; box-shadow: inset 0 -2px 0 #58a6ff; }
|
|
59
|
+
.logs-tab.exited { color: #6e7681; }
|
|
60
|
+
.logs-tab.exited.failed { color: #ff7b72; }
|
|
61
|
+
.logs-tab.exited.active { color: #ff9e80; }
|
|
62
|
+
.logs-tab .logs-tab-label { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
|
|
63
|
+
.logs-tab .logs-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: #3fb950; flex-shrink: 0; box-shadow: 0 0 4px #3fb950; }
|
|
64
|
+
.logs-tab.exited .logs-tab-dot { background: #6e7681; box-shadow: none; }
|
|
65
|
+
.logs-tab.exited.failed .logs-tab-dot { background: #ff7b72; box-shadow: none; }
|
|
66
|
+
.logs-tab .logs-tab-close { color: #6e7681; font-size: 13px; line-height: 1; padding: 0 2px; border-radius: 2px; margin-left: 2px; }
|
|
67
|
+
.logs-tab .logs-tab-close:hover { color: #f85149; background: #30363d; }
|
|
68
|
+
/* Primitives wave (P11): per-tab Kill / Restart controls. Kept
|
|
69
|
+
small + monochrome so they don't compete with the close X for
|
|
70
|
+
attention. Hover state mirrors the close-X red. */
|
|
71
|
+
.logs-tab .logs-tab-action { color: #6e7681; font-size: 11px; line-height: 1; padding: 0 4px; border-radius: 2px; margin-left: 2px; cursor: pointer; user-select: none; }
|
|
72
|
+
.logs-tab .logs-tab-action:hover { color: #c9d1d9; background: #30363d; }
|
|
73
|
+
.logs-tab .logs-tab-kill:hover { color: #f85149; }
|
|
74
|
+
.logs-tab.exited .logs-tab-action { display: none; }
|
|
75
|
+
.logs-panel-body { flex: 1; overflow: auto; background: #0d1117; position: relative; min-height: 0; }
|
|
76
|
+
.logs-view { display: none; white-space: pre; font-family: 'JetBrains Mono', 'Fira Code', Menlo, monospace; font-size: 12px; line-height: 1.5; color: #c9d1d9; padding: 8px 10px; word-break: break-all; min-height: 100%; }
|
|
77
|
+
.logs-view.active { display: block; }
|
|
78
|
+
.logs-view .stderr { color: #ff7b72; }
|
|
79
|
+
.logs-view .binary { color: #8b949e; font-style: italic; }
|
|
80
|
+
.logs-view .banner { display: block; color: #d29922; background: #221a00; border-left: 2px solid #d29922; padding: 4px 8px; margin: 6px 0; font-family: inherit; }
|
|
81
|
+
.logs-view .banner.ok { color: #3fb950; background: #0f2416; border-left-color: #3fb950; }
|
|
82
|
+
.logs-view .banner.failed { color: #ff7b72; background: #2d0d0c; border-left-color: #ff7b72; }
|
|
83
|
+
|
|
84
|
+
/* Resize handle */
|
|
85
|
+
.resize-handle { width: 4px; background: #30363d; cursor: col-resize; flex-shrink: 0; }
|
|
86
|
+
.resize-handle:hover { background: #58a6ff; }
|
|
87
|
+
/* monaco-polish (2026-05-14): vertical resize handle for editor (top) ↔
|
|
88
|
+
terminal (bottom) inside the editor-stack column. Hidden outside
|
|
89
|
+
editor mode (rule under .main.editor below). */
|
|
90
|
+
.vresize-handle {
|
|
91
|
+
display: none; height: 4px; background: #30363d;
|
|
92
|
+
cursor: row-resize; flex-shrink: 0;
|
|
93
|
+
}
|
|
94
|
+
.vresize-handle:hover { background: #58a6ff; }
|
|
95
|
+
|
|
96
|
+
/* Preview panel */
|
|
97
|
+
.panel-preview { flex: 1; display: flex; flex-direction: column; background: #0d1117; overflow: hidden; min-width: 0; }
|
|
98
|
+
.panel-preview.hidden { display: none; }
|
|
99
|
+
.preview-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
|
|
100
|
+
.preview-bar .url-bar { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 2px 8px; font-size: 11px; border-radius: 4px; font-family: monospace; }
|
|
101
|
+
.preview-bar button { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 4px; }
|
|
102
|
+
.preview-bar button:hover { color: #c9d1d9; }
|
|
103
|
+
#preview-frame { flex: 1; border: none; background: white; width: 100%; }
|
|
104
|
+
|
|
105
|
+
/* Footer */
|
|
106
|
+
.footer { padding: 3px 12px; background: #161b22; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; }
|
|
107
|
+
.footer .stats { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
108
|
+
.footer .stat-label { color: #484f58; }
|
|
109
|
+
.footer .stat-value { color: #c9d1d9; }
|
|
110
|
+
.footer .stat-value.good { color: #3fb950; }
|
|
111
|
+
|
|
112
|
+
/* Layout modes */
|
|
113
|
+
.main.terminal-only .panel-preview,
|
|
114
|
+
.main.terminal-only .panel-editor,
|
|
115
|
+
.main.terminal-only .resize-handle { display: none; }
|
|
116
|
+
.main.terminal-only .panel-terminal { flex: 1; }
|
|
117
|
+
.main.preview-only .panel-terminal,
|
|
118
|
+
.main.preview-only .panel-editor,
|
|
119
|
+
.main.preview-only .resize-handle { display: none; }
|
|
120
|
+
.main.preview-only .panel-preview { flex: 1; }
|
|
121
|
+
|
|
122
|
+
/* monaco-wave-a + Wave-B (2026-05-13/14):
|
|
123
|
+
Editor layout mode — 4-pane VSCode-style:
|
|
124
|
+
FILE TREE | editor-stack (editor TOP + terminal BOTTOM) | PREVIEW
|
|
125
|
+
Single mode: 'editor'. Dropped earlier 'editor-split' (editor+preview
|
|
126
|
+
without terminal) per user feedback "I don't need 'edit + preview'
|
|
127
|
+
at all". Renamed 'editor-split-with-term' → 'editor' for clarity.
|
|
128
|
+
|
|
129
|
+
Editor + terminal live INSIDE .panel-left-stack. In 1D modes
|
|
130
|
+
(terminal-only / split / preview-only) the stack is
|
|
131
|
+
display:contents (layout-transparent — children flex directly
|
|
132
|
+
into .main). In editor mode the stack flips to flex column so
|
|
133
|
+
editor (top) + terminal (bottom) stack vertically. */
|
|
134
|
+
|
|
135
|
+
/* Editor panel — hidden by default; shown by mode-specific rules. */
|
|
136
|
+
.panel-editor {
|
|
137
|
+
flex: 1; display: none; flex-direction: column;
|
|
138
|
+
background: #1e1e1e; /* VSCode dark editor background */
|
|
139
|
+
overflow: hidden; min-width: 0; min-height: 0;
|
|
140
|
+
}
|
|
141
|
+
.panel-editor.active { display: flex; }
|
|
142
|
+
|
|
143
|
+
/* Wrapper for the editor-stack column. display:contents in 1D
|
|
144
|
+
modes (transparent). flex column in editor mode. */
|
|
145
|
+
.panel-left-stack { display: contents; }
|
|
146
|
+
|
|
147
|
+
/* monaco-wave-b: File-tree sidebar. Lives as a FIRST child of
|
|
148
|
+
.main; visible only in 'editor' mode. ~250px default width,
|
|
149
|
+
resizable via .tree-resize-handle. */
|
|
150
|
+
.panel-tree {
|
|
151
|
+
display: none; flex-direction: column;
|
|
152
|
+
width: 250px; min-width: 160px; max-width: 600px;
|
|
153
|
+
background: #181818; /* slightly darker than editor bg */
|
|
154
|
+
border-right: 1px solid #30363d;
|
|
155
|
+
flex-shrink: 0;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
}
|
|
158
|
+
.tree-resize-handle {
|
|
159
|
+
display: none;
|
|
160
|
+
width: 4px; background: #30363d; cursor: col-resize; flex-shrink: 0;
|
|
161
|
+
}
|
|
162
|
+
.tree-resize-handle:hover { background: #58a6ff; }
|
|
163
|
+
|
|
164
|
+
.tree-toolbar {
|
|
165
|
+
display: flex; align-items: center; gap: 4px;
|
|
166
|
+
padding: 4px 6px;
|
|
167
|
+
background: #161b22;
|
|
168
|
+
border-bottom: 1px solid #30363d;
|
|
169
|
+
flex-shrink: 0;
|
|
170
|
+
min-height: 28px;
|
|
171
|
+
}
|
|
172
|
+
.tree-toolbar .tree-title {
|
|
173
|
+
flex: 1;
|
|
174
|
+
font-size: 11px; color: #8b949e; text-transform: uppercase;
|
|
175
|
+
letter-spacing: 0.5px; font-family: ui-monospace, Menlo, monospace;
|
|
176
|
+
padding-left: 4px;
|
|
177
|
+
}
|
|
178
|
+
.tree-toolbar button {
|
|
179
|
+
background: transparent; border: 1px solid transparent;
|
|
180
|
+
color: #8b949e; padding: 2px 6px; font-size: 14px;
|
|
181
|
+
cursor: pointer; border-radius: 3px; line-height: 1;
|
|
182
|
+
}
|
|
183
|
+
.tree-toolbar button:hover { background: #30363d; color: #c9d1d9; }
|
|
184
|
+
|
|
185
|
+
.tree-search-wrap {
|
|
186
|
+
padding: 4px 6px; background: #161b22;
|
|
187
|
+
border-bottom: 1px solid #30363d; flex-shrink: 0;
|
|
188
|
+
}
|
|
189
|
+
.tree-search {
|
|
190
|
+
width: 100%;
|
|
191
|
+
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
|
192
|
+
padding: 3px 6px; font-size: 11px; border-radius: 3px;
|
|
193
|
+
font-family: ui-monospace, Menlo, monospace; outline: none;
|
|
194
|
+
}
|
|
195
|
+
.tree-search:focus { border-color: #58a6ff; }
|
|
196
|
+
|
|
197
|
+
.tree-body {
|
|
198
|
+
flex: 1; overflow: auto; padding: 4px 0;
|
|
199
|
+
font-family: ui-monospace, Menlo, monospace; font-size: 12px;
|
|
200
|
+
color: #c9d1d9;
|
|
201
|
+
}
|
|
202
|
+
.tree-body::-webkit-scrollbar { width: 8px; }
|
|
203
|
+
.tree-body::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
|
204
|
+
.tree-body::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
|
205
|
+
|
|
206
|
+
/* Tree nodes — each row has its own indent calculated via padding-left.
|
|
207
|
+
Hover + selected states match VSCode dark. */
|
|
208
|
+
.tree-node {
|
|
209
|
+
display: flex; align-items: center; gap: 4px;
|
|
210
|
+
padding: 2px 8px 2px 4px;
|
|
211
|
+
cursor: pointer; user-select: none;
|
|
212
|
+
white-space: nowrap; overflow: hidden;
|
|
213
|
+
line-height: 20px;
|
|
214
|
+
}
|
|
215
|
+
.tree-node:hover { background: #21262d; }
|
|
216
|
+
.tree-node.selected { background: #1f6feb44; color: #58a6ff; }
|
|
217
|
+
.tree-node.hidden { display: none; }
|
|
218
|
+
.tree-node .tree-chevron {
|
|
219
|
+
width: 12px; flex-shrink: 0; color: #6e7681;
|
|
220
|
+
font-size: 9px; text-align: center;
|
|
221
|
+
transition: transform 0.15s;
|
|
222
|
+
}
|
|
223
|
+
.tree-node.dir > .tree-chevron { color: #c9d1d9; }
|
|
224
|
+
.tree-node.dir.expanded > .tree-chevron { transform: rotate(90deg); }
|
|
225
|
+
.tree-node.file > .tree-chevron { visibility: hidden; }
|
|
226
|
+
.tree-node .tree-icon { width: 14px; flex-shrink: 0; text-align: center; }
|
|
227
|
+
.tree-node .tree-label { overflow: hidden; text-overflow: ellipsis; }
|
|
228
|
+
.tree-node.dirty .tree-label::after { content: ' ●'; color: #d29922; }
|
|
229
|
+
.tree-children { /* unused; tree is flat with calculated indent */ }
|
|
230
|
+
.tree-empty {
|
|
231
|
+
padding: 16px 12px; color: #6e7681; font-style: italic;
|
|
232
|
+
font-size: 11px; text-align: center;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* Editor layout (single mode, monaco-wave-b): file tree | editor-stack | preview */
|
|
236
|
+
.main.editor .panel-tree { display: flex; }
|
|
237
|
+
.main.editor .tree-resize-handle { display: block; }
|
|
238
|
+
.main.editor .panel-left-stack {
|
|
239
|
+
display: flex; flex: 1; flex-direction: column;
|
|
240
|
+
min-width: 0; min-height: 0; overflow: hidden;
|
|
241
|
+
}
|
|
242
|
+
.main.editor .panel-editor {
|
|
243
|
+
display: flex; flex: 1 1 60%; min-height: 100px;
|
|
244
|
+
}
|
|
245
|
+
.main.editor .panel-terminal {
|
|
246
|
+
display: flex; flex: 0 0 40%; min-height: 80px;
|
|
247
|
+
}
|
|
248
|
+
.main.editor .vresize-handle { display: block; }
|
|
249
|
+
.main.editor .resize-handle { display: block; }
|
|
250
|
+
.main.editor .panel-preview { display: flex; flex: 1; min-width: 0; }
|
|
251
|
+
|
|
252
|
+
/* VSCode-like editor statusbar (bottom-of-editor bar with
|
|
253
|
+
line:col, language, encoding). */
|
|
254
|
+
.editor-statusbar {
|
|
255
|
+
display: flex; align-items: center; gap: 8px;
|
|
256
|
+
padding: 2px 12px;
|
|
257
|
+
background: #007acc; /* VSCode blue */
|
|
258
|
+
color: #ffffff;
|
|
259
|
+
font-family: ui-monospace, Menlo, monospace;
|
|
260
|
+
font-size: 11px;
|
|
261
|
+
flex-shrink: 0;
|
|
262
|
+
min-height: 20px;
|
|
263
|
+
}
|
|
264
|
+
.editor-statusbar .sep { color: rgba(255,255,255,0.5); }
|
|
265
|
+
.editor-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; font-size: 11px; color: #8b949e; min-height: 24px; }
|
|
266
|
+
.editor-bar .editor-tab { padding: 2px 8px; background: #21262d; border: 1px solid #30363d; border-radius: 3px; color: #c9d1d9; font-family: ui-monospace, Menlo, monospace; font-size: 11px; display: flex; align-items: center; gap: 6px; }
|
|
267
|
+
.editor-bar .editor-tab.dirty::after { content: '●'; color: #d29922; font-size: 10px; }
|
|
268
|
+
.editor-bar .editor-tab-empty { color: #6e7681; font-style: italic; }
|
|
269
|
+
.editor-bar .editor-hint { margin-left: auto; color: #6e7681; font-family: ui-monospace, Menlo, monospace; }
|
|
270
|
+
#monaco-container { flex: 1; min-height: 0; min-width: 0; }
|
|
271
|
+
.editor-placeholder { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: #8b949e; font-family: ui-monospace, Menlo, monospace; font-size: 13px; padding: 24px; text-align: center; }
|
|
272
|
+
.editor-placeholder .key { background: #21262d; border: 1px solid #30363d; padding: 1px 6px; border-radius: 3px; color: #c9d1d9; font-size: 11px; }
|
|
273
|
+
|
|
274
|
+
/* Command palette (Ctrl+P) */
|
|
275
|
+
.palette-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); z-index: 1000; display: none; align-items: flex-start; justify-content: center; padding-top: 80px; }
|
|
276
|
+
.palette-overlay.active { display: flex; }
|
|
277
|
+
.palette-box { background: #161b22; border: 1px solid #30363d; border-radius: 6px; width: 520px; max-width: 90vw; max-height: 60vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
|
|
278
|
+
.palette-input { background: #0d1117; border: none; border-bottom: 1px solid #30363d; color: #c9d1d9; padding: 10px 14px; font-size: 13px; outline: none; font-family: ui-monospace, Menlo, monospace; border-radius: 6px 6px 0 0; }
|
|
279
|
+
.palette-list { overflow-y: auto; max-height: 400px; }
|
|
280
|
+
.palette-item { padding: 6px 14px; color: #c9d1d9; font-family: ui-monospace, Menlo, monospace; font-size: 12px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
281
|
+
.palette-item.active { background: #1f6feb44; color: #58a6ff; }
|
|
282
|
+
.palette-item:hover { background: #21262d; }
|
|
283
|
+
.palette-empty { padding: 14px; color: #6e7681; font-style: italic; font-size: 12px; text-align: center; }
|
|
284
|
+
</style>
|
|
285
|
+
</head>
|
|
286
|
+
<body>
|
|
287
|
+
<div class="header">
|
|
288
|
+
<h1>
|
|
289
|
+
<a href="/" style="color:inherit;text-decoration:none">Nimbus</a>
|
|
290
|
+
<span id="sessionLabel" title="Session ID (shareable) — click to copy URL"></span>
|
|
291
|
+
</h1>
|
|
292
|
+
<div class="status">
|
|
293
|
+
<div class="status-dot" id="statusDot"></div>
|
|
294
|
+
<span class="status-text" id="statusText">Disconnected</span>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="toolbar" id="toolbar">
|
|
298
|
+
<button id="btnTerminal" class="active" title="Terminal">Terminal</button>
|
|
299
|
+
<button id="btnPreview" title="Preview">Preview</button>
|
|
300
|
+
<button id="btnSplit" title="Split view">Split</button>
|
|
301
|
+
<!-- monaco-wave-b (2026-05-14): single canonical Editor mode.
|
|
302
|
+
Drops Edit+Preview (per user "I don't need edit+preview at all").
|
|
303
|
+
Layout: file tree | editor (top) + terminal (bottom) | preview.
|
|
304
|
+
Lazy-loads Monaco + the tree on first click. -->
|
|
305
|
+
<button id="btnEditor" title="Editor (file tree + Monaco + terminal + preview)">Editor</button>
|
|
306
|
+
<div class="sep"></div>
|
|
307
|
+
<button id="btnProcs" title="Toggle process logs panel">
|
|
308
|
+
<span class="procs-dot"></span>
|
|
309
|
+
<span>Processes</span>
|
|
310
|
+
<span id="procsCount" style="color:#8b949e">0</span>
|
|
311
|
+
</button>
|
|
312
|
+
<button id="btnRefresh" title="Reload preview">↻</button>
|
|
313
|
+
<span class="vite-status" id="viteStatus"></span>
|
|
314
|
+
</div>
|
|
315
|
+
<div class="main" id="mainPanel">
|
|
316
|
+
<!-- monaco-wave-b (2026-05-14): file-tree sidebar. Visible ONLY
|
|
317
|
+
in editor mode (CSS toggles display:flex on .main.editor).
|
|
318
|
+
The .tree-resize-handle sits between tree and editor stack;
|
|
319
|
+
dragging it adjusts .panel-tree's width inline. -->
|
|
320
|
+
<div class="panel-tree" id="treePanel">
|
|
321
|
+
<div class="tree-toolbar">
|
|
322
|
+
<span class="tree-title">Explorer</span>
|
|
323
|
+
<button id="btnTreeNewFile" title="New File">+</button>
|
|
324
|
+
<button id="btnTreeNewDir" title="New Folder">📁</button>
|
|
325
|
+
<button id="btnTreeRefresh" title="Refresh tree (re-fetch root)">⟳</button>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="tree-search-wrap">
|
|
328
|
+
<input id="treeSearch" class="tree-search" placeholder="Filter files…" autocomplete="off" spellcheck="false">
|
|
329
|
+
</div>
|
|
330
|
+
<div class="tree-body" id="treeBody"></div>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="tree-resize-handle" id="treeResizeHandle"></div>
|
|
333
|
+
<!-- monaco-wave-a (2026-05-13, hotfix course-correct):
|
|
334
|
+
The .panel-left-stack wrapper is display:contents in 1D modes
|
|
335
|
+
(terminal-only, split, preview-only) — its children act as
|
|
336
|
+
direct flex children of .main. In editor mode (monaco-wave-b),
|
|
337
|
+
CSS flips it to display:flex; flex-direction:column so editor
|
|
338
|
+
and terminal stack vertically in the MIDDLE column. -->
|
|
339
|
+
<div class="panel-left-stack" id="leftStack">
|
|
340
|
+
<div class="panel-editor" id="editorPanel">
|
|
341
|
+
<div class="editor-bar">
|
|
342
|
+
<div class="editor-tab editor-tab-empty" id="editorTab">no file</div>
|
|
343
|
+
<span class="editor-hint">
|
|
344
|
+
<kbd>Ctrl+P</kbd> open file · <kbd>Ctrl+S</kbd> save
|
|
345
|
+
</span>
|
|
346
|
+
<span class="editor-status" id="editorStatus" style="margin-left:8px;color:#6e7681;font-family:ui-monospace,Menlo,monospace;font-size:10px"></span>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="editor-placeholder" id="editorPlaceholder">
|
|
349
|
+
<div>Press <span class="key">Ctrl</span>+<span class="key">P</span> to open a file.</div>
|
|
350
|
+
<div style="font-size:11px;color:#6e7681">Monaco loads on demand.</div>
|
|
351
|
+
</div>
|
|
352
|
+
<div id="monaco-container" style="display:none"></div>
|
|
353
|
+
<div class="editor-statusbar" id="editorStatusBar">
|
|
354
|
+
<span id="editorStatusPos">—</span>
|
|
355
|
+
<span class="sep">·</span>
|
|
356
|
+
<span id="editorStatusLang">plaintext</span>
|
|
357
|
+
<span class="sep">·</span>
|
|
358
|
+
<span id="editorStatusEnc">UTF-8</span>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
<!-- monaco-polish (2026-05-14): vertical resize handle between
|
|
362
|
+
editor (top) and terminal (bottom) inside the editor-stack
|
|
363
|
+
column. Visible only in editor mode (.main.editor). -->
|
|
364
|
+
<div class="vresize-handle" id="editorTerminalResizeHandle"></div>
|
|
365
|
+
<div class="panel-terminal">
|
|
366
|
+
<div id="terminal-container"></div>
|
|
367
|
+
<div class="logs-panel" id="logsPanel">
|
|
368
|
+
<div class="logs-tabbar" id="logsTabBar"></div>
|
|
369
|
+
<div class="logs-panel-body" id="logsPanelBody"></div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="resize-handle" id="resizeHandle"></div>
|
|
374
|
+
<div class="panel-preview" id="previewPanel">
|
|
375
|
+
<div class="preview-bar">
|
|
376
|
+
<span style="color:#3fb950;font-size:12px">●</span>
|
|
377
|
+
<input class="url-bar" id="urlBar" value="" readonly>
|
|
378
|
+
<button id="btnReloadPreview" title="Reload">↻</button>
|
|
379
|
+
</div>
|
|
380
|
+
<iframe id="preview-frame" src="about:blank"></iframe>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
<!-- monaco-wave-a (2026-05-13): Ctrl+P command palette. Lazy-populated
|
|
384
|
+
fuzzy file finder; fs-list message sent on first open per
|
|
385
|
+
editor session. -->
|
|
386
|
+
<div class="palette-overlay" id="paletteOverlay">
|
|
387
|
+
<div class="palette-box">
|
|
388
|
+
<input class="palette-input" id="paletteInput" placeholder="Type a file name…" autocomplete="off" spellcheck="false">
|
|
389
|
+
<div class="palette-list" id="paletteList"></div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
<div class="footer">
|
|
393
|
+
<div class="stats" id="statsBar">
|
|
394
|
+
<span><span class="stat-label">FS:</span> <span class="stat-value" id="stFiles">-</span></span>
|
|
395
|
+
<span><span class="stat-label">Size:</span> <span class="stat-value" id="stSize">-</span></span>
|
|
396
|
+
<span><span class="stat-label">Cache:</span> <span class="stat-value" id="stCache">-</span></span>
|
|
397
|
+
<span><span class="stat-label">Hit:</span> <span class="stat-value good" id="stHitRate">-</span></span>
|
|
398
|
+
</div>
|
|
399
|
+
<span style="color:#484f58">DO + SQLite demand-paged | esbuild-wasm | vite dev server</span>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
403
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
404
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
|
405
|
+
<script>
|
|
406
|
+
// ── Session-aware URL helpers ──
|
|
407
|
+
// This page is served at /s/<id>/ via the asset binding. All DO-bound
|
|
408
|
+
// paths (/ws, /api/*, /preview/*) live under the same session prefix.
|
|
409
|
+
// Compute once at load time — location.pathname won't change.
|
|
410
|
+
const SESSION_PREFIX = (function() {
|
|
411
|
+
const m = location.pathname.match(/^\/s\/([a-z]{3,14}-[a-z]{3,14}-\d{4})(?:\/|$)/);
|
|
412
|
+
return m ? '/s/' + m[1] : '';
|
|
413
|
+
})();
|
|
414
|
+
const WS_URL = (location.protocol === 'https:' ? 'wss:' : 'ws:') +
|
|
415
|
+
'//' + location.host + SESSION_PREFIX + '/ws';
|
|
416
|
+
const STATS_URL = SESSION_PREFIX + '/api/stats';
|
|
417
|
+
const VITE_URL = SESSION_PREFIX + '/preview/';
|
|
418
|
+
const WORKER_URL = SESSION_PREFIX + '/worker/';
|
|
419
|
+
// The iframe starts pointing at /preview/ which handles its own
|
|
420
|
+
// "no dev server running" placeholder. When the user spawns a
|
|
421
|
+
// wrangler dev (via `wrangler dev` in the terminal), stats polling
|
|
422
|
+
// below flips this to WORKER_URL automatically.
|
|
423
|
+
const PREVIEW_URL = VITE_URL;
|
|
424
|
+
|
|
425
|
+
// Populate the URL bar with the full session preview URL so users can
|
|
426
|
+
// copy-paste it (e.g. to share just the preview with a teammate).
|
|
427
|
+
// The value is kept in sync with the iframe by applyPreviewTarget().
|
|
428
|
+
document.getElementById('urlBar').value = PREVIEW_URL;
|
|
429
|
+
|
|
430
|
+
// Update document title to include the session ID — makes tabs easy to
|
|
431
|
+
// distinguish when a user has multiple Nimbus sessions open.
|
|
432
|
+
// Also populate the session-label chip in the header; clicking it copies
|
|
433
|
+
// the full session URL to the clipboard for easy sharing.
|
|
434
|
+
if (SESSION_PREFIX) {
|
|
435
|
+
const sessionId = SESSION_PREFIX.slice(3); // strip "/s/"
|
|
436
|
+
document.title = 'Nimbus — ' + sessionId;
|
|
437
|
+
const chip = document.getElementById('sessionLabel');
|
|
438
|
+
if (chip) {
|
|
439
|
+
chip.textContent = sessionId;
|
|
440
|
+
chip.addEventListener('click', async () => {
|
|
441
|
+
const shareUrl = location.origin + SESSION_PREFIX + '/';
|
|
442
|
+
try {
|
|
443
|
+
await navigator.clipboard.writeText(shareUrl);
|
|
444
|
+
const original = chip.textContent;
|
|
445
|
+
chip.textContent = 'copied!';
|
|
446
|
+
chip.classList.add('copied');
|
|
447
|
+
setTimeout(() => { chip.textContent = original; chip.classList.remove('copied'); }, 1200);
|
|
448
|
+
} catch { /* clipboard API unavailable — silently ignore */ }
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Terminal setup ──
|
|
454
|
+
const term = new Terminal({
|
|
455
|
+
cursorBlink: true, cursorStyle: 'bar', fontSize: 13,
|
|
456
|
+
fontFamily: "'JetBrains Mono', 'Fira Code', Menlo, monospace",
|
|
457
|
+
theme: {
|
|
458
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff', selectionBackground: '#264f78',
|
|
459
|
+
black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
|
|
460
|
+
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39d353', white: '#c9d1d9',
|
|
461
|
+
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341',
|
|
462
|
+
brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d364', brightWhite: '#f0f6fc',
|
|
463
|
+
},
|
|
464
|
+
allowProposedApi: true,
|
|
465
|
+
// macOS Option key emits Meta-prefixed sequences (\x1b<letter>),
|
|
466
|
+
// matching Linux Alt behaviour. Without this, Option+B/F/D on
|
|
467
|
+
// Mac silently inserts unicode `∫ƒ∂` glyphs instead of triggering
|
|
468
|
+
// word-by-word readline edits. xterm.js default is false; bash /
|
|
469
|
+
// Terminal.app / iTerm2 default this to true via their own
|
|
470
|
+
// "Use Option as Meta" preference. We hard-set true so Mac users
|
|
471
|
+
// get Linux-parity readline keybindings out of the box.
|
|
472
|
+
macOptionIsMeta: true,
|
|
473
|
+
});
|
|
474
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
475
|
+
term.loadAddon(fitAddon);
|
|
476
|
+
try { term.loadAddon(new WebglAddon.WebglAddon()); } catch (e) {}
|
|
477
|
+
term.open(document.getElementById('terminal-container'));
|
|
478
|
+
fitAddon.fit();
|
|
479
|
+
|
|
480
|
+
// ── State ──
|
|
481
|
+
const dot = document.getElementById('statusDot');
|
|
482
|
+
const stText = document.getElementById('statusText');
|
|
483
|
+
const previewFrame = document.getElementById('preview-frame');
|
|
484
|
+
const previewPanel = document.getElementById('previewPanel');
|
|
485
|
+
const mainPanel = document.getElementById('mainPanel');
|
|
486
|
+
const viteStatus = document.getElementById('viteStatus');
|
|
487
|
+
let ws = null, rd = 1000, statsTimer = null;
|
|
488
|
+
// monaco-wave-b (2026-05-14): single 'editor' mode replaces the
|
|
489
|
+
// Wave-A duo of 'editor-split' (dropped per user feedback) and
|
|
490
|
+
// 'editor-split-with-term' (renamed → 'editor'). Layout:
|
|
491
|
+
// file tree | editor (top) + terminal (bottom) | preview
|
|
492
|
+
// Old modes preserved verbatim: 'split', 'terminal-only', 'preview-only'.
|
|
493
|
+
let layout = 'split';
|
|
494
|
+
|
|
495
|
+
// ── Layout controls ──
|
|
496
|
+
function setLayout(mode) {
|
|
497
|
+
layout = mode;
|
|
498
|
+
mainPanel.className = 'main' + (mode !== 'split' ? ' ' + mode : '');
|
|
499
|
+
document.getElementById('btnTerminal').classList.toggle('active', mode === 'terminal-only');
|
|
500
|
+
document.getElementById('btnPreview').classList.toggle('active', mode === 'preview-only');
|
|
501
|
+
document.getElementById('btnSplit').classList.toggle('active', mode === 'split');
|
|
502
|
+
document.getElementById('btnEditor').classList.toggle('active', mode === 'editor');
|
|
503
|
+
// Editor panel visibility — drives display:flex via .active.
|
|
504
|
+
const editorPanel = document.getElementById('editorPanel');
|
|
505
|
+
const wantEditor = (mode === 'editor');
|
|
506
|
+
editorPanel.classList.toggle('active', wantEditor);
|
|
507
|
+
// Lazy-init Monaco + the file tree the FIRST time the user
|
|
508
|
+
// switches to editor mode. Cold session pays neither cost.
|
|
509
|
+
if (wantEditor) {
|
|
510
|
+
Editor.ensureLoaded();
|
|
511
|
+
FileTree.ensureLoaded();
|
|
512
|
+
}
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
// monaco-polish: re-apply persisted pane dims for this layout.
|
|
515
|
+
// PaneResizer uses inline flex which is mode-specific (split vs
|
|
516
|
+
// editor have different pane structures); onLayoutChange picks
|
|
517
|
+
// the right elements for the active mode.
|
|
518
|
+
try { if (typeof PaneResizer !== 'undefined') PaneResizer.onLayoutChange(); } catch {}
|
|
519
|
+
try { fitAddon.fit(); } catch {}
|
|
520
|
+
// Re-layout Monaco after the panel resizes. automaticLayout:true
|
|
521
|
+
// already handles this but explicit layout() on mode switch
|
|
522
|
+
// avoids a one-frame flash where the editor sees its old size.
|
|
523
|
+
if (window.__nimbusMonacoEditor) {
|
|
524
|
+
try { window.__nimbusMonacoEditor.layout(); } catch {}
|
|
525
|
+
}
|
|
526
|
+
}, 50);
|
|
527
|
+
}
|
|
528
|
+
document.getElementById('btnTerminal').onclick = () => setLayout('terminal-only');
|
|
529
|
+
document.getElementById('btnPreview').onclick = () => setLayout('preview-only');
|
|
530
|
+
document.getElementById('btnSplit').onclick = () => setLayout('split');
|
|
531
|
+
document.getElementById('btnEditor').onclick = () => setLayout('editor');
|
|
532
|
+
document.getElementById('btnRefresh').onclick = () => reloadPreview();
|
|
533
|
+
document.getElementById('btnReloadPreview').onclick = () => reloadPreview();
|
|
534
|
+
|
|
535
|
+
// ── monaco-wave-a (2026-05-13): Editor module ──
|
|
536
|
+
//
|
|
537
|
+
// Lazy-loads Monaco via cdnjs UMD on first call to ensureLoaded().
|
|
538
|
+
// Cold session (terminal-only flow) never pays the ~5MB download.
|
|
539
|
+
//
|
|
540
|
+
// Owns the open file, dirty state, Ctrl+P palette, Ctrl+S save.
|
|
541
|
+
// All fs operations route through the live WS via fs-read /
|
|
542
|
+
// fs-write / fs-list messages → init.ts handler → SqliteVFS.
|
|
543
|
+
//
|
|
544
|
+
// Perf-invariant: probes assert monaco-* scripts NOT loaded on a
|
|
545
|
+
// cold terminal-only session (see tests/behavioral/monaco-wave-a/
|
|
546
|
+
// new/monaco-lazy-not-on-cold.mjs).
|
|
547
|
+
const Editor = (function() {
|
|
548
|
+
// CDN choice: cdnjs hosts Monaco's UMD AMD-loader (`loader.min.js`)
|
|
549
|
+
// which fetches the remaining vs/* chunks on demand. Total cold
|
|
550
|
+
// download is ~2 MB for the editor + lazy languages. Pinned to
|
|
551
|
+
// 0.52.0 (latest as of 2025-Q1) — same major as VSCode 1.95+.
|
|
552
|
+
const MONACO_VERSION = '0.52.0';
|
|
553
|
+
const MONACO_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/' + MONACO_VERSION + '/min';
|
|
554
|
+
let loaded = false;
|
|
555
|
+
let loading = null;
|
|
556
|
+
// Cached fs-list result from a previous Ctrl+P (5s TTL).
|
|
557
|
+
let fileListCache = null;
|
|
558
|
+
let fileListCachedAt = 0;
|
|
559
|
+
// Pending fs-read / fs-write / fs-list resolves keyed by op id.
|
|
560
|
+
// The terminal WS multiplexes; we tag each message with a `reqId`
|
|
561
|
+
// so concurrent reads (e.g. preview tab + palette refresh) don't
|
|
562
|
+
// race on the same path.
|
|
563
|
+
const pending = new Map();
|
|
564
|
+
let nextReqId = 1;
|
|
565
|
+
|
|
566
|
+
// Current open file state.
|
|
567
|
+
let openPath = null; // POSIX absolute path or null
|
|
568
|
+
let originalContent = ''; // last-saved content (for dirty check)
|
|
569
|
+
let editor = null; // Monaco editor instance
|
|
570
|
+
|
|
571
|
+
function tabEl() { return document.getElementById('editorTab'); }
|
|
572
|
+
function setTabFile(path, dirty) {
|
|
573
|
+
const el = tabEl();
|
|
574
|
+
if (!path) {
|
|
575
|
+
el.textContent = 'no file';
|
|
576
|
+
el.classList.add('editor-tab-empty');
|
|
577
|
+
el.classList.remove('dirty');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// Show just the basename in the tab for clarity; full path in
|
|
581
|
+
// the title attribute.
|
|
582
|
+
const base = path.split('/').pop() || path;
|
|
583
|
+
el.textContent = base;
|
|
584
|
+
el.title = path;
|
|
585
|
+
el.classList.remove('editor-tab-empty');
|
|
586
|
+
el.classList.toggle('dirty', !!dirty);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Send a fs-* message; await the paired result. Resolves with the
|
|
590
|
+
// result frame; rejects on error or timeout (15s — generous for
|
|
591
|
+
// big trees, tight enough that a hung session surfaces fast).
|
|
592
|
+
// file-tree-fix (2026-05-14): outbound queue for fs-* frames
|
|
593
|
+
// sent before ws.onopen fires. User repro: "fs-list failed:
|
|
594
|
+
// WS not connected" when clicking Editor before terminal prompt.
|
|
595
|
+
//
|
|
596
|
+
// Race window: connect() runs at page-load and ws is in CONNECTING
|
|
597
|
+
// state (readyState === 0). If the user clicks Editor BEFORE
|
|
598
|
+
// ws.onopen fires, setLayout('editor') invokes Editor.ensureLoaded
|
|
599
|
+
// → eventually fsRequest, which pre-fix rejected synchronously.
|
|
600
|
+
//
|
|
601
|
+
// Fix: register the pending request synchronously (reqId assigned,
|
|
602
|
+
// resolve/reject stored), queue the frame in `outQueue`, return
|
|
603
|
+
// the promise. When ws.onopen fires (drainFsQueue below),
|
|
604
|
+
// we ws.send() every queued frame. The response handler
|
|
605
|
+
// (tryHandleFsResult) is unchanged; it resolves the same pending
|
|
606
|
+
// entry whenever the server replies.
|
|
607
|
+
const outQueue = [];
|
|
608
|
+
function fsRequest(frame, resultType) {
|
|
609
|
+
const reqId = nextReqId++;
|
|
610
|
+
frame.reqId = reqId;
|
|
611
|
+
return new Promise((resolve, reject) => {
|
|
612
|
+
const t = setTimeout(() => {
|
|
613
|
+
if (pending.has(reqId)) {
|
|
614
|
+
pending.delete(reqId);
|
|
615
|
+
reject(new Error('fs request timed out'));
|
|
616
|
+
}
|
|
617
|
+
}, 15000);
|
|
618
|
+
pending.set(reqId, { resolve, reject, t, resultType });
|
|
619
|
+
// Try to send now if the WS is OPEN; otherwise queue.
|
|
620
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
621
|
+
try { ws.send(JSON.stringify(frame)); }
|
|
622
|
+
catch (e) {
|
|
623
|
+
// OPEN but send threw — surface the failure immediately.
|
|
624
|
+
clearTimeout(t); pending.delete(reqId); reject(e);
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
outQueue.push(frame);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
// Drain the outbound queue. Called from ws.onopen. Idempotent
|
|
632
|
+
// (safe to call multiple times — empty queue is a no-op).
|
|
633
|
+
// Resilient to reconnects: queue accumulates while ws.readyState
|
|
634
|
+
// is CONNECTING/CLOSED and flushes on next onopen.
|
|
635
|
+
function drainFsQueue() {
|
|
636
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
637
|
+
const drained = outQueue.splice(0);
|
|
638
|
+
for (const frame of drained) {
|
|
639
|
+
try { ws.send(JSON.stringify(frame)); }
|
|
640
|
+
catch (e) {
|
|
641
|
+
// Send failed on a drained frame — reject the pending entry.
|
|
642
|
+
const slot = pending.get(frame.reqId);
|
|
643
|
+
if (slot) {
|
|
644
|
+
pending.delete(frame.reqId);
|
|
645
|
+
clearTimeout(slot.t);
|
|
646
|
+
slot.reject(e);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Hook into the main WS onmessage to deliver fs-*-result frames.
|
|
652
|
+
// Called by connect() right after parsing each incoming message —
|
|
653
|
+
// returns true to indicate the message was handled here (and the
|
|
654
|
+
// shell-side dispatch should skip it).
|
|
655
|
+
function tryHandleFsResult(msg) {
|
|
656
|
+
if (!msg || typeof msg.type !== 'string') return false;
|
|
657
|
+
if (!msg.type.endsWith('-result')) return false;
|
|
658
|
+
const reqId = msg.reqId;
|
|
659
|
+
if (typeof reqId !== 'number') return false;
|
|
660
|
+
const slot = pending.get(reqId);
|
|
661
|
+
if (!slot) return false;
|
|
662
|
+
pending.delete(reqId);
|
|
663
|
+
clearTimeout(slot.t);
|
|
664
|
+
slot.resolve(msg);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Detect language from extension. Monaco built-ins cover most.
|
|
669
|
+
function languageFor(path) {
|
|
670
|
+
const m = /\.([^.]+)$/.exec(path || '');
|
|
671
|
+
const ext = m ? m[1].toLowerCase() : '';
|
|
672
|
+
const map = {
|
|
673
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
674
|
+
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
|
675
|
+
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
|
|
676
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
|
|
677
|
+
java: 'java', kt: 'kotlin', swift: 'swift',
|
|
678
|
+
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini',
|
|
679
|
+
md: 'markdown', html: 'html', css: 'css', scss: 'scss',
|
|
680
|
+
sh: 'shell', bash: 'shell', zsh: 'shell',
|
|
681
|
+
xml: 'xml', sql: 'sql', dockerfile: 'dockerfile',
|
|
682
|
+
};
|
|
683
|
+
return map[ext] || 'plaintext';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function ensureLoaded() {
|
|
687
|
+
if (loaded) return Promise.resolve();
|
|
688
|
+
if (loading) return loading;
|
|
689
|
+
loading = new Promise((resolve, reject) => {
|
|
690
|
+
// Step 1: AMD loader. cdnjs ships the UMD `loader.min.js` that
|
|
691
|
+
// bootstraps Monaco's internal AMD module graph.
|
|
692
|
+
const script = document.createElement('script');
|
|
693
|
+
script.src = MONACO_BASE + '/vs/loader.min.js';
|
|
694
|
+
script.onload = () => {
|
|
695
|
+
// Step 2: configure paths + require the editor module.
|
|
696
|
+
const requireJs = window.require;
|
|
697
|
+
if (!requireJs || typeof requireJs.config !== 'function') {
|
|
698
|
+
reject(new Error('Monaco AMD loader failed to register'));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
requireJs.config({ paths: { 'vs': MONACO_BASE + '/vs' } });
|
|
702
|
+
requireJs(['vs/editor/editor.main'], () => {
|
|
703
|
+
try {
|
|
704
|
+
createEditor();
|
|
705
|
+
loaded = true;
|
|
706
|
+
resolve();
|
|
707
|
+
} catch (e) {
|
|
708
|
+
reject(e);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
};
|
|
712
|
+
script.onerror = () => reject(new Error('Failed to load Monaco AMD loader from ' + script.src));
|
|
713
|
+
document.head.appendChild(script);
|
|
714
|
+
});
|
|
715
|
+
return loading;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function createEditor() {
|
|
719
|
+
const container = document.getElementById('monaco-container');
|
|
720
|
+
container.style.display = 'block';
|
|
721
|
+
// Hide the placeholder once Monaco mounts.
|
|
722
|
+
const ph = document.getElementById('editorPlaceholder');
|
|
723
|
+
if (ph) ph.style.display = 'none';
|
|
724
|
+
|
|
725
|
+
// monaco-wave-a hotfix (2026-05-13): full VSCode-like options
|
|
726
|
+
// (per user feedback "current is useless — make it proper, nice").
|
|
727
|
+
// This config mirrors VSCode 1.95+ defaults for a TS/JS file.
|
|
728
|
+
// Verified against actual VSCode source (monaco.IEditorOptions).
|
|
729
|
+
editor = window.monaco.editor.create(container, {
|
|
730
|
+
value: '',
|
|
731
|
+
language: 'plaintext',
|
|
732
|
+
theme: 'vs-dark', // VSCode dark theme
|
|
733
|
+
// Critical for split-pane resize — Monaco re-measures on
|
|
734
|
+
// ResizeObserver hits without explicit layout() calls.
|
|
735
|
+
automaticLayout: true,
|
|
736
|
+
// Visual chrome.
|
|
737
|
+
minimap: { enabled: true, side: 'right', renderCharacters: true, maxColumn: 120, scale: 1 },
|
|
738
|
+
lineNumbers: 'on',
|
|
739
|
+
lineNumbersMinChars: 3,
|
|
740
|
+
glyphMargin: false,
|
|
741
|
+
folding: true,
|
|
742
|
+
renderLineHighlight: 'all',
|
|
743
|
+
renderWhitespace: 'selection',
|
|
744
|
+
renderControlCharacters: false,
|
|
745
|
+
renderFinalNewline: 'on',
|
|
746
|
+
// Cursor + selection chrome.
|
|
747
|
+
cursorBlinking: 'smooth',
|
|
748
|
+
cursorSmoothCaretAnimation: 'on',
|
|
749
|
+
cursorStyle: 'line',
|
|
750
|
+
cursorWidth: 2,
|
|
751
|
+
// Typography. VSCode defaults to 14px Menlo/Monaco/Consolas.
|
|
752
|
+
fontFamily: "Menlo, Monaco, 'Courier New', 'JetBrains Mono', 'Fira Code', monospace",
|
|
753
|
+
fontSize: 14,
|
|
754
|
+
lineHeight: 22,
|
|
755
|
+
fontLigatures: true,
|
|
756
|
+
// Whitespace / indent.
|
|
757
|
+
tabSize: 2,
|
|
758
|
+
insertSpaces: true,
|
|
759
|
+
detectIndentation: true,
|
|
760
|
+
trimAutoWhitespace: true,
|
|
761
|
+
// Bracket / indent guides — the colorful nested brackets +
|
|
762
|
+
// dotted vertical indent lines users expect from VSCode.
|
|
763
|
+
bracketPairColorization: { enabled: true, independentColorPoolPerBracketType: true },
|
|
764
|
+
guides: {
|
|
765
|
+
bracketPairs: 'active',
|
|
766
|
+
bracketPairsHorizontal: 'active',
|
|
767
|
+
indentation: true,
|
|
768
|
+
highlightActiveIndentation: true,
|
|
769
|
+
highlightActiveBracketPair: true,
|
|
770
|
+
},
|
|
771
|
+
matchBrackets: 'always',
|
|
772
|
+
// Editing behavior.
|
|
773
|
+
autoIndent: 'full',
|
|
774
|
+
autoClosingBrackets: 'languageDefined',
|
|
775
|
+
autoClosingQuotes: 'languageDefined',
|
|
776
|
+
autoSurround: 'languageDefined',
|
|
777
|
+
formatOnPaste: true,
|
|
778
|
+
formatOnType: true,
|
|
779
|
+
// Multi-cursor and selection ergonomics.
|
|
780
|
+
multiCursorModifier: 'alt',
|
|
781
|
+
columnSelection: false,
|
|
782
|
+
// Word wrap (toggle-friendly default).
|
|
783
|
+
wordWrap: 'on',
|
|
784
|
+
wordWrapColumn: 120,
|
|
785
|
+
wrappingIndent: 'indent',
|
|
786
|
+
// Scrolling.
|
|
787
|
+
smoothScrolling: true,
|
|
788
|
+
mouseWheelScrollSensitivity: 1,
|
|
789
|
+
fastScrollSensitivity: 5,
|
|
790
|
+
scrollBeyondLastLine: false,
|
|
791
|
+
scrollbar: {
|
|
792
|
+
vertical: 'auto',
|
|
793
|
+
horizontal: 'auto',
|
|
794
|
+
useShadows: true,
|
|
795
|
+
verticalScrollbarSize: 14,
|
|
796
|
+
horizontalScrollbarSize: 14,
|
|
797
|
+
},
|
|
798
|
+
// Suggest / IntelliSense (works for built-in languages even
|
|
799
|
+
// without LSP — Monaco ships JS/TS/JSON/HTML/CSS workers
|
|
800
|
+
// lazily; we leave the defaults).
|
|
801
|
+
suggestOnTriggerCharacters: true,
|
|
802
|
+
acceptSuggestionOnEnter: 'on',
|
|
803
|
+
quickSuggestions: { other: true, comments: false, strings: false },
|
|
804
|
+
parameterHints: { enabled: true, cycle: true },
|
|
805
|
+
// Misc polish.
|
|
806
|
+
stickyScroll: { enabled: true },
|
|
807
|
+
links: true,
|
|
808
|
+
dragAndDrop: true,
|
|
809
|
+
mouseStyle: 'text',
|
|
810
|
+
contextmenu: true,
|
|
811
|
+
copyWithSyntaxHighlighting: false,
|
|
812
|
+
});
|
|
813
|
+
window.__nimbusMonacoEditor = editor;
|
|
814
|
+
// Dirty-state tracking. Also propagate to FileTree so the
|
|
815
|
+
// tree row shows the dirty dot (matches editor tab).
|
|
816
|
+
editor.onDidChangeModelContent(() => {
|
|
817
|
+
if (!openPath) return;
|
|
818
|
+
const cur = editor.getValue();
|
|
819
|
+
const dirty = cur !== originalContent;
|
|
820
|
+
setTabFile(openPath, dirty);
|
|
821
|
+
if (typeof FileTree !== 'undefined') {
|
|
822
|
+
try { FileTree.markDirty(openPath, dirty); } catch {}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
// Cursor position → status bar.
|
|
826
|
+
const statusPos = document.getElementById('editorStatusPos');
|
|
827
|
+
const statusLang = document.getElementById('editorStatusLang');
|
|
828
|
+
editor.onDidChangeCursorPosition((e) => {
|
|
829
|
+
if (statusPos) statusPos.textContent = 'Ln ' + e.position.lineNumber + ', Col ' + e.position.column;
|
|
830
|
+
});
|
|
831
|
+
editor.onDidChangeModelLanguage((e) => {
|
|
832
|
+
if (statusLang) statusLang.textContent = e.newLanguage || 'plaintext';
|
|
833
|
+
});
|
|
834
|
+
// Ctrl+S save — Monaco intercepts the keystroke when focused.
|
|
835
|
+
editor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyS, () => save());
|
|
836
|
+
// Cmd+P / Ctrl+P inside the editor → palette (palette also has
|
|
837
|
+
// a global keydown handler but registering at the editor level
|
|
838
|
+
// is what real VSCode does, so feel matches).
|
|
839
|
+
editor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyP, () => openPalette());
|
|
840
|
+
// Layout once after creation so the editor sizes correctly.
|
|
841
|
+
setTimeout(() => editor.layout(), 0);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function openFile(path) {
|
|
845
|
+
if (!path) return;
|
|
846
|
+
// monaco-wave-b: dirty-prompt before switching files. If the
|
|
847
|
+
// current buffer has unsaved changes, ask the user whether
|
|
848
|
+
// to (a) save first, (b) discard and switch, or (c) cancel.
|
|
849
|
+
// Skip when switching to the SAME file (no-op) or when there
|
|
850
|
+
// is no editor yet.
|
|
851
|
+
if (editor && openPath && openPath !== path) {
|
|
852
|
+
const cur = editor.getValue();
|
|
853
|
+
if (cur !== originalContent) {
|
|
854
|
+
const choice = confirm(
|
|
855
|
+
'Unsaved changes in ' + openPath + '\n\n' +
|
|
856
|
+
'OK = save and switch.\n' +
|
|
857
|
+
'Cancel = discard changes and switch.'
|
|
858
|
+
);
|
|
859
|
+
if (choice) {
|
|
860
|
+
await save();
|
|
861
|
+
}
|
|
862
|
+
// Either way, proceed to load the new file (the user
|
|
863
|
+
// explicitly chose to leave the current buffer).
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
await ensureLoaded();
|
|
867
|
+
let result;
|
|
868
|
+
try {
|
|
869
|
+
result = await fsRequest({ type: 'fs-read', path }, 'fs-read-result');
|
|
870
|
+
} catch (e) {
|
|
871
|
+
setStatusFlash('open failed: ' + (e?.message || e));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (result.error) {
|
|
875
|
+
if (result.binary) {
|
|
876
|
+
setStatusFlash('binary file refused: ' + path);
|
|
877
|
+
} else {
|
|
878
|
+
setStatusFlash('open failed: ' + result.error);
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const content = result.content || '';
|
|
883
|
+
openPath = path;
|
|
884
|
+
originalContent = content;
|
|
885
|
+
const lang = languageFor(path);
|
|
886
|
+
// Recreate model so language detection takes effect (Monaco
|
|
887
|
+
// doesn't auto-relanguage on setValue).
|
|
888
|
+
const model = window.monaco.editor.createModel(content, lang);
|
|
889
|
+
const old = editor.getModel();
|
|
890
|
+
editor.setModel(model);
|
|
891
|
+
if (old) try { old.dispose(); } catch {}
|
|
892
|
+
setTabFile(path, false);
|
|
893
|
+
// Reflect language in the bottom statusbar.
|
|
894
|
+
const statusLang = document.getElementById('editorStatusLang');
|
|
895
|
+
if (statusLang) statusLang.textContent = lang;
|
|
896
|
+
const statusPos = document.getElementById('editorStatusPos');
|
|
897
|
+
if (statusPos) statusPos.textContent = 'Ln 1, Col 1';
|
|
898
|
+
// monaco-wave-b: mirror selection into the file tree (so
|
|
899
|
+
// palette/Ctrl+P opens highlight the row).
|
|
900
|
+
if (typeof FileTree !== 'undefined') {
|
|
901
|
+
try { FileTree.setSelected(path); FileTree.markDirty(path, false); } catch {}
|
|
902
|
+
}
|
|
903
|
+
editor.focus();
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function save() {
|
|
907
|
+
if (!openPath || !editor) return;
|
|
908
|
+
const content = editor.getValue();
|
|
909
|
+
let result;
|
|
910
|
+
try {
|
|
911
|
+
result = await fsRequest({ type: 'fs-write', path: openPath, content }, 'fs-write-result');
|
|
912
|
+
} catch (e) {
|
|
913
|
+
setStatusFlash('save failed: ' + (e?.message || e));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (!result.ok) {
|
|
917
|
+
setStatusFlash('save failed: ' + (result.error || 'unknown'));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
originalContent = content;
|
|
921
|
+
setTabFile(openPath, false);
|
|
922
|
+
if (typeof FileTree !== 'undefined') {
|
|
923
|
+
try { FileTree.markDirty(openPath, false); } catch {}
|
|
924
|
+
}
|
|
925
|
+
setStatusFlash('saved');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
let flashTimer = null;
|
|
929
|
+
function setStatusFlash(text) {
|
|
930
|
+
const el = tabEl();
|
|
931
|
+
const prior = el.textContent;
|
|
932
|
+
el.textContent = text;
|
|
933
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
934
|
+
flashTimer = setTimeout(() => setTabFile(openPath, false), 1400);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── Ctrl+P palette ──
|
|
938
|
+
const overlay = document.getElementById('paletteOverlay');
|
|
939
|
+
const input = document.getElementById('paletteInput');
|
|
940
|
+
const list = document.getElementById('paletteList');
|
|
941
|
+
let paletteFiles = [];
|
|
942
|
+
let paletteFiltered = [];
|
|
943
|
+
let paletteActive = 0;
|
|
944
|
+
|
|
945
|
+
async function openPalette() {
|
|
946
|
+
await ensureLoaded();
|
|
947
|
+
overlay.classList.add('active');
|
|
948
|
+
input.value = '';
|
|
949
|
+
input.focus();
|
|
950
|
+
// Re-fetch list if cache stale.
|
|
951
|
+
const now = Date.now();
|
|
952
|
+
if (!fileListCache || (now - fileListCachedAt) > 5000) {
|
|
953
|
+
list.innerHTML = '<div class="palette-empty">Loading…</div>';
|
|
954
|
+
try {
|
|
955
|
+
const r = await fsRequest({ type: 'fs-list', dir: '/home/user', recursive: true }, 'fs-list-result');
|
|
956
|
+
fileListCache = (r.entries || []).filter(e => e.type !== 'directory').map(e => e.path);
|
|
957
|
+
fileListCachedAt = Date.now();
|
|
958
|
+
} catch (e) {
|
|
959
|
+
list.innerHTML = '<div class="palette-empty">fs-list failed: ' + (e?.message || e) + '</div>';
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
paletteFiles = fileListCache.slice();
|
|
964
|
+
renderPalette('');
|
|
965
|
+
}
|
|
966
|
+
function closePalette() {
|
|
967
|
+
overlay.classList.remove('active');
|
|
968
|
+
if (editor) editor.focus();
|
|
969
|
+
}
|
|
970
|
+
function renderPalette(q) {
|
|
971
|
+
const needle = q.toLowerCase();
|
|
972
|
+
// Simple substring + suffix-rank filter. Plenty fast for a few
|
|
973
|
+
// thousand files (5 MB cap on fs-list anyway).
|
|
974
|
+
paletteFiltered = paletteFiles
|
|
975
|
+
.filter(p => p.toLowerCase().includes(needle))
|
|
976
|
+
.slice(0, 80);
|
|
977
|
+
paletteActive = 0;
|
|
978
|
+
if (paletteFiltered.length === 0) {
|
|
979
|
+
list.innerHTML = '<div class="palette-empty">No matches.</div>';
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
list.innerHTML = paletteFiltered.map((p, i) =>
|
|
983
|
+
'<div class="palette-item' + (i === 0 ? ' active' : '') + '" data-idx="' + i + '">' + p + '</div>'
|
|
984
|
+
).join('');
|
|
985
|
+
// Click to open.
|
|
986
|
+
list.querySelectorAll('.palette-item').forEach(el => {
|
|
987
|
+
el.addEventListener('click', () => {
|
|
988
|
+
const idx = parseInt(el.dataset.idx, 10);
|
|
989
|
+
const path = paletteFiltered[idx];
|
|
990
|
+
if (path) {
|
|
991
|
+
closePalette();
|
|
992
|
+
openFile(path);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
function moveActive(delta) {
|
|
998
|
+
const next = Math.max(0, Math.min(paletteFiltered.length - 1, paletteActive + delta));
|
|
999
|
+
if (next === paletteActive) return;
|
|
1000
|
+
paletteActive = next;
|
|
1001
|
+
list.querySelectorAll('.palette-item').forEach((el, i) => {
|
|
1002
|
+
el.classList.toggle('active', i === paletteActive);
|
|
1003
|
+
if (i === paletteActive) el.scrollIntoView({ block: 'nearest' });
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
input.addEventListener('input', () => renderPalette(input.value));
|
|
1007
|
+
input.addEventListener('keydown', (e) => {
|
|
1008
|
+
if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; }
|
|
1009
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; }
|
|
1010
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; }
|
|
1011
|
+
if (e.key === 'Enter') {
|
|
1012
|
+
e.preventDefault();
|
|
1013
|
+
const path = paletteFiltered[paletteActive];
|
|
1014
|
+
if (path) { closePalette(); openFile(path); }
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
overlay.addEventListener('click', (e) => {
|
|
1018
|
+
if (e.target === overlay) closePalette();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Global Ctrl+P / Cmd+P binding. Fires anywhere on the page so
|
|
1022
|
+
// users can open the palette from the terminal pane too — matches
|
|
1023
|
+
// VSCode UX.
|
|
1024
|
+
document.addEventListener('keydown', (e) => {
|
|
1025
|
+
const isCtrlP = (e.ctrlKey || e.metaKey) && e.key === 'p' && !e.shiftKey && !e.altKey;
|
|
1026
|
+
if (isCtrlP) {
|
|
1027
|
+
e.preventDefault();
|
|
1028
|
+
openPalette();
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
// Ctrl+S anywhere also saves (Monaco's own binding covers
|
|
1032
|
+
// the editor-focus case but a click outside the editor would
|
|
1033
|
+
// otherwise lose the keystroke).
|
|
1034
|
+
const isCtrlS = (e.ctrlKey || e.metaKey) && e.key === 's' && !e.shiftKey && !e.altKey;
|
|
1035
|
+
if (isCtrlS && openPath) {
|
|
1036
|
+
e.preventDefault();
|
|
1037
|
+
save();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// file-tree-watch (2026-05-15): external invalidation of the
|
|
1042
|
+
// Cmd+P 5s-TTL palette cache. Called by the fs-watch-event
|
|
1043
|
+
// dispatcher in ws.onmessage so a terminal-side write makes
|
|
1044
|
+
// the next openPalette re-run fs-list instead of serving
|
|
1045
|
+
// stale results.
|
|
1046
|
+
function invalidateFileListCache() {
|
|
1047
|
+
fileListCache = null;
|
|
1048
|
+
fileListCachedAt = 0;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return { ensureLoaded, openFile, save, openPalette, tryHandleFsResult, drainFsQueue, invalidateFileListCache };
|
|
1052
|
+
})();
|
|
1053
|
+
|
|
1054
|
+
// ── monaco-wave-b (2026-05-14): FileTree module ──
|
|
1055
|
+
//
|
|
1056
|
+
// VSCode-style left sidebar showing the user VFS (rooted at
|
|
1057
|
+
// /home/user). Lazy-loads on first editor-mode activation. Tree
|
|
1058
|
+
// is flat in DOM (one .tree-node per row) with padding-left
|
|
1059
|
+
// calculated per depth — gives a clean expand/collapse animation
|
|
1060
|
+
// without nested ULs.
|
|
1061
|
+
//
|
|
1062
|
+
// Behavior:
|
|
1063
|
+
// - First render: lazy fs-list /home/user (recursive=false)
|
|
1064
|
+
// - Folder click: toggle expanded; on first expand, lazy
|
|
1065
|
+
// fs-list that folder
|
|
1066
|
+
// - File click: Editor.openFile(path) — Editor handles dirty
|
|
1067
|
+
// prompt + Monaco model swap
|
|
1068
|
+
// - Search filter: client-side substring on path, hides nodes
|
|
1069
|
+
// - New file/folder: prompt + fs-write (empty content for
|
|
1070
|
+
// file; for folder we synthesize an empty .keep file inside
|
|
1071
|
+
// so fs-list shows it — VFS has no separate mkdir over WS)
|
|
1072
|
+
// - Refresh: re-fetch root, drop cached children
|
|
1073
|
+
// - Expanded state persisted via localStorage keyed on session id
|
|
1074
|
+
//
|
|
1075
|
+
// Memory: tree state is a flat Map<path, node>; max 10k entries
|
|
1076
|
+
// before we stop expanding to protect the browser.
|
|
1077
|
+
const FileTree = (function() {
|
|
1078
|
+
const panel = document.getElementById('treePanel');
|
|
1079
|
+
const body = document.getElementById('treeBody');
|
|
1080
|
+
const searchInput = document.getElementById('treeSearch');
|
|
1081
|
+
const btnNewFile = document.getElementById('btnTreeNewFile');
|
|
1082
|
+
const btnNewDir = document.getElementById('btnTreeNewDir');
|
|
1083
|
+
const btnRefresh = document.getElementById('btnTreeRefresh');
|
|
1084
|
+
|
|
1085
|
+
const ROOT = '/home/user';
|
|
1086
|
+
const MAX_NODES = 10_000;
|
|
1087
|
+
const LS_KEY = 'nimbus.tree.expanded.' + (SESSION_PREFIX || 'noid');
|
|
1088
|
+
|
|
1089
|
+
// node: { path, name, type:'file'|'directory', depth, parent, children?:Set<path>, loaded:bool, selected:bool, hidden:bool }
|
|
1090
|
+
const nodes = new Map();
|
|
1091
|
+
// Insertion-ordered list of paths for stable render.
|
|
1092
|
+
const renderOrder = [];
|
|
1093
|
+
let loaded = false;
|
|
1094
|
+
let pending = null;
|
|
1095
|
+
let nextReqId = 70_000;
|
|
1096
|
+
const pendingReqs = new Map();
|
|
1097
|
+
let selectedPath = null;
|
|
1098
|
+
|
|
1099
|
+
function loadExpandedSet() {
|
|
1100
|
+
try {
|
|
1101
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
1102
|
+
if (!raw) return new Set([ROOT]);
|
|
1103
|
+
return new Set(JSON.parse(raw));
|
|
1104
|
+
} catch { return new Set([ROOT]); }
|
|
1105
|
+
}
|
|
1106
|
+
function saveExpandedSet(set) {
|
|
1107
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(Array.from(set))); } catch {}
|
|
1108
|
+
}
|
|
1109
|
+
const expanded = loadExpandedSet();
|
|
1110
|
+
expanded.add(ROOT);
|
|
1111
|
+
|
|
1112
|
+
// file-tree-fix (2026-05-14): outbound queue mirroring Editor's
|
|
1113
|
+
// pattern. See Editor.fsRequest for the race rationale.
|
|
1114
|
+
const outQueue = [];
|
|
1115
|
+
function fsRequest(frame, resultType) {
|
|
1116
|
+
const reqId = nextReqId++;
|
|
1117
|
+
frame.reqId = reqId;
|
|
1118
|
+
return new Promise((resolve, reject) => {
|
|
1119
|
+
const t = setTimeout(() => {
|
|
1120
|
+
if (pendingReqs.has(reqId)) {
|
|
1121
|
+
pendingReqs.delete(reqId);
|
|
1122
|
+
reject(new Error('fs request timed out'));
|
|
1123
|
+
}
|
|
1124
|
+
}, 15000);
|
|
1125
|
+
pendingReqs.set(reqId, { resolve, reject, t });
|
|
1126
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1127
|
+
try { ws.send(JSON.stringify(frame)); }
|
|
1128
|
+
catch (e) {
|
|
1129
|
+
clearTimeout(t); pendingReqs.delete(reqId); reject(e);
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
outQueue.push(frame);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
function drainFsQueue() {
|
|
1137
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1138
|
+
const drained = outQueue.splice(0);
|
|
1139
|
+
for (const frame of drained) {
|
|
1140
|
+
try { ws.send(JSON.stringify(frame)); }
|
|
1141
|
+
catch (e) {
|
|
1142
|
+
const slot = pendingReqs.get(frame.reqId);
|
|
1143
|
+
if (slot) {
|
|
1144
|
+
pendingReqs.delete(frame.reqId);
|
|
1145
|
+
clearTimeout(slot.t);
|
|
1146
|
+
if (slot.reject) slot.reject(e);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// Hook into main WS onmessage like the Editor module does.
|
|
1152
|
+
function tryHandleFsResult(msg) {
|
|
1153
|
+
if (!msg || typeof msg.type !== 'string') return false;
|
|
1154
|
+
if (!msg.type.endsWith('-result')) return false;
|
|
1155
|
+
const reqId = msg.reqId;
|
|
1156
|
+
if (typeof reqId !== 'number') return false;
|
|
1157
|
+
const slot = pendingReqs.get(reqId);
|
|
1158
|
+
if (!slot) return false;
|
|
1159
|
+
pendingReqs.delete(reqId);
|
|
1160
|
+
clearTimeout(slot.t);
|
|
1161
|
+
slot.resolve(msg);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function iconFor(node) {
|
|
1166
|
+
if (node.type === 'directory') return node.expanded ? '📂' : '📁';
|
|
1167
|
+
// Light extension-based icons.
|
|
1168
|
+
const ext = (node.name.match(/\.([^.]+)$/) || [, ''])[1].toLowerCase();
|
|
1169
|
+
const m = {
|
|
1170
|
+
js:'📜', mjs:'📜', cjs:'📜', ts:'📘', tsx:'📘', jsx:'📜',
|
|
1171
|
+
py:'🐍', rb:'💎', go:'🐹', rs:'🦀',
|
|
1172
|
+
c:'🔧', h:'🔧', cpp:'🔧', hpp:'🔧',
|
|
1173
|
+
json:'⚙️', yaml:'⚙️', yml:'⚙️', toml:'⚙️',
|
|
1174
|
+
md:'📝', html:'🌐', css:'🎨', scss:'🎨',
|
|
1175
|
+
sh:'🐚', bash:'🐚',
|
|
1176
|
+
png:'🖼️', jpg:'🖼️', jpeg:'🖼️', gif:'🖼️', svg:'🖼️',
|
|
1177
|
+
lock:'🔒',
|
|
1178
|
+
};
|
|
1179
|
+
return m[ext] || '📄';
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function relativeName(path) {
|
|
1183
|
+
const i = path.lastIndexOf('/');
|
|
1184
|
+
return i >= 0 ? path.substring(i + 1) : path;
|
|
1185
|
+
}
|
|
1186
|
+
function parentOf(path) {
|
|
1187
|
+
const i = path.lastIndexOf('/');
|
|
1188
|
+
if (i <= 0) return '';
|
|
1189
|
+
return path.substring(0, i);
|
|
1190
|
+
}
|
|
1191
|
+
function depthOf(path) {
|
|
1192
|
+
// /home/user/foo/bar.txt → 2 (relative to /home/user which is root, depth 0)
|
|
1193
|
+
if (!path.startsWith(ROOT)) return 0;
|
|
1194
|
+
const rest = path.substring(ROOT.length).replace(/^\/+/, '');
|
|
1195
|
+
if (!rest) return 0;
|
|
1196
|
+
return rest.split('/').length;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function ensureNode(path, type) {
|
|
1200
|
+
let n = nodes.get(path);
|
|
1201
|
+
if (n) {
|
|
1202
|
+
if (type) n.type = type;
|
|
1203
|
+
return n;
|
|
1204
|
+
}
|
|
1205
|
+
n = {
|
|
1206
|
+
path, name: relativeName(path) || path,
|
|
1207
|
+
type: type || 'file',
|
|
1208
|
+
depth: depthOf(path),
|
|
1209
|
+
loaded: false, expanded: expanded.has(path),
|
|
1210
|
+
dirty: false,
|
|
1211
|
+
};
|
|
1212
|
+
nodes.set(path, n);
|
|
1213
|
+
return n;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function loadFolder(path) {
|
|
1217
|
+
// Show a loading hint if this is the FIRST load AND the WS
|
|
1218
|
+
// hasn't opened yet (queue fix — request is pending until
|
|
1219
|
+
// ws.onopen drains it). Without this hint, users staring at
|
|
1220
|
+
// an empty tree-body would see no feedback. Cleared on
|
|
1221
|
+
// success / error path below.
|
|
1222
|
+
if (path === ROOT && !loaded && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
|
1223
|
+
body.innerHTML = '<div class="tree-empty">Waiting for connection…</div>';
|
|
1224
|
+
}
|
|
1225
|
+
let result;
|
|
1226
|
+
try {
|
|
1227
|
+
result = await fsRequest({ type: 'fs-list', dir: path, recursive: false }, 'fs-list-result');
|
|
1228
|
+
} catch (e) {
|
|
1229
|
+
body.innerHTML = '<div class="tree-empty">fs-list failed: ' + escapeHtml(e?.message || String(e)) + '</div>';
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (result.error) {
|
|
1233
|
+
body.innerHTML = '<div class="tree-empty">' + escapeHtml(result.error) + '</div>';
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const entries = result.entries || [];
|
|
1237
|
+
const folder = ensureNode(path, 'directory');
|
|
1238
|
+
folder.loaded = true;
|
|
1239
|
+
// Insert children — sort: directories first, alphabetical.
|
|
1240
|
+
entries.sort((a, b) => {
|
|
1241
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
1242
|
+
return a.path.localeCompare(b.path);
|
|
1243
|
+
});
|
|
1244
|
+
for (const e of entries) {
|
|
1245
|
+
if (nodes.size >= MAX_NODES) break;
|
|
1246
|
+
ensureNode(e.path, e.type);
|
|
1247
|
+
}
|
|
1248
|
+
rerender();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function escapeHtml(s) {
|
|
1252
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
1253
|
+
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
|
1254
|
+
})[c]);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Compute the rendered order: walk tree depth-first, including
|
|
1258
|
+
// only children of expanded folders. Hidden filter applied at
|
|
1259
|
+
// search time.
|
|
1260
|
+
function computeOrder() {
|
|
1261
|
+
const out = [];
|
|
1262
|
+
function walk(parentPath, depth) {
|
|
1263
|
+
// Collect direct children of parentPath in the nodes map.
|
|
1264
|
+
const children = [];
|
|
1265
|
+
for (const n of nodes.values()) {
|
|
1266
|
+
if (parentOf(n.path) === parentPath) children.push(n);
|
|
1267
|
+
}
|
|
1268
|
+
children.sort((a, b) => {
|
|
1269
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
1270
|
+
return a.name.localeCompare(b.name);
|
|
1271
|
+
});
|
|
1272
|
+
for (const c of children) {
|
|
1273
|
+
out.push(c);
|
|
1274
|
+
if (c.type === 'directory' && expanded.has(c.path)) {
|
|
1275
|
+
walk(c.path, depth + 1);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
walk(ROOT, 1);
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function rerender() {
|
|
1284
|
+
const order = computeOrder();
|
|
1285
|
+
const filter = (searchInput.value || '').trim().toLowerCase();
|
|
1286
|
+
body.innerHTML = '';
|
|
1287
|
+
if (order.length === 0) {
|
|
1288
|
+
body.innerHTML = '<div class="tree-empty">Empty</div>';
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const frag = document.createDocumentFragment();
|
|
1292
|
+
let visibleCount = 0;
|
|
1293
|
+
for (const n of order) {
|
|
1294
|
+
const matchesFilter = !filter || n.path.toLowerCase().includes(filter);
|
|
1295
|
+
if (!matchesFilter) continue;
|
|
1296
|
+
visibleCount++;
|
|
1297
|
+
const div = document.createElement('div');
|
|
1298
|
+
let cls = 'tree-node ' + (n.type === 'directory' ? 'dir' : 'file');
|
|
1299
|
+
if (n.type === 'directory' && expanded.has(n.path)) cls += ' expanded';
|
|
1300
|
+
if (selectedPath === n.path) cls += ' selected';
|
|
1301
|
+
if (n.dirty) cls += ' dirty';
|
|
1302
|
+
div.className = cls;
|
|
1303
|
+
div.dataset.path = n.path;
|
|
1304
|
+
div.style.paddingLeft = (8 + (n.depth - 1) * 14) + 'px';
|
|
1305
|
+
div.innerHTML =
|
|
1306
|
+
'<span class="tree-chevron">▶</span>' +
|
|
1307
|
+
'<span class="tree-icon">' + iconFor(n) + '</span>' +
|
|
1308
|
+
'<span class="tree-label"></span>';
|
|
1309
|
+
div.querySelector('.tree-label').textContent = n.name;
|
|
1310
|
+
div.addEventListener('click', () => handleNodeClick(n));
|
|
1311
|
+
frag.appendChild(div);
|
|
1312
|
+
}
|
|
1313
|
+
body.appendChild(frag);
|
|
1314
|
+
if (visibleCount === 0 && filter) {
|
|
1315
|
+
body.innerHTML = '<div class="tree-empty">No matches for "' + escapeHtml(filter) + '"</div>';
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function setSelected(path) {
|
|
1320
|
+
selectedPath = path;
|
|
1321
|
+
rerender();
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function handleNodeClick(node) {
|
|
1325
|
+
if (node.type === 'directory') {
|
|
1326
|
+
if (expanded.has(node.path)) {
|
|
1327
|
+
expanded.delete(node.path);
|
|
1328
|
+
} else {
|
|
1329
|
+
expanded.add(node.path);
|
|
1330
|
+
if (!node.loaded) {
|
|
1331
|
+
// Show a tentative state immediately, then async load.
|
|
1332
|
+
rerender();
|
|
1333
|
+
await loadFolder(node.path);
|
|
1334
|
+
saveExpandedSet(expanded);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
saveExpandedSet(expanded);
|
|
1339
|
+
rerender();
|
|
1340
|
+
} else {
|
|
1341
|
+
setSelected(node.path);
|
|
1342
|
+
Editor.openFile(node.path);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function ensureLoaded() {
|
|
1347
|
+
if (loaded) return;
|
|
1348
|
+
if (pending) return pending;
|
|
1349
|
+
panel.classList.add('loading');
|
|
1350
|
+
pending = (async () => {
|
|
1351
|
+
// Ensure root node + load it.
|
|
1352
|
+
ensureNode(ROOT, 'directory');
|
|
1353
|
+
await loadFolder(ROOT);
|
|
1354
|
+
// Auto-expand any persisted-expanded folders that are
|
|
1355
|
+
// already in the tree from the root load.
|
|
1356
|
+
for (const path of expanded) {
|
|
1357
|
+
if (path === ROOT) continue;
|
|
1358
|
+
const n = nodes.get(path);
|
|
1359
|
+
if (n && n.type === 'directory' && !n.loaded) {
|
|
1360
|
+
await loadFolder(path);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
loaded = true;
|
|
1364
|
+
})();
|
|
1365
|
+
try { await pending; } finally { pending = null; panel.classList.remove('loading'); }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Toolbar actions.
|
|
1369
|
+
async function refreshTree() {
|
|
1370
|
+
// Drop all cached children of root + re-fetch.
|
|
1371
|
+
nodes.clear();
|
|
1372
|
+
loaded = false;
|
|
1373
|
+
await ensureLoaded();
|
|
1374
|
+
}
|
|
1375
|
+
async function newFile() {
|
|
1376
|
+
// Place in the currently-selected directory, or the root.
|
|
1377
|
+
let parent = ROOT;
|
|
1378
|
+
if (selectedPath) {
|
|
1379
|
+
const n = nodes.get(selectedPath);
|
|
1380
|
+
if (n) parent = n.type === 'directory' ? n.path : parentOf(n.path);
|
|
1381
|
+
}
|
|
1382
|
+
const name = prompt('New file name (relative to ' + parent + '):');
|
|
1383
|
+
if (!name) return;
|
|
1384
|
+
const path = parent + '/' + name.replace(/^\/+/, '');
|
|
1385
|
+
try {
|
|
1386
|
+
await fsRequest({ type: 'fs-write', path, content: '' }, 'fs-write-result');
|
|
1387
|
+
// Refresh the parent folder.
|
|
1388
|
+
const pn = nodes.get(parent);
|
|
1389
|
+
if (pn) {
|
|
1390
|
+
pn.loaded = false;
|
|
1391
|
+
await loadFolder(parent);
|
|
1392
|
+
}
|
|
1393
|
+
expanded.add(parent);
|
|
1394
|
+
saveExpandedSet(expanded);
|
|
1395
|
+
setSelected(path);
|
|
1396
|
+
Editor.openFile(path);
|
|
1397
|
+
} catch (e) {
|
|
1398
|
+
alert('Create file failed: ' + (e?.message || e));
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
async function newDir() {
|
|
1402
|
+
let parent = ROOT;
|
|
1403
|
+
if (selectedPath) {
|
|
1404
|
+
const n = nodes.get(selectedPath);
|
|
1405
|
+
if (n) parent = n.type === 'directory' ? n.path : parentOf(n.path);
|
|
1406
|
+
}
|
|
1407
|
+
const name = prompt('New folder name (relative to ' + parent + '):');
|
|
1408
|
+
if (!name) return;
|
|
1409
|
+
const dirPath = parent + '/' + name.replace(/^\/+/, '');
|
|
1410
|
+
// Synthesize via .keep file — fs-write auto-mkdirs the parent
|
|
1411
|
+
// chain (init.ts handler does this).
|
|
1412
|
+
try {
|
|
1413
|
+
await fsRequest({ type: 'fs-write', path: dirPath + '/.keep', content: '' }, 'fs-write-result');
|
|
1414
|
+
const pn = nodes.get(parent);
|
|
1415
|
+
if (pn) { pn.loaded = false; await loadFolder(parent); }
|
|
1416
|
+
expanded.add(dirPath);
|
|
1417
|
+
saveExpandedSet(expanded);
|
|
1418
|
+
rerender();
|
|
1419
|
+
} catch (e) {
|
|
1420
|
+
alert('Create folder failed: ' + (e?.message || e));
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
btnNewFile.addEventListener('click', newFile);
|
|
1425
|
+
btnNewDir.addEventListener('click', newDir);
|
|
1426
|
+
btnRefresh.addEventListener('click', refreshTree);
|
|
1427
|
+
searchInput.addEventListener('input', rerender);
|
|
1428
|
+
|
|
1429
|
+
// monaco-polish (2026-05-14): tree-resize handler moved into
|
|
1430
|
+
// PaneResizer (below module def, top-level scope) so all 3
|
|
1431
|
+
// handles share a single persistence layer. Inline width is
|
|
1432
|
+
// restored from localStorage in PaneResizer.restoreDims().
|
|
1433
|
+
|
|
1434
|
+
// Mark a path dirty in the tree (called by Editor when buffer
|
|
1435
|
+
// diverges from disk).
|
|
1436
|
+
function markDirty(path, isDirty) {
|
|
1437
|
+
const n = nodes.get(path);
|
|
1438
|
+
if (!n) return;
|
|
1439
|
+
if (n.dirty === !!isDirty) return;
|
|
1440
|
+
n.dirty = !!isDirty;
|
|
1441
|
+
rerender();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// ── file-tree-watch (2026-05-15): subscribe + event apply ─────
|
|
1445
|
+
//
|
|
1446
|
+
// Server-side fs-watch protocol (src/session/fs-watch.ts):
|
|
1447
|
+
//
|
|
1448
|
+
// IN { type:'fs-watch-subscribe', reqId, paths:['/home/user'] }
|
|
1449
|
+
// OUT { type:'fs-watch-subscribe-result', reqId, ok, subId? }
|
|
1450
|
+
// PUSH { type:'fs-watch-event', subId, events:VfsEvent[], dropped? }
|
|
1451
|
+
// IN { type:'fs-watch-unsubscribe', reqId, subId? }
|
|
1452
|
+
//
|
|
1453
|
+
// Local lifecycle:
|
|
1454
|
+
// - subscribeOnce() is called from ws.onopen (after drainFsQueue).
|
|
1455
|
+
// Re-invocations are no-ops while a subId is live; on WS close
|
|
1456
|
+
// the server-side listener is reaped (ws.ts cleanup) so the
|
|
1457
|
+
// next reconnect re-subscribes fresh.
|
|
1458
|
+
// - applyWatchEvent() coalesces all 'fs-watch-event' frames into
|
|
1459
|
+
// per-parent refresh actions on a single 100ms window — 5+
|
|
1460
|
+
// events that share the same parent dir produce ONE re-fetch
|
|
1461
|
+
// of that folder, not N.
|
|
1462
|
+
//
|
|
1463
|
+
// Coalescing layers (composed):
|
|
1464
|
+
// Server (50ms debounce, SUB_MAX_QUEUE=200, drop-oldest)
|
|
1465
|
+
// → batches of up to 200 VfsEvents per frame
|
|
1466
|
+
// Client (100ms per-parent debounce)
|
|
1467
|
+
// → one fs-list per unique parent per window
|
|
1468
|
+
//
|
|
1469
|
+
// A `dropped:N` field on the incoming frame means the server
|
|
1470
|
+
// dropped N events to honor its cap; we respond by force-
|
|
1471
|
+
// refreshing the entire root (cheapest correct recovery).
|
|
1472
|
+
let watchSubId = null;
|
|
1473
|
+
const pendingParentRefresh = new Set();
|
|
1474
|
+
let watchRefreshTimer = null;
|
|
1475
|
+
let lastWatchFrameAt = 0;
|
|
1476
|
+
let watchFrameCount = 0;
|
|
1477
|
+
let watchEventCount = 0;
|
|
1478
|
+
const WATCH_COALESCE_MS = 100;
|
|
1479
|
+
|
|
1480
|
+
function subscribeOnce() {
|
|
1481
|
+
if (watchSubId) return; // already subscribed
|
|
1482
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1483
|
+
// Fire-and-forget — record the subId on the success result.
|
|
1484
|
+
// We use fsRequest's queued-send semantics for the reply
|
|
1485
|
+
// routing (pendingReqs keyed by reqId), but tolerate failures.
|
|
1486
|
+
fsRequest(
|
|
1487
|
+
{ type: 'fs-watch-subscribe', paths: [ROOT] },
|
|
1488
|
+
'fs-watch-subscribe-result',
|
|
1489
|
+
).then((r) => {
|
|
1490
|
+
if (r && r.ok && typeof r.subId === 'string') {
|
|
1491
|
+
watchSubId = r.subId;
|
|
1492
|
+
}
|
|
1493
|
+
}).catch(() => { /* server may not support fs-watch yet */ });
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function scheduleParentRefreshFlush() {
|
|
1497
|
+
if (watchRefreshTimer) return;
|
|
1498
|
+
watchRefreshTimer = setTimeout(async () => {
|
|
1499
|
+
watchRefreshTimer = null;
|
|
1500
|
+
if (pendingParentRefresh.size === 0) return;
|
|
1501
|
+
const parents = Array.from(pendingParentRefresh);
|
|
1502
|
+
pendingParentRefresh.clear();
|
|
1503
|
+
for (const parent of parents) {
|
|
1504
|
+
const pn = nodes.get(parent);
|
|
1505
|
+
// Only re-fetch dirs we've already loaded (cheap + bounded).
|
|
1506
|
+
// Unloaded dirs are loaded lazily on expand.
|
|
1507
|
+
if (pn && pn.type === 'directory' && pn.loaded) {
|
|
1508
|
+
// Drop cached children then re-load the folder. The
|
|
1509
|
+
// ensureNode at the end of loadFolder keeps the node
|
|
1510
|
+
// entry alive (avoiding flicker on the user's selection).
|
|
1511
|
+
// We drop child nodes from the Map so loadFolder's
|
|
1512
|
+
// re-insert produces a clean view that reflects deletes.
|
|
1513
|
+
const prefix = parent + '/';
|
|
1514
|
+
for (const path of Array.from(nodes.keys())) {
|
|
1515
|
+
if (path !== parent && parentOf(path) === parent) {
|
|
1516
|
+
nodes.delete(path);
|
|
1517
|
+
}
|
|
1518
|
+
else if (path !== parent && path.startsWith(prefix)) {
|
|
1519
|
+
// children of children — drop too so a deleted subtree
|
|
1520
|
+
// disappears in one cycle
|
|
1521
|
+
nodes.delete(path);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
try { await loadFolder(parent); } catch { /* fail-soft */ }
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}, WATCH_COALESCE_MS);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// file-tree-watch: dispatch an inbound fs-watch-event frame to
|
|
1531
|
+
// the tree's incremental refresh pipeline. Returns true if the
|
|
1532
|
+
// frame was consumed (caller short-circuits the rest of its
|
|
1533
|
+
// onmessage chain), false otherwise.
|
|
1534
|
+
function applyWatchEvent(msg) {
|
|
1535
|
+
if (!msg || msg.type !== 'fs-watch-event') return false;
|
|
1536
|
+
watchFrameCount++;
|
|
1537
|
+
lastWatchFrameAt = Date.now();
|
|
1538
|
+
// Server-side drop signal → cheapest correct recovery is a
|
|
1539
|
+
// full root re-fetch. Clear the queue + force refresh.
|
|
1540
|
+
if (typeof msg.dropped === 'number' && msg.dropped > 0) {
|
|
1541
|
+
pendingParentRefresh.clear();
|
|
1542
|
+
if (watchRefreshTimer) {
|
|
1543
|
+
clearTimeout(watchRefreshTimer);
|
|
1544
|
+
watchRefreshTimer = null;
|
|
1545
|
+
}
|
|
1546
|
+
// schedule async refresh of root (preserves expanded state)
|
|
1547
|
+
(async () => {
|
|
1548
|
+
const expandedSnapshot = new Set(expanded);
|
|
1549
|
+
const wasLoaded = new Set();
|
|
1550
|
+
for (const n of nodes.values()) {
|
|
1551
|
+
if (n.type === 'directory' && n.loaded) wasLoaded.add(n.path);
|
|
1552
|
+
}
|
|
1553
|
+
nodes.clear();
|
|
1554
|
+
loaded = false;
|
|
1555
|
+
try { await ensureLoaded(); } catch {}
|
|
1556
|
+
// Re-load previously-loaded subdirs that are still expanded.
|
|
1557
|
+
for (const path of wasLoaded) {
|
|
1558
|
+
if (path === ROOT) continue;
|
|
1559
|
+
if (!expandedSnapshot.has(path)) continue;
|
|
1560
|
+
try { await loadFolder(path); } catch {}
|
|
1561
|
+
}
|
|
1562
|
+
})();
|
|
1563
|
+
return true;
|
|
1564
|
+
}
|
|
1565
|
+
const events = Array.isArray(msg.events) ? msg.events : [];
|
|
1566
|
+
if (events.length === 0) return true;
|
|
1567
|
+
watchEventCount += events.length;
|
|
1568
|
+
// Per-parent coalescing — collect unique parent dirs that need
|
|
1569
|
+
// a refresh. SqliteVFS paths are bare ('home/user/x.txt'); we
|
|
1570
|
+
// prepend '/' to match the absolute-path convention used by
|
|
1571
|
+
// the FileTree (ROOT === '/home/user').
|
|
1572
|
+
for (const ev of events) {
|
|
1573
|
+
if (!ev || typeof ev.path !== 'string') continue;
|
|
1574
|
+
const abs = ev.path.startsWith('/') ? ev.path : '/' + ev.path;
|
|
1575
|
+
if (!abs.startsWith(ROOT)) continue; // outside our watched root
|
|
1576
|
+
// For unlinkDir / rename(old), the affected parent is the
|
|
1577
|
+
// *old* path's parent; the new-path's parent gets refresh
|
|
1578
|
+
// via the same path entry below. For other events, just the
|
|
1579
|
+
// event path's parent.
|
|
1580
|
+
pendingParentRefresh.add(parentOf(abs));
|
|
1581
|
+
if (ev.type === 'rename' && typeof ev.oldPath === 'string') {
|
|
1582
|
+
const oldAbs = ev.oldPath.startsWith('/') ? ev.oldPath : '/' + ev.oldPath;
|
|
1583
|
+
if (oldAbs.startsWith(ROOT)) {
|
|
1584
|
+
pendingParentRefresh.add(parentOf(oldAbs));
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
// Invalidate the Cmd+P palette cache so the next openPalette
|
|
1589
|
+
// re-fetches the file list. Editor exposes this; tolerate
|
|
1590
|
+
// either lazy-init order.
|
|
1591
|
+
try {
|
|
1592
|
+
if (typeof Editor !== 'undefined' && typeof Editor.invalidateFileListCache === 'function') {
|
|
1593
|
+
Editor.invalidateFileListCache();
|
|
1594
|
+
}
|
|
1595
|
+
} catch { /* best-effort */ }
|
|
1596
|
+
if (pendingParentRefresh.size > 0) scheduleParentRefreshFlush();
|
|
1597
|
+
return true;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Diagnostic accessor for probes (cleanup-on-disconnect verifies
|
|
1601
|
+
// counts return to 0 on the server side; this is the client-side
|
|
1602
|
+
// mirror — useful for the coalescing probe).
|
|
1603
|
+
function getWatchStats() {
|
|
1604
|
+
return {
|
|
1605
|
+
subId: watchSubId,
|
|
1606
|
+
frameCount: watchFrameCount,
|
|
1607
|
+
eventCount: watchEventCount,
|
|
1608
|
+
lastFrameAt: lastWatchFrameAt,
|
|
1609
|
+
pendingParents: pendingParentRefresh.size,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
return {
|
|
1614
|
+
ensureLoaded, tryHandleFsResult, markDirty, setSelected, drainFsQueue,
|
|
1615
|
+
subscribeOnce, applyWatchEvent, getWatchStats,
|
|
1616
|
+
};
|
|
1617
|
+
})();
|
|
1618
|
+
|
|
1619
|
+
// Which preview backend the iframe currently points at. Driven by
|
|
1620
|
+
// /api/stats polling: wrangler.running > vite.running > placeholder.
|
|
1621
|
+
// Tracked separately from iframe.src so user reloads / hash changes
|
|
1622
|
+
// don't confuse the switcher. Starts as null so the first
|
|
1623
|
+
// applyPreviewTarget call always paints (even if nothing is running
|
|
1624
|
+
// yet — that still wants the VITE_URL placeholder loaded).
|
|
1625
|
+
let activePreview = null; // null | 'vite' | 'worker' | 'none'
|
|
1626
|
+
|
|
1627
|
+
function previewUrlFor(kind) {
|
|
1628
|
+
return kind === 'worker' ? WORKER_URL : VITE_URL;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function applyPreviewTarget(kind) {
|
|
1632
|
+
// Idempotent: only update iframe + URL bar when the backend kind
|
|
1633
|
+
// actually changes. Avoids tearing down the inner app on every
|
|
1634
|
+
// 5-second stats poll.
|
|
1635
|
+
if (activePreview === kind) return;
|
|
1636
|
+
activePreview = kind;
|
|
1637
|
+
const url = previewUrlFor(kind);
|
|
1638
|
+
// Always use the VITE_URL path when nothing is running — that
|
|
1639
|
+
// endpoint serves the styled "no dev server" placeholder, which
|
|
1640
|
+
// self-refreshes via its own polling loop.
|
|
1641
|
+
previewFrame.src = url;
|
|
1642
|
+
const urlBar = document.getElementById('urlBar');
|
|
1643
|
+
if (urlBar) urlBar.value = url;
|
|
1644
|
+
// If the user had collapsed the preview because nothing was up,
|
|
1645
|
+
// surface it automatically when the first backend comes online.
|
|
1646
|
+
if (kind !== 'none' && layout === 'terminal-only') setLayout('split');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function reloadPreview() {
|
|
1650
|
+
// Refreshing without a running backend should re-poll the
|
|
1651
|
+
// placeholder so users see dev-server status update promptly.
|
|
1652
|
+
if (previewFrame.src === 'about:blank' ||
|
|
1653
|
+
(!previewFrame.src.includes('/preview') && !previewFrame.src.includes('/worker'))) {
|
|
1654
|
+
previewFrame.src = previewUrlFor(
|
|
1655
|
+
(activePreview === 'none' || activePreview === null) ? 'vite' : activePreview
|
|
1656
|
+
);
|
|
1657
|
+
} else {
|
|
1658
|
+
try { previewFrame.contentWindow?.location.reload(); }
|
|
1659
|
+
catch { previewFrame.src = previewFrame.src; }
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ── monaco-polish (2026-05-14): pane resize manager ──
|
|
1664
|
+
//
|
|
1665
|
+
// Owns the 3 resize handles in editor mode + 1 in split mode:
|
|
1666
|
+
// H1 tree ↔ editor-stack (col, in editor mode) — TreeResizeHandle in FileTree IIFE
|
|
1667
|
+
// H2 editor-stack ↔ preview (col, in editor + split) — #resizeHandle (mode-aware)
|
|
1668
|
+
// V1 editor ↔ terminal (row, in editor mode) — #editorTerminalResizeHandle
|
|
1669
|
+
//
|
|
1670
|
+
// Each handle persists its dimension to localStorage so a reload
|
|
1671
|
+
// restores the user's layout. Keyed per-session.
|
|
1672
|
+
//
|
|
1673
|
+
// Triggers Monaco editor.layout() + xterm fitAddon.fit() after
|
|
1674
|
+
// every drag-end so the wrapped panes re-measure. Mid-drag does
|
|
1675
|
+
// a cheap fit too (xterm) for the "live shrink" feel; Monaco
|
|
1676
|
+
// automaticLayout:true handles itself.
|
|
1677
|
+
const PaneResizer = (function() {
|
|
1678
|
+
const LS_DIMS = 'nimbus.pane.dims.' + (SESSION_PREFIX || 'noid');
|
|
1679
|
+
// Default flex basis percentages — match the CSS defaults above.
|
|
1680
|
+
// Persisted to LS; restored before first setLayout call.
|
|
1681
|
+
let dims = {
|
|
1682
|
+
treeWidth: 250, // px
|
|
1683
|
+
middlePct: 60, // % of main width for left-stack/terminal (rest = preview)
|
|
1684
|
+
editorPct: 60, // % of left-stack height for editor (rest = terminal)
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
function loadDims() {
|
|
1688
|
+
try {
|
|
1689
|
+
const raw = localStorage.getItem(LS_DIMS);
|
|
1690
|
+
if (raw) Object.assign(dims, JSON.parse(raw));
|
|
1691
|
+
} catch {}
|
|
1692
|
+
}
|
|
1693
|
+
function saveDims() {
|
|
1694
|
+
try { localStorage.setItem(LS_DIMS, JSON.stringify(dims)); } catch {}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function applyMonacoLayout() {
|
|
1698
|
+
if (window.__nimbusMonacoEditor) {
|
|
1699
|
+
try { window.__nimbusMonacoEditor.layout(); } catch {}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
function applyTerminalFit() {
|
|
1703
|
+
try { fitAddon.fit(); } catch {}
|
|
1704
|
+
}
|
|
1705
|
+
function applyAll() {
|
|
1706
|
+
// Tree width — applied as inline width on .panel-tree.
|
|
1707
|
+
const tree = document.getElementById('treePanel');
|
|
1708
|
+
if (tree) tree.style.width = dims.treeWidth + 'px';
|
|
1709
|
+
// Middle ↔ preview flex.
|
|
1710
|
+
applyMiddlePreviewFlex();
|
|
1711
|
+
// Editor ↔ terminal flex (inside left-stack).
|
|
1712
|
+
applyEditorTerminalFlex();
|
|
1713
|
+
applyTerminalFit();
|
|
1714
|
+
applyMonacoLayout();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Map middlePct → flex basis on the two children that actually
|
|
1718
|
+
// compete for horizontal space in each mode:
|
|
1719
|
+
// - split mode: .panel-terminal vs .panel-preview
|
|
1720
|
+
// - editor mode: .panel-left-stack vs .panel-preview
|
|
1721
|
+
// In editor mode .panel-left-stack is flex:1 (CSS rule); we
|
|
1722
|
+
// override with explicit basis. Same for .panel-terminal in split.
|
|
1723
|
+
function applyMiddlePreviewFlex() {
|
|
1724
|
+
const stack = document.getElementById('leftStack');
|
|
1725
|
+
const term = mainPanel.querySelector('.panel-terminal');
|
|
1726
|
+
const prev = mainPanel.querySelector('.panel-preview');
|
|
1727
|
+
const middlePct = Math.max(20, Math.min(80, dims.middlePct));
|
|
1728
|
+
if (layout === 'editor') {
|
|
1729
|
+
// stack drives middle width; terminal is INSIDE stack.
|
|
1730
|
+
if (stack) stack.style.flex = middlePct + ' 0 0%';
|
|
1731
|
+
if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
|
|
1732
|
+
if (term) term.style.flex = ''; // unset; vertical handler owns it
|
|
1733
|
+
} else if (layout === 'split') {
|
|
1734
|
+
if (term) term.style.flex = middlePct + ' 0 0%';
|
|
1735
|
+
if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
|
|
1736
|
+
if (stack) stack.style.flex = '';
|
|
1737
|
+
} else {
|
|
1738
|
+
// Other modes (terminal-only / preview-only) don't use either
|
|
1739
|
+
// handle visibly; clear inline so CSS defaults take effect.
|
|
1740
|
+
if (term) term.style.flex = '';
|
|
1741
|
+
if (prev) prev.style.flex = '';
|
|
1742
|
+
if (stack) stack.style.flex = '';
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function applyEditorTerminalFlex() {
|
|
1747
|
+
if (layout !== 'editor') return;
|
|
1748
|
+
const editor = document.getElementById('editorPanel');
|
|
1749
|
+
const term = mainPanel.querySelector('.panel-terminal');
|
|
1750
|
+
const editorPct = Math.max(10, Math.min(90, dims.editorPct));
|
|
1751
|
+
if (editor) editor.style.flex = editorPct + ' 0 0%';
|
|
1752
|
+
if (term) term.style.flex = (100 - editorPct) + ' 0 0%';
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Drag wiring.
|
|
1756
|
+
let activeHandle = null;
|
|
1757
|
+
let dragStartXY = 0;
|
|
1758
|
+
let dragStartDim = 0;
|
|
1759
|
+
let dragStartTotal = 0;
|
|
1760
|
+
|
|
1761
|
+
function startDrag(kind, e) {
|
|
1762
|
+
activeHandle = kind;
|
|
1763
|
+
e.preventDefault();
|
|
1764
|
+
const rect = mainPanel.getBoundingClientRect();
|
|
1765
|
+
const stack = document.getElementById('leftStack');
|
|
1766
|
+
if (kind === 'tree') {
|
|
1767
|
+
dragStartXY = e.clientX;
|
|
1768
|
+
dragStartDim = dims.treeWidth;
|
|
1769
|
+
dragStartTotal = rect.width;
|
|
1770
|
+
document.body.style.cursor = 'col-resize';
|
|
1771
|
+
} else if (kind === 'middle') {
|
|
1772
|
+
dragStartXY = e.clientX;
|
|
1773
|
+
dragStartDim = dims.middlePct;
|
|
1774
|
+
dragStartTotal = rect.width;
|
|
1775
|
+
document.body.style.cursor = 'col-resize';
|
|
1776
|
+
} else if (kind === 'editor-terminal') {
|
|
1777
|
+
dragStartXY = e.clientY;
|
|
1778
|
+
dragStartDim = dims.editorPct;
|
|
1779
|
+
// The vertical handle moves within the left-stack column.
|
|
1780
|
+
dragStartTotal = stack ? stack.getBoundingClientRect().height : rect.height;
|
|
1781
|
+
document.body.style.cursor = 'row-resize';
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function onMove(e) {
|
|
1786
|
+
if (!activeHandle) return;
|
|
1787
|
+
if (activeHandle === 'tree') {
|
|
1788
|
+
const dx = e.clientX - dragStartXY;
|
|
1789
|
+
dims.treeWidth = Math.max(160, Math.min(600, dragStartDim + dx));
|
|
1790
|
+
const tree = document.getElementById('treePanel');
|
|
1791
|
+
if (tree) tree.style.width = dims.treeWidth + 'px';
|
|
1792
|
+
applyTerminalFit();
|
|
1793
|
+
applyMonacoLayout();
|
|
1794
|
+
} else if (activeHandle === 'middle') {
|
|
1795
|
+
const dx = e.clientX - dragStartXY;
|
|
1796
|
+
const pctDelta = (dx / dragStartTotal) * 100;
|
|
1797
|
+
dims.middlePct = Math.max(20, Math.min(80, dragStartDim + pctDelta));
|
|
1798
|
+
applyMiddlePreviewFlex();
|
|
1799
|
+
applyTerminalFit();
|
|
1800
|
+
applyMonacoLayout();
|
|
1801
|
+
} else if (activeHandle === 'editor-terminal') {
|
|
1802
|
+
const dy = e.clientY - dragStartXY;
|
|
1803
|
+
const pctDelta = (dy / dragStartTotal) * 100;
|
|
1804
|
+
dims.editorPct = Math.max(10, Math.min(90, dragStartDim + pctDelta));
|
|
1805
|
+
applyEditorTerminalFlex();
|
|
1806
|
+
applyTerminalFit();
|
|
1807
|
+
applyMonacoLayout();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function endDrag() {
|
|
1812
|
+
if (!activeHandle) return;
|
|
1813
|
+
activeHandle = null;
|
|
1814
|
+
document.body.style.cursor = '';
|
|
1815
|
+
saveDims();
|
|
1816
|
+
applyTerminalFit();
|
|
1817
|
+
applyMonacoLayout();
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Wire up the three handles.
|
|
1821
|
+
function bindHandles() {
|
|
1822
|
+
const h1 = document.getElementById('treeResizeHandle');
|
|
1823
|
+
const h2 = document.getElementById('resizeHandle');
|
|
1824
|
+
const v1 = document.getElementById('editorTerminalResizeHandle');
|
|
1825
|
+
if (h1) h1.addEventListener('mousedown', (e) => startDrag('tree', e));
|
|
1826
|
+
if (h2) h2.addEventListener('mousedown', (e) => startDrag('middle', e));
|
|
1827
|
+
if (v1) v1.addEventListener('mousedown', (e) => startDrag('editor-terminal', e));
|
|
1828
|
+
document.addEventListener('mousemove', onMove);
|
|
1829
|
+
document.addEventListener('mouseup', endDrag);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function restoreDims() {
|
|
1833
|
+
loadDims();
|
|
1834
|
+
// Apply tree width inline so first paint shows the persisted size.
|
|
1835
|
+
const tree = document.getElementById('treePanel');
|
|
1836
|
+
if (tree) tree.style.width = dims.treeWidth + 'px';
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// Re-apply dims any time setLayout() switches modes — the inline
|
|
1840
|
+
// flex values are mode-specific (split vs editor have different
|
|
1841
|
+
// pane structures).
|
|
1842
|
+
function onLayoutChange() {
|
|
1843
|
+
applyMiddlePreviewFlex();
|
|
1844
|
+
applyEditorTerminalFlex();
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
bindHandles();
|
|
1848
|
+
return { restoreDims, applyAll, onLayoutChange };
|
|
1849
|
+
})();
|
|
1850
|
+
|
|
1851
|
+
// ── Status ──
|
|
1852
|
+
function setStatus(s) {
|
|
1853
|
+
dot.className = 'status-dot ' + s;
|
|
1854
|
+
stText.textContent = s === 'connected' ? 'Connected' : s === 'connecting' ? 'Connecting...' : 'Disconnected';
|
|
1855
|
+
}
|
|
1856
|
+
function fmtB(b) {
|
|
1857
|
+
if (b >= 1e9) return (b/1e9).toFixed(1)+' GB';
|
|
1858
|
+
if (b >= 1e6) return (b/1e6).toFixed(1)+' MB';
|
|
1859
|
+
if (b >= 1e3) return (b/1e3).toFixed(1)+' KB';
|
|
1860
|
+
return b+' B';
|
|
1861
|
+
}
|
|
1862
|
+
function refreshStats() {
|
|
1863
|
+
fetch(STATS_URL).then(r => r.json()).then(s => {
|
|
1864
|
+
document.getElementById('stFiles').textContent = s.files + ' files, ' + s.directories + ' dirs';
|
|
1865
|
+
document.getElementById('stSize').textContent = fmtB(s.usedBytes);
|
|
1866
|
+
if (s.cache) {
|
|
1867
|
+
document.getElementById('stCache').textContent = s.cache.entries + '/' + s.cache.maxEntries;
|
|
1868
|
+
document.getElementById('stHitRate').textContent = s.cache.hitRate + '%';
|
|
1869
|
+
}
|
|
1870
|
+
// Pick the active preview backend: wrangler takes precedence
|
|
1871
|
+
// over vite when both are running, because users only start
|
|
1872
|
+
// wrangler deliberately (vs. vite which auto-starts on certain
|
|
1873
|
+
// workflows). If neither is running, we still point at the
|
|
1874
|
+
// vite /preview/ URL so its placeholder page renders.
|
|
1875
|
+
let kind = 'none';
|
|
1876
|
+
if (s.wrangler?.running) kind = 'worker';
|
|
1877
|
+
else if (s.vite?.running) kind = 'vite';
|
|
1878
|
+
applyPreviewTarget(kind);
|
|
1879
|
+
|
|
1880
|
+
if (s.wrangler?.running) {
|
|
1881
|
+
viteStatus.textContent = 'worker running (' + (s.wrangler.name || '?') + ')';
|
|
1882
|
+
viteStatus.className = 'vite-status running';
|
|
1883
|
+
} else if (s.vite?.running) {
|
|
1884
|
+
viteStatus.textContent = 'vite running (' + s.vite.root + ')';
|
|
1885
|
+
viteStatus.className = 'vite-status running';
|
|
1886
|
+
} else {
|
|
1887
|
+
viteStatus.textContent = '';
|
|
1888
|
+
viteStatus.className = 'vite-status';
|
|
1889
|
+
}
|
|
1890
|
+
}).catch(() => {});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// ── WebSocket ──
|
|
1894
|
+
function connect() {
|
|
1895
|
+
setStatus('connecting');
|
|
1896
|
+
ws = new WebSocket(WS_URL);
|
|
1897
|
+
ws.onopen = () => {
|
|
1898
|
+
setStatus('connected'); rd = 1000;
|
|
1899
|
+
ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
|
|
1900
|
+
// file-tree-fix (2026-05-14): drain any fs-* frames that
|
|
1901
|
+
// Editor / FileTree queued before WS opened. User repro
|
|
1902
|
+
// was a cold session where clicking Editor before the first
|
|
1903
|
+
// ws.onopen produced "fs-list failed: WS not connected".
|
|
1904
|
+
// Drain order doesn't matter — server processes per-reqId.
|
|
1905
|
+
try { if (typeof Editor !== 'undefined') Editor.drainFsQueue(); } catch {}
|
|
1906
|
+
try { if (typeof FileTree !== 'undefined') FileTree.drainFsQueue(); } catch {}
|
|
1907
|
+
// file-tree-watch (2026-05-15): subscribe the FileTree to
|
|
1908
|
+
// server-pushed fs-watch-event frames so terminal-side writes
|
|
1909
|
+
// (e.g. `touch foo.c`) appear in the sidebar within ~150 ms
|
|
1910
|
+
// without needing a manual refresh click.
|
|
1911
|
+
try { if (typeof FileTree !== 'undefined') FileTree.subscribeOnce(); } catch {}
|
|
1912
|
+
refreshStats();
|
|
1913
|
+
if (statsTimer) clearInterval(statsTimer);
|
|
1914
|
+
statsTimer = setInterval(() => {
|
|
1915
|
+
refreshStats();
|
|
1916
|
+
// Keep the process count + tab state reconciled even if a
|
|
1917
|
+
// spawn or exit event was dropped during a reconnect or
|
|
1918
|
+
// brief network blip. /api/processes is cheap (in-memory).
|
|
1919
|
+
if (typeof LogTabs !== 'undefined') LogTabs.hydrate();
|
|
1920
|
+
}, 5000);
|
|
1921
|
+
// Initial hydrate — DO is warm, ProcessTable populated.
|
|
1922
|
+
if (typeof LogTabs !== 'undefined') LogTabs.hydrate();
|
|
1923
|
+
};
|
|
1924
|
+
ws.onmessage = (e) => {
|
|
1925
|
+
try {
|
|
1926
|
+
const msg = JSON.parse(e.data);
|
|
1927
|
+
// monaco-wave-a (2026-05-13): fs-*-result frames route to
|
|
1928
|
+
// Editor's pending-request resolver. Return early so the
|
|
1929
|
+
// shell-side dispatch doesn't see them as unknown types.
|
|
1930
|
+
if (Editor.tryHandleFsResult(msg)) return;
|
|
1931
|
+
// monaco-wave-b: FileTree consumes fs-list results too.
|
|
1932
|
+
if (FileTree.tryHandleFsResult(msg)) return;
|
|
1933
|
+
// file-tree-watch (2026-05-15): server-pushed fs-watch-event
|
|
1934
|
+
// frames (no reqId) route to FileTree.applyWatchEvent which
|
|
1935
|
+
// coalesces per-parent refreshes on a 100ms window.
|
|
1936
|
+
if (FileTree.applyWatchEvent(msg)) return;
|
|
1937
|
+
if (msg.type === 'output') {
|
|
1938
|
+
term.write(msg.data);
|
|
1939
|
+
// Snappy preview switch: both vite (/preview/) and wrangler
|
|
1940
|
+
// (/worker/, "Worker built") emit recognisable output lines
|
|
1941
|
+
// when they come online. Triggering an immediate stats
|
|
1942
|
+
// refresh here shaves up to 5s off the otherwise-periodic
|
|
1943
|
+
// polling interval so the iframe flips as soon as the user
|
|
1944
|
+
// sees the dev-server banner in the terminal.
|
|
1945
|
+
if (msg.data.includes('/preview/') ||
|
|
1946
|
+
msg.data.includes('/worker/') ||
|
|
1947
|
+
msg.data.includes('Worker built') ||
|
|
1948
|
+
msg.data.includes('wrangler ready')) {
|
|
1949
|
+
refreshStats();
|
|
1950
|
+
}
|
|
1951
|
+
} else if (msg.type === 'ready') {
|
|
1952
|
+
refreshStats();
|
|
1953
|
+
} else if (msg.type === 'hmr') {
|
|
1954
|
+
// Forward HMR to preview iframe
|
|
1955
|
+
if (previewFrame.contentWindow) {
|
|
1956
|
+
previewFrame.contentWindow.postMessage(msg.data, '*');
|
|
1957
|
+
}
|
|
1958
|
+
} else if (msg.type === 'spawn') {
|
|
1959
|
+
// Auto-open a log tab for EVERY process the supervisor spawns.
|
|
1960
|
+
// Short-lived processes (node foo.js) immediately receive an
|
|
1961
|
+
// exit event and the tab flips to "exited" — kept around for
|
|
1962
|
+
// 10 min so the user can read the final output. Long-running
|
|
1963
|
+
// processes stream live. Gating on longRunning made the tabs
|
|
1964
|
+
// panel invisible for the most common user actions (node
|
|
1965
|
+
// script.js, npm run <non-dev-script>), which defeats the
|
|
1966
|
+
// whole point of the panel.
|
|
1967
|
+
LogTabs.openTab(msg.pid, msg.command);
|
|
1968
|
+
} else if (msg.type === 'exit') {
|
|
1969
|
+
// An exit can arrive for a pid we never saw a spawn for
|
|
1970
|
+
// (e.g. `node -e`, which the supervisor intentionally
|
|
1971
|
+
// suppresses the spawn banner for). Surface those too so
|
|
1972
|
+
// the user sees SOMETHING — we open a tab on-exit, fetch
|
|
1973
|
+
// the command from /api/processes if we haven't already,
|
|
1974
|
+
// and mark it exited. The ring buffer keeps the output
|
|
1975
|
+
// for 10 min.
|
|
1976
|
+
if (!LogTabs.hasTab(msg.pid)) {
|
|
1977
|
+
LogTabs.openTab(msg.pid, msg.command || ('pid ' + msg.pid));
|
|
1978
|
+
}
|
|
1979
|
+
LogTabs.markExit(msg.pid, msg.code, msg.reason);
|
|
1980
|
+
}
|
|
1981
|
+
} catch {}
|
|
1982
|
+
};
|
|
1983
|
+
ws.onclose = () => {
|
|
1984
|
+
setStatus('disconnected');
|
|
1985
|
+
if (statsTimer) { clearInterval(statsTimer); statsTimer = null; }
|
|
1986
|
+
setTimeout(() => { rd = Math.min(rd*1.5, 10000); connect(); }, rd);
|
|
1987
|
+
};
|
|
1988
|
+
ws.onerror = () => ws.close();
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
term.onData(d => { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'input',data:d})); });
|
|
1992
|
+
window.addEventListener('resize', () => {
|
|
1993
|
+
fitAddon.fit();
|
|
1994
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
// ── Process log tabs panel ─────────────────────────────────────────
|
|
1998
|
+
// One tab per PID streaming `/api/logs/<pid>` over a WebSocket.
|
|
1999
|
+
// Auto-opens on EVERY `{type:'spawn'}` from the main WS; manually
|
|
2000
|
+
// closable via the ✕ on each tab, or collapsible via the toolbar
|
|
2001
|
+
// "Processes" button. The ring buffer retains logs for 10 min
|
|
2002
|
+
// post-exit, so a tab left open after a crash keeps showing the
|
|
2003
|
+
// final output until the buffer expires (or the user closes it).
|
|
2004
|
+
const LogTabs = (function() {
|
|
2005
|
+
const panel = document.getElementById('logsPanel');
|
|
2006
|
+
const tabBar = document.getElementById('logsTabBar');
|
|
2007
|
+
const body = document.getElementById('logsPanelBody');
|
|
2008
|
+
const btnProcs = document.getElementById('btnProcs');
|
|
2009
|
+
const procsCount = document.getElementById('procsCount');
|
|
2010
|
+
/** pid -> { tabEl, viewEl, ws, exited, failed, lastPainted, label } */
|
|
2011
|
+
const tabs = new Map();
|
|
2012
|
+
let activePid = null;
|
|
2013
|
+
// User-controlled visibility. `true` = show panel when ≥1 tab open.
|
|
2014
|
+
// `false` = keep panel collapsed even with tabs (toolbar button
|
|
2015
|
+
// dropped down once). Defaults to true; toggled by #btnProcs.
|
|
2016
|
+
let userWantsVisible = true;
|
|
2017
|
+
|
|
2018
|
+
// Minimal ANSI CSI stripper — the log stream passes ANSI through;
|
|
2019
|
+
// we keep stderr coloring via a CSS class rather than trying to
|
|
2020
|
+
// faithfully render xterm sequences inside a <pre>.
|
|
2021
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[=>()][^\s]?/g;
|
|
2022
|
+
function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
|
|
2023
|
+
|
|
2024
|
+
function escapeHtml(s) {
|
|
2025
|
+
return s
|
|
2026
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
2027
|
+
.replace(/>/g, '>').replace(/"/g, '"')
|
|
2028
|
+
.replace(/'/g, ''');
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Derive a short label from the command: strip argv[0] path prefix,
|
|
2032
|
+
// drop anything after the first flag-looking token, cap at ~28 chars.
|
|
2033
|
+
function labelFor(command) {
|
|
2034
|
+
if (!command) return '';
|
|
2035
|
+
const trimmed = command.trim();
|
|
2036
|
+
const parts = trimmed.split(/\s+/);
|
|
2037
|
+
// Strip env-var prefixes like FOO=bar
|
|
2038
|
+
let i = 0;
|
|
2039
|
+
while (i < parts.length && /^[A-Z_][A-Z0-9_]*=/.test(parts[i])) i++;
|
|
2040
|
+
let display = parts.slice(i, i + 3).join(' ');
|
|
2041
|
+
if (display.length > 32) display = display.slice(0, 29) + '…';
|
|
2042
|
+
return display || trimmed.slice(0, 32);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Refit the xterm + push new cols/rows to the server. Called after
|
|
2046
|
+
// ANY change to the panel's visibility or tab count — because the
|
|
2047
|
+
// terminal's viewport just grew or shrunk and the shell needs the
|
|
2048
|
+
// new size so its line-wrapping stays correct.
|
|
2049
|
+
function refitTerminal() {
|
|
2050
|
+
try { fitAddon.fit(); } catch {}
|
|
2051
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
2052
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// Single source of truth for "should the panel be visible right
|
|
2057
|
+
// now?". The panel shows iff there's ≥1 tab AND the user hasn't
|
|
2058
|
+
// collapsed it. The toolbar button is visible iff we've ever seen
|
|
2059
|
+
// a process (so brand-new sessions don't see a useless "0 Processes"
|
|
2060
|
+
// chip), and its "active" highlight tracks panel visibility.
|
|
2061
|
+
function applyVisibility() {
|
|
2062
|
+
const haveTabs = tabs.size > 0;
|
|
2063
|
+
const shouldShow = haveTabs && userWantsVisible;
|
|
2064
|
+
const wasShown = panel.classList.contains('active');
|
|
2065
|
+
if (shouldShow) panel.classList.add('active');
|
|
2066
|
+
else panel.classList.remove('active');
|
|
2067
|
+
if (wasShown !== shouldShow) refitTerminal();
|
|
2068
|
+
|
|
2069
|
+
// Running vs total — the dot pulses when ≥1 tab is running.
|
|
2070
|
+
let running = 0;
|
|
2071
|
+
for (const t of tabs.values()) if (!t.exited) running++;
|
|
2072
|
+
procsCount.textContent = running > 0
|
|
2073
|
+
? (running === tabs.size ? String(running) : running + '/' + tabs.size)
|
|
2074
|
+
: String(tabs.size);
|
|
2075
|
+
btnProcs.classList.toggle('visible', haveTabs);
|
|
2076
|
+
btnProcs.classList.toggle('has-running', running > 0);
|
|
2077
|
+
btnProcs.classList.toggle('active', shouldShow);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Hard cap on child nodes per view — a long-running `vite` session
|
|
2081
|
+
// can produce thousands of chunks per hour; without this, the tab's
|
|
2082
|
+
// DOM grows unboundedly and the browser tab eventually becomes
|
|
2083
|
+
// sluggish. When over the cap we drop the oldest 20% in one go to
|
|
2084
|
+
// amortize layout thrash (vs. one-at-a-time on every render).
|
|
2085
|
+
const MAX_NODES_PER_VIEW = 2000;
|
|
2086
|
+
const TRIM_BATCH = 400;
|
|
2087
|
+
|
|
2088
|
+
function appendToView(tab, el) {
|
|
2089
|
+
// Pre-compute bottom-ness against the actively-rendered view (not
|
|
2090
|
+
// the panel body, which might be scrolled by a different tab).
|
|
2091
|
+
const view = tab.viewEl;
|
|
2092
|
+
const isActive = activePid === tab.pid;
|
|
2093
|
+
const atBottom = isActive &&
|
|
2094
|
+
(body.scrollHeight - body.scrollTop - body.clientHeight) < 24;
|
|
2095
|
+
view.appendChild(el);
|
|
2096
|
+
if (view.childElementCount > MAX_NODES_PER_VIEW) {
|
|
2097
|
+
// Trim oldest. DocumentFragment swap would be faster for huge
|
|
2098
|
+
// drops, but TRIM_BATCH is small and this runs at most once per
|
|
2099
|
+
// append so the measured cost is negligible.
|
|
2100
|
+
for (let i = 0; i < TRIM_BATCH && view.firstChild; i++) {
|
|
2101
|
+
view.removeChild(view.firstChild);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
if (atBottom) body.scrollTop = body.scrollHeight;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function renderChunk(tab, chunk) {
|
|
2108
|
+
const span = document.createElement('span');
|
|
2109
|
+
span.textContent = stripAnsi(chunk.data);
|
|
2110
|
+
if (chunk.binary) span.className = 'binary';
|
|
2111
|
+
else if (chunk.stream === 'stderr') span.className = 'stderr';
|
|
2112
|
+
appendToView(tab, span);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function renderBanner(tab, text, cls) {
|
|
2116
|
+
const div = document.createElement('span');
|
|
2117
|
+
div.className = 'banner' + (cls ? ' ' + cls : '');
|
|
2118
|
+
div.textContent = text;
|
|
2119
|
+
appendToView(tab, div);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function setActive(pid) {
|
|
2123
|
+
activePid = pid;
|
|
2124
|
+
for (const [p, t] of tabs) {
|
|
2125
|
+
t.tabEl.classList.toggle('active', p === pid);
|
|
2126
|
+
t.viewEl.classList.toggle('active', p === pid);
|
|
2127
|
+
}
|
|
2128
|
+
if (pid !== null) body.scrollTop = body.scrollHeight;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
function closeTab(pid) {
|
|
2132
|
+
const t = tabs.get(pid);
|
|
2133
|
+
if (!t) return;
|
|
2134
|
+
try { t.ws?.close(); } catch {}
|
|
2135
|
+
t.tabEl.remove();
|
|
2136
|
+
t.viewEl.remove();
|
|
2137
|
+
tabs.delete(pid);
|
|
2138
|
+
if (activePid === pid) {
|
|
2139
|
+
// Pick any other open tab, or collapse.
|
|
2140
|
+
const next = tabs.keys().next().value;
|
|
2141
|
+
if (next !== undefined) setActive(next);
|
|
2142
|
+
else activePid = null;
|
|
2143
|
+
}
|
|
2144
|
+
applyVisibility();
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Open the log-stream WebSocket for a tab, if it hasn't been
|
|
2148
|
+
// opened already. Audit R3 / PERF-AUDIT.md: since auto-open-
|
|
2149
|
+
// every-spawn landed, every short-lived `node foo.js` opened a
|
|
2150
|
+
// persistent log WS that stayed alive 10 min. Every facet
|
|
2151
|
+
// stdout chunk then fanned out to every idle subscriber on the
|
|
2152
|
+
// DO side, competing with the shell WS for input-gate time and
|
|
2153
|
+
// making typing feel laggy.
|
|
2154
|
+
//
|
|
2155
|
+
// We now defer the WS connection until the user actually clicks
|
|
2156
|
+
// the tab. The tab strip still appears immediately on spawn
|
|
2157
|
+
// (good UX — "I see you, here's your process") but the server
|
|
2158
|
+
// never ships bytes to a reader nobody's reading. The 95% of
|
|
2159
|
+
// short-lived processes that the user never clicks pay zero
|
|
2160
|
+
// network/DO cost beyond the initial {spawn}/{exit} frames on
|
|
2161
|
+
// the main WS.
|
|
2162
|
+
function ensureLogWs(tab) {
|
|
2163
|
+
if (tab.wsOpened) return;
|
|
2164
|
+
tab.wsOpened = true;
|
|
2165
|
+
// Drop the "click this tab to attach" placeholder (if any).
|
|
2166
|
+
// Backlog + live chunks will repopulate the view.
|
|
2167
|
+
tab.viewEl.replaceChildren();
|
|
2168
|
+
const pid = tab.pid;
|
|
2169
|
+
const wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') +
|
|
2170
|
+
'//' + location.host + SESSION_PREFIX + '/api/logs/' + pid;
|
|
2171
|
+
let logWs;
|
|
2172
|
+
try { logWs = new WebSocket(wsUrl); } catch {
|
|
2173
|
+
renderBanner(tab, '[log stream could not be opened]', 'failed');
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
tab.ws = logWs;
|
|
2177
|
+
logWs.onmessage = (ev) => {
|
|
2178
|
+
let msg;
|
|
2179
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
2180
|
+
if (msg.type === 'backlog') {
|
|
2181
|
+
for (const c of (msg.chunks || [])) renderChunk(tab, c);
|
|
2182
|
+
} else if (msg.type === 'chunk') {
|
|
2183
|
+
renderChunk(tab, msg);
|
|
2184
|
+
} else if (msg.type === 'exit') {
|
|
2185
|
+
markExit(pid, msg.code, msg.reason);
|
|
2186
|
+
} else if (msg.type === 'notfound') {
|
|
2187
|
+
// Mark exited so the server-side close doesn't emit a second
|
|
2188
|
+
// "[log stream closed]" banner on top of this one.
|
|
2189
|
+
tab.exited = true;
|
|
2190
|
+
tab.failed = true;
|
|
2191
|
+
tab.tabEl.classList.add('exited', 'failed');
|
|
2192
|
+
renderBanner(tab, '[no log buffer for pid ' + pid + ' — process may have been reaped]', 'failed');
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
logWs.onclose = () => {
|
|
2196
|
+
// If we haven't seen an exit yet, this is a server-side tear-down
|
|
2197
|
+
// or a network blip. The UI keeps the tab open so the user can
|
|
2198
|
+
// still read the backlog; reconnect would require a manual close
|
|
2199
|
+
// + reopen via the shell.
|
|
2200
|
+
if (!tab.exited) {
|
|
2201
|
+
renderBanner(tab, '[log stream closed]', '');
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
logWs.onerror = () => {
|
|
2205
|
+
renderBanner(tab, '[log stream error]', 'failed');
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function openTab(pid, command) {
|
|
2210
|
+
if (tabs.has(pid)) { setActive(pid); userWantsVisible = true; applyVisibility(); return; }
|
|
2211
|
+
|
|
2212
|
+
const label = labelFor(command);
|
|
2213
|
+
const tabEl = document.createElement('div');
|
|
2214
|
+
tabEl.className = 'logs-tab';
|
|
2215
|
+
tabEl.title = 'pid ' + pid + ': ' + (command || '(unknown)');
|
|
2216
|
+
tabEl.innerHTML =
|
|
2217
|
+
'<span class="logs-tab-dot"></span>' +
|
|
2218
|
+
'<span class="logs-tab-label"></span>' +
|
|
2219
|
+
'<span class="logs-tab-pid" style="color:#484f58">#' + pid + '</span>' +
|
|
2220
|
+
// Primitives wave (P11): Kill (^C/SIGTERM-equivalent) and
|
|
2221
|
+
// Restart per-tab. Hidden via CSS when the process has
|
|
2222
|
+
// exited. The close X is unchanged — it just removes the
|
|
2223
|
+
// local tab; Kill is what tears down the process.
|
|
2224
|
+
'<span class="logs-tab-action logs-tab-restart" title="Restart process (kill + spawn fresh; vite only)">↻</span>' +
|
|
2225
|
+
'<span class="logs-tab-action logs-tab-kill" title="Kill process">⏹</span>' +
|
|
2226
|
+
'<span class="logs-tab-close" title="Close tab (process keeps running)">✕</span>';
|
|
2227
|
+
tabEl.querySelector('.logs-tab-label').textContent = label;
|
|
2228
|
+
tabEl.addEventListener('click', (e) => {
|
|
2229
|
+
const target = e.target;
|
|
2230
|
+
if (target.classList && (
|
|
2231
|
+
target.classList.contains('logs-tab-close') ||
|
|
2232
|
+
target.classList.contains('logs-tab-action')
|
|
2233
|
+
)) return;
|
|
2234
|
+
// User click: this is the signal that they want to read the
|
|
2235
|
+
// log stream. Open the WS if it isn't already, then activate.
|
|
2236
|
+
const t = tabs.get(pid);
|
|
2237
|
+
if (t) ensureLogWs(t);
|
|
2238
|
+
setActive(pid);
|
|
2239
|
+
});
|
|
2240
|
+
tabEl.querySelector('.logs-tab-close').addEventListener('click', (e) => {
|
|
2241
|
+
e.stopPropagation();
|
|
2242
|
+
closeTab(pid);
|
|
2243
|
+
});
|
|
2244
|
+
const sendAction = async (action) => {
|
|
2245
|
+
// POST /api/<action>/<pid>; surface failures as a banner inside
|
|
2246
|
+
// the tab so the user sees WHY without hunting in network tools.
|
|
2247
|
+
let resp;
|
|
2248
|
+
try {
|
|
2249
|
+
resp = await fetch(SESSION_PREFIX + '/api/' + action + '/' + pid, {
|
|
2250
|
+
method: 'POST', cache: 'no-store',
|
|
2251
|
+
});
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
const t = tabs.get(pid);
|
|
2254
|
+
if (t) renderBanner(t, '[' + action + ' failed: network] ' + (err && err.message || err), 'failed');
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
const t = tabs.get(pid);
|
|
2258
|
+
if (!t) return;
|
|
2259
|
+
if (!resp.ok) {
|
|
2260
|
+
let msg = '[' + action + ' failed: HTTP ' + resp.status + ']';
|
|
2261
|
+
try {
|
|
2262
|
+
const j = await resp.json();
|
|
2263
|
+
if (j && j.error) msg += ' ' + j.error;
|
|
2264
|
+
} catch {}
|
|
2265
|
+
renderBanner(t, msg, 'failed');
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
renderBanner(t, '[' + action + ' requested]', 'ok');
|
|
2269
|
+
};
|
|
2270
|
+
tabEl.querySelector('.logs-tab-kill').addEventListener('click', (e) => {
|
|
2271
|
+
e.stopPropagation();
|
|
2272
|
+
sendAction('kill');
|
|
2273
|
+
});
|
|
2274
|
+
tabEl.querySelector('.logs-tab-restart').addEventListener('click', (e) => {
|
|
2275
|
+
e.stopPropagation();
|
|
2276
|
+
sendAction('restart');
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
const viewEl = document.createElement('div');
|
|
2280
|
+
viewEl.className = 'logs-view';
|
|
2281
|
+
viewEl.dataset.pid = String(pid);
|
|
2282
|
+
|
|
2283
|
+
tabBar.appendChild(tabEl);
|
|
2284
|
+
body.appendChild(viewEl);
|
|
2285
|
+
|
|
2286
|
+
const tab = { pid, tabEl, viewEl, ws: null, wsOpened: false, exited: false, failed: false, label };
|
|
2287
|
+
tabs.set(pid, tab);
|
|
2288
|
+
// Placeholder until the user clicks — so the active tab doesn't
|
|
2289
|
+
// look broken on auto-activate. renderBanner uses the existing
|
|
2290
|
+
// `.banner` styling. Replaced by real content when the WS
|
|
2291
|
+
// delivers a backlog frame (ensureLogWs → onmessage).
|
|
2292
|
+
renderBanner(tab, '[click this tab to attach to the log stream]', '');
|
|
2293
|
+
// Any new tab re-opens the panel if the user had collapsed it —
|
|
2294
|
+
// seeing a new process spawn is a stronger signal than a stale
|
|
2295
|
+
// "I don't want this right now" click. setActive is purely a
|
|
2296
|
+
// UI paint here (no WS opened) so the panel and "Processes N"
|
|
2297
|
+
// chip still react immediately to the spawn event.
|
|
2298
|
+
userWantsVisible = true;
|
|
2299
|
+
setActive(pid);
|
|
2300
|
+
applyVisibility();
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function markExit(pid, code, reason) {
|
|
2304
|
+
const tab = tabs.get(pid);
|
|
2305
|
+
if (!tab) return;
|
|
2306
|
+
if (tab.exited) return; // idempotent — server + log-WS may both fire
|
|
2307
|
+
tab.exited = true;
|
|
2308
|
+
tab.failed = code !== 0;
|
|
2309
|
+
tab.tabEl.classList.add('exited');
|
|
2310
|
+
if (tab.failed) tab.tabEl.classList.add('failed');
|
|
2311
|
+
// Update the dot color via the class; append the exit banner.
|
|
2312
|
+
const reasonSuffix = reason ? ' (' + reason + ')' : '';
|
|
2313
|
+
renderBanner(
|
|
2314
|
+
tab,
|
|
2315
|
+
'[process exited with code ' + code + reasonSuffix + ' — tab kept; close with ✕]',
|
|
2316
|
+
code === 0 ? 'ok' : 'failed',
|
|
2317
|
+
);
|
|
2318
|
+
// Running-count changed; refresh toolbar badge.
|
|
2319
|
+
applyVisibility();
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// Restore any processes already in the DO's ring buffer / process
|
|
2323
|
+
// table when the page loads. Covers two cases: (a) full page
|
|
2324
|
+
// refresh mid-session — the DO's process table persists, so we
|
|
2325
|
+
// should re-attach to any running process; (b) main WS reconnect
|
|
2326
|
+
// after a transient network blip — same table, same goal.
|
|
2327
|
+
//
|
|
2328
|
+
// We open tabs for ALL state='running' processes so users see
|
|
2329
|
+
// everything happening. Exited ones stay accessible via `logs
|
|
2330
|
+
// <pid>` in the shell for 10 min, but we don't auto-open them
|
|
2331
|
+
// here because that would clutter the UI after a handful of
|
|
2332
|
+
// short-lived commands.
|
|
2333
|
+
function hydrate() {
|
|
2334
|
+
fetch(SESSION_PREFIX + '/api/processes', { cache: 'no-store' })
|
|
2335
|
+
.then(r => r.ok ? r.json() : null)
|
|
2336
|
+
.then(data => {
|
|
2337
|
+
if (!data?.processes) return;
|
|
2338
|
+
for (const p of data.processes) {
|
|
2339
|
+
if (p.state === 'running' && !tabs.has(p.pid)) {
|
|
2340
|
+
openTab(p.pid, p.command);
|
|
2341
|
+
} else if (p.state !== 'running' && tabs.has(p.pid)) {
|
|
2342
|
+
// Server says exited; reconcile in case our main-WS
|
|
2343
|
+
// exit event got dropped during reconnect.
|
|
2344
|
+
markExit(p.pid, p.exitCode ?? 0, undefined);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
applyVisibility();
|
|
2348
|
+
})
|
|
2349
|
+
.catch(() => {});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Toolbar button: toggle user-facing visibility. If currently
|
|
2353
|
+
// shown, a click hides; if hidden, a click re-shows. Clicking
|
|
2354
|
+
// with no tabs open is a no-op (the button is hidden via CSS in
|
|
2355
|
+
// that state anyway).
|
|
2356
|
+
btnProcs.addEventListener('click', () => {
|
|
2357
|
+
userWantsVisible = !panel.classList.contains('active');
|
|
2358
|
+
applyVisibility();
|
|
2359
|
+
});
|
|
2360
|
+
|
|
2361
|
+
return {
|
|
2362
|
+
openTab, closeTab, markExit, hydrate, applyVisibility,
|
|
2363
|
+
hasTab: (pid) => tabs.has(pid),
|
|
2364
|
+
};
|
|
2365
|
+
})();
|
|
2366
|
+
// LogTabs.hydrate is invoked from ws.onopen above — the DO is warm
|
|
2367
|
+
// and ProcessTable is populated by then, no arbitrary delay needed.
|
|
2368
|
+
|
|
2369
|
+
term.focus();
|
|
2370
|
+
// monaco-polish (2026-05-14): default to editor mode on page load.
|
|
2371
|
+
// Restore any persisted pane dims (tree width, middle/preview ratio,
|
|
2372
|
+
// editor/terminal ratio) BEFORE activating layout so the first paint
|
|
2373
|
+
// shows the user's prior arrangement.
|
|
2374
|
+
PaneResizer.restoreDims();
|
|
2375
|
+
setLayout('editor');
|
|
2376
|
+
connect();
|
|
2377
|
+
</script>
|
|
2378
|
+
</body>
|
|
2379
|
+
</html>
|