From 71b0be76b08a23a34d02ff02769d95fe94ace27f Mon Sep 17 00:00:00 2001 From: Exelo Date: Sun, 4 Oct 2026 23:54:41 +0200 Subject: [PATCH 1/8] feat: rewrite v2 --- .dockerignore | 8 + .env.example | 40 +- ..._report.yml => 01-packages_bug_report.yml} | 37 +- .../ISSUE_TEMPLATE/02-tooling_bug_report.yml | 56 + ...ure_request.yml => 03-feature_request.yml} | 37 + ...n_issue.yml => 04-documentation_issue.yml} | 0 .github/actions/prepare/action.yml | 6 +- .github/issue-labeler.yml | 86 + .github/labeler.yml | 174 + .github/labels.yml | 116 + .github/workflows/e2e.yml | 36 + .github/workflows/tests.yml | 6 + .gitignore | 22 + .husky/pre-push | 2 +- .nvmrc | 2 +- .prettierignore | 25 +- CLAUDE.md | 281 + Dockerfile | 23 +- README.md | 13 + .../editor/.cliff-jumperrc.json | 2 +- apps/editor/.prettierignore | 21 + CHANGELOG.md => apps/editor/CHANGELOG.md | 0 apps/editor/LICENSE | 21 + apps/editor/README.md | 52 + cliff.toml => apps/editor/cliff.toml | 0 apps/editor/e2e/code-editor.spec.ts | 163 + apps/editor/e2e/command-palette.spec.ts | 210 + apps/editor/e2e/console.spec.ts | 227 + apps/editor/e2e/ecs-live.spec.ts | 176 + apps/editor/e2e/ecs.spec.ts | 344 + apps/editor/e2e/file-manager.spec.ts | 157 + apps/editor/e2e/git.spec.ts | 288 + apps/editor/e2e/helpers.ts | 38 + apps/editor/e2e/history-panel.spec.ts | 93 + apps/editor/e2e/inspectors.spec.ts | 174 + apps/editor/e2e/manual-pass.spec.ts | 96 + apps/editor/e2e/marketplace.spec.ts | 192 + apps/editor/e2e/prepare-workspace.mjs | 207 + apps/editor/e2e/project-plugins.spec.ts | 62 + apps/editor/e2e/project.spec.ts | 183 + apps/editor/e2e/runtime.spec.ts | 105 + apps/editor/e2e/scene.spec.ts | 285 + apps/editor/e2e/settings-ui.spec.ts | 156 + apps/editor/e2e/startup.bench.ts | 104 + apps/editor/e2e/test.ts | 25 + apps/editor/e2e/viewport.spec.ts | 106 + apps/editor/e2e/workbench.spec.ts | 179 + apps/editor/e2e/workspace.ts | 13 + apps/editor/package.json | 114 + apps/editor/playwright.config.ts | 46 + apps/editor/scripts/build-server.ts | 44 + apps/editor/server/main.ts | 46 + apps/editor/server/tsconfig.json | 5 + apps/editor/src/app.d.ts | 9 + {src => apps/editor/src}/app.html | 2 +- .../src/lib/code/follow-project-code.ts | 222 + apps/editor/src/lib/editor/Loading.svelte | 154 + apps/editor/src/lib/editor/create-editor.ts | 268 + apps/editor/src/lib/editor/editor-context.ts | 18 + .../src/lib/editor/error-description.ts | 21 + .../lib/editor/register-app-contributions.ts | 79 + .../lib/history/indexed-db-history-store.ts | 38 + .../src/lib/plugin/follow-project-plugins.ts | 88 + apps/editor/src/lib/plugin/shared-modules.ts | 52 + .../src/lib/plugin/svelte-internal.d.ts | 2 + .../src/lib/project/AddAppDialog.svelte | 117 + .../src/lib/project/AddLibraryDialog.svelte | 119 + .../src/lib/project/ProjectOverview.svelte | 346 + .../src/lib/project/register-core-panels.ts | 47 + .../src/lib/runtime/follow-project-runtime.ts | 392 ++ .../src/lib/runtime/runtime-toolbar.const.ts | 59 + .../src/lib/workspace/workspace-actions.ts | 313 + .../editor/src/routes/(editor)/+layout.svelte | 64 + apps/editor/src/routes/(editor)/+page.svelte | 383 ++ .../src/routes/(editor)/load/+page.svelte | 126 + .../routes/(editor)/project/[id]/+page.svelte | 200 + apps/editor/src/routes/+layout.svelte | 19 + apps/editor/src/routes/+layout.ts | 2 + apps/editor/src/routes/play/[id]/+page.svelte | 76 + {public => apps/editor/static}/favicon.svg | 0 {public => apps/editor/static}/robots.txt | 0 apps/editor/svelte.config.js | 15 + .../editor/test/plugin/shared-modules.spec.ts | 18 + apps/editor/tsconfig.json | 12 + apps/editor/vite.config.ts | 93 + commitlint.config.cjs | 32 +- components.json | 15 - docs/adr/0001-plugin-ui-runtime.md | 59 + docs/adr/0002-editor-server.md | 51 + docs/adr/0003-component-and-system-docs.md | 168 + docs/adr/0004-shared-libraries.md | 141 + docs/adr/0005-package-installs.md | 18 + docs/adr/0006-source-layout.md | 127 + docs/api/README.md | 68 + docs/api/registry.md | 157 + docs/api/settings-sync.md | 81 + docs/docs/README.md | 48 + docs/docs/editor/code-editor.mdx | 58 + docs/docs/editor/components.mdx | 102 +- docs/docs/editor/entities.mdx | 61 +- docs/docs/editor/export.mdx | 15 - docs/docs/editor/files.mdx | 46 + docs/docs/editor/git.mdx | 63 + docs/docs/editor/layout.mdx | 50 + docs/docs/editor/overview.mdx | 82 + docs/docs/editor/play.mdx | 102 + docs/docs/editor/plugins-and-packages.mdx | 68 + docs/docs/editor/scenes.mdx | 87 + docs/docs/editor/settings.mdx | 52 + docs/docs/editor/systems.mdx | 63 +- docs/docs/editor/viewport.mdx | 21 - .../create-your-first-project.mdx | 44 +- docs/docs/getting-started/introduction.mdx | 30 +- docs/docs/images/code-editor.png | Bin 0 -> 226866 bytes docs/docs/images/command-palette.png | Bin 0 -> 219823 bytes docs/docs/images/components-access.png | Bin 10986 -> 0 bytes docs/docs/images/components-create-name.png | Bin 4157 -> 0 bytes docs/docs/images/components-create.png | Bin 6851 -> 0 bytes docs/docs/images/components-data.png | Bin 7841 -> 0 bytes docs/docs/images/components-entity-add.png | Bin 7744 -> 0 bytes docs/docs/images/components-entity-panel.png | Bin 4393 -> 0 bytes docs/docs/images/components-entity.png | Bin 6273 -> 0 bytes docs/docs/images/components-import.png | Bin 6950 -> 0 bytes docs/docs/images/components-navigation.png | Bin 5655 -> 0 bytes docs/docs/images/download-export.png | Bin 7257 -> 0 bytes docs/docs/images/ecs-components.png | Bin 0 -> 129378 bytes docs/docs/images/ecs-scene.png | Bin 0 -> 127446 bytes docs/docs/images/editor-overview.png | Bin 0 -> 130275 bytes docs/docs/images/entities-new.png | Bin 8159 -> 0 bytes docs/docs/images/entities-rename.png | Bin 10400 -> 0 bytes docs/docs/images/export.png | Bin 9069 -> 0 bytes docs/docs/images/game-playing.png | Bin 0 -> 144522 bytes docs/docs/images/keyboard-shortcuts.png | Bin 0 -> 185153 bytes docs/docs/images/main-editor.png | Bin 45813 -> 0 bytes docs/docs/images/packages.png | Bin 0 -> 144172 bytes docs/docs/images/plugins-marketplace.png | Bin 0 -> 159933 bytes docs/docs/images/profiler.png | Bin 0 -> 109584 bytes docs/docs/images/settings.png | Bin 0 -> 161398 bytes docs/docs/images/system-access.png | Bin 9822 -> 0 bytes docs/docs/images/system-add-to-scene-1.png | Bin 10390 -> 0 bytes docs/docs/images/system-add-to-scene-2.png | Bin 6474 -> 0 bytes docs/docs/images/system-create-name.png | Bin 3909 -> 0 bytes docs/docs/images/system-import-selection.png | Bin 37673 -> 0 bytes docs/docs/images/system-import.png | Bin 8037 -> 0 bytes docs/docs/images/systems-create.png | Bin 6660 -> 0 bytes docs/docs/images/systems-navigate.png | Bin 6795 -> 0 bytes docs/docs/images/viewport-panel-edit.png | Bin 1250 -> 0 bytes docs/docs/images/viewport-panel-play.png | Bin 1242 -> 0 bytes docs/docs/plugins/code-and-codegen.mdx | 137 + .../docs/plugins/create-your-first-plugin.mdx | 193 + docs/docs/plugins/engine-libs.mdx | 97 + docs/docs/plugins/extension-points.mdx | 84 + docs/docs/plugins/history.mdx | 98 + docs/docs/plugins/manifest.mdx | 190 + docs/docs/plugins/overview.mdx | 64 + docs/docs/plugins/sdk.mdx | 197 + docs/docs/plugins/settings.mdx | 127 + docs/docs/plugins/widgets-and-styles.mdx | 110 + docs/drafts/item-schema.ts | 467 ++ docs/performance.md | 81 + docs/specs/code-editor.md | 75 + docs/specs/command-palette.md | 110 + docs/specs/console.md | 97 + docs/specs/ecs.md | 191 + docs/specs/file-manager.md | 73 + docs/specs/git.md | 99 + docs/specs/history-panel.md | 41 + docs/specs/inspectors.md | 77 + docs/specs/marketplace.md | 78 + docs/specs/scene.md | 161 + docs/specs/settings-ui.md | 57 + docs/specs/viewport.md | 67 + e2e/demo.test.ts | 6 - eslint.config.js | 60 +- examples/README.md | 41 + examples/breakout/.gitignore | 4 + examples/breakout/.prettierrc | 3 + examples/breakout/README.md | 68 + .../breakout/apps/client/nanoforge.config.ts | 5 + examples/breakout/apps/client/package.json | 18 + examples/breakout/apps/client/src/arena.ts | 8 + .../apps/client/src/components/ball.ts | 9 + .../apps/client/src/components/box.ts | 16 + .../apps/client/src/components/brick.ts | 16 + .../apps/client/src/components/hud-text.ts | 14 + .../apps/client/src/components/paddle.ts | 14 + .../apps/client/src/components/position.ts | 16 + .../apps/client/src/components/velocity.ts | 16 + .../apps/client/src/components/visual.ts | 30 + examples/breakout/apps/client/src/keys.ts | 19 + examples/breakout/apps/client/src/layer.ts | 4 + examples/breakout/apps/client/src/main.ts | 51 + .../breakout/apps/client/src/scene-vars.ts | 26 + .../apps/client/src/scenes/game-over.ts | 81 + .../breakout/apps/client/src/scenes/levels.ts | 820 +++ .../breakout/apps/client/src/scenes/menu.ts | 98 + .../breakout/apps/client/src/scenes/pause.ts | 78 + .../breakout/apps/client/src/scenes/run.ts | 115 + .../apps/client/src/scenes/victory.ts | 81 + .../apps/client/src/systems/back-to-menu.ts | 16 + .../apps/client/src/systems/draw-visuals.ts | 16 + .../apps/client/src/systems/lose-ball.ts | 23 + .../apps/client/src/systems/move-ball.ts | 117 + .../apps/client/src/systems/move-paddle.ts | 26 + .../apps/client/src/systems/record-best.ts | 14 + .../apps/client/src/systems/update-hud.ts | 22 + examples/breakout/apps/client/tsconfig.json | 5 + examples/breakout/nanoforge.config.ts | 6 + examples/breakout/package.json | 21 + examples/breakout/tsconfig.json | 20 + examples/counter-plugin/.gitignore | 2 + examples/counter-plugin/README.md | 23 + .../counter-plugin/nanoforge.manifest.json | 49 + examples/counter-plugin/package.json | 21 + examples/counter-plugin/src/Panel.svelte | 35 + examples/counter-plugin/src/counter.ts | 4 + examples/counter-plugin/src/index.ts | 52 + examples/counter-plugin/svelte.config.js | 2 + examples/counter-plugin/tsconfig.json | 14 + examples/counter-plugin/vite.config.ts | 12 + messages/en.json | 4 - messages/fr.json | 4 - package.json | 118 +- packages/code/.prettierignore | 14 + packages/code/LICENSE | 21 + packages/code/README.md | 37 + packages/code/package.json | 72 + packages/code/src/catalog/catalog-service.ts | 415 ++ packages/code/src/catalog/catalog.type.ts | 33 + packages/code/src/catalog/index.ts | 3 + packages/code/src/catalog/item-fits-app.ts | 29 + packages/code/src/codegen/codegen-target.ts | 36 + packages/code/src/codegen/index.ts | 1 + .../src/diagnostics/diagnostics-service.ts | 58 + packages/code/src/diagnostics/index.ts | 1 + .../document/document-changed.exception.ts | 6 + .../src/document/document-contributions.ts | 20 + .../code/src/document/document-preview.ts | 28 + .../code/src/document/document-service.ts | 272 + packages/code/src/document/document.type.ts | 51 + packages/code/src/document/index.ts | 6 + packages/code/src/document/match-glob.ts | 45 + packages/code/src/engine/code-engine.ts | 376 ++ packages/code/src/engine/code-engine.type.ts | 45 + packages/code/src/engine/code-file.const.ts | 2 + packages/code/src/engine/code.exception.ts | 11 + packages/code/src/engine/edit-builder.ts | 85 + packages/code/src/engine/engine.type.ts | 46 + packages/code/src/engine/extract-meta.ts | 111 + packages/code/src/engine/index.ts | 10 + packages/code/src/engine/literals.ts | 133 + packages/code/src/engine/module-specifier.ts | 56 + packages/code/src/engine/worker-sdk.ts | 33 + packages/code/src/index.ts | 10 + packages/code/src/service/code-service.ts | 357 + packages/code/src/service/index.ts | 1 + .../src/worker-rpc/create-browser-worker.ts | 5 + packages/code/src/worker-rpc/index.ts | 3 + packages/code/src/worker-rpc/serve-engine.ts | 70 + packages/code/src/worker-rpc/worker-rpc.ts | 105 + packages/code/src/worker.ts | 36 + .../code/test/catalog/catalog-service.spec.ts | 186 + .../code/test/catalog/item-fits-app.spec.ts | 32 + .../test/document/document-service.spec.ts | 101 + packages/code/test/engine/code-engine.spec.ts | 349 + .../code/test/engine/extract-meta.spec.ts | 111 + .../code/test/service/code-service.spec.ts | 217 + packages/code/tsconfig.json | 9 + packages/history/.prettierignore | 14 + packages/history/LICENSE | 21 + packages/history/README.md | 37 + packages/history/package.json | 63 + .../history/src/command/composite-command.ts | 50 + .../src/command/history-command.type.ts | 51 + packages/history/src/command/index.ts | 2 + packages/history/src/file/apply-text-edits.ts | 36 + .../history/src/file/file-history-tracker.ts | 120 + packages/history/src/file/index.ts | 2 + packages/history/src/index.ts | 4 + .../src/service/history-context-keys.const.ts | 4 + .../history/src/service/history-service.ts | 222 + .../src/service/history-service.type.ts | 60 + packages/history/src/service/index.ts | 5 + .../src/service/register-history-commands.ts | 48 + packages/history/src/service/undo-target.ts | 18 + packages/history/src/stack/history-stack.ts | 284 + .../history/src/stack/history-stack.type.ts | 36 + packages/history/src/stack/index.ts | 2 + .../test/file/apply-text-edits.spec.ts | 21 + .../test/file/file-history-tracker.spec.ts | 65 + packages/history/test/file/project-fs.spec.ts | 62 + packages/history/test/fixtures/history.ts | 34 + .../test/service/history-service.spec.ts | 77 + .../history/test/service/undo-target.spec.ts | 52 + .../history/test/stack/history-stack.spec.ts | 143 + packages/history/tsconfig.json | 8 + packages/kernel/.prettierignore | 14 + packages/kernel/LICENSE | 21 + packages/kernel/README.md | 37 + packages/kernel/package.json | 64 + packages/kernel/schemas/plugin.schema.json | 222 + packages/kernel/scripts/generate-schema.ts | 7 + .../command/command-not-found.exception.ts | 6 + .../command/command-precondition.exception.ts | 9 + .../kernel/src/command/command-registry.ts | 83 + packages/kernel/src/command/command.type.ts | 40 + packages/kernel/src/command/index.ts | 4 + .../kernel/src/context/context-key-service.ts | 104 + packages/kernel/src/context/index.ts | 4 + packages/kernel/src/context/parse-when.ts | 134 + .../src/context/when-parse.exception.ts | 10 + packages/kernel/src/context/when-tokenizer.ts | 83 + packages/kernel/src/context/when.type.ts | 20 + packages/kernel/src/di/container.ts | 271 + packages/kernel/src/di/container.type.ts | 15 + .../src/di/cyclic-dependency.exception.ts | 6 + packages/kernel/src/di/index.ts | 5 + .../src/di/service-not-found.exception.ts | 8 + packages/kernel/src/di/service-token.ts | 8 + packages/kernel/src/event/emitter.ts | 70 + packages/kernel/src/event/event-bus.ts | 37 + packages/kernel/src/event/events.ts | 75 + packages/kernel/src/event/index.ts | 3 + .../src/extension/contribution.exception.ts | 10 + .../src/extension/define-extension-point.ts | 8 + .../src/extension/extension-point.type.ts | 31 + .../src/extension/extension-registry.ts | 86 + packages/kernel/src/extension/index.ts | 4 + packages/kernel/src/i18n/index.ts | 2 + .../kernel/src/i18n/localization-service.ts | 79 + packages/kernel/src/i18n/message-format.ts | 87 + packages/kernel/src/index.ts | 10 + .../lifecycle/disposable-object.abstract.ts | 18 + packages/kernel/src/lifecycle/disposable.ts | 114 + packages/kernel/src/lifecycle/index.ts | 3 + .../src/lifecycle/mutable-disposable.ts | 32 + packages/kernel/src/log/format-log-value.ts | 55 + packages/kernel/src/log/index.ts | 6 + packages/kernel/src/log/log-level.enum.ts | 7 + packages/kernel/src/log/log-value.type.ts | 56 + packages/kernel/src/log/logger-service.ts | 57 + packages/kernel/src/log/logger.type.ts | 33 + .../kernel/src/log/serialize-log-value.ts | 125 + packages/kernel/src/observable/index.ts | 2 + .../kernel/src/observable/observable-value.ts | 44 + packages/kernel/src/observable/observable.ts | 121 + .../kernel/src/plugin/command-metadata.ts | 7 + .../kernel/src/plugin/core-services.const.ts | 20 + .../src/plugin/create-core-container.ts | 24 + .../src/plugin/create-plugin-context.ts | 54 + packages/kernel/src/plugin/describe-status.ts | 24 + packages/kernel/src/plugin/engine-libs.ts | 90 + packages/kernel/src/plugin/index.ts | 17 + packages/kernel/src/plugin/manifest-fields.ts | 44 + .../kernel/src/plugin/package-manifest.ts | 31 + .../src/plugin/plugin-activation.exception.ts | 9 + .../kernel/src/plugin/plugin-context.type.ts | 77 + packages/kernel/src/plugin/plugin-host.ts | 406 ++ .../kernel/src/plugin/plugin-host.type.ts | 44 + .../src/plugin/plugin-manifest.exception.ts | 12 + packages/kernel/src/plugin/plugin-manifest.ts | 93 + .../kernel/src/plugin/plugin-module-loader.ts | 24 + .../src/plugin/plugin-resolution.type.ts | 41 + .../kernel/src/plugin/plugin-source.enum.ts | 3 + packages/kernel/src/plugin/resolve-plugins.ts | 178 + .../src/plugin/shared-module-registry.ts | 55 + .../test/command/command-registry.spec.ts | 66 + .../test/context/context-key-service.spec.ts | 47 + .../kernel/test/context/parse-when.spec.ts | 49 + packages/kernel/test/di/container.spec.ts | 113 + packages/kernel/test/event/emitter.spec.ts | 117 + .../test/extension/extension-registry.spec.ts | 57 + .../test/i18n/localization-service.spec.ts | 51 + .../kernel/test/lifecycle/disposable.spec.ts | 115 + packages/kernel/test/log/log-value.spec.ts | 111 + .../kernel/test/log/logger-service.spec.ts | 36 + .../kernel/test/observable/observable.spec.ts | 106 + .../kernel/test/plugin/plugin-host.spec.ts | 300 + .../plugin/plugin-manifest-schema.spec.ts | 12 + .../test/plugin/plugin-manifest.spec.ts | 73 + .../test/plugin/resolve-plugins.spec.ts | 124 + .../plugin/shared-module-registry.spec.ts | 26 + packages/kernel/tsconfig.json | 8 + packages/layout/.prettierignore | 14 + packages/layout/LICENSE | 21 + packages/layout/README.md | 37 + packages/layout/bench/layout.bench.ts | 71 + packages/layout/package.json | 62 + packages/layout/src/default/default-layout.ts | 72 + packages/layout/src/default/index.ts | 1 + packages/layout/src/index.ts | 4 + packages/layout/src/model/empty-layout.ts | 22 + packages/layout/src/model/index.ts | 5 + packages/layout/src/model/layout-queries.ts | 45 + packages/layout/src/model/layout.const.ts | 4 + packages/layout/src/model/layout.type.ts | 61 + packages/layout/src/model/slot-id.enum.ts | 13 + packages/layout/src/operations/index.ts | 4 + .../layout/src/operations/layout-op.type.ts | 44 + packages/layout/src/operations/layout-ops.ts | 31 + .../layout/src/operations/layout.exception.ts | 6 + packages/layout/src/operations/reduce.ts | 342 + packages/layout/src/serialization/index.ts | 2 + .../layout/src/serialization/layout.schema.ts | 21 + .../layout/src/serialization/repair-layout.ts | 97 + .../src/serialization/serialize-layout.ts | 37 + .../test/default/default-layout.spec.ts | 44 + packages/layout/test/fixtures/layout.ts | 16 + .../operations/inverse-operations.spec.ts | 114 + .../layout/test/operations/layout-ops.spec.ts | 22 + .../layout/test/operations/reduce.spec.ts | 74 + .../serialization/serialize-layout.spec.ts | 61 + packages/layout/tsconfig.json | 8 + packages/meta/.prettierignore | 14 + packages/meta/LICENSE | 21 + packages/meta/README.md | 37 + packages/meta/package.json | 63 + packages/meta/src/doc/doc-comment.ts | 102 + packages/meta/src/doc/doc.type.ts | 11 + packages/meta/src/doc/index.ts | 2 + .../meta/src/extract/exported-declarations.ts | 64 + packages/meta/src/extract/extract-items.ts | 202 + packages/meta/src/extract/extract.type.ts | 35 + packages/meta/src/extract/index.ts | 5 + packages/meta/src/extract/meta-tags.const.ts | 46 + packages/meta/src/extract/param-fields.ts | 92 + packages/meta/src/extract/param-layout.ts | 47 + packages/meta/src/extract/requires.ts | 22 + packages/meta/src/hash/index.ts | 1 + packages/meta/src/hash/source-hash.ts | 19 + packages/meta/src/index.ts | 6 + packages/meta/src/owner/index.ts | 1 + packages/meta/src/owner/item-owner.type.ts | 59 + packages/meta/src/pure.ts | 8 + packages/meta/src/schema/element.schema.ts | 131 + packages/meta/src/schema/index.ts | 5 + packages/meta/src/schema/item-ref.schema.ts | 32 + packages/meta/src/schema/meta-file.schema.ts | 80 + .../meta/src/schema/meta-generator.const.ts | 2 + packages/meta/src/schema/owner-object.ts | 31 + packages/meta/src/type/default-of.ts | 66 + packages/meta/src/type/element-of-type.ts | 114 + packages/meta/src/type/index.ts | 4 + packages/meta/src/type/package-of-path.ts | 11 + packages/meta/src/type/type-context.type.ts | 6 + .../meta/test/extract/extract-items.spec.ts | 115 + packages/meta/test/extract/inline.spec.ts | 23 + packages/meta/tsconfig.json | 8 + packages/project/.prettierignore | 14 + packages/project/LICENSE | 21 + packages/project/README.md | 37 + packages/project/package.json | 66 + packages/project/src/cache/cache.type.ts | 21 + packages/project/src/cache/index.ts | 5 + .../src/cache/indexed-db.tree-cache.ts | 52 + .../project/src/cache/memory.content-cache.ts | 26 + .../project/src/cache/memory.tree-cache.ts | 16 + .../project/src/cache/opfs.content-cache.ts | 83 + packages/project/src/fs/index.ts | 1 + packages/project/src/fs/project-fs.ts | 233 + packages/project/src/index.ts | 4 + .../project/src/service/client-project.ts | 73 + packages/project/src/service/index.ts | 2 + .../project/src/service/project-service.ts | 63 + .../project/src/workspace/app-operations.ts | 104 + packages/project/src/workspace/changes.ts | 47 + .../project/src/workspace/editor-library.ts | 65 + packages/project/src/workspace/index.ts | 16 + .../project/src/workspace/library-config.ts | 83 + .../src/workspace/library-operations.ts | 168 + .../project/src/workspace/workspace-files.ts | 82 + .../project/src/workspace/workspace-io.ts | 16 + .../src/workspace/workspace-queries.ts | 26 + .../project/src/workspace/workspace.const.ts | 74 + .../src/workspace/workspace.exception.ts | 11 + .../project/src/workspace/workspace.type.ts | 45 + packages/project/test/fixtures/workspace.ts | 88 + packages/project/test/fs/project-fs.spec.ts | 115 + .../test/workspace/app-operations.spec.ts | 146 + .../test/workspace/library-operations.spec.ts | 150 + packages/project/tsconfig.json | 8 + packages/protocol/.prettierignore | 14 + packages/protocol/LICENSE | 21 + packages/protocol/README.md | 37 + packages/protocol/package.json | 63 + packages/protocol/src/code/code.contract.ts | 32 + packages/protocol/src/code/index.ts | 1 + packages/protocol/src/fs/fs.contract.ts | 64 + packages/protocol/src/fs/fs.schema.ts | 31 + packages/protocol/src/fs/index.ts | 2 + packages/protocol/src/git/git.contract.ts | 101 + packages/protocol/src/git/git.schema.ts | 58 + packages/protocol/src/git/index.ts | 2 + packages/protocol/src/index.ts | 10 + packages/protocol/src/path/index.ts | 1 + packages/protocol/src/path/project-path.ts | 30 + packages/protocol/src/plugins/index.ts | 2 + .../protocol/src/plugins/plugins.contract.ts | 23 + .../protocol/src/plugins/plugins.schema.ts | 26 + packages/protocol/src/project/index.ts | 2 + .../protocol/src/project/project.contract.ts | 50 + .../protocol/src/project/project.schema.ts | 70 + packages/protocol/src/registry/index.ts | 2 + .../src/registry/registry.contract.ts | 57 + .../protocol/src/registry/registry.schema.ts | 31 + .../src/runtime/engine-bridge.const.ts | 2 + .../src/runtime/engine-bridge.type.ts | 107 + packages/protocol/src/runtime/index.ts | 5 + .../protocol/src/runtime/runtime-output.ts | 9 + .../protocol/src/runtime/runtime.contract.ts | 75 + .../protocol/src/runtime/runtime.schema.ts | 57 + packages/protocol/src/session/index.ts | 2 + .../protocol/src/session/session.contract.ts | 12 + .../protocol/src/session/session.schema.ts | 23 + packages/protocol/src/settings/index.ts | 2 + .../src/settings/settings.contract.ts | 27 + .../protocol/src/settings/settings.schema.ts | 13 + .../protocol/test/path/project-path.spec.ts | 21 + packages/protocol/tsconfig.json | 8 + packages/registry/.prettierignore | 14 + packages/registry/LICENSE | 21 + packages/registry/README.md | 37 + packages/registry/package.json | 68 + packages/registry/src/archive/archive.ts | 98 + packages/registry/src/archive/index.ts | 1 + packages/registry/src/client/index.ts | 1 + .../registry/src/client/registry-client.ts | 93 + .../src/directory/directory-registry.ts | 173 + packages/registry/src/directory/index.ts | 1 + packages/registry/src/index.ts | 10 + packages/registry/src/install/index.ts | 1 + packages/registry/src/install/install-item.ts | 75 + packages/registry/src/item/index.ts | 1 + packages/registry/src/item/item.schema.ts | 80 + packages/registry/src/project/index.ts | 3 + .../src/project/packages-file.schema.ts | 19 + .../src/project/project-packages.const.ts | 5 + .../registry/src/project/project-packages.ts | 315 + .../src/project/project-packages.type.ts | 17 + packages/registry/src/registry/index.ts | 2 + .../src/registry/registry.exception.ts | 12 + .../registry/src/registry/registry.type.ts | 13 + packages/registry/src/serve/index.ts | 1 + packages/registry/src/serve/serve-registry.ts | 51 + packages/registry/src/tsconfig-paths.ts | 96 + packages/registry/src/types.ts | 3 + packages/registry/src/wire/index.ts | 2 + packages/registry/src/wire/packument.ts | 75 + packages/registry/src/wire/wire.schema.ts | 76 + .../test/contract/registry-contract.ts | 111 + .../test/contract/registry.contract.spec.ts | 51 + packages/registry/test/registry.spec.ts | 426 ++ packages/registry/tsconfig.json | 8 + packages/rpc/.prettierignore | 14 + packages/rpc/LICENSE | 21 + packages/rpc/README.md | 37 + packages/rpc/package.json | 62 + packages/rpc/src/client/index.ts | 2 + packages/rpc/src/client/rpc-client.ts | 227 + packages/rpc/src/client/rpc-client.type.ts | 35 + packages/rpc/src/codec/codec.ts | 41 + packages/rpc/src/codec/index.ts | 1 + packages/rpc/src/contract/contract.type.ts | 62 + packages/rpc/src/contract/define-contract.ts | 17 + packages/rpc/src/contract/index.ts | 3 + packages/rpc/src/contract/rpc.const.ts | 4 + packages/rpc/src/error/index.ts | 1 + packages/rpc/src/error/rpc.exception.ts | 51 + packages/rpc/src/index.ts | 5 + packages/rpc/src/router/index.ts | 3 + packages/rpc/src/router/router-entries.ts | 27 + packages/rpc/src/router/router.type.ts | 59 + packages/rpc/src/router/rpc-router.ts | 92 + packages/rpc/src/router/rpc-session.ts | 156 + packages/rpc/test/codec/codec.spec.ts | 19 + packages/rpc/test/fixtures/harness.ts | 77 + packages/rpc/test/rpc.spec.ts | 175 + packages/rpc/tsconfig.json | 8 + packages/runtime/.prettierignore | 14 + packages/runtime/LICENSE | 21 + packages/runtime/README.md | 37 + packages/runtime/package.json | 68 + .../runtime/src/bridge/bridge-emitter.type.ts | 9 + packages/runtime/src/bridge/direct-emitter.ts | 19 + packages/runtime/src/bridge/index.ts | 3 + packages/runtime/src/bridge/queued-emitter.ts | 36 + .../runtime/src/client/game-client-runner.ts | 126 + .../src/client/game-client-runner.type.ts | 39 + packages/runtime/src/client/index.ts | 2 + .../src/compatibility/check-compatibility.ts | 43 + .../compatibility/game-compatibility.type.ts | 15 + packages/runtime/src/compatibility/index.ts | 3 + .../src/compatibility/merge-features.ts | 51 + packages/runtime/src/index.ts | 6 + packages/runtime/src/service/index.ts | 2 + .../runtime/src/service/play.exception.ts | 10 + .../runtime/src/service/runtime-service.ts | 592 ++ .../src/service/runtime-service.type.ts | 71 + packages/runtime/src/settings/index.ts | 1 + .../runtime/src/settings/runtime-settings.ts | 56 + packages/runtime/src/source-map/index.ts | 2 + .../runtime/src/source-map/source-maps.ts | 95 + .../src/source-map/source-maps.type.ts | 17 + .../compatibility/check-compatibility.spec.ts | 21 + .../test/compatibility/merge-features.spec.ts | 39 + .../test/service/runtime-service.spec.ts | 300 + .../test/source-map/source-maps.spec.ts | 69 + packages/runtime/tsconfig.json | 8 + packages/sdk/.cliff-jumperrc.json | 8 + packages/sdk/.prettierignore | 14 + packages/sdk/CHANGELOG.md | 3 + packages/sdk/LICENSE | 21 + packages/sdk/README.md | 52 + packages/sdk/cliff.toml | 79 + packages/sdk/package.json | 94 + packages/sdk/scripts/build-types.ts | 29 + packages/sdk/src/index.ts | 289 + packages/sdk/src/ui.ts | 117 + packages/sdk/src/worker.ts | 54 + packages/sdk/test/index.spec.ts | 123 + packages/sdk/tsconfig.json | 8 + packages/sdk/tsconfig.types.json | 24 + packages/sdk/tsdown.config.ts | 20 + packages/server-core/.prettierignore | 14 + packages/server-core/LICENSE | 21 + packages/server-core/README.md | 37 + packages/server-core/package.json | 79 + packages/server-core/src/api/api-client.ts | 114 + packages/server-core/src/api/index.ts | 1 + .../server-core/src/env/editor-env.type.ts | 25 + packages/server-core/src/env/env.exception.ts | 6 + packages/server-core/src/env/env.schema.ts | 74 + packages/server-core/src/env/index.ts | 4 + packages/server-core/src/env/load-env.ts | 42 + packages/server-core/src/fs/archive.ts | 25 + packages/server-core/src/fs/fs-service.ts | 269 + packages/server-core/src/fs/index.ts | 5 + packages/server-core/src/fs/path-jail.ts | 53 + packages/server-core/src/fs/reveal.ts | 22 + packages/server-core/src/fs/watcher.ts | 115 + packages/server-core/src/git/git-service.ts | 427 ++ packages/server-core/src/git/git-token.ts | 10 + packages/server-core/src/git/index.ts | 2 + packages/server-core/src/git/parse-status.ts | 57 + packages/server-core/src/http/static-files.ts | 37 + packages/server-core/src/index.ts | 11 + packages/server-core/src/plugin/index.ts | 2 + .../server-core/src/plugin/plugin-sources.ts | 238 + .../server-core/src/plugin/server-plugins.ts | 115 + .../server-core/src/process/cli-service.ts | 92 + packages/server-core/src/process/index.ts | 2 + .../server-core/src/process/run-process.ts | 73 + .../server-core/src/project/config-loader.ts | 159 + packages/server-core/src/project/discovery.ts | 298 + packages/server-core/src/project/formatter.ts | 90 + packages/server-core/src/project/index.ts | 4 + .../src/project/project-registry.ts | 241 + .../server-core/src/project/type-files.ts | 50 + packages/server-core/src/rpc/core-rpc.type.ts | 30 + .../server-core/src/rpc/implement-core-rpc.ts | 34 + .../rpc/implementation/code.implementation.ts | 30 + .../rpc/implementation/fs.implementation.ts | 47 + .../rpc/implementation/git.implementation.ts | 55 + .../implementation/plugins.implementation.ts | 37 + .../implementation/projects.implementation.ts | 95 + .../implementation/registry.implementation.ts | 165 + .../implementation/runtime.implementation.ts | 59 + .../implementation/session.implementation.ts | 32 + .../implementation/settings.implementation.ts | 36 + .../server-core/src/rpc/package-manager-of.ts | 14 + packages/server-core/src/rpc/require-user.ts | 9 + .../src/runtime/build-orchestrator.ts | 228 + .../server-core/src/runtime/build-output.ts | 58 + packages/server-core/src/runtime/game-env.ts | 68 + .../server-core/src/runtime/game-server.ts | 242 + packages/server-core/src/runtime/index.ts | 5 + .../src/runtime/project-runtime.ts | 170 + .../src/server/create-editor-server.ts | 294 + .../src/server/editor-server.type.ts | 59 + packages/server-core/src/server/index.ts | 2 + packages/server-core/src/session/auth.ts | 17 + packages/server-core/src/session/cookies.ts | 55 + packages/server-core/src/session/index.ts | 2 + .../server-core/src/session/session-store.ts | 130 + .../src/settings/account-backends.ts | 158 + packages/server-core/src/settings/index.ts | 1 + packages/server-core/src/testing.ts | 85 + packages/server-core/src/util/glob.ts | 63 + packages/server-core/src/util/hash.ts | 7 + packages/server-core/src/util/keyed-mutex.ts | 15 + .../server-core/test/contract/api-contract.ts | 182 + .../test/contract/api.contract.spec.ts | 42 + .../test/fixtures/plugins/@acme/echo/index.js | 1 + .../@acme/echo/nanoforge.manifest.json | 7 + .../fixtures/plugins/@acme/echo/server.js | 15 + .../test/fixtures/pong-network/.gitignore | 0 .../fixtures/pong-network/.prettierignore | 11 + .../pong-network/apps/client/.prettierignore | 11 + .../pong-network/apps/client/assets/.gitkeep | 0 .../pong-network/apps/client/eslint.config.js | 3 + .../apps/client/nanoforge.config.ts | 5 + .../pong-network/apps/client/package.json | 37 + .../apps/client/prettier.config.js | 3 + .../apps/client/src/components/components.ts | 66 + .../pong-network/apps/client/src/main.ts | 109 + .../apps/client/src/scenes/.gitkeep | 0 .../apps/client/src/systems/systems.ts | 91 + .../pong-network/apps/client/tsconfig.json | 6 + .../pong-network/apps/server/.prettierignore | 11 + .../pong-network/apps/server/assets/.gitkeep | 0 .../pong-network/apps/server/eslint.config.js | 3 + .../apps/server/nanoforge.config.ts | 5 + .../pong-network/apps/server/package.json | 35 + .../apps/server/prettier.config.js | 3 + .../apps/server/src/components/components.ts | 41 + .../pong-network/apps/server/src/main.ts | 38 + .../apps/server/src/scenes/.gitkeep | 0 .../apps/server/src/systems/systems.ts | 186 + .../pong-network/apps/server/tsconfig.json | 6 + .../fixtures/pong-network/nanoforge.config.ts | 6 + .../test/fixtures/pong-network/package.json | 32 + .../fixtures/pong-network/prettier.config.js | 3 + .../test/fixtures/pong-network/tsconfig.json | 6 + .../fixtures/pong-network/tsconfig.spec.json | 10 + .../test/fixtures/runtime/bridge-game.js | 36 + .../test/fixtures/runtime/legacy-game.js | 6 + .../server-core/test/fixtures/runtime/nf.cjs | 18 + .../server-core/test/fs/fs-service.spec.ts | 160 + .../server-core/test/git/git-service.spec.ts | 293 + .../server-core/test/mocks/nanoforge-api.ts | 99 + .../test/project/discovery.spec.ts | 322 + .../test/project/formatter.spec.ts | 59 + .../server-core/test/rpc/registry.spec.ts | 274 + .../test/runtime/project-runtime.spec.ts | 260 + .../test/server/create-editor-server.spec.ts | 228 + .../test/settings/account-backends.spec.ts | 82 + packages/server-core/tsconfig.json | 9 + packages/settings/.prettierignore | 14 + packages/settings/LICENSE | 21 + packages/settings/README.md | 37 + packages/settings/package.json | 66 + .../src/account-sync/account-sync-store.ts | 199 + .../src/account-sync/account-sync.type.ts | 31 + packages/settings/src/account-sync/index.ts | 3 + .../src/account-sync/rpc-account-remote.ts | 25 + .../settings/src/definition/core-settings.ts | 104 + .../settings/src/definition/define-setting.ts | 29 + packages/settings/src/definition/index.ts | 5 + .../settings/src/definition/json-schema-of.ts | 7 + .../src/definition/setting-declaration.ts | 100 + .../src/definition/setting-definition.type.ts | 32 + packages/settings/src/index.ts | 9 + packages/settings/src/key-value/index.ts | 3 + .../key-value/indexed-db.key-value-store.ts | 46 + .../src/key-value/key-value-store.type.ts | 6 + .../src/key-value/memory.key-value-store.ts | 19 + packages/settings/src/merge/deep-merge.ts | 46 + packages/settings/src/merge/index.ts | 2 + packages/settings/src/merge/merge3.ts | 39 + packages/settings/src/registry/index.ts | 2 + .../src/registry/settings-contribution.ts | 18 + .../src/registry/settings-registry.ts | 59 + packages/settings/src/scope/index.ts | 1 + .../settings/src/scope/setting-scope.enum.ts | 23 + packages/settings/src/service/index.ts | 3 + .../settings/src/service/settings-service.ts | 276 + .../src/service/settings-service.type.ts | 32 + .../src/service/settings.exception.ts | 6 + .../src/setup/follow-current-project.ts | 38 + packages/settings/src/setup/index.ts | 2 + packages/settings/src/setup/setup-settings.ts | 99 + packages/settings/src/store/index.ts | 8 + .../src/store/json-file.scope-store.ts | 81 + .../src/store/key-value.scope-store.ts | 31 + .../settings/src/store/memory.scope-store.ts | 27 + .../src/store/project-file-adapter.ts | 45 + .../store/remote-project-local.scope-store.ts | 42 + .../settings/src/store/scope-store.type.ts | 24 + packages/settings/src/store/setting-values.ts | 25 + .../src/store/settings-file.exception.ts | 6 + .../account-sync/account-sync-store.spec.ts | 142 + .../definition/setting-declaration.spec.ts | 25 + packages/settings/test/end-to-end.spec.ts | 101 + packages/settings/test/fixtures/settings.ts | 32 + packages/settings/test/merge/merge3.spec.ts | 15 + .../test/service/settings-service.spec.ts | 128 + .../test/store/json-file.scope-store.spec.ts | 62 + packages/settings/tsconfig.json | 8 + packages/ui/.prettierignore | 14 + ...b15f3f113918a38cd8e641b8a17d93aa33ebb2.png | Bin 0 -> 6745 bytes ...a793f05c95fc860a68c6e341bc411b7e66b6a1.png | Bin 0 -> 2843 bytes ...6f5a286116b3a615006044f826a559850964e3.png | Bin 0 -> 2887 bytes ...050746551e183630dbddf1ae7ffec904ce7a4f.png | Bin 0 -> 2082 bytes packages/ui/LICENSE | 21 + packages/ui/README.md | 37 + packages/ui/package.json | 83 + packages/ui/src/components/MenuEntries.svelte | 41 + packages/ui/src/components/button.svelte | 88 + packages/ui/src/components/checkbox.svelte | 62 + .../ui/src/components/color-picker.svelte | 95 + packages/ui/src/components/combobox.svelte | 53 + .../ui/src/components/context-menu.svelte | 70 + packages/ui/src/components/dialog.svelte | 52 + packages/ui/src/components/empty-state.svelte | 50 + packages/ui/src/components/icon-button.svelte | 54 + packages/ui/src/components/icon.svelte | 25 + packages/ui/src/components/icons.ts | 130 + packages/ui/src/components/index.ts | 25 + packages/ui/src/components/input.svelte | 45 + packages/ui/src/components/menu-entry.type.ts | 19 + packages/ui/src/components/menu.svelte | 30 + .../ui/src/components/number-input.svelte | 164 + .../ui/src/components/property-field.type.ts | 20 + .../ui/src/components/property-grid.svelte | 115 + packages/ui/src/components/select.svelte | 54 + packages/ui/src/components/splitter.svelte | 85 + packages/ui/src/components/switch.svelte | 63 + packages/ui/src/components/tabs.svelte | 68 + packages/ui/src/components/toasts.svelte | 107 + packages/ui/src/components/tooltip.svelte | 25 + packages/ui/src/components/tree-model.ts | 44 + packages/ui/src/components/tree.svelte | 411 ++ .../ui/src/components/vector2-input.svelte | 43 + packages/ui/src/index.ts | 4 + packages/ui/src/style/index.ts | 1 + packages/ui/src/style/style-service.ts | 64 + packages/ui/src/theme/base-css.ts | 22 + packages/ui/src/theme/fonts.css | 5 + packages/ui/src/theme/index.ts | 5 + packages/ui/src/theme/kit-css.ts | 83 + packages/ui/src/theme/theme-service.ts | 78 + packages/ui/src/theme/tokens.ts | 154 + packages/ui/src/theme/uno.ts | 9 + packages/ui/src/workbench/action/index.ts | 1 + .../ui/src/workbench/action/list-actions.ts | 108 + .../code-editor.extension-point.ts | 32 + .../extension-point/file.extension-point.ts | 79 + .../ui/src/workbench/extension-point/index.ts | 9 + .../keybinding.extension-point.ts | 15 + .../extension-point/menu.extension-point.ts | 34 + .../settings-page.extension-point.ts | 43 + .../status-bar.extension-point.ts | 16 + .../toolbar.extension-point.ts | 15 + .../viewport.extension-point.ts | 52 + .../extension-point/widget.extension-point.ts | 56 + packages/ui/src/workbench/index.ts | 12 + packages/ui/src/workbench/keybinding/index.ts | 13 + .../keybinding/keybinding-service.ts | 127 + .../src/workbench/keybinding/keymap-edit.ts | 91 + .../workbench/keybinding/keymap-presets.ts | 27 + .../src/workbench/keybinding/keymap.type.ts | 21 + .../ui/src/workbench/keybinding/keystroke.ts | 102 + .../workbench/keybinding/resolve-keymap.ts | 110 + .../src/workbench/layout/drag-controller.ts | 96 + packages/ui/src/workbench/layout/index.ts | 3 + .../src/workbench/layout/layout-controller.ts | 309 + .../layout/layout-controller.type.ts | 10 + packages/ui/src/workbench/menu/index.ts | 1 + .../ui/src/workbench/menu/resolve-menu.ts | 76 + .../ui/src/workbench/notification/index.ts | 1 + .../notification/notification-service.ts | 71 + .../ui/src/workbench/parts/DockStack.svelte | 350 + .../ui/src/workbench/parts/DragOverlay.svelte | 46 + .../ui/src/workbench/parts/FloatWindow.svelte | 113 + .../ui/src/workbench/parts/MenuBarMenu.svelte | 38 + .../ui/src/workbench/parts/PromptHost.svelte | 69 + .../ui/src/workbench/parts/StatusBar.svelte | 155 + .../ui/src/workbench/parts/StatusText.svelte | 18 + .../ui/src/workbench/parts/ToolStripe.svelte | 173 + packages/ui/src/workbench/parts/TopBar.svelte | 156 + .../ui/src/workbench/parts/WidgetHost.svelte | 94 + packages/ui/src/workbench/parts/tab-menu.ts | 103 + packages/ui/src/workbench/prompt/index.ts | 1 + .../ui/src/workbench/prompt/prompt-service.ts | 40 + packages/ui/src/workbench/service/index.ts | 2 + .../workbench/service/workbench-service.ts | 300 + .../service/workbench-service.type.ts | 37 + packages/ui/src/workbench/setup/index.ts | 2 + .../ui/src/workbench/setup/setup-shell.ts | 174 + .../ui/src/workbench/setup/setup-workbench.ts | 166 + packages/ui/src/workbench/widget/index.ts | 1 + .../workbench/widget/widget-instance.type.ts | 22 + .../ui/src/workbench/workbench-context.ts | 41 + packages/ui/src/workbench/workbench.svelte | 385 ++ ...-renders-visible-rows-of-large-trees-1.png | Bin 0 -> 6745 bytes ...ols-ColorPicker-normalizes-hex-input-1.png | Bin 0 -> 2843 bytes .../controls-Select-picks-an-option-1.png | Bin 0 -> 2887 bytes ...s-Splitter-resizes-with-the-keyboard-1.png | Bin 0 -> 2082 bytes packages/ui/test/fixtures/MenuHarness.svelte | 13 + packages/ui/test/fixtures/TreeHarness.svelte | 21 + packages/ui/test/kit.svelte.spec.ts | 180 + packages/ui/test/style.svelte.spec.ts | 61 + .../workbench/action/list-actions.spec.ts | 83 + .../workbench/keybinding/keybindings.spec.ts | 245 + .../workbench/keybinding/keymap-edit.spec.ts | 128 + packages/ui/tsconfig.json | 8 + packages/ui/vitest.config.ts | 40 + playwright.config.ts | 6 - plugins/code-editor/.prettierignore | 14 + plugins/code-editor/LICENSE | 21 + plugins/code-editor/README.md | 37 + plugins/code-editor/nanoforge.manifest.json | 175 + plugins/code-editor/package.json | 64 + .../src/document/editor-document.ts | 245 + .../src/document/editor-document.type.ts | 19 + .../code-editor/src/document/unsaved-store.ts | 60 + plugins/code-editor/src/index.ts | 149 + .../code-editor/src/monaco/monaco-theme.ts | 29 + plugins/code-editor/src/monaco/monaco.ts | 72 + .../src/service/code-editor-service.ts | 446 ++ .../src/service/code-editor-settings.const.ts | 11 + .../src/service/code-editor.type.ts | 53 + .../code-editor/src/service/editor-edits.ts | 47 + .../code-editor/src/service/pair-renames.ts | 58 + .../src/session/code-editor-session.ts | 9 + .../code-editor/src/typing/type-libraries.ts | 127 + .../src/widgets/script/EditorGroupView.svelte | 381 ++ .../src/widgets/script/ScriptScreen.svelte | 167 + plugins/code-editor/svelte.config.js | 2 + .../test/service/pair-renames.spec.ts | 45 + plugins/code-editor/tsconfig.json | 8 + plugins/code-editor/vite.config.ts | 10 + plugins/command-palette/.prettierignore | 14 + plugins/command-palette/LICENSE | 21 + plugins/command-palette/README.md | 37 + .../command-palette/nanoforge.manifest.json | 62 + plugins/command-palette/package.json | 64 + plugins/command-palette/src/icons.ts | 6 + plugins/command-palette/src/index.ts | 136 + .../src/keymap/KeymapPage.svelte | 332 + .../src/keymap/RecordDialog.svelte | 201 + .../command-palette/src/keymap/keymap-rows.ts | 116 + .../src/keymap/keymap.const.ts | 6 + .../src/keymap/open-record-dialog.ts | 21 + .../src/palette/Palette.svelte | 369 + plugins/command-palette/src/palette/fuzzy.ts | 100 + .../src/palette/palette-model.ts | 108 + plugins/command-palette/src/palette/recent.ts | 22 + plugins/command-palette/svelte.config.js | 2 + .../test/palette/fuzzy.spec.ts | 55 + .../test/palette/palette-model.spec.ts | 137 + plugins/command-palette/tsconfig.json | 8 + plugins/command-palette/vite.config.ts | 11 + plugins/console/.prettierignore | 14 + plugins/console/LICENSE | 21 + plugins/console/README.md | 37 + plugins/console/nanoforge.manifest.json | 56 + plugins/console/package.json | 64 + plugins/console/src/icons.ts | 6 + plugins/console/src/index.ts | 103 + plugins/console/src/link/frame-locator.ts | 51 + plugins/console/src/link/linkify.ts | 85 + .../console/src/problems/group-problems.ts | 100 + .../console/src/session/console-session.ts | 13 + plugins/console/src/store/console-store.ts | 119 + plugins/console/src/store/log-sources.ts | 28 + .../src/widgets/console/ConsoleLineRow.svelte | 143 + .../src/widgets/console/ConsoleView.svelte | 290 + .../src/widgets/console/GroupChips.svelte | 85 + .../src/widgets/console/LinkedText.svelte | 65 + .../src/widgets/console/ValueTree.svelte | 157 + .../src/widgets/console/console-filters.ts | 60 + .../src/widgets/problems/ProblemsView.svelte | 226 + plugins/console/svelte.config.js | 2 + plugins/console/test/link/linkify.spec.ts | 79 + .../test/problems/group-problems.spec.ts | 60 + .../console/test/store/console-store.spec.ts | 119 + plugins/console/tsconfig.json | 8 + plugins/console/vite.config.ts | 11 + plugins/ecs/.prettierignore | 14 + plugins/ecs/LICENSE | 21 + plugins/ecs/README.md | 37 + plugins/ecs/nanoforge.manifest.json | 103 + plugins/ecs/package.json | 68 + plugins/ecs/src/create/create-item.ts | 82 + .../extension/ecs-source.extension-point.ts | 41 + .../extension/field-editor.extension-point.ts | 35 + .../extension/param-preset.extension-point.ts | 22 + plugins/ecs/src/index.ts | 136 + plugins/ecs/src/layout/param-layout.ts | 243 + plugins/ecs/src/library/check-libraries.ts | 77 + .../ecs/src/library/create-shared-library.ts | 46 + plugins/ecs/src/library/refactors-history.ts | 28 + plugins/ecs/src/library/relocate-item.ts | 103 + plugins/ecs/src/live/live-service.ts | 366 + plugins/ecs/src/live/live.type.ts | 53 + plugins/ecs/src/live/move-in-game.ts | 10 + plugins/ecs/src/model/arg-values.ts | 69 + plugins/ecs/src/model/ecs-model.type.ts | 157 + plugins/ecs/src/model/ecs.const.ts | 9 + plugins/ecs/src/model/item-docs.type.ts | 26 + plugins/ecs/src/model/scope-name.ts | 5 + plugins/ecs/src/scene/build-scene.ts | 119 + plugins/ecs/src/service/code-services.ts | 18 + plugins/ecs/src/service/ecs-data.ts | 64 + plugins/ecs/src/service/ecs-service.ts | 546 ++ plugins/ecs/src/session/panel-services.ts | 13 + .../ecs/src/widgets/browser/Browser.svelte | 216 + .../src/widgets/browser/ItemDetails.svelte | 154 + .../src/widgets/browser/browser-sections.ts | 55 + .../ecs/src/widgets/browser/create-flows.ts | 79 + .../component-panel/ComponentPanel.svelte | 207 + .../component-panel/GroupsSection.svelte | 94 + .../component-panel/ParamEditor.svelte | 152 + .../widgets/component-panel/component-docs.ts | 86 + .../src/widgets/hierarchy/Hierarchy.svelte | 275 + .../hierarchy/InheritedEntitiesTree.svelte | 102 + .../widgets/hierarchy/LiveEntitiesTree.svelte | 127 + .../widgets/inspector/ComponentCard.svelte | 342 + .../src/widgets/inspector/Inspector.svelte | 171 + .../widgets/inspector/LiveInspector.svelte | 199 + .../src/widgets/inspector/ParamField.svelte | 161 + .../src/widgets/scene/GameMoveOverlay.svelte | 207 + .../ecs/src/widgets/scene/SceneView.svelte | 344 + .../ecs/src/widgets/systems/Systems.svelte | 237 + plugins/ecs/src/worker/bundle-spawns.ts | 99 + plugins/ecs/src/worker/component-args.ts | 39 + plugins/ecs/src/worker/ensure-imports.ts | 53 + plugins/ecs/src/worker/entry-file.ts | 297 + plugins/ecs/src/worker/index.ts | 34 + plugins/ecs/src/worker/item-docs.ts | 176 + plugins/ecs/src/worker/owner.ts | 127 + plugins/ecs/src/worker/refactor.ts | 67 + plugins/ecs/src/worker/transform-helpers.ts | 84 + plugins/ecs/src/worker/transforms.ts | 203 + plugins/ecs/svelte.config.js | 2 + .../ecs/test/extension/field-editor.spec.ts | 52 + plugins/ecs/test/fixtures/bundle.js | 14 + .../e2e-game/apps/client/nanoforge.config.ts | 5 + .../src/components/example.component.ts | 55 + .../fixtures/e2e-game/apps/client/src/main.ts | 48 + .../apps/client/src/systems/example.system.ts | 31 + .../e2e-game/apps/server/nanoforge.config.ts | 5 + .../src/components/example.component.ts | 55 + .../fixtures/e2e-game/apps/server/src/main.ts | 30 + .../apps/server/src/systems/example.system.ts | 31 + .../apps/server/src/ticker.library.ts | 35 + .../apps/client/src/components/components.ts | 23 + .../pong-game/apps/client/src/main.ts | 51 + .../libs/shared/src/components/position.ts | 16 + .../libs/shared/src/components/velocity.ts | 16 + .../ecs/test/fixtures/pong-game/tsconfig.json | 13 + plugins/ecs/test/fixtures/run-bundle.mjs | 14 + plugins/ecs/test/layout/param-layout.spec.ts | 58 + plugins/ecs/test/scene/build-scene.spec.ts | 122 + .../__snapshots__/entry-file.spec.ts.snap | 146 + plugins/ecs/test/worker/bundle-spawns.spec.ts | 57 + plugins/ecs/test/worker/e2e-game.spec.ts | 103 + plugins/ecs/test/worker/entry-file.spec.ts | 370 + plugins/ecs/test/worker/item-docs.spec.ts | 135 + plugins/ecs/test/worker/refactor.spec.ts | 65 + plugins/ecs/tsconfig.json | 8 + plugins/ecs/vite.config.ts | 15 + plugins/file-manager/.prettierignore | 14 + plugins/file-manager/LICENSE | 21 + plugins/file-manager/README.md | 37 + plugins/file-manager/nanoforge.manifest.json | 216 + plugins/file-manager/package.json | 63 + .../src/dialogs/preview/PreviewDialog.svelte | 86 + .../file-manager/src/filter/file-filter.ts | 45 + plugins/file-manager/src/icons.ts | 45 + plugins/file-manager/src/index.ts | 158 + plugins/file-manager/src/menu/file-menu.ts | 160 + .../src/service/file-manager-service.ts | 315 + .../src/service/file-manager-service.type.ts | 29 + .../src/service/file-operations.ts | 229 + .../src/session/file-manager-session.ts | 6 + .../settings/file-manager-settings.const.ts | 7 + .../src/widgets/files/FilesView.svelte | 354 + .../src/widgets/files/GridView.svelte | 246 + .../src/widgets/files/file-tree.ts | 88 + plugins/file-manager/svelte.config.js | 2 + plugins/file-manager/tsconfig.json | 8 + plugins/file-manager/vite.config.ts | 11 + plugins/git/.prettierignore | 14 + plugins/git/LICENSE | 21 + plugins/git/README.md | 37 + plugins/git/nanoforge.manifest.json | 66 + plugins/git/package.json | 64 + plugins/git/src/actions/create-actions.ts | 174 + plugins/git/src/dialogs/PushDialog.svelte | 87 + plugins/git/src/dialogs/open-push-dialog.ts | 42 + plugins/git/src/icons.ts | 34 + plugins/git/src/index.ts | 136 + plugins/git/src/model/branch-groups.ts | 11 + plugins/git/src/model/branch-label.ts | 11 + plugins/git/src/model/change-groups.ts | 61 + plugins/git/src/model/change-titles.const.ts | 11 + plugins/git/src/model/file-decoration.ts | 33 + plugins/git/src/model/relative-time.ts | 21 + plugins/git/src/session/git-session.ts | 25 + plugins/git/src/store/git-store.ts | 171 + plugins/git/src/widgets/ConfirmDialog.svelte | 26 + plugins/git/src/widgets/RepositoryGate.svelte | 34 + .../src/widgets/commit/ChangeGroupNode.svelte | 223 + .../git/src/widgets/commit/ChangesTab.svelte | 280 + .../src/widgets/commit/CommitMessage.svelte | 133 + .../git/src/widgets/commit/CommitView.svelte | 85 + .../git/src/widgets/commit/StashTab.svelte | 93 + .../git/src/widgets/commit/change-group.ts | 16 + .../commit/commit-panel-state.svelte.ts | 88 + plugins/git/src/widgets/confirmation.type.ts | 8 + plugins/git/src/widgets/log/BranchTree.svelte | 321 + .../git/src/widgets/log/CommitDetails.svelte | 166 + .../git/src/widgets/log/CommitTable.svelte | 257 + plugins/git/src/widgets/log/LogView.svelte | 48 + plugins/git/src/widgets/log/RefLabel.svelte | 33 + plugins/git/src/widgets/log/commit-format.ts | 32 + plugins/git/svelte.config.js | 2 + plugins/git/test/fixtures/status.ts | 18 + plugins/git/test/model/branch-groups.spec.ts | 15 + plugins/git/test/model/branch-label.spec.ts | 15 + plugins/git/test/model/change-groups.spec.ts | 38 + .../git/test/model/file-decoration.spec.ts | 33 + plugins/git/test/model/relative-time.spec.ts | 15 + plugins/git/test/store/git-store.spec.ts | 86 + plugins/git/tsconfig.json | 8 + plugins/git/vite.config.ts | 11 + plugins/history-panel/.prettierignore | 14 + plugins/history-panel/LICENSE | 21 + plugins/history-panel/README.md | 37 + plugins/history-panel/nanoforge.manifest.json | 22 + plugins/history-panel/package.json | 61 + plugins/history-panel/src/index.ts | 21 + .../src/widgets/history/ContextSection.svelte | 280 + .../src/widgets/history/HistoryView.svelte | 134 + .../src/widgets/history/relative-time.ts | 8 + plugins/history-panel/svelte.config.js | 2 + plugins/history-panel/tsconfig.json | 8 + plugins/history-panel/vite.config.ts | 11 + plugins/inspectors/.prettierignore | 14 + plugins/inspectors/LICENSE | 21 + plugins/inspectors/README.md | 37 + plugins/inspectors/nanoforge.manifest.json | 53 + plugins/inspectors/package.json | 64 + plugins/inspectors/src/icons.ts | 14 + plugins/inspectors/src/index.ts | 68 + plugins/inspectors/src/network/packets.ts | 75 + plugins/inspectors/src/recorder/recorder.ts | 240 + .../src/session/recorder-session.ts | 13 + .../inspectors/src/widgets/JsonTree.svelte | 97 + .../src/widgets/SourcePicker.svelte | 55 + .../src/widgets/chart/LineChart.svelte | 289 + .../src/widgets/chart/chart-scale.ts | 41 + .../inspectors/src/widgets/follow.svelte.ts | 62 + .../src/widgets/network/NetworkView.svelte | 282 + .../src/widgets/network/PacketDetail.svelte | 92 + .../widgets/network/ThroughputCharts.svelte | 55 + .../src/widgets/profiler/ProfilerView.svelte | 249 + .../src/widgets/world/WorldView.svelte | 144 + plugins/inspectors/src/world/world-query.ts | 111 + plugins/inspectors/svelte.config.js | 2 + .../inspectors/test/network/packets.spec.ts | 53 + .../inspectors/test/recorder/recorder.spec.ts | 170 + .../test/widgets/chart/chart-scale.spec.ts | 27 + .../inspectors/test/world/world-query.spec.ts | 71 + plugins/inspectors/tsconfig.json | 8 + plugins/inspectors/vite.config.ts | 11 + plugins/packages/.prettierignore | 14 + plugins/packages/LICENSE | 21 + plugins/packages/README.md | 37 + plugins/packages/nanoforge.manifest.json | 15 + plugins/packages/package.json | 63 + .../src/dialogs/packages/BrowseTab.svelte | 185 + .../src/dialogs/packages/InstalledTab.svelte | 99 + .../dialogs/packages/PackagesDialog.svelte | 128 + .../dialogs/packages/open-packages-dialog.ts | 33 + .../packages/packages-dialog-state.svelte.ts | 137 + plugins/packages/src/index.ts | 21 + .../packages/src/model/installed-package.ts | 45 + plugins/packages/svelte.config.js | 2 + .../test/model/installed-package.spec.ts | 28 + plugins/packages/tsconfig.json | 8 + plugins/packages/vite.config.ts | 11 + plugins/scene/.prettierignore | 14 + plugins/scene/LICENSE | 21 + plugins/scene/README.md | 37 + plugins/scene/nanoforge.manifest.json | 60 + plugins/scene/package.json | 67 + .../extension/ecs-source.extension-point.ts | 33 + plugins/scene/src/icons.ts | 6 + plugins/scene/src/index.ts | 76 + plugins/scene/src/live/scene-live-service.ts | 139 + plugins/scene/src/model/scene-model.type.ts | 91 + plugins/scene/src/model/scene-params.ts | 22 + plugins/scene/src/model/scene.const.ts | 13 + plugins/scene/src/model/vars-model.type.ts | 56 + plugins/scene/src/service/saved-command.ts | 14 + .../scene/src/service/scene-history.const.ts | 2 + plugins/scene/src/service/scene-service.ts | 576 ++ plugins/scene/src/service/scene-step.type.ts | 4 + plugins/scene/src/service/scene-steps.ts | 104 + plugins/scene/src/template/scene-template.ts | 63 + plugins/scene/src/template/vars-template.ts | 26 + .../scene/src/widgets/scenes/Scenes.svelte | 285 + .../scene/src/widgets/scenes/scene-prompts.ts | 35 + .../scene/src/widgets/scenes/scene-tree.ts | 63 + plugins/scene/src/widgets/vars/VarRow.svelte | 130 + plugins/scene/src/widgets/vars/Vars.svelte | 306 + plugins/scene/src/widgets/vars/var-prompts.ts | 92 + plugins/scene/src/worker/edits.ts | 227 + plugins/scene/src/worker/index.ts | 38 + plugins/scene/src/worker/scenes.ts | 250 + plugins/scene/src/worker/vars.ts | 178 + plugins/scene/svelte.config.js | 2 + plugins/scene/test/fixtures/breakout.ts | 31 + plugins/scene/test/model/scene-params.spec.ts | 20 + plugins/scene/test/worker/edits.spec.ts | 137 + plugins/scene/test/worker/scenes.spec.ts | 78 + plugins/scene/test/worker/vars.spec.ts | 96 + plugins/scene/tsconfig.json | 8 + plugins/scene/vite.config.ts | 15 + plugins/settings-ui/.prettierignore | 14 + plugins/settings-ui/LICENSE | 21 + plugins/settings-ui/README.md | 37 + plugins/settings-ui/nanoforge.manifest.json | 19 + plugins/settings-ui/package.json | 63 + .../src/dialogs/settings/ConflictsPage.svelte | 58 + .../dialogs/settings/ImportExportPage.svelte | 124 + .../src/dialogs/settings/SettingRow.svelte | 302 + .../dialogs/settings/SettingsDialog.svelte | 318 + .../dialogs/settings/open-settings-dialog.ts | 33 + .../plugins/InstalledPluginsTab.svelte | 141 + .../settings/plugins/MarketplaceTab.svelte | 232 + .../settings/plugins/PluginsPage.svelte | 152 + .../plugins/plugins-page-state.svelte.ts | 162 + .../src/dialogs/settings/settings-page-api.ts | 26 + .../src/dialogs/settings/settings-tree.ts | 29 + .../src/dialogs/settings/sync-status.const.ts | 11 + plugins/settings-ui/src/index.ts | 84 + .../src/marketplace/account-plugins.ts | 113 + .../src/marketplace/marketplace.ts | 98 + .../src/settings/setting-categories.ts | 80 + .../src/settings/setting-control.ts | 63 + .../src/settings/settings-draft.ts | 98 + plugins/settings-ui/svelte.config.js | 2 + .../test/marketplace/marketplace.spec.ts | 69 + plugins/settings-ui/tsconfig.json | 8 + plugins/settings-ui/vite.config.ts | 11 + plugins/viewport/.prettierignore | 14 + plugins/viewport/LICENSE | 21 + plugins/viewport/README.md | 37 + plugins/viewport/nanoforge.manifest.json | 190 + plugins/viewport/package.json | 62 + plugins/viewport/src/game/popout.ts | 54 + plugins/viewport/src/game/resolutions.ts | 45 + plugins/viewport/src/game/screenshot.ts | 30 + plugins/viewport/src/icons.ts | 24 + plugins/viewport/src/index.ts | 217 + .../src/settings/viewport-settings.const.ts | 13 + .../src/toolbar/viewport-tools.const.ts | 69 + .../src/widgets/ViewportToolbar.svelte | 67 + .../src/widgets/game/GameScreen.svelte | 417 ++ .../src/widgets/game/StatsOverlay.svelte | 78 + .../src/widgets/scene/SceneScreen.svelte | 92 + plugins/viewport/svelte.config.js | 2 + plugins/viewport/tsconfig.json | 8 + plugins/viewport/vite.config.ts | 11 + pnpm-lock.yaml | 6008 ++++++++--------- pnpm-workspace.yaml | 77 +- prettier.config.js | 10 +- project.inlang/settings.json | 12 - scripts/engine-bridge.mjs | 81 + scripts/example.mjs | 88 + src/app.css | 123 - src/app.d.ts | 19 - src/demo.spec.ts | 5 - src/hooks.server.ts | 16 - src/hooks.ts | 3 - src/lib/assets/Tools/play.png | Bin 961 -> 0 bytes src/lib/assets/defaultProjectCover.png | Bin 11596 -> 0 bytes src/lib/assets/logo.png | Bin 17982 -> 0 bytes src/lib/client/action/base.repository.ts | 58 - src/lib/client/action/client.ts | 36 - src/lib/client/action/index.ts | 2 - .../action/repositories/archive.repository.ts | 8 - .../action/repositories/fs.repository.ts | 8 - .../action/repositories/library.repository.ts | 8 - .../action/repositories/loader.repository.ts | 16 - .../action/repositories/package.repository.ts | 42 - .../action/repositories/project.repository.ts | 40 - .../action/repositories/save.repository.ts | 11 - src/lib/client/action/types/archive.type.ts | 3 - src/lib/client/action/types/fs.type.ts | 6 - src/lib/client/action/types/index.ts | 7 - src/lib/client/action/types/library.type.ts | 6 - src/lib/client/action/types/loader.type.ts | 5 - src/lib/client/action/types/package.type.ts | 30 - src/lib/client/action/types/project.type.ts | 18 - src/lib/client/action/types/save.type.ts | 7 - src/lib/client/config/config.ts | 9 - src/lib/client/config/config.type.ts | 3 - src/lib/client/config/index.ts | 2 - src/lib/client/ecs/asset/asset-handle.ts | 40 - src/lib/client/ecs/asset/asset-manager.ts | 113 - src/lib/client/ecs/asset/asset.type.ts | 4 - .../client/ecs/component/component-handle.ts | 40 - .../client/ecs/component/component-manager.ts | 102 - .../client/ecs/component/component.type.ts | 10 - src/lib/client/ecs/ecs-handler.ts | 105 - src/lib/client/ecs/index.ts | 34 - src/lib/client/ecs/library/library-handle.ts | 40 - src/lib/client/ecs/library/library-manager.ts | 73 - src/lib/client/ecs/library/library.type.ts | 4 - .../entity/component/component-handle.ts | 39 - .../entity/component/component-manager.ts | 112 - .../component/component-param-handle.ts | 58 - .../component/component-param-manager.ts | 99 - .../client/ecs/scene/entity/entity-handle.ts | 70 - .../client/ecs/scene/entity/entity-manager.ts | 102 - .../client/ecs/scene/entity/entity.type.ts | 6 - src/lib/client/ecs/scene/scene-handle.ts | 86 - src/lib/client/ecs/scene/scene-manager.ts | 142 - src/lib/client/ecs/scene/scene.type.ts | 11 - .../client/ecs/scene/system/system-handle.ts | 29 - .../client/ecs/scene/system/system-manager.ts | 62 - src/lib/client/ecs/system/system-handle.ts | 40 - src/lib/client/ecs/system/system-manager.ts | 101 - src/lib/client/ecs/system/system.type.ts | 5 - src/lib/client/ecs/transformers.ts | 55 - src/lib/client/ecs/utils.ts | 63 - src/lib/client/event/event-emitter.ts | 79 - src/lib/client/event/event-handler.ts | 35 - src/lib/client/event/events/core-events.ts | 17 - src/lib/client/event/events/editor-events.ts | 11 - src/lib/client/event/index.ts | 4 - .../listeners/move-component.listener.ts | 13 - .../client/event/types/event-emitter.type.ts | 40 - src/lib/client/event/types/index.ts | 1 - src/lib/client/info/index.ts | 1 - src/lib/client/info/info.store.ts | 5 - src/lib/client/info/info.ts | 36 - src/lib/client/info/info.type.ts | 5 - src/lib/client/loader/index.ts | 3 - src/lib/client/loader/loader.ts | 84 - .../client/loader/types/event-emitter.type.ts | 3 - src/lib/client/loader/types/game.type.ts | 14 - src/lib/client/loader/types/manifest.type.ts | 9 - src/lib/client/project/index.ts | 9 - src/lib/client/project/package-handler.ts | 20 - .../project/project-loader/exceptions.ts | 74 - .../project/project-loader/init-functions.ts | 6 - .../project/project-loader/project-cache.ts | 94 - .../project/project-loader/project-loader.ts | 152 - src/lib/client/project/project.ts | 85 - src/lib/client/project/save-handler.ts | 130 - src/lib/client/sync-file-system/index.ts | 4 - .../client/sync-file-system/sfs-directory.ts | 46 - src/lib/client/sync-file-system/sfs-file.ts | 107 - .../client/sync-file-system/sfs-tree-cache.ts | 78 - src/lib/client/sync-file-system/sfs.ts | 46 - src/lib/client/utils/error.ts | 35 - .../file-system/file-system-directory.ts | 80 - .../utils/file-system/file-system-file.ts | 155 - .../utils/file-system/file-system-handler.ts | 66 - .../utils/file-system/file-system-manager.ts | 47 - src/lib/client/utils/file-system/index.ts | 3 - src/lib/client/utils/indexed-db/idb-object.ts | 35 - src/lib/client/utils/indexed-db/idb.ts | 69 - src/lib/client/utils/indexed-db/index.ts | 1 - src/lib/client/utils/store.ts | 24 - src/lib/client/utils/toasts.ts | 8 - src/lib/client/utils/window.ts | 1 - src/lib/components/Menu/MenuBar.svelte | 132 - src/lib/components/Menu/MenuButton.svelte | 35 - src/lib/components/Menu/MenuItem.svelte | 35 - src/lib/components/Menu/export-dialog.svelte | 79 - src/lib/components/Panel/Layout.svelte | 120 - src/lib/components/Panel/Panel.svelte | 15 - src/lib/components/Panel/Widget.svelte | 28 - .../components/Panel/WidgetResizeBar.svelte | 112 - src/lib/components/Panel/types.ts | 14 - src/lib/components/Panel/utils.ts | 19 - .../Tabs/CodeEditor/CodeEditor.svelte | 29 - .../components/Tabs/MainTab/MainTab.svelte | 44 - src/lib/components/Tabs/Tab.svelte | 62 - src/lib/components/Tabs/TabBar.svelte | 20 - src/lib/components/Tabs/registry.ts | 26 - src/lib/components/Tabs/store.ts | 134 - src/lib/components/Tabs/types.ts | 23 - .../Widget/CodeEditor/MonacoEditor.svelte | 97 - .../Widget/CodeEditor/project-loader.ts | 50 - .../Widget/EditorGame/EditorGame.svelte | 122 - .../Widget/EditorGame/game.svelte.ts | 8 - .../Widget/OutputLog/OutputLogWidget.svelte | 63 - .../content-browser/content-item.svelte | 44 - .../content-browser/content-list.svelte | 30 - .../Widget/content-browser/header.svelte | 36 - .../Widget/content-browser/icon.svelte | 38 - .../Widget/content-browser/index.ts | 3 - .../Widget/content-browser/side-item.svelte | 45 - .../Widget/content-browser/side-list.svelte | 18 - .../Widget/content-browser/skeleton.svelte | 27 - .../Widget/content-browser/store.ts | 3 - .../Widget/content-browser/types.ts | 14 - .../Widget/content-browser/utils.ts | 1 - .../Widget/content-browser/widget.svelte | 49 - .../Widget/ecs-tree/assets/asset.items.ts | 11 - .../Widget/ecs-tree/assets/assets-tab.svelte | 12 - .../assets/dialog-create-asset.svelte | 147 - .../ecs-tree/components/component.items.ts | 14 - .../ecs-tree/components/components-tab.svelte | 12 - src/lib/components/Widget/ecs-tree/const.ts | 1 - .../ecs-tree/dialogs/dialog-name.svelte | 68 - .../ecs-tree/dialogs/dialog-select.svelte | 75 - src/lib/components/Widget/ecs-tree/index.ts | 3 - .../ecs-tree/libraries/libraries-tab.svelte | 12 - .../ecs-tree/libraries/library.items.ts | 8 - .../packages/dialog-create-package.svelte | 15 - .../packages/dialog-import-package.svelte | 19 - .../ecs-tree/packages/package-row.svelte | 197 - .../ecs-tree/packages/package-tab.svelte | 175 - .../ecs-tree/scenes/dialog-edit-scene.svelte | 19 - .../ecs-tree/scenes/dialog-new-scene.svelte | 12 - .../entities/dialog-delete-entity.svelte | 15 - .../scenes/entities/dialog-edit-entity.svelte | 22 - .../scenes/entities/dialog-new-entity.svelte | 15 - .../scenes/entities/entity-tree-node.svelte | 152 - .../scenes/entities/entity-tree.svelte | 360 - .../scenes/entities/folder-tree-node.svelte | 202 - .../ecs-tree/scenes/scene-detail.svelte | 49 - .../Widget/ecs-tree/scenes/scene-list.svelte | 265 - .../Widget/ecs-tree/scenes/scene-row.svelte | 147 - .../Widget/ecs-tree/scenes/scenes-tab.svelte | 34 - .../systems/dialog-add-scene-system.svelte | 20 - .../scenes/systems/scene-system-list.svelte | 194 - .../scenes/systems/scene-system-row.svelte | 14 - .../Widget/ecs-tree/systems/system.items.ts | 14 - .../ecs-tree/systems/systems-tab.svelte | 12 - src/lib/components/Widget/ecs-tree/types.ts | 34 - src/lib/components/Widget/ecs-tree/utils.ts | 62 - .../components/Widget/ecs-tree/widget.svelte | 57 - .../entity-inspector/component-list.svelte | 20 - .../component-param-list.svelte | 18 - .../component-param-row.svelte | 98 - .../entity-inspector/component-row.svelte | 53 - .../dialog-component-selector.svelte | 77 - .../Widget/entity-inspector/index.ts | 3 - .../Widget/entity-inspector/widget.svelte | 56 - src/lib/components/Widget/widgets.ts | 40 - src/lib/components/dialogs/base-dialog.svelte | 80 - .../dialogs/delete-confirm-dialog.svelte | 71 - src/lib/components/dialogs/index.ts | 5 - .../components/dialogs/input-dialog.svelte | 27 - src/lib/components/forms/Form.svelte | 20 - src/lib/components/forms/FormField.svelte | 40 - src/lib/components/forms/form.svelte.ts | 28 - src/lib/components/forms/form.type.ts | 1 - .../forms/inputs/FormFieldInput.svelte | 44 - .../forms/inputs/FormFieldMultiSelect.svelte | 50 - .../forms/inputs/FormFieldSelect.svelte | 59 - .../forms/inputs/FormFieldSwitch.svelte | 30 - src/lib/components/marketplace/index.ts | 3 - .../marketplace/marketplace-dialog.svelte | 149 - .../marketplace/marketplace-footer.svelte | 72 - .../marketplace-package-details.svelte | 89 - .../marketplace-package-item.svelte | 90 - .../marketplace-package-list.svelte | 58 - .../marketplace-package-tabs.svelte | 94 - .../marketplace/marketplace-search.svelte | 40 - .../marketplace/marketplace.context.ts | 15 - .../full-page-project-spinner.svelte | 7 - src/lib/components/project-loader/index.ts | 5 - .../project-loader/progress-bar.svelte | 41 - .../project-loader/project-spinner.svelte | 28 - src/lib/components/ui/badge/badge.svelte | 50 - src/lib/components/ui/badge/index.ts | 2 - .../ui/breadcrumb/breadcrumb-ellipsis.svelte | 23 - .../ui/breadcrumb/breadcrumb-item.svelte | 20 - .../ui/breadcrumb/breadcrumb-link.svelte | 31 - .../ui/breadcrumb/breadcrumb-list.svelte | 23 - .../ui/breadcrumb/breadcrumb-page.svelte | 23 - .../ui/breadcrumb/breadcrumb-separator.svelte | 27 - .../ui/breadcrumb/breadcrumb.svelte | 22 - src/lib/components/ui/breadcrumb/index.ts | 25 - src/lib/components/ui/button/button.svelte | 89 - src/lib/components/ui/button/index.ts | 17 - src/lib/components/ui/card/card-action.svelte | 23 - .../components/ui/card/card-content.svelte | 20 - .../ui/card/card-description.svelte | 20 - src/lib/components/ui/card/card-footer.svelte | 23 - src/lib/components/ui/card/card-header.svelte | 23 - src/lib/components/ui/card/card-title.svelte | 20 - src/lib/components/ui/card/card.svelte | 25 - src/lib/components/ui/card/index.ts | 25 - .../components/ui/checkbox/checkbox.svelte | 39 - src/lib/components/ui/checkbox/index.ts | 7 - .../context-menu-checkbox-item.svelte | 41 - .../context-menu/context-menu-content.svelte | 28 - .../context-menu-group-heading.svelte | 21 - .../ui/context-menu/context-menu-group.svelte | 7 - .../ui/context-menu/context-menu-item.svelte | 27 - .../ui/context-menu/context-menu-label.svelte | 27 - .../context-menu/context-menu-portal.svelte | 7 - .../context-menu-radio-group.svelte | 16 - .../context-menu-radio-item.svelte | 35 - .../context-menu-separator.svelte | 17 - .../context-menu/context-menu-shortcut.svelte | 23 - .../context-menu-sub-content.svelte | 20 - .../context-menu-sub-trigger.svelte | 29 - .../ui/context-menu/context-menu-sub.svelte | 7 - .../context-menu/context-menu-trigger.svelte | 17 - .../ui/context-menu/context-menu.svelte | 7 - src/lib/components/ui/context-menu/index.ts | 52 - .../components/ui/dialog/dialog-close.svelte | 11 - .../ui/dialog/dialog-content.svelte | 48 - .../ui/dialog/dialog-description.svelte | 20 - .../components/ui/dialog/dialog-footer.svelte | 35 - .../components/ui/dialog/dialog-header.svelte | 20 - .../ui/dialog/dialog-overlay.svelte | 20 - .../components/ui/dialog/dialog-portal.svelte | 7 - .../components/ui/dialog/dialog-title.svelte | 17 - .../ui/dialog/dialog-trigger.svelte | 11 - src/lib/components/ui/dialog/dialog.svelte | 7 - src/lib/components/ui/dialog/index.ts | 34 - .../dropdown-menu-checkbox-group.svelte | 16 - .../dropdown-menu-checkbox-item.svelte | 44 - .../dropdown-menu-content.svelte | 31 - .../dropdown-menu-group-heading.svelte | 22 - .../dropdown-menu/dropdown-menu-group.svelte | 7 - .../dropdown-menu/dropdown-menu-item.svelte | 27 - .../dropdown-menu/dropdown-menu-label.svelte | 27 - .../dropdown-menu/dropdown-menu-portal.svelte | 7 - .../dropdown-menu-radio-group.svelte | 16 - .../dropdown-menu-radio-item.svelte | 34 - .../dropdown-menu-separator.svelte | 17 - .../dropdown-menu-shortcut.svelte | 23 - .../dropdown-menu-sub-content.svelte | 20 - .../dropdown-menu-sub-trigger.svelte | 29 - .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 - .../dropdown-menu-trigger.svelte | 7 - .../ui/dropdown-menu/dropdown-menu.svelte | 7 - src/lib/components/ui/dropdown-menu/index.ts | 54 - src/lib/components/ui/form/form-button.svelte | 7 - .../ui/form/form-description.svelte | 17 - .../ui/form/form-element-field.svelte | 24 - .../ui/form/form-field-errors.svelte | 30 - src/lib/components/ui/form/form-field.svelte | 24 - .../components/ui/form/form-fieldset.svelte | 15 - src/lib/components/ui/form/form-label.svelte | 24 - src/lib/components/ui/form/form-legend.svelte | 16 - src/lib/components/ui/form/index.ts | 34 - src/lib/components/ui/input-group/index.ts | 22 - .../ui/input-group/input-group-addon.svelte | 53 - .../ui/input-group/input-group-button.svelte | 49 - .../ui/input-group/input-group-input.svelte | 23 - .../ui/input-group/input-group-text.svelte | 22 - .../input-group/input-group-textarea.svelte | 23 - .../ui/input-group/input-group.svelte | 24 - src/lib/components/ui/input/index.ts | 7 - src/lib/components/ui/input/input.svelte | 48 - src/lib/components/ui/kbd/index.ts | 10 - src/lib/components/ui/kbd/kbd-group.svelte | 20 - src/lib/components/ui/kbd/kbd.svelte | 23 - src/lib/components/ui/label/index.ts | 7 - src/lib/components/ui/label/label.svelte | 20 - src/lib/components/ui/loading-button/index.ts | 9 - .../ui/loading-button/loading-button.svelte | 21 - src/lib/components/ui/multi-select/index.ts | 7 - .../ui/multi-select/multi-select.svelte | 106 - src/lib/components/ui/progress/index.ts | 7 - .../components/ui/progress/progress.svelte | 30 - src/lib/components/ui/scroll-area/index.ts | 10 - .../scroll-area/scroll-area-scrollbar.svelte | 30 - .../ui/scroll-area/scroll-area.svelte | 43 - src/lib/components/ui/select/index.ts | 37 - .../ui/select/select-content.svelte | 42 - .../ui/select/select-group-heading.svelte | 21 - .../components/ui/select/select-group.svelte | 17 - .../components/ui/select/select-item.svelte | 38 - .../components/ui/select/select-label.svelte | 20 - .../components/ui/select/select-portal.svelte | 7 - .../select/select-scroll-down-button.svelte | 23 - .../ui/select/select-scroll-up-button.svelte | 23 - .../ui/select/select-separator.svelte | 18 - .../ui/select/select-trigger.svelte | 29 - src/lib/components/ui/select/select.svelte | 11 - src/lib/components/ui/separator/index.ts | 7 - .../components/ui/separator/separator.svelte | 23 - src/lib/components/ui/skeleton/index.ts | 7 - .../components/ui/skeleton/skeleton.svelte | 17 - src/lib/components/ui/sonner/index.ts | 1 - src/lib/components/ui/sonner/sonner.svelte | 34 - src/lib/components/ui/spinner/index.ts | 1 - src/lib/components/ui/spinner/spinner.svelte | 26 - src/lib/components/ui/switch/index.ts | 7 - src/lib/components/ui/switch/switch.svelte | 31 - src/lib/components/ui/tabs/index.ts | 18 - .../components/ui/tabs/tabs-content.svelte | 17 - src/lib/components/ui/tabs/tabs-list.svelte | 40 - .../components/ui/tabs/tabs-trigger.svelte | 23 - src/lib/components/ui/tabs/tabs.svelte | 19 - src/lib/components/ui/textarea/index.ts | 7 - .../components/ui/textarea/textarea.svelte | 22 - src/lib/components/ui/tooltip-text/index.ts | 3 - .../ui/tooltip-text/tooltip-text.svelte | 23 - src/lib/components/ui/tooltip/index.ts | 19 - .../ui/tooltip/tooltip-content.svelte | 52 - .../ui/tooltip/tooltip-portal.svelte | 7 - .../ui/tooltip/tooltip-provider.svelte | 7 - .../ui/tooltip/tooltip-trigger.svelte | 7 - src/lib/components/ui/tooltip/tooltip.svelte | 7 - .../components/ui/tristate-switch/index.ts | 8 - .../ui/tristate-switch/tristate-switch.svelte | 63 - .../actions/project/archive/create.action.ts | 10 - .../server/actions/project/complete.action.ts | 59 - .../actions/project/fs/readdir.action.ts | 19 - .../server/actions/project/gateway.action.ts | 26 - src/lib/server/actions/project/info.action.ts | 40 - .../project/library/install-library.action.ts | 22 - src/lib/server/actions/project/load.action.ts | 107 - .../actions/project/loader/build.action.ts | 6 - .../actions/project/loader/env.action.ts | 5 - .../actions/project/loader/manifest.action.ts | 5 - src/lib/server/actions/project/new.action.ts | 83 - .../package/create-component.action.ts | 22 - .../project/package/create-system.action.ts | 22 - .../project/package/get-assets.action.ts | 7 - .../project/package/get-components.action.ts | 9 - .../project/package/get-systems.action.ts | 7 - .../package/install-packages.action.ts | 22 - .../project/package/search-packages.action.ts | 32 - .../actions/project/save/get-save.action.ts | 5 - .../actions/project/save/set-save.action.ts | 22 - src/lib/server/api/base.repository.ts | 88 - src/lib/server/api/client.ts | 53 - src/lib/server/api/index.ts | 2 - .../middlewares/refresh-token.middleware.ts | 57 - .../api/repositories/auth.repository.ts | 8 - .../api/repositories/package.repository.ts | 10 - .../api/repositories/projects.repository.ts | 12 - .../api/repositories/registry.repository.ts | 8 - src/lib/server/api/types/auth.type.ts | 9 - src/lib/server/api/types/index.ts | 5 - src/lib/server/api/types/package.type.ts | 9 - src/lib/server/api/types/paginate.type.ts | 16 - src/lib/server/api/types/project.type.ts | 11 - src/lib/server/api/types/registry.type.ts | 9 - src/lib/server/cli/cli-defaults.ts | 34 - src/lib/server/cli/cli-error.ts | 7 - src/lib/server/cli/cli.ts | 192 - src/lib/server/cli/cli.type.ts | 75 - src/lib/server/cli/index.ts | 1 - src/lib/server/file-system/archive.ts | 88 - .../server/file-system/file-system-error.ts | 7 - .../file-system/file-system.functions.ts | 13 - src/lib/server/file-system/file-system.ts | 32 - src/lib/server/file-system/index.ts | 4 - .../server/file-system/project-directory.ts | 151 - src/lib/server/file-system/project-file.ts | 154 - src/lib/server/git/git.ts | 56 - src/lib/server/git/index.ts | 1 - src/lib/server/project/index.ts | 4 - src/lib/server/project/library/index.ts | 1 - .../server/project/library/library-handler.ts | 36 - .../server/project/library/library.type.ts | 5 - src/lib/server/project/load-project.ts | 53 - src/lib/server/project/loader/env.ts | 32 - src/lib/server/project/loader/index.ts | 1 - src/lib/server/project/loader/loader.ts | 39 - src/lib/server/project/loader/types.ts | 4 - src/lib/server/project/package/index.ts | 2 - .../package/manifest/manifest-handler.ts | 94 - .../package/manifest/manifest-resolver.ts | 85 - .../project/package/manifest/manifest.type.ts | 9 - .../server/project/package/package-handler.ts | 101 - .../server/project/package/package.const.ts | 21 - .../server/project/package/package.enum.ts | 5 - .../server/project/package/package.type.ts | 31 - src/lib/server/project/project-handler.ts | 70 - src/lib/server/project/project-manager.ts | 34 - src/lib/server/project/project.type.ts | 4 - src/lib/server/project/save/save-handler.ts | 23 - src/lib/server/session/index.ts | 5 - .../session/project/project-functions.ts | 36 - .../server/session/project/project.store.ts | 3 - .../server/session/project/project.type.ts | 7 - src/lib/server/session/session-functions.ts | 53 - src/lib/server/session/session-handle.ts | 24 - src/lib/server/session/session.const.ts | 1 - src/lib/server/session/session.store.ts | 3 - src/lib/server/session/session.type.ts | 4 - src/lib/server/utils/cookies/index.ts | 26 - .../utils/request-handler/action-handler.ts | 38 - src/lib/server/utils/request-handler/body.ts | 33 - .../server/utils/request-handler/context.ts | 27 - .../server/utils/request-handler/handler.ts | 81 - src/lib/server/utils/request-handler/index.ts | 5 - .../utils/request-handler/request-handler.ts | 38 - .../utils/request-handler/request.policy.ts | 37 - src/lib/server/utils/request-handler/types.ts | 12 - src/lib/server/utils/string.ts | 6 - src/lib/server/utils/validators/index.ts | 1 - .../is-string-or-false.validator.ts | 20 - src/lib/utils/const.ts | 1 - src/lib/utils/exception.ts | 10 - src/lib/utils/file.ts | 15 - src/lib/utils/format.ts | 58 - src/lib/utils/http/client.ts | 123 - src/lib/utils/http/index.ts | 7 - src/lib/utils/http/query.ts | 5 - src/lib/utils/null.ts | 3 - src/lib/utils/object.ts | 27 - src/lib/utils/string.ts | 18 - src/lib/utils/types/base.type.ts | 1 - src/lib/utils/types/class.type.ts | 1 - src/lib/utils/types/index.ts | 5 - src/lib/utils/types/part.type.ts | 1 - src/lib/utils/types/promise.type.ts | 1 - src/lib/utils/types/save.type.ts | 39 - src/lib/utils/ui.ts | 10 - src/routes/+layout.svelte | 25 - src/routes/+page.svelte | 129 - src/routes/actions/project/+page.server.ts | 21 - .../actions/project/archive/+page.server.ts | 5 - src/routes/actions/project/fs/+page.server.ts | 5 - .../actions/project/library/+page.server.ts | 5 - .../actions/project/loader/+page.server.ts | 9 - .../actions/project/package/+page.server.ts | 17 - .../actions/project/save/+page.server.ts | 7 - .../cache-project-list-skeleton.svelte | 15 - src/routes/components/cache-project.svelte | 58 - .../components/create-project-dialog.svelte | 20 - .../components/create-project-form.svelte | 181 - src/routes/components/header.svelte | 10 - src/routes/components/index.ts | 21 - .../components/offline-project-buttons.svelte | 20 - .../components/online-project-buttons.svelte | 22 - .../components/open-project-dialog.svelte | 20 - .../components/open-project-form.svelte | 79 - src/routes/dashboard/+page.svelte | 110 - src/routes/fs/archive/[slug]/+server.ts | 17 - src/routes/fs/build/+server.ts | 20 - src/routes/fs/project/+server.ts | 59 - src/routes/load/+page.svelte | 87 - .../components/complete-project-form.svelte | 95 - svelte.config.js | 24 - tooling/example-plugin/.prettierignore | 14 + tooling/example-plugin/LICENSE | 21 + tooling/example-plugin/README.md | 37 + .../example-plugin/nanoforge.manifest.json | 57 + tooling/example-plugin/package.json | 56 + tooling/example-plugin/src/index.ts | 58 + tooling/example-plugin/vite.config.ts | 7 + tooling/vite-plugin/.cliff-jumperrc.json | 8 + tooling/vite-plugin/.prettierignore | 14 + tooling/vite-plugin/CHANGELOG.md | 3 + tooling/vite-plugin/LICENSE | 21 + tooling/vite-plugin/README.md | 52 + tooling/vite-plugin/cliff.toml | 79 + tooling/vite-plugin/package.json | 80 + tooling/vite-plugin/src/index.ts | 14 + .../src/manifest/build-manifest.ts | 40 + .../src/plugin/nanoforge-editor-plugin.ts | 55 + .../src/rewrite/rewrite-shared-imports.ts | 100 + .../src/shared-module/shared-modules.ts | 61 + .../test/manifest/build-manifest.spec.ts | 38 + .../rewrite/rewrite-shared-imports.spec.ts | 55 + tooling/vite-plugin/tsconfig.json | 6 + tooling/vite-plugin/tsdown.config.ts | 13 + tsconfig.json | 53 +- turbo.json | 44 + uno.config.ts | 173 - vite.config.ts | 63 - vitest.config.ts | 19 + 1765 files changed, 89530 insertions(+), 20663 deletions(-) create mode 100644 .dockerignore rename .github/ISSUE_TEMPLATE/{01-command_bug_report.yml => 01-packages_bug_report.yml} (76%) create mode 100644 .github/ISSUE_TEMPLATE/02-tooling_bug_report.yml rename .github/ISSUE_TEMPLATE/{02-feature_request.yml => 03-feature_request.yml} (60%) rename .github/ISSUE_TEMPLATE/{03-documentation_issue.yml => 04-documentation_issue.yml} (100%) create mode 100644 .github/workflows/e2e.yml create mode 100644 CLAUDE.md rename .cliff-jumperrc.json => apps/editor/.cliff-jumperrc.json (87%) create mode 100644 apps/editor/.prettierignore rename CHANGELOG.md => apps/editor/CHANGELOG.md (100%) create mode 100644 apps/editor/LICENSE create mode 100644 apps/editor/README.md rename cliff.toml => apps/editor/cliff.toml (100%) create mode 100644 apps/editor/e2e/code-editor.spec.ts create mode 100644 apps/editor/e2e/command-palette.spec.ts create mode 100644 apps/editor/e2e/console.spec.ts create mode 100644 apps/editor/e2e/ecs-live.spec.ts create mode 100644 apps/editor/e2e/ecs.spec.ts create mode 100644 apps/editor/e2e/file-manager.spec.ts create mode 100644 apps/editor/e2e/git.spec.ts create mode 100644 apps/editor/e2e/helpers.ts create mode 100644 apps/editor/e2e/history-panel.spec.ts create mode 100644 apps/editor/e2e/inspectors.spec.ts create mode 100644 apps/editor/e2e/manual-pass.spec.ts create mode 100644 apps/editor/e2e/marketplace.spec.ts create mode 100644 apps/editor/e2e/prepare-workspace.mjs create mode 100644 apps/editor/e2e/project-plugins.spec.ts create mode 100644 apps/editor/e2e/project.spec.ts create mode 100644 apps/editor/e2e/runtime.spec.ts create mode 100644 apps/editor/e2e/scene.spec.ts create mode 100644 apps/editor/e2e/settings-ui.spec.ts create mode 100644 apps/editor/e2e/startup.bench.ts create mode 100644 apps/editor/e2e/test.ts create mode 100644 apps/editor/e2e/viewport.spec.ts create mode 100644 apps/editor/e2e/workbench.spec.ts create mode 100644 apps/editor/e2e/workspace.ts create mode 100644 apps/editor/package.json create mode 100644 apps/editor/playwright.config.ts create mode 100644 apps/editor/scripts/build-server.ts create mode 100644 apps/editor/server/main.ts create mode 100644 apps/editor/server/tsconfig.json create mode 100644 apps/editor/src/app.d.ts rename {src => apps/editor/src}/app.html (91%) create mode 100644 apps/editor/src/lib/code/follow-project-code.ts create mode 100644 apps/editor/src/lib/editor/Loading.svelte create mode 100644 apps/editor/src/lib/editor/create-editor.ts create mode 100644 apps/editor/src/lib/editor/editor-context.ts create mode 100644 apps/editor/src/lib/editor/error-description.ts create mode 100644 apps/editor/src/lib/editor/register-app-contributions.ts create mode 100644 apps/editor/src/lib/history/indexed-db-history-store.ts create mode 100644 apps/editor/src/lib/plugin/follow-project-plugins.ts create mode 100644 apps/editor/src/lib/plugin/shared-modules.ts create mode 100644 apps/editor/src/lib/plugin/svelte-internal.d.ts create mode 100644 apps/editor/src/lib/project/AddAppDialog.svelte create mode 100644 apps/editor/src/lib/project/AddLibraryDialog.svelte create mode 100644 apps/editor/src/lib/project/ProjectOverview.svelte create mode 100644 apps/editor/src/lib/project/register-core-panels.ts create mode 100644 apps/editor/src/lib/runtime/follow-project-runtime.ts create mode 100644 apps/editor/src/lib/runtime/runtime-toolbar.const.ts create mode 100644 apps/editor/src/lib/workspace/workspace-actions.ts create mode 100644 apps/editor/src/routes/(editor)/+layout.svelte create mode 100644 apps/editor/src/routes/(editor)/+page.svelte create mode 100644 apps/editor/src/routes/(editor)/load/+page.svelte create mode 100644 apps/editor/src/routes/(editor)/project/[id]/+page.svelte create mode 100644 apps/editor/src/routes/+layout.svelte create mode 100644 apps/editor/src/routes/+layout.ts create mode 100644 apps/editor/src/routes/play/[id]/+page.svelte rename {public => apps/editor/static}/favicon.svg (100%) rename {public => apps/editor/static}/robots.txt (100%) create mode 100644 apps/editor/svelte.config.js create mode 100644 apps/editor/test/plugin/shared-modules.spec.ts create mode 100644 apps/editor/tsconfig.json create mode 100644 apps/editor/vite.config.ts delete mode 100644 components.json create mode 100644 docs/adr/0001-plugin-ui-runtime.md create mode 100644 docs/adr/0002-editor-server.md create mode 100644 docs/adr/0003-component-and-system-docs.md create mode 100644 docs/adr/0004-shared-libraries.md create mode 100644 docs/adr/0005-package-installs.md create mode 100644 docs/adr/0006-source-layout.md create mode 100644 docs/api/README.md create mode 100644 docs/api/registry.md create mode 100644 docs/api/settings-sync.md create mode 100644 docs/docs/README.md create mode 100644 docs/docs/editor/code-editor.mdx delete mode 100644 docs/docs/editor/export.mdx create mode 100644 docs/docs/editor/files.mdx create mode 100644 docs/docs/editor/git.mdx create mode 100644 docs/docs/editor/layout.mdx create mode 100644 docs/docs/editor/overview.mdx create mode 100644 docs/docs/editor/play.mdx create mode 100644 docs/docs/editor/plugins-and-packages.mdx create mode 100644 docs/docs/editor/scenes.mdx create mode 100644 docs/docs/editor/settings.mdx delete mode 100644 docs/docs/editor/viewport.mdx create mode 100644 docs/docs/images/code-editor.png create mode 100644 docs/docs/images/command-palette.png delete mode 100644 docs/docs/images/components-access.png delete mode 100644 docs/docs/images/components-create-name.png delete mode 100644 docs/docs/images/components-create.png delete mode 100644 docs/docs/images/components-data.png delete mode 100644 docs/docs/images/components-entity-add.png delete mode 100644 docs/docs/images/components-entity-panel.png delete mode 100644 docs/docs/images/components-entity.png delete mode 100644 docs/docs/images/components-import.png delete mode 100644 docs/docs/images/components-navigation.png delete mode 100644 docs/docs/images/download-export.png create mode 100644 docs/docs/images/ecs-components.png create mode 100644 docs/docs/images/ecs-scene.png create mode 100644 docs/docs/images/editor-overview.png delete mode 100644 docs/docs/images/entities-new.png delete mode 100644 docs/docs/images/entities-rename.png delete mode 100644 docs/docs/images/export.png create mode 100644 docs/docs/images/game-playing.png create mode 100644 docs/docs/images/keyboard-shortcuts.png delete mode 100644 docs/docs/images/main-editor.png create mode 100644 docs/docs/images/packages.png create mode 100644 docs/docs/images/plugins-marketplace.png create mode 100644 docs/docs/images/profiler.png create mode 100644 docs/docs/images/settings.png delete mode 100644 docs/docs/images/system-access.png delete mode 100644 docs/docs/images/system-add-to-scene-1.png delete mode 100644 docs/docs/images/system-add-to-scene-2.png delete mode 100644 docs/docs/images/system-create-name.png delete mode 100644 docs/docs/images/system-import-selection.png delete mode 100644 docs/docs/images/system-import.png delete mode 100644 docs/docs/images/systems-create.png delete mode 100644 docs/docs/images/systems-navigate.png delete mode 100644 docs/docs/images/viewport-panel-edit.png delete mode 100644 docs/docs/images/viewport-panel-play.png create mode 100644 docs/docs/plugins/code-and-codegen.mdx create mode 100644 docs/docs/plugins/create-your-first-plugin.mdx create mode 100644 docs/docs/plugins/engine-libs.mdx create mode 100644 docs/docs/plugins/extension-points.mdx create mode 100644 docs/docs/plugins/history.mdx create mode 100644 docs/docs/plugins/manifest.mdx create mode 100644 docs/docs/plugins/overview.mdx create mode 100644 docs/docs/plugins/sdk.mdx create mode 100644 docs/docs/plugins/settings.mdx create mode 100644 docs/docs/plugins/widgets-and-styles.mdx create mode 100644 docs/drafts/item-schema.ts create mode 100644 docs/performance.md create mode 100644 docs/specs/code-editor.md create mode 100644 docs/specs/command-palette.md create mode 100644 docs/specs/console.md create mode 100644 docs/specs/ecs.md create mode 100644 docs/specs/file-manager.md create mode 100644 docs/specs/git.md create mode 100644 docs/specs/history-panel.md create mode 100644 docs/specs/inspectors.md create mode 100644 docs/specs/marketplace.md create mode 100644 docs/specs/scene.md create mode 100644 docs/specs/settings-ui.md create mode 100644 docs/specs/viewport.md delete mode 100644 e2e/demo.test.ts create mode 100644 examples/README.md create mode 100644 examples/breakout/.gitignore create mode 100644 examples/breakout/.prettierrc create mode 100644 examples/breakout/README.md create mode 100644 examples/breakout/apps/client/nanoforge.config.ts create mode 100644 examples/breakout/apps/client/package.json create mode 100644 examples/breakout/apps/client/src/arena.ts create mode 100644 examples/breakout/apps/client/src/components/ball.ts create mode 100644 examples/breakout/apps/client/src/components/box.ts create mode 100644 examples/breakout/apps/client/src/components/brick.ts create mode 100644 examples/breakout/apps/client/src/components/hud-text.ts create mode 100644 examples/breakout/apps/client/src/components/paddle.ts create mode 100644 examples/breakout/apps/client/src/components/position.ts create mode 100644 examples/breakout/apps/client/src/components/velocity.ts create mode 100644 examples/breakout/apps/client/src/components/visual.ts create mode 100644 examples/breakout/apps/client/src/keys.ts create mode 100644 examples/breakout/apps/client/src/layer.ts create mode 100644 examples/breakout/apps/client/src/main.ts create mode 100644 examples/breakout/apps/client/src/scene-vars.ts create mode 100644 examples/breakout/apps/client/src/scenes/game-over.ts create mode 100644 examples/breakout/apps/client/src/scenes/levels.ts create mode 100644 examples/breakout/apps/client/src/scenes/menu.ts create mode 100644 examples/breakout/apps/client/src/scenes/pause.ts create mode 100644 examples/breakout/apps/client/src/scenes/run.ts create mode 100644 examples/breakout/apps/client/src/scenes/victory.ts create mode 100644 examples/breakout/apps/client/src/systems/back-to-menu.ts create mode 100644 examples/breakout/apps/client/src/systems/draw-visuals.ts create mode 100644 examples/breakout/apps/client/src/systems/lose-ball.ts create mode 100644 examples/breakout/apps/client/src/systems/move-ball.ts create mode 100644 examples/breakout/apps/client/src/systems/move-paddle.ts create mode 100644 examples/breakout/apps/client/src/systems/record-best.ts create mode 100644 examples/breakout/apps/client/src/systems/update-hud.ts create mode 100644 examples/breakout/apps/client/tsconfig.json create mode 100644 examples/breakout/nanoforge.config.ts create mode 100644 examples/breakout/package.json create mode 100644 examples/breakout/tsconfig.json create mode 100644 examples/counter-plugin/.gitignore create mode 100644 examples/counter-plugin/README.md create mode 100644 examples/counter-plugin/nanoforge.manifest.json create mode 100644 examples/counter-plugin/package.json create mode 100644 examples/counter-plugin/src/Panel.svelte create mode 100644 examples/counter-plugin/src/counter.ts create mode 100644 examples/counter-plugin/src/index.ts create mode 100644 examples/counter-plugin/svelte.config.js create mode 100644 examples/counter-plugin/tsconfig.json create mode 100644 examples/counter-plugin/vite.config.ts delete mode 100644 messages/en.json delete mode 100644 messages/fr.json create mode 100644 packages/code/.prettierignore create mode 100644 packages/code/LICENSE create mode 100644 packages/code/README.md create mode 100644 packages/code/package.json create mode 100644 packages/code/src/catalog/catalog-service.ts create mode 100644 packages/code/src/catalog/catalog.type.ts create mode 100644 packages/code/src/catalog/index.ts create mode 100644 packages/code/src/catalog/item-fits-app.ts create mode 100644 packages/code/src/codegen/codegen-target.ts create mode 100644 packages/code/src/codegen/index.ts create mode 100644 packages/code/src/diagnostics/diagnostics-service.ts create mode 100644 packages/code/src/diagnostics/index.ts create mode 100644 packages/code/src/document/document-changed.exception.ts create mode 100644 packages/code/src/document/document-contributions.ts create mode 100644 packages/code/src/document/document-preview.ts create mode 100644 packages/code/src/document/document-service.ts create mode 100644 packages/code/src/document/document.type.ts create mode 100644 packages/code/src/document/index.ts create mode 100644 packages/code/src/document/match-glob.ts create mode 100644 packages/code/src/engine/code-engine.ts create mode 100644 packages/code/src/engine/code-engine.type.ts create mode 100644 packages/code/src/engine/code-file.const.ts create mode 100644 packages/code/src/engine/code.exception.ts create mode 100644 packages/code/src/engine/edit-builder.ts create mode 100644 packages/code/src/engine/engine.type.ts create mode 100644 packages/code/src/engine/extract-meta.ts create mode 100644 packages/code/src/engine/index.ts create mode 100644 packages/code/src/engine/literals.ts create mode 100644 packages/code/src/engine/module-specifier.ts create mode 100644 packages/code/src/engine/worker-sdk.ts create mode 100644 packages/code/src/index.ts create mode 100644 packages/code/src/service/code-service.ts create mode 100644 packages/code/src/service/index.ts create mode 100644 packages/code/src/worker-rpc/create-browser-worker.ts create mode 100644 packages/code/src/worker-rpc/index.ts create mode 100644 packages/code/src/worker-rpc/serve-engine.ts create mode 100644 packages/code/src/worker-rpc/worker-rpc.ts create mode 100644 packages/code/src/worker.ts create mode 100644 packages/code/test/catalog/catalog-service.spec.ts create mode 100644 packages/code/test/catalog/item-fits-app.spec.ts create mode 100644 packages/code/test/document/document-service.spec.ts create mode 100644 packages/code/test/engine/code-engine.spec.ts create mode 100644 packages/code/test/engine/extract-meta.spec.ts create mode 100644 packages/code/test/service/code-service.spec.ts create mode 100644 packages/code/tsconfig.json create mode 100644 packages/history/.prettierignore create mode 100644 packages/history/LICENSE create mode 100644 packages/history/README.md create mode 100644 packages/history/package.json create mode 100644 packages/history/src/command/composite-command.ts create mode 100644 packages/history/src/command/history-command.type.ts create mode 100644 packages/history/src/command/index.ts create mode 100644 packages/history/src/file/apply-text-edits.ts create mode 100644 packages/history/src/file/file-history-tracker.ts create mode 100644 packages/history/src/file/index.ts create mode 100644 packages/history/src/index.ts create mode 100644 packages/history/src/service/history-context-keys.const.ts create mode 100644 packages/history/src/service/history-service.ts create mode 100644 packages/history/src/service/history-service.type.ts create mode 100644 packages/history/src/service/index.ts create mode 100644 packages/history/src/service/register-history-commands.ts create mode 100644 packages/history/src/service/undo-target.ts create mode 100644 packages/history/src/stack/history-stack.ts create mode 100644 packages/history/src/stack/history-stack.type.ts create mode 100644 packages/history/src/stack/index.ts create mode 100644 packages/history/test/file/apply-text-edits.spec.ts create mode 100644 packages/history/test/file/file-history-tracker.spec.ts create mode 100644 packages/history/test/file/project-fs.spec.ts create mode 100644 packages/history/test/fixtures/history.ts create mode 100644 packages/history/test/service/history-service.spec.ts create mode 100644 packages/history/test/service/undo-target.spec.ts create mode 100644 packages/history/test/stack/history-stack.spec.ts create mode 100644 packages/history/tsconfig.json create mode 100644 packages/kernel/.prettierignore create mode 100644 packages/kernel/LICENSE create mode 100644 packages/kernel/README.md create mode 100644 packages/kernel/package.json create mode 100644 packages/kernel/schemas/plugin.schema.json create mode 100644 packages/kernel/scripts/generate-schema.ts create mode 100644 packages/kernel/src/command/command-not-found.exception.ts create mode 100644 packages/kernel/src/command/command-precondition.exception.ts create mode 100644 packages/kernel/src/command/command-registry.ts create mode 100644 packages/kernel/src/command/command.type.ts create mode 100644 packages/kernel/src/command/index.ts create mode 100644 packages/kernel/src/context/context-key-service.ts create mode 100644 packages/kernel/src/context/index.ts create mode 100644 packages/kernel/src/context/parse-when.ts create mode 100644 packages/kernel/src/context/when-parse.exception.ts create mode 100644 packages/kernel/src/context/when-tokenizer.ts create mode 100644 packages/kernel/src/context/when.type.ts create mode 100644 packages/kernel/src/di/container.ts create mode 100644 packages/kernel/src/di/container.type.ts create mode 100644 packages/kernel/src/di/cyclic-dependency.exception.ts create mode 100644 packages/kernel/src/di/index.ts create mode 100644 packages/kernel/src/di/service-not-found.exception.ts create mode 100644 packages/kernel/src/di/service-token.ts create mode 100644 packages/kernel/src/event/emitter.ts create mode 100644 packages/kernel/src/event/event-bus.ts create mode 100644 packages/kernel/src/event/events.ts create mode 100644 packages/kernel/src/event/index.ts create mode 100644 packages/kernel/src/extension/contribution.exception.ts create mode 100644 packages/kernel/src/extension/define-extension-point.ts create mode 100644 packages/kernel/src/extension/extension-point.type.ts create mode 100644 packages/kernel/src/extension/extension-registry.ts create mode 100644 packages/kernel/src/extension/index.ts create mode 100644 packages/kernel/src/i18n/index.ts create mode 100644 packages/kernel/src/i18n/localization-service.ts create mode 100644 packages/kernel/src/i18n/message-format.ts create mode 100644 packages/kernel/src/index.ts create mode 100644 packages/kernel/src/lifecycle/disposable-object.abstract.ts create mode 100644 packages/kernel/src/lifecycle/disposable.ts create mode 100644 packages/kernel/src/lifecycle/index.ts create mode 100644 packages/kernel/src/lifecycle/mutable-disposable.ts create mode 100644 packages/kernel/src/log/format-log-value.ts create mode 100644 packages/kernel/src/log/index.ts create mode 100644 packages/kernel/src/log/log-level.enum.ts create mode 100644 packages/kernel/src/log/log-value.type.ts create mode 100644 packages/kernel/src/log/logger-service.ts create mode 100644 packages/kernel/src/log/logger.type.ts create mode 100644 packages/kernel/src/log/serialize-log-value.ts create mode 100644 packages/kernel/src/observable/index.ts create mode 100644 packages/kernel/src/observable/observable-value.ts create mode 100644 packages/kernel/src/observable/observable.ts create mode 100644 packages/kernel/src/plugin/command-metadata.ts create mode 100644 packages/kernel/src/plugin/core-services.const.ts create mode 100644 packages/kernel/src/plugin/create-core-container.ts create mode 100644 packages/kernel/src/plugin/create-plugin-context.ts create mode 100644 packages/kernel/src/plugin/describe-status.ts create mode 100644 packages/kernel/src/plugin/engine-libs.ts create mode 100644 packages/kernel/src/plugin/index.ts create mode 100644 packages/kernel/src/plugin/manifest-fields.ts create mode 100644 packages/kernel/src/plugin/package-manifest.ts create mode 100644 packages/kernel/src/plugin/plugin-activation.exception.ts create mode 100644 packages/kernel/src/plugin/plugin-context.type.ts create mode 100644 packages/kernel/src/plugin/plugin-host.ts create mode 100644 packages/kernel/src/plugin/plugin-host.type.ts create mode 100644 packages/kernel/src/plugin/plugin-manifest.exception.ts create mode 100644 packages/kernel/src/plugin/plugin-manifest.ts create mode 100644 packages/kernel/src/plugin/plugin-module-loader.ts create mode 100644 packages/kernel/src/plugin/plugin-resolution.type.ts create mode 100644 packages/kernel/src/plugin/plugin-source.enum.ts create mode 100644 packages/kernel/src/plugin/resolve-plugins.ts create mode 100644 packages/kernel/src/plugin/shared-module-registry.ts create mode 100644 packages/kernel/test/command/command-registry.spec.ts create mode 100644 packages/kernel/test/context/context-key-service.spec.ts create mode 100644 packages/kernel/test/context/parse-when.spec.ts create mode 100644 packages/kernel/test/di/container.spec.ts create mode 100644 packages/kernel/test/event/emitter.spec.ts create mode 100644 packages/kernel/test/extension/extension-registry.spec.ts create mode 100644 packages/kernel/test/i18n/localization-service.spec.ts create mode 100644 packages/kernel/test/lifecycle/disposable.spec.ts create mode 100644 packages/kernel/test/log/log-value.spec.ts create mode 100644 packages/kernel/test/log/logger-service.spec.ts create mode 100644 packages/kernel/test/observable/observable.spec.ts create mode 100644 packages/kernel/test/plugin/plugin-host.spec.ts create mode 100644 packages/kernel/test/plugin/plugin-manifest-schema.spec.ts create mode 100644 packages/kernel/test/plugin/plugin-manifest.spec.ts create mode 100644 packages/kernel/test/plugin/resolve-plugins.spec.ts create mode 100644 packages/kernel/test/plugin/shared-module-registry.spec.ts create mode 100644 packages/kernel/tsconfig.json create mode 100644 packages/layout/.prettierignore create mode 100644 packages/layout/LICENSE create mode 100644 packages/layout/README.md create mode 100644 packages/layout/bench/layout.bench.ts create mode 100644 packages/layout/package.json create mode 100644 packages/layout/src/default/default-layout.ts create mode 100644 packages/layout/src/default/index.ts create mode 100644 packages/layout/src/index.ts create mode 100644 packages/layout/src/model/empty-layout.ts create mode 100644 packages/layout/src/model/index.ts create mode 100644 packages/layout/src/model/layout-queries.ts create mode 100644 packages/layout/src/model/layout.const.ts create mode 100644 packages/layout/src/model/layout.type.ts create mode 100644 packages/layout/src/model/slot-id.enum.ts create mode 100644 packages/layout/src/operations/index.ts create mode 100644 packages/layout/src/operations/layout-op.type.ts create mode 100644 packages/layout/src/operations/layout-ops.ts create mode 100644 packages/layout/src/operations/layout.exception.ts create mode 100644 packages/layout/src/operations/reduce.ts create mode 100644 packages/layout/src/serialization/index.ts create mode 100644 packages/layout/src/serialization/layout.schema.ts create mode 100644 packages/layout/src/serialization/repair-layout.ts create mode 100644 packages/layout/src/serialization/serialize-layout.ts create mode 100644 packages/layout/test/default/default-layout.spec.ts create mode 100644 packages/layout/test/fixtures/layout.ts create mode 100644 packages/layout/test/operations/inverse-operations.spec.ts create mode 100644 packages/layout/test/operations/layout-ops.spec.ts create mode 100644 packages/layout/test/operations/reduce.spec.ts create mode 100644 packages/layout/test/serialization/serialize-layout.spec.ts create mode 100644 packages/layout/tsconfig.json create mode 100644 packages/meta/.prettierignore create mode 100644 packages/meta/LICENSE create mode 100644 packages/meta/README.md create mode 100644 packages/meta/package.json create mode 100644 packages/meta/src/doc/doc-comment.ts create mode 100644 packages/meta/src/doc/doc.type.ts create mode 100644 packages/meta/src/doc/index.ts create mode 100644 packages/meta/src/extract/exported-declarations.ts create mode 100644 packages/meta/src/extract/extract-items.ts create mode 100644 packages/meta/src/extract/extract.type.ts create mode 100644 packages/meta/src/extract/index.ts create mode 100644 packages/meta/src/extract/meta-tags.const.ts create mode 100644 packages/meta/src/extract/param-fields.ts create mode 100644 packages/meta/src/extract/param-layout.ts create mode 100644 packages/meta/src/extract/requires.ts create mode 100644 packages/meta/src/hash/index.ts create mode 100644 packages/meta/src/hash/source-hash.ts create mode 100644 packages/meta/src/index.ts create mode 100644 packages/meta/src/owner/index.ts create mode 100644 packages/meta/src/owner/item-owner.type.ts create mode 100644 packages/meta/src/pure.ts create mode 100644 packages/meta/src/schema/element.schema.ts create mode 100644 packages/meta/src/schema/index.ts create mode 100644 packages/meta/src/schema/item-ref.schema.ts create mode 100644 packages/meta/src/schema/meta-file.schema.ts create mode 100644 packages/meta/src/schema/meta-generator.const.ts create mode 100644 packages/meta/src/schema/owner-object.ts create mode 100644 packages/meta/src/type/default-of.ts create mode 100644 packages/meta/src/type/element-of-type.ts create mode 100644 packages/meta/src/type/index.ts create mode 100644 packages/meta/src/type/package-of-path.ts create mode 100644 packages/meta/src/type/type-context.type.ts create mode 100644 packages/meta/test/extract/extract-items.spec.ts create mode 100644 packages/meta/test/extract/inline.spec.ts create mode 100644 packages/meta/tsconfig.json create mode 100644 packages/project/.prettierignore create mode 100644 packages/project/LICENSE create mode 100644 packages/project/README.md create mode 100644 packages/project/package.json create mode 100644 packages/project/src/cache/cache.type.ts create mode 100644 packages/project/src/cache/index.ts create mode 100644 packages/project/src/cache/indexed-db.tree-cache.ts create mode 100644 packages/project/src/cache/memory.content-cache.ts create mode 100644 packages/project/src/cache/memory.tree-cache.ts create mode 100644 packages/project/src/cache/opfs.content-cache.ts create mode 100644 packages/project/src/fs/index.ts create mode 100644 packages/project/src/fs/project-fs.ts create mode 100644 packages/project/src/index.ts create mode 100644 packages/project/src/service/client-project.ts create mode 100644 packages/project/src/service/index.ts create mode 100644 packages/project/src/service/project-service.ts create mode 100644 packages/project/src/workspace/app-operations.ts create mode 100644 packages/project/src/workspace/changes.ts create mode 100644 packages/project/src/workspace/editor-library.ts create mode 100644 packages/project/src/workspace/index.ts create mode 100644 packages/project/src/workspace/library-config.ts create mode 100644 packages/project/src/workspace/library-operations.ts create mode 100644 packages/project/src/workspace/workspace-files.ts create mode 100644 packages/project/src/workspace/workspace-io.ts create mode 100644 packages/project/src/workspace/workspace-queries.ts create mode 100644 packages/project/src/workspace/workspace.const.ts create mode 100644 packages/project/src/workspace/workspace.exception.ts create mode 100644 packages/project/src/workspace/workspace.type.ts create mode 100644 packages/project/test/fixtures/workspace.ts create mode 100644 packages/project/test/fs/project-fs.spec.ts create mode 100644 packages/project/test/workspace/app-operations.spec.ts create mode 100644 packages/project/test/workspace/library-operations.spec.ts create mode 100644 packages/project/tsconfig.json create mode 100644 packages/protocol/.prettierignore create mode 100644 packages/protocol/LICENSE create mode 100644 packages/protocol/README.md create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/code/code.contract.ts create mode 100644 packages/protocol/src/code/index.ts create mode 100644 packages/protocol/src/fs/fs.contract.ts create mode 100644 packages/protocol/src/fs/fs.schema.ts create mode 100644 packages/protocol/src/fs/index.ts create mode 100644 packages/protocol/src/git/git.contract.ts create mode 100644 packages/protocol/src/git/git.schema.ts create mode 100644 packages/protocol/src/git/index.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/path/index.ts create mode 100644 packages/protocol/src/path/project-path.ts create mode 100644 packages/protocol/src/plugins/index.ts create mode 100644 packages/protocol/src/plugins/plugins.contract.ts create mode 100644 packages/protocol/src/plugins/plugins.schema.ts create mode 100644 packages/protocol/src/project/index.ts create mode 100644 packages/protocol/src/project/project.contract.ts create mode 100644 packages/protocol/src/project/project.schema.ts create mode 100644 packages/protocol/src/registry/index.ts create mode 100644 packages/protocol/src/registry/registry.contract.ts create mode 100644 packages/protocol/src/registry/registry.schema.ts create mode 100644 packages/protocol/src/runtime/engine-bridge.const.ts create mode 100644 packages/protocol/src/runtime/engine-bridge.type.ts create mode 100644 packages/protocol/src/runtime/index.ts create mode 100644 packages/protocol/src/runtime/runtime-output.ts create mode 100644 packages/protocol/src/runtime/runtime.contract.ts create mode 100644 packages/protocol/src/runtime/runtime.schema.ts create mode 100644 packages/protocol/src/session/index.ts create mode 100644 packages/protocol/src/session/session.contract.ts create mode 100644 packages/protocol/src/session/session.schema.ts create mode 100644 packages/protocol/src/settings/index.ts create mode 100644 packages/protocol/src/settings/settings.contract.ts create mode 100644 packages/protocol/src/settings/settings.schema.ts create mode 100644 packages/protocol/test/path/project-path.spec.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/registry/.prettierignore create mode 100644 packages/registry/LICENSE create mode 100644 packages/registry/README.md create mode 100644 packages/registry/package.json create mode 100644 packages/registry/src/archive/archive.ts create mode 100644 packages/registry/src/archive/index.ts create mode 100644 packages/registry/src/client/index.ts create mode 100644 packages/registry/src/client/registry-client.ts create mode 100644 packages/registry/src/directory/directory-registry.ts create mode 100644 packages/registry/src/directory/index.ts create mode 100644 packages/registry/src/index.ts create mode 100644 packages/registry/src/install/index.ts create mode 100644 packages/registry/src/install/install-item.ts create mode 100644 packages/registry/src/item/index.ts create mode 100644 packages/registry/src/item/item.schema.ts create mode 100644 packages/registry/src/project/index.ts create mode 100644 packages/registry/src/project/packages-file.schema.ts create mode 100644 packages/registry/src/project/project-packages.const.ts create mode 100644 packages/registry/src/project/project-packages.ts create mode 100644 packages/registry/src/project/project-packages.type.ts create mode 100644 packages/registry/src/registry/index.ts create mode 100644 packages/registry/src/registry/registry.exception.ts create mode 100644 packages/registry/src/registry/registry.type.ts create mode 100644 packages/registry/src/serve/index.ts create mode 100644 packages/registry/src/serve/serve-registry.ts create mode 100644 packages/registry/src/tsconfig-paths.ts create mode 100644 packages/registry/src/types.ts create mode 100644 packages/registry/src/wire/index.ts create mode 100644 packages/registry/src/wire/packument.ts create mode 100644 packages/registry/src/wire/wire.schema.ts create mode 100644 packages/registry/test/contract/registry-contract.ts create mode 100644 packages/registry/test/contract/registry.contract.spec.ts create mode 100644 packages/registry/test/registry.spec.ts create mode 100644 packages/registry/tsconfig.json create mode 100644 packages/rpc/.prettierignore create mode 100644 packages/rpc/LICENSE create mode 100644 packages/rpc/README.md create mode 100644 packages/rpc/package.json create mode 100644 packages/rpc/src/client/index.ts create mode 100644 packages/rpc/src/client/rpc-client.ts create mode 100644 packages/rpc/src/client/rpc-client.type.ts create mode 100644 packages/rpc/src/codec/codec.ts create mode 100644 packages/rpc/src/codec/index.ts create mode 100644 packages/rpc/src/contract/contract.type.ts create mode 100644 packages/rpc/src/contract/define-contract.ts create mode 100644 packages/rpc/src/contract/index.ts create mode 100644 packages/rpc/src/contract/rpc.const.ts create mode 100644 packages/rpc/src/error/index.ts create mode 100644 packages/rpc/src/error/rpc.exception.ts create mode 100644 packages/rpc/src/index.ts create mode 100644 packages/rpc/src/router/index.ts create mode 100644 packages/rpc/src/router/router-entries.ts create mode 100644 packages/rpc/src/router/router.type.ts create mode 100644 packages/rpc/src/router/rpc-router.ts create mode 100644 packages/rpc/src/router/rpc-session.ts create mode 100644 packages/rpc/test/codec/codec.spec.ts create mode 100644 packages/rpc/test/fixtures/harness.ts create mode 100644 packages/rpc/test/rpc.spec.ts create mode 100644 packages/rpc/tsconfig.json create mode 100644 packages/runtime/.prettierignore create mode 100644 packages/runtime/LICENSE create mode 100644 packages/runtime/README.md create mode 100644 packages/runtime/package.json create mode 100644 packages/runtime/src/bridge/bridge-emitter.type.ts create mode 100644 packages/runtime/src/bridge/direct-emitter.ts create mode 100644 packages/runtime/src/bridge/index.ts create mode 100644 packages/runtime/src/bridge/queued-emitter.ts create mode 100644 packages/runtime/src/client/game-client-runner.ts create mode 100644 packages/runtime/src/client/game-client-runner.type.ts create mode 100644 packages/runtime/src/client/index.ts create mode 100644 packages/runtime/src/compatibility/check-compatibility.ts create mode 100644 packages/runtime/src/compatibility/game-compatibility.type.ts create mode 100644 packages/runtime/src/compatibility/index.ts create mode 100644 packages/runtime/src/compatibility/merge-features.ts create mode 100644 packages/runtime/src/index.ts create mode 100644 packages/runtime/src/service/index.ts create mode 100644 packages/runtime/src/service/play.exception.ts create mode 100644 packages/runtime/src/service/runtime-service.ts create mode 100644 packages/runtime/src/service/runtime-service.type.ts create mode 100644 packages/runtime/src/settings/index.ts create mode 100644 packages/runtime/src/settings/runtime-settings.ts create mode 100644 packages/runtime/src/source-map/index.ts create mode 100644 packages/runtime/src/source-map/source-maps.ts create mode 100644 packages/runtime/src/source-map/source-maps.type.ts create mode 100644 packages/runtime/test/compatibility/check-compatibility.spec.ts create mode 100644 packages/runtime/test/compatibility/merge-features.spec.ts create mode 100644 packages/runtime/test/service/runtime-service.spec.ts create mode 100644 packages/runtime/test/source-map/source-maps.spec.ts create mode 100644 packages/runtime/tsconfig.json create mode 100644 packages/sdk/.cliff-jumperrc.json create mode 100644 packages/sdk/.prettierignore create mode 100644 packages/sdk/CHANGELOG.md create mode 100644 packages/sdk/LICENSE create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/cliff.toml create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/scripts/build-types.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/ui.ts create mode 100644 packages/sdk/src/worker.ts create mode 100644 packages/sdk/test/index.spec.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsconfig.types.json create mode 100644 packages/sdk/tsdown.config.ts create mode 100644 packages/server-core/.prettierignore create mode 100644 packages/server-core/LICENSE create mode 100644 packages/server-core/README.md create mode 100644 packages/server-core/package.json create mode 100644 packages/server-core/src/api/api-client.ts create mode 100644 packages/server-core/src/api/index.ts create mode 100644 packages/server-core/src/env/editor-env.type.ts create mode 100644 packages/server-core/src/env/env.exception.ts create mode 100644 packages/server-core/src/env/env.schema.ts create mode 100644 packages/server-core/src/env/index.ts create mode 100644 packages/server-core/src/env/load-env.ts create mode 100644 packages/server-core/src/fs/archive.ts create mode 100644 packages/server-core/src/fs/fs-service.ts create mode 100644 packages/server-core/src/fs/index.ts create mode 100644 packages/server-core/src/fs/path-jail.ts create mode 100644 packages/server-core/src/fs/reveal.ts create mode 100644 packages/server-core/src/fs/watcher.ts create mode 100644 packages/server-core/src/git/git-service.ts create mode 100644 packages/server-core/src/git/git-token.ts create mode 100644 packages/server-core/src/git/index.ts create mode 100644 packages/server-core/src/git/parse-status.ts create mode 100644 packages/server-core/src/http/static-files.ts create mode 100644 packages/server-core/src/index.ts create mode 100644 packages/server-core/src/plugin/index.ts create mode 100644 packages/server-core/src/plugin/plugin-sources.ts create mode 100644 packages/server-core/src/plugin/server-plugins.ts create mode 100644 packages/server-core/src/process/cli-service.ts create mode 100644 packages/server-core/src/process/index.ts create mode 100644 packages/server-core/src/process/run-process.ts create mode 100644 packages/server-core/src/project/config-loader.ts create mode 100644 packages/server-core/src/project/discovery.ts create mode 100644 packages/server-core/src/project/formatter.ts create mode 100644 packages/server-core/src/project/index.ts create mode 100644 packages/server-core/src/project/project-registry.ts create mode 100644 packages/server-core/src/project/type-files.ts create mode 100644 packages/server-core/src/rpc/core-rpc.type.ts create mode 100644 packages/server-core/src/rpc/implement-core-rpc.ts create mode 100644 packages/server-core/src/rpc/implementation/code.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/fs.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/git.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/plugins.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/projects.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/registry.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/runtime.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/session.implementation.ts create mode 100644 packages/server-core/src/rpc/implementation/settings.implementation.ts create mode 100644 packages/server-core/src/rpc/package-manager-of.ts create mode 100644 packages/server-core/src/rpc/require-user.ts create mode 100644 packages/server-core/src/runtime/build-orchestrator.ts create mode 100644 packages/server-core/src/runtime/build-output.ts create mode 100644 packages/server-core/src/runtime/game-env.ts create mode 100644 packages/server-core/src/runtime/game-server.ts create mode 100644 packages/server-core/src/runtime/index.ts create mode 100644 packages/server-core/src/runtime/project-runtime.ts create mode 100644 packages/server-core/src/server/create-editor-server.ts create mode 100644 packages/server-core/src/server/editor-server.type.ts create mode 100644 packages/server-core/src/server/index.ts create mode 100644 packages/server-core/src/session/auth.ts create mode 100644 packages/server-core/src/session/cookies.ts create mode 100644 packages/server-core/src/session/index.ts create mode 100644 packages/server-core/src/session/session-store.ts create mode 100644 packages/server-core/src/settings/account-backends.ts create mode 100644 packages/server-core/src/settings/index.ts create mode 100644 packages/server-core/src/testing.ts create mode 100644 packages/server-core/src/util/glob.ts create mode 100644 packages/server-core/src/util/hash.ts create mode 100644 packages/server-core/src/util/keyed-mutex.ts create mode 100644 packages/server-core/test/contract/api-contract.ts create mode 100644 packages/server-core/test/contract/api.contract.spec.ts create mode 100644 packages/server-core/test/fixtures/plugins/@acme/echo/index.js create mode 100644 packages/server-core/test/fixtures/plugins/@acme/echo/nanoforge.manifest.json create mode 100644 packages/server-core/test/fixtures/plugins/@acme/echo/server.js rename src/lib/server/project/package/package-resolver.ts => packages/server-core/test/fixtures/pong-network/.gitignore (100%) create mode 100644 packages/server-core/test/fixtures/pong-network/.prettierignore create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/.prettierignore rename src/routes/+page.server.ts => packages/server-core/test/fixtures/pong-network/apps/client/assets/.gitkeep (100%) create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/eslint.config.js create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/nanoforge.config.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/package.json create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/prettier.config.js create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/src/components/components.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/src/main.ts rename src/routes/+page.ts => packages/server-core/test/fixtures/pong-network/apps/client/src/scenes/.gitkeep (100%) create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/src/systems/systems.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/client/tsconfig.json create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/.prettierignore create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/assets/.gitkeep create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/eslint.config.js create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/nanoforge.config.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/package.json create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/prettier.config.js create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/src/components/components.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/src/main.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/src/scenes/.gitkeep create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/src/systems/systems.ts create mode 100644 packages/server-core/test/fixtures/pong-network/apps/server/tsconfig.json create mode 100644 packages/server-core/test/fixtures/pong-network/nanoforge.config.ts create mode 100644 packages/server-core/test/fixtures/pong-network/package.json create mode 100644 packages/server-core/test/fixtures/pong-network/prettier.config.js create mode 100644 packages/server-core/test/fixtures/pong-network/tsconfig.json create mode 100644 packages/server-core/test/fixtures/pong-network/tsconfig.spec.json create mode 100644 packages/server-core/test/fixtures/runtime/bridge-game.js create mode 100644 packages/server-core/test/fixtures/runtime/legacy-game.js create mode 100644 packages/server-core/test/fixtures/runtime/nf.cjs create mode 100644 packages/server-core/test/fs/fs-service.spec.ts create mode 100644 packages/server-core/test/git/git-service.spec.ts create mode 100644 packages/server-core/test/mocks/nanoforge-api.ts create mode 100644 packages/server-core/test/project/discovery.spec.ts create mode 100644 packages/server-core/test/project/formatter.spec.ts create mode 100644 packages/server-core/test/rpc/registry.spec.ts create mode 100644 packages/server-core/test/runtime/project-runtime.spec.ts create mode 100644 packages/server-core/test/server/create-editor-server.spec.ts create mode 100644 packages/server-core/test/settings/account-backends.spec.ts create mode 100644 packages/server-core/tsconfig.json create mode 100644 packages/settings/.prettierignore create mode 100644 packages/settings/LICENSE create mode 100644 packages/settings/README.md create mode 100644 packages/settings/package.json create mode 100644 packages/settings/src/account-sync/account-sync-store.ts create mode 100644 packages/settings/src/account-sync/account-sync.type.ts create mode 100644 packages/settings/src/account-sync/index.ts create mode 100644 packages/settings/src/account-sync/rpc-account-remote.ts create mode 100644 packages/settings/src/definition/core-settings.ts create mode 100644 packages/settings/src/definition/define-setting.ts create mode 100644 packages/settings/src/definition/index.ts create mode 100644 packages/settings/src/definition/json-schema-of.ts create mode 100644 packages/settings/src/definition/setting-declaration.ts create mode 100644 packages/settings/src/definition/setting-definition.type.ts create mode 100644 packages/settings/src/index.ts create mode 100644 packages/settings/src/key-value/index.ts create mode 100644 packages/settings/src/key-value/indexed-db.key-value-store.ts create mode 100644 packages/settings/src/key-value/key-value-store.type.ts create mode 100644 packages/settings/src/key-value/memory.key-value-store.ts create mode 100644 packages/settings/src/merge/deep-merge.ts create mode 100644 packages/settings/src/merge/index.ts create mode 100644 packages/settings/src/merge/merge3.ts create mode 100644 packages/settings/src/registry/index.ts create mode 100644 packages/settings/src/registry/settings-contribution.ts create mode 100644 packages/settings/src/registry/settings-registry.ts create mode 100644 packages/settings/src/scope/index.ts create mode 100644 packages/settings/src/scope/setting-scope.enum.ts create mode 100644 packages/settings/src/service/index.ts create mode 100644 packages/settings/src/service/settings-service.ts create mode 100644 packages/settings/src/service/settings-service.type.ts create mode 100644 packages/settings/src/service/settings.exception.ts create mode 100644 packages/settings/src/setup/follow-current-project.ts create mode 100644 packages/settings/src/setup/index.ts create mode 100644 packages/settings/src/setup/setup-settings.ts create mode 100644 packages/settings/src/store/index.ts create mode 100644 packages/settings/src/store/json-file.scope-store.ts create mode 100644 packages/settings/src/store/key-value.scope-store.ts create mode 100644 packages/settings/src/store/memory.scope-store.ts create mode 100644 packages/settings/src/store/project-file-adapter.ts create mode 100644 packages/settings/src/store/remote-project-local.scope-store.ts create mode 100644 packages/settings/src/store/scope-store.type.ts create mode 100644 packages/settings/src/store/setting-values.ts create mode 100644 packages/settings/src/store/settings-file.exception.ts create mode 100644 packages/settings/test/account-sync/account-sync-store.spec.ts create mode 100644 packages/settings/test/definition/setting-declaration.spec.ts create mode 100644 packages/settings/test/end-to-end.spec.ts create mode 100644 packages/settings/test/fixtures/settings.ts create mode 100644 packages/settings/test/merge/merge3.spec.ts create mode 100644 packages/settings/test/service/settings-service.spec.ts create mode 100644 packages/settings/test/store/json-file.scope-store.spec.ts create mode 100644 packages/settings/tsconfig.json create mode 100644 packages/ui/.prettierignore create mode 100644 packages/ui/.vitest-attachments/02b15f3f113918a38cd8e641b8a17d93aa33ebb2.png create mode 100644 packages/ui/.vitest-attachments/27a793f05c95fc860a68c6e341bc411b7e66b6a1.png create mode 100644 packages/ui/.vitest-attachments/a56f5a286116b3a615006044f826a559850964e3.png create mode 100644 packages/ui/.vitest-attachments/f3050746551e183630dbddf1ae7ffec904ce7a4f.png create mode 100644 packages/ui/LICENSE create mode 100644 packages/ui/README.md create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/MenuEntries.svelte create mode 100644 packages/ui/src/components/button.svelte create mode 100644 packages/ui/src/components/checkbox.svelte create mode 100644 packages/ui/src/components/color-picker.svelte create mode 100644 packages/ui/src/components/combobox.svelte create mode 100644 packages/ui/src/components/context-menu.svelte create mode 100644 packages/ui/src/components/dialog.svelte create mode 100644 packages/ui/src/components/empty-state.svelte create mode 100644 packages/ui/src/components/icon-button.svelte create mode 100644 packages/ui/src/components/icon.svelte create mode 100644 packages/ui/src/components/icons.ts create mode 100644 packages/ui/src/components/index.ts create mode 100644 packages/ui/src/components/input.svelte create mode 100644 packages/ui/src/components/menu-entry.type.ts create mode 100644 packages/ui/src/components/menu.svelte create mode 100644 packages/ui/src/components/number-input.svelte create mode 100644 packages/ui/src/components/property-field.type.ts create mode 100644 packages/ui/src/components/property-grid.svelte create mode 100644 packages/ui/src/components/select.svelte create mode 100644 packages/ui/src/components/splitter.svelte create mode 100644 packages/ui/src/components/switch.svelte create mode 100644 packages/ui/src/components/tabs.svelte create mode 100644 packages/ui/src/components/toasts.svelte create mode 100644 packages/ui/src/components/tooltip.svelte create mode 100644 packages/ui/src/components/tree-model.ts create mode 100644 packages/ui/src/components/tree.svelte create mode 100644 packages/ui/src/components/vector2-input.svelte create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/style/index.ts create mode 100644 packages/ui/src/style/style-service.ts create mode 100644 packages/ui/src/theme/base-css.ts create mode 100644 packages/ui/src/theme/fonts.css create mode 100644 packages/ui/src/theme/index.ts create mode 100644 packages/ui/src/theme/kit-css.ts create mode 100644 packages/ui/src/theme/theme-service.ts create mode 100644 packages/ui/src/theme/tokens.ts create mode 100644 packages/ui/src/theme/uno.ts create mode 100644 packages/ui/src/workbench/action/index.ts create mode 100644 packages/ui/src/workbench/action/list-actions.ts create mode 100644 packages/ui/src/workbench/extension-point/code-editor.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/file.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/index.ts create mode 100644 packages/ui/src/workbench/extension-point/keybinding.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/menu.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/settings-page.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/status-bar.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/toolbar.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/viewport.extension-point.ts create mode 100644 packages/ui/src/workbench/extension-point/widget.extension-point.ts create mode 100644 packages/ui/src/workbench/index.ts create mode 100644 packages/ui/src/workbench/keybinding/index.ts create mode 100644 packages/ui/src/workbench/keybinding/keybinding-service.ts create mode 100644 packages/ui/src/workbench/keybinding/keymap-edit.ts create mode 100644 packages/ui/src/workbench/keybinding/keymap-presets.ts create mode 100644 packages/ui/src/workbench/keybinding/keymap.type.ts create mode 100644 packages/ui/src/workbench/keybinding/keystroke.ts create mode 100644 packages/ui/src/workbench/keybinding/resolve-keymap.ts create mode 100644 packages/ui/src/workbench/layout/drag-controller.ts create mode 100644 packages/ui/src/workbench/layout/index.ts create mode 100644 packages/ui/src/workbench/layout/layout-controller.ts create mode 100644 packages/ui/src/workbench/layout/layout-controller.type.ts create mode 100644 packages/ui/src/workbench/menu/index.ts create mode 100644 packages/ui/src/workbench/menu/resolve-menu.ts create mode 100644 packages/ui/src/workbench/notification/index.ts create mode 100644 packages/ui/src/workbench/notification/notification-service.ts create mode 100644 packages/ui/src/workbench/parts/DockStack.svelte create mode 100644 packages/ui/src/workbench/parts/DragOverlay.svelte create mode 100644 packages/ui/src/workbench/parts/FloatWindow.svelte create mode 100644 packages/ui/src/workbench/parts/MenuBarMenu.svelte create mode 100644 packages/ui/src/workbench/parts/PromptHost.svelte create mode 100644 packages/ui/src/workbench/parts/StatusBar.svelte create mode 100644 packages/ui/src/workbench/parts/StatusText.svelte create mode 100644 packages/ui/src/workbench/parts/ToolStripe.svelte create mode 100644 packages/ui/src/workbench/parts/TopBar.svelte create mode 100644 packages/ui/src/workbench/parts/WidgetHost.svelte create mode 100644 packages/ui/src/workbench/parts/tab-menu.ts create mode 100644 packages/ui/src/workbench/prompt/index.ts create mode 100644 packages/ui/src/workbench/prompt/prompt-service.ts create mode 100644 packages/ui/src/workbench/service/index.ts create mode 100644 packages/ui/src/workbench/service/workbench-service.ts create mode 100644 packages/ui/src/workbench/service/workbench-service.type.ts create mode 100644 packages/ui/src/workbench/setup/index.ts create mode 100644 packages/ui/src/workbench/setup/setup-shell.ts create mode 100644 packages/ui/src/workbench/setup/setup-workbench.ts create mode 100644 packages/ui/src/workbench/widget/index.ts create mode 100644 packages/ui/src/workbench/widget/widget-instance.type.ts create mode 100644 packages/ui/src/workbench/workbench-context.ts create mode 100644 packages/ui/src/workbench/workbench.svelte create mode 100644 packages/ui/test/__screenshots__/kit.svelte.spec.ts/Tree-only-renders-visible-rows-of-large-trees-1.png create mode 100644 packages/ui/test/__screenshots__/kit.svelte.spec.ts/controls-ColorPicker-normalizes-hex-input-1.png create mode 100644 packages/ui/test/__screenshots__/kit.svelte.spec.ts/controls-Select-picks-an-option-1.png create mode 100644 packages/ui/test/__screenshots__/kit.svelte.spec.ts/controls-Splitter-resizes-with-the-keyboard-1.png create mode 100644 packages/ui/test/fixtures/MenuHarness.svelte create mode 100644 packages/ui/test/fixtures/TreeHarness.svelte create mode 100644 packages/ui/test/kit.svelte.spec.ts create mode 100644 packages/ui/test/style.svelte.spec.ts create mode 100644 packages/ui/test/workbench/action/list-actions.spec.ts create mode 100644 packages/ui/test/workbench/keybinding/keybindings.spec.ts create mode 100644 packages/ui/test/workbench/keybinding/keymap-edit.spec.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vitest.config.ts delete mode 100644 playwright.config.ts create mode 100644 plugins/code-editor/.prettierignore create mode 100644 plugins/code-editor/LICENSE create mode 100644 plugins/code-editor/README.md create mode 100644 plugins/code-editor/nanoforge.manifest.json create mode 100644 plugins/code-editor/package.json create mode 100644 plugins/code-editor/src/document/editor-document.ts create mode 100644 plugins/code-editor/src/document/editor-document.type.ts create mode 100644 plugins/code-editor/src/document/unsaved-store.ts create mode 100644 plugins/code-editor/src/index.ts create mode 100644 plugins/code-editor/src/monaco/monaco-theme.ts create mode 100644 plugins/code-editor/src/monaco/monaco.ts create mode 100644 plugins/code-editor/src/service/code-editor-service.ts create mode 100644 plugins/code-editor/src/service/code-editor-settings.const.ts create mode 100644 plugins/code-editor/src/service/code-editor.type.ts create mode 100644 plugins/code-editor/src/service/editor-edits.ts create mode 100644 plugins/code-editor/src/service/pair-renames.ts create mode 100644 plugins/code-editor/src/session/code-editor-session.ts create mode 100644 plugins/code-editor/src/typing/type-libraries.ts create mode 100644 plugins/code-editor/src/widgets/script/EditorGroupView.svelte create mode 100644 plugins/code-editor/src/widgets/script/ScriptScreen.svelte create mode 100644 plugins/code-editor/svelte.config.js create mode 100644 plugins/code-editor/test/service/pair-renames.spec.ts create mode 100644 plugins/code-editor/tsconfig.json create mode 100644 plugins/code-editor/vite.config.ts create mode 100644 plugins/command-palette/.prettierignore create mode 100644 plugins/command-palette/LICENSE create mode 100644 plugins/command-palette/README.md create mode 100644 plugins/command-palette/nanoforge.manifest.json create mode 100644 plugins/command-palette/package.json create mode 100644 plugins/command-palette/src/icons.ts create mode 100644 plugins/command-palette/src/index.ts create mode 100644 plugins/command-palette/src/keymap/KeymapPage.svelte create mode 100644 plugins/command-palette/src/keymap/RecordDialog.svelte create mode 100644 plugins/command-palette/src/keymap/keymap-rows.ts create mode 100644 plugins/command-palette/src/keymap/keymap.const.ts create mode 100644 plugins/command-palette/src/keymap/open-record-dialog.ts create mode 100644 plugins/command-palette/src/palette/Palette.svelte create mode 100644 plugins/command-palette/src/palette/fuzzy.ts create mode 100644 plugins/command-palette/src/palette/palette-model.ts create mode 100644 plugins/command-palette/src/palette/recent.ts create mode 100644 plugins/command-palette/svelte.config.js create mode 100644 plugins/command-palette/test/palette/fuzzy.spec.ts create mode 100644 plugins/command-palette/test/palette/palette-model.spec.ts create mode 100644 plugins/command-palette/tsconfig.json create mode 100644 plugins/command-palette/vite.config.ts create mode 100644 plugins/console/.prettierignore create mode 100644 plugins/console/LICENSE create mode 100644 plugins/console/README.md create mode 100644 plugins/console/nanoforge.manifest.json create mode 100644 plugins/console/package.json create mode 100644 plugins/console/src/icons.ts create mode 100644 plugins/console/src/index.ts create mode 100644 plugins/console/src/link/frame-locator.ts create mode 100644 plugins/console/src/link/linkify.ts create mode 100644 plugins/console/src/problems/group-problems.ts create mode 100644 plugins/console/src/session/console-session.ts create mode 100644 plugins/console/src/store/console-store.ts create mode 100644 plugins/console/src/store/log-sources.ts create mode 100644 plugins/console/src/widgets/console/ConsoleLineRow.svelte create mode 100644 plugins/console/src/widgets/console/ConsoleView.svelte create mode 100644 plugins/console/src/widgets/console/GroupChips.svelte create mode 100644 plugins/console/src/widgets/console/LinkedText.svelte create mode 100644 plugins/console/src/widgets/console/ValueTree.svelte create mode 100644 plugins/console/src/widgets/console/console-filters.ts create mode 100644 plugins/console/src/widgets/problems/ProblemsView.svelte create mode 100644 plugins/console/svelte.config.js create mode 100644 plugins/console/test/link/linkify.spec.ts create mode 100644 plugins/console/test/problems/group-problems.spec.ts create mode 100644 plugins/console/test/store/console-store.spec.ts create mode 100644 plugins/console/tsconfig.json create mode 100644 plugins/console/vite.config.ts create mode 100644 plugins/ecs/.prettierignore create mode 100644 plugins/ecs/LICENSE create mode 100644 plugins/ecs/README.md create mode 100644 plugins/ecs/nanoforge.manifest.json create mode 100644 plugins/ecs/package.json create mode 100644 plugins/ecs/src/create/create-item.ts create mode 100644 plugins/ecs/src/extension/ecs-source.extension-point.ts create mode 100644 plugins/ecs/src/extension/field-editor.extension-point.ts create mode 100644 plugins/ecs/src/extension/param-preset.extension-point.ts create mode 100644 plugins/ecs/src/index.ts create mode 100644 plugins/ecs/src/layout/param-layout.ts create mode 100644 plugins/ecs/src/library/check-libraries.ts create mode 100644 plugins/ecs/src/library/create-shared-library.ts create mode 100644 plugins/ecs/src/library/refactors-history.ts create mode 100644 plugins/ecs/src/library/relocate-item.ts create mode 100644 plugins/ecs/src/live/live-service.ts create mode 100644 plugins/ecs/src/live/live.type.ts create mode 100644 plugins/ecs/src/live/move-in-game.ts create mode 100644 plugins/ecs/src/model/arg-values.ts create mode 100644 plugins/ecs/src/model/ecs-model.type.ts create mode 100644 plugins/ecs/src/model/ecs.const.ts create mode 100644 plugins/ecs/src/model/item-docs.type.ts create mode 100644 plugins/ecs/src/model/scope-name.ts create mode 100644 plugins/ecs/src/scene/build-scene.ts create mode 100644 plugins/ecs/src/service/code-services.ts create mode 100644 plugins/ecs/src/service/ecs-data.ts create mode 100644 plugins/ecs/src/service/ecs-service.ts create mode 100644 plugins/ecs/src/session/panel-services.ts create mode 100644 plugins/ecs/src/widgets/browser/Browser.svelte create mode 100644 plugins/ecs/src/widgets/browser/ItemDetails.svelte create mode 100644 plugins/ecs/src/widgets/browser/browser-sections.ts create mode 100644 plugins/ecs/src/widgets/browser/create-flows.ts create mode 100644 plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte create mode 100644 plugins/ecs/src/widgets/component-panel/GroupsSection.svelte create mode 100644 plugins/ecs/src/widgets/component-panel/ParamEditor.svelte create mode 100644 plugins/ecs/src/widgets/component-panel/component-docs.ts create mode 100644 plugins/ecs/src/widgets/hierarchy/Hierarchy.svelte create mode 100644 plugins/ecs/src/widgets/hierarchy/InheritedEntitiesTree.svelte create mode 100644 plugins/ecs/src/widgets/hierarchy/LiveEntitiesTree.svelte create mode 100644 plugins/ecs/src/widgets/inspector/ComponentCard.svelte create mode 100644 plugins/ecs/src/widgets/inspector/Inspector.svelte create mode 100644 plugins/ecs/src/widgets/inspector/LiveInspector.svelte create mode 100644 plugins/ecs/src/widgets/inspector/ParamField.svelte create mode 100644 plugins/ecs/src/widgets/scene/GameMoveOverlay.svelte create mode 100644 plugins/ecs/src/widgets/scene/SceneView.svelte create mode 100644 plugins/ecs/src/widgets/systems/Systems.svelte create mode 100644 plugins/ecs/src/worker/bundle-spawns.ts create mode 100644 plugins/ecs/src/worker/component-args.ts create mode 100644 plugins/ecs/src/worker/ensure-imports.ts create mode 100644 plugins/ecs/src/worker/entry-file.ts create mode 100644 plugins/ecs/src/worker/index.ts create mode 100644 plugins/ecs/src/worker/item-docs.ts create mode 100644 plugins/ecs/src/worker/owner.ts create mode 100644 plugins/ecs/src/worker/refactor.ts create mode 100644 plugins/ecs/src/worker/transform-helpers.ts create mode 100644 plugins/ecs/src/worker/transforms.ts create mode 100644 plugins/ecs/svelte.config.js create mode 100644 plugins/ecs/test/extension/field-editor.spec.ts create mode 100644 plugins/ecs/test/fixtures/bundle.js create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/client/nanoforge.config.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/client/src/components/example.component.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/client/src/main.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/client/src/systems/example.system.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/server/nanoforge.config.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/server/src/components/example.component.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/server/src/main.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/server/src/systems/example.system.ts create mode 100644 plugins/ecs/test/fixtures/e2e-game/apps/server/src/ticker.library.ts create mode 100644 plugins/ecs/test/fixtures/pong-game/apps/client/src/components/components.ts create mode 100644 plugins/ecs/test/fixtures/pong-game/apps/client/src/main.ts create mode 100644 plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/position.ts create mode 100644 plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/velocity.ts create mode 100644 plugins/ecs/test/fixtures/pong-game/tsconfig.json create mode 100644 plugins/ecs/test/fixtures/run-bundle.mjs create mode 100644 plugins/ecs/test/layout/param-layout.spec.ts create mode 100644 plugins/ecs/test/scene/build-scene.spec.ts create mode 100644 plugins/ecs/test/worker/__snapshots__/entry-file.spec.ts.snap create mode 100644 plugins/ecs/test/worker/bundle-spawns.spec.ts create mode 100644 plugins/ecs/test/worker/e2e-game.spec.ts create mode 100644 plugins/ecs/test/worker/entry-file.spec.ts create mode 100644 plugins/ecs/test/worker/item-docs.spec.ts create mode 100644 plugins/ecs/test/worker/refactor.spec.ts create mode 100644 plugins/ecs/tsconfig.json create mode 100644 plugins/ecs/vite.config.ts create mode 100644 plugins/file-manager/.prettierignore create mode 100644 plugins/file-manager/LICENSE create mode 100644 plugins/file-manager/README.md create mode 100644 plugins/file-manager/nanoforge.manifest.json create mode 100644 plugins/file-manager/package.json create mode 100644 plugins/file-manager/src/dialogs/preview/PreviewDialog.svelte create mode 100644 plugins/file-manager/src/filter/file-filter.ts create mode 100644 plugins/file-manager/src/icons.ts create mode 100644 plugins/file-manager/src/index.ts create mode 100644 plugins/file-manager/src/menu/file-menu.ts create mode 100644 plugins/file-manager/src/service/file-manager-service.ts create mode 100644 plugins/file-manager/src/service/file-manager-service.type.ts create mode 100644 plugins/file-manager/src/service/file-operations.ts create mode 100644 plugins/file-manager/src/session/file-manager-session.ts create mode 100644 plugins/file-manager/src/settings/file-manager-settings.const.ts create mode 100644 plugins/file-manager/src/widgets/files/FilesView.svelte create mode 100644 plugins/file-manager/src/widgets/files/GridView.svelte create mode 100644 plugins/file-manager/src/widgets/files/file-tree.ts create mode 100644 plugins/file-manager/svelte.config.js create mode 100644 plugins/file-manager/tsconfig.json create mode 100644 plugins/file-manager/vite.config.ts create mode 100644 plugins/git/.prettierignore create mode 100644 plugins/git/LICENSE create mode 100644 plugins/git/README.md create mode 100644 plugins/git/nanoforge.manifest.json create mode 100644 plugins/git/package.json create mode 100644 plugins/git/src/actions/create-actions.ts create mode 100644 plugins/git/src/dialogs/PushDialog.svelte create mode 100644 plugins/git/src/dialogs/open-push-dialog.ts create mode 100644 plugins/git/src/icons.ts create mode 100644 plugins/git/src/index.ts create mode 100644 plugins/git/src/model/branch-groups.ts create mode 100644 plugins/git/src/model/branch-label.ts create mode 100644 plugins/git/src/model/change-groups.ts create mode 100644 plugins/git/src/model/change-titles.const.ts create mode 100644 plugins/git/src/model/file-decoration.ts create mode 100644 plugins/git/src/model/relative-time.ts create mode 100644 plugins/git/src/session/git-session.ts create mode 100644 plugins/git/src/store/git-store.ts create mode 100644 plugins/git/src/widgets/ConfirmDialog.svelte create mode 100644 plugins/git/src/widgets/RepositoryGate.svelte create mode 100644 plugins/git/src/widgets/commit/ChangeGroupNode.svelte create mode 100644 plugins/git/src/widgets/commit/ChangesTab.svelte create mode 100644 plugins/git/src/widgets/commit/CommitMessage.svelte create mode 100644 plugins/git/src/widgets/commit/CommitView.svelte create mode 100644 plugins/git/src/widgets/commit/StashTab.svelte create mode 100644 plugins/git/src/widgets/commit/change-group.ts create mode 100644 plugins/git/src/widgets/commit/commit-panel-state.svelte.ts create mode 100644 plugins/git/src/widgets/confirmation.type.ts create mode 100644 plugins/git/src/widgets/log/BranchTree.svelte create mode 100644 plugins/git/src/widgets/log/CommitDetails.svelte create mode 100644 plugins/git/src/widgets/log/CommitTable.svelte create mode 100644 plugins/git/src/widgets/log/LogView.svelte create mode 100644 plugins/git/src/widgets/log/RefLabel.svelte create mode 100644 plugins/git/src/widgets/log/commit-format.ts create mode 100644 plugins/git/svelte.config.js create mode 100644 plugins/git/test/fixtures/status.ts create mode 100644 plugins/git/test/model/branch-groups.spec.ts create mode 100644 plugins/git/test/model/branch-label.spec.ts create mode 100644 plugins/git/test/model/change-groups.spec.ts create mode 100644 plugins/git/test/model/file-decoration.spec.ts create mode 100644 plugins/git/test/model/relative-time.spec.ts create mode 100644 plugins/git/test/store/git-store.spec.ts create mode 100644 plugins/git/tsconfig.json create mode 100644 plugins/git/vite.config.ts create mode 100644 plugins/history-panel/.prettierignore create mode 100644 plugins/history-panel/LICENSE create mode 100644 plugins/history-panel/README.md create mode 100644 plugins/history-panel/nanoforge.manifest.json create mode 100644 plugins/history-panel/package.json create mode 100644 plugins/history-panel/src/index.ts create mode 100644 plugins/history-panel/src/widgets/history/ContextSection.svelte create mode 100644 plugins/history-panel/src/widgets/history/HistoryView.svelte create mode 100644 plugins/history-panel/src/widgets/history/relative-time.ts create mode 100644 plugins/history-panel/svelte.config.js create mode 100644 plugins/history-panel/tsconfig.json create mode 100644 plugins/history-panel/vite.config.ts create mode 100644 plugins/inspectors/.prettierignore create mode 100644 plugins/inspectors/LICENSE create mode 100644 plugins/inspectors/README.md create mode 100644 plugins/inspectors/nanoforge.manifest.json create mode 100644 plugins/inspectors/package.json create mode 100644 plugins/inspectors/src/icons.ts create mode 100644 plugins/inspectors/src/index.ts create mode 100644 plugins/inspectors/src/network/packets.ts create mode 100644 plugins/inspectors/src/recorder/recorder.ts create mode 100644 plugins/inspectors/src/session/recorder-session.ts create mode 100644 plugins/inspectors/src/widgets/JsonTree.svelte create mode 100644 plugins/inspectors/src/widgets/SourcePicker.svelte create mode 100644 plugins/inspectors/src/widgets/chart/LineChart.svelte create mode 100644 plugins/inspectors/src/widgets/chart/chart-scale.ts create mode 100644 plugins/inspectors/src/widgets/follow.svelte.ts create mode 100644 plugins/inspectors/src/widgets/network/NetworkView.svelte create mode 100644 plugins/inspectors/src/widgets/network/PacketDetail.svelte create mode 100644 plugins/inspectors/src/widgets/network/ThroughputCharts.svelte create mode 100644 plugins/inspectors/src/widgets/profiler/ProfilerView.svelte create mode 100644 plugins/inspectors/src/widgets/world/WorldView.svelte create mode 100644 plugins/inspectors/src/world/world-query.ts create mode 100644 plugins/inspectors/svelte.config.js create mode 100644 plugins/inspectors/test/network/packets.spec.ts create mode 100644 plugins/inspectors/test/recorder/recorder.spec.ts create mode 100644 plugins/inspectors/test/widgets/chart/chart-scale.spec.ts create mode 100644 plugins/inspectors/test/world/world-query.spec.ts create mode 100644 plugins/inspectors/tsconfig.json create mode 100644 plugins/inspectors/vite.config.ts create mode 100644 plugins/packages/.prettierignore create mode 100644 plugins/packages/LICENSE create mode 100644 plugins/packages/README.md create mode 100644 plugins/packages/nanoforge.manifest.json create mode 100644 plugins/packages/package.json create mode 100644 plugins/packages/src/dialogs/packages/BrowseTab.svelte create mode 100644 plugins/packages/src/dialogs/packages/InstalledTab.svelte create mode 100644 plugins/packages/src/dialogs/packages/PackagesDialog.svelte create mode 100644 plugins/packages/src/dialogs/packages/open-packages-dialog.ts create mode 100644 plugins/packages/src/dialogs/packages/packages-dialog-state.svelte.ts create mode 100644 plugins/packages/src/index.ts create mode 100644 plugins/packages/src/model/installed-package.ts create mode 100644 plugins/packages/svelte.config.js create mode 100644 plugins/packages/test/model/installed-package.spec.ts create mode 100644 plugins/packages/tsconfig.json create mode 100644 plugins/packages/vite.config.ts create mode 100644 plugins/scene/.prettierignore create mode 100644 plugins/scene/LICENSE create mode 100644 plugins/scene/README.md create mode 100644 plugins/scene/nanoforge.manifest.json create mode 100644 plugins/scene/package.json create mode 100644 plugins/scene/src/extension/ecs-source.extension-point.ts create mode 100644 plugins/scene/src/icons.ts create mode 100644 plugins/scene/src/index.ts create mode 100644 plugins/scene/src/live/scene-live-service.ts create mode 100644 plugins/scene/src/model/scene-model.type.ts create mode 100644 plugins/scene/src/model/scene-params.ts create mode 100644 plugins/scene/src/model/scene.const.ts create mode 100644 plugins/scene/src/model/vars-model.type.ts create mode 100644 plugins/scene/src/service/saved-command.ts create mode 100644 plugins/scene/src/service/scene-history.const.ts create mode 100644 plugins/scene/src/service/scene-service.ts create mode 100644 plugins/scene/src/service/scene-step.type.ts create mode 100644 plugins/scene/src/service/scene-steps.ts create mode 100644 plugins/scene/src/template/scene-template.ts create mode 100644 plugins/scene/src/template/vars-template.ts create mode 100644 plugins/scene/src/widgets/scenes/Scenes.svelte create mode 100644 plugins/scene/src/widgets/scenes/scene-prompts.ts create mode 100644 plugins/scene/src/widgets/scenes/scene-tree.ts create mode 100644 plugins/scene/src/widgets/vars/VarRow.svelte create mode 100644 plugins/scene/src/widgets/vars/Vars.svelte create mode 100644 plugins/scene/src/widgets/vars/var-prompts.ts create mode 100644 plugins/scene/src/worker/edits.ts create mode 100644 plugins/scene/src/worker/index.ts create mode 100644 plugins/scene/src/worker/scenes.ts create mode 100644 plugins/scene/src/worker/vars.ts create mode 100644 plugins/scene/svelte.config.js create mode 100644 plugins/scene/test/fixtures/breakout.ts create mode 100644 plugins/scene/test/model/scene-params.spec.ts create mode 100644 plugins/scene/test/worker/edits.spec.ts create mode 100644 plugins/scene/test/worker/scenes.spec.ts create mode 100644 plugins/scene/test/worker/vars.spec.ts create mode 100644 plugins/scene/tsconfig.json create mode 100644 plugins/scene/vite.config.ts create mode 100644 plugins/settings-ui/.prettierignore create mode 100644 plugins/settings-ui/LICENSE create mode 100644 plugins/settings-ui/README.md create mode 100644 plugins/settings-ui/nanoforge.manifest.json create mode 100644 plugins/settings-ui/package.json create mode 100644 plugins/settings-ui/src/dialogs/settings/ConflictsPage.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/ImportExportPage.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/SettingRow.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/open-settings-dialog.ts create mode 100644 plugins/settings-ui/src/dialogs/settings/plugins/InstalledPluginsTab.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/plugins/MarketplaceTab.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/plugins/PluginsPage.svelte create mode 100644 plugins/settings-ui/src/dialogs/settings/plugins/plugins-page-state.svelte.ts create mode 100644 plugins/settings-ui/src/dialogs/settings/settings-page-api.ts create mode 100644 plugins/settings-ui/src/dialogs/settings/settings-tree.ts create mode 100644 plugins/settings-ui/src/dialogs/settings/sync-status.const.ts create mode 100644 plugins/settings-ui/src/index.ts create mode 100644 plugins/settings-ui/src/marketplace/account-plugins.ts create mode 100644 plugins/settings-ui/src/marketplace/marketplace.ts create mode 100644 plugins/settings-ui/src/settings/setting-categories.ts create mode 100644 plugins/settings-ui/src/settings/setting-control.ts create mode 100644 plugins/settings-ui/src/settings/settings-draft.ts create mode 100644 plugins/settings-ui/svelte.config.js create mode 100644 plugins/settings-ui/test/marketplace/marketplace.spec.ts create mode 100644 plugins/settings-ui/tsconfig.json create mode 100644 plugins/settings-ui/vite.config.ts create mode 100644 plugins/viewport/.prettierignore create mode 100644 plugins/viewport/LICENSE create mode 100644 plugins/viewport/README.md create mode 100644 plugins/viewport/nanoforge.manifest.json create mode 100644 plugins/viewport/package.json create mode 100644 plugins/viewport/src/game/popout.ts create mode 100644 plugins/viewport/src/game/resolutions.ts create mode 100644 plugins/viewport/src/game/screenshot.ts create mode 100644 plugins/viewport/src/icons.ts create mode 100644 plugins/viewport/src/index.ts create mode 100644 plugins/viewport/src/settings/viewport-settings.const.ts create mode 100644 plugins/viewport/src/toolbar/viewport-tools.const.ts create mode 100644 plugins/viewport/src/widgets/ViewportToolbar.svelte create mode 100644 plugins/viewport/src/widgets/game/GameScreen.svelte create mode 100644 plugins/viewport/src/widgets/game/StatsOverlay.svelte create mode 100644 plugins/viewport/src/widgets/scene/SceneScreen.svelte create mode 100644 plugins/viewport/svelte.config.js create mode 100644 plugins/viewport/tsconfig.json create mode 100644 plugins/viewport/vite.config.ts delete mode 100644 project.inlang/settings.json create mode 100644 scripts/engine-bridge.mjs create mode 100644 scripts/example.mjs delete mode 100644 src/app.css delete mode 100644 src/app.d.ts delete mode 100644 src/demo.spec.ts delete mode 100644 src/hooks.server.ts delete mode 100644 src/hooks.ts delete mode 100644 src/lib/assets/Tools/play.png delete mode 100644 src/lib/assets/defaultProjectCover.png delete mode 100644 src/lib/assets/logo.png delete mode 100644 src/lib/client/action/base.repository.ts delete mode 100644 src/lib/client/action/client.ts delete mode 100644 src/lib/client/action/index.ts delete mode 100644 src/lib/client/action/repositories/archive.repository.ts delete mode 100644 src/lib/client/action/repositories/fs.repository.ts delete mode 100644 src/lib/client/action/repositories/library.repository.ts delete mode 100644 src/lib/client/action/repositories/loader.repository.ts delete mode 100644 src/lib/client/action/repositories/package.repository.ts delete mode 100644 src/lib/client/action/repositories/project.repository.ts delete mode 100644 src/lib/client/action/repositories/save.repository.ts delete mode 100644 src/lib/client/action/types/archive.type.ts delete mode 100644 src/lib/client/action/types/fs.type.ts delete mode 100644 src/lib/client/action/types/index.ts delete mode 100644 src/lib/client/action/types/library.type.ts delete mode 100644 src/lib/client/action/types/loader.type.ts delete mode 100644 src/lib/client/action/types/package.type.ts delete mode 100644 src/lib/client/action/types/project.type.ts delete mode 100644 src/lib/client/action/types/save.type.ts delete mode 100644 src/lib/client/config/config.ts delete mode 100644 src/lib/client/config/config.type.ts delete mode 100644 src/lib/client/config/index.ts delete mode 100644 src/lib/client/ecs/asset/asset-handle.ts delete mode 100644 src/lib/client/ecs/asset/asset-manager.ts delete mode 100644 src/lib/client/ecs/asset/asset.type.ts delete mode 100644 src/lib/client/ecs/component/component-handle.ts delete mode 100644 src/lib/client/ecs/component/component-manager.ts delete mode 100644 src/lib/client/ecs/component/component.type.ts delete mode 100644 src/lib/client/ecs/ecs-handler.ts delete mode 100644 src/lib/client/ecs/index.ts delete mode 100644 src/lib/client/ecs/library/library-handle.ts delete mode 100644 src/lib/client/ecs/library/library-manager.ts delete mode 100644 src/lib/client/ecs/library/library.type.ts delete mode 100644 src/lib/client/ecs/scene/entity/component/component-handle.ts delete mode 100644 src/lib/client/ecs/scene/entity/component/component-manager.ts delete mode 100644 src/lib/client/ecs/scene/entity/component/component-param-handle.ts delete mode 100644 src/lib/client/ecs/scene/entity/component/component-param-manager.ts delete mode 100644 src/lib/client/ecs/scene/entity/entity-handle.ts delete mode 100644 src/lib/client/ecs/scene/entity/entity-manager.ts delete mode 100644 src/lib/client/ecs/scene/entity/entity.type.ts delete mode 100644 src/lib/client/ecs/scene/scene-handle.ts delete mode 100644 src/lib/client/ecs/scene/scene-manager.ts delete mode 100644 src/lib/client/ecs/scene/scene.type.ts delete mode 100644 src/lib/client/ecs/scene/system/system-handle.ts delete mode 100644 src/lib/client/ecs/scene/system/system-manager.ts delete mode 100644 src/lib/client/ecs/system/system-handle.ts delete mode 100644 src/lib/client/ecs/system/system-manager.ts delete mode 100644 src/lib/client/ecs/system/system.type.ts delete mode 100644 src/lib/client/ecs/transformers.ts delete mode 100644 src/lib/client/ecs/utils.ts delete mode 100644 src/lib/client/event/event-emitter.ts delete mode 100644 src/lib/client/event/event-handler.ts delete mode 100644 src/lib/client/event/events/core-events.ts delete mode 100644 src/lib/client/event/events/editor-events.ts delete mode 100644 src/lib/client/event/index.ts delete mode 100644 src/lib/client/event/listeners/move-component.listener.ts delete mode 100644 src/lib/client/event/types/event-emitter.type.ts delete mode 100644 src/lib/client/event/types/index.ts delete mode 100644 src/lib/client/info/index.ts delete mode 100644 src/lib/client/info/info.store.ts delete mode 100644 src/lib/client/info/info.ts delete mode 100644 src/lib/client/info/info.type.ts delete mode 100644 src/lib/client/loader/index.ts delete mode 100644 src/lib/client/loader/loader.ts delete mode 100644 src/lib/client/loader/types/event-emitter.type.ts delete mode 100644 src/lib/client/loader/types/game.type.ts delete mode 100644 src/lib/client/loader/types/manifest.type.ts delete mode 100644 src/lib/client/project/index.ts delete mode 100644 src/lib/client/project/package-handler.ts delete mode 100644 src/lib/client/project/project-loader/exceptions.ts delete mode 100644 src/lib/client/project/project-loader/init-functions.ts delete mode 100644 src/lib/client/project/project-loader/project-cache.ts delete mode 100644 src/lib/client/project/project-loader/project-loader.ts delete mode 100644 src/lib/client/project/project.ts delete mode 100644 src/lib/client/project/save-handler.ts delete mode 100644 src/lib/client/sync-file-system/index.ts delete mode 100644 src/lib/client/sync-file-system/sfs-directory.ts delete mode 100644 src/lib/client/sync-file-system/sfs-file.ts delete mode 100644 src/lib/client/sync-file-system/sfs-tree-cache.ts delete mode 100644 src/lib/client/sync-file-system/sfs.ts delete mode 100644 src/lib/client/utils/error.ts delete mode 100644 src/lib/client/utils/file-system/file-system-directory.ts delete mode 100644 src/lib/client/utils/file-system/file-system-file.ts delete mode 100644 src/lib/client/utils/file-system/file-system-handler.ts delete mode 100644 src/lib/client/utils/file-system/file-system-manager.ts delete mode 100644 src/lib/client/utils/file-system/index.ts delete mode 100644 src/lib/client/utils/indexed-db/idb-object.ts delete mode 100644 src/lib/client/utils/indexed-db/idb.ts delete mode 100644 src/lib/client/utils/indexed-db/index.ts delete mode 100644 src/lib/client/utils/store.ts delete mode 100644 src/lib/client/utils/toasts.ts delete mode 100644 src/lib/client/utils/window.ts delete mode 100644 src/lib/components/Menu/MenuBar.svelte delete mode 100644 src/lib/components/Menu/MenuButton.svelte delete mode 100644 src/lib/components/Menu/MenuItem.svelte delete mode 100644 src/lib/components/Menu/export-dialog.svelte delete mode 100644 src/lib/components/Panel/Layout.svelte delete mode 100644 src/lib/components/Panel/Panel.svelte delete mode 100644 src/lib/components/Panel/Widget.svelte delete mode 100644 src/lib/components/Panel/WidgetResizeBar.svelte delete mode 100644 src/lib/components/Panel/types.ts delete mode 100644 src/lib/components/Panel/utils.ts delete mode 100644 src/lib/components/Tabs/CodeEditor/CodeEditor.svelte delete mode 100644 src/lib/components/Tabs/MainTab/MainTab.svelte delete mode 100644 src/lib/components/Tabs/Tab.svelte delete mode 100644 src/lib/components/Tabs/TabBar.svelte delete mode 100644 src/lib/components/Tabs/registry.ts delete mode 100644 src/lib/components/Tabs/store.ts delete mode 100644 src/lib/components/Tabs/types.ts delete mode 100644 src/lib/components/Widget/CodeEditor/MonacoEditor.svelte delete mode 100644 src/lib/components/Widget/CodeEditor/project-loader.ts delete mode 100644 src/lib/components/Widget/EditorGame/EditorGame.svelte delete mode 100644 src/lib/components/Widget/EditorGame/game.svelte.ts delete mode 100644 src/lib/components/Widget/OutputLog/OutputLogWidget.svelte delete mode 100644 src/lib/components/Widget/content-browser/content-item.svelte delete mode 100644 src/lib/components/Widget/content-browser/content-list.svelte delete mode 100644 src/lib/components/Widget/content-browser/header.svelte delete mode 100644 src/lib/components/Widget/content-browser/icon.svelte delete mode 100644 src/lib/components/Widget/content-browser/index.ts delete mode 100644 src/lib/components/Widget/content-browser/side-item.svelte delete mode 100644 src/lib/components/Widget/content-browser/side-list.svelte delete mode 100644 src/lib/components/Widget/content-browser/skeleton.svelte delete mode 100644 src/lib/components/Widget/content-browser/store.ts delete mode 100644 src/lib/components/Widget/content-browser/types.ts delete mode 100644 src/lib/components/Widget/content-browser/utils.ts delete mode 100644 src/lib/components/Widget/content-browser/widget.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/assets/asset.items.ts delete mode 100644 src/lib/components/Widget/ecs-tree/assets/assets-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/assets/dialog-create-asset.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/components/component.items.ts delete mode 100644 src/lib/components/Widget/ecs-tree/components/components-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/const.ts delete mode 100644 src/lib/components/Widget/ecs-tree/dialogs/dialog-name.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/dialogs/dialog-select.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/index.ts delete mode 100644 src/lib/components/Widget/ecs-tree/libraries/libraries-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/libraries/library.items.ts delete mode 100644 src/lib/components/Widget/ecs-tree/packages/dialog-create-package.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/packages/dialog-import-package.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/packages/package-row.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/packages/package-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/dialog-edit-scene.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/dialog-new-scene.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/dialog-delete-entity.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/dialog-edit-entity.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/dialog-new-entity.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree-node.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/entities/folder-tree-node.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/scene-detail.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/scene-list.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/scene-row.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/scenes-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/systems/dialog-add-scene-system.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-list.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-row.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/systems/system.items.ts delete mode 100644 src/lib/components/Widget/ecs-tree/systems/systems-tab.svelte delete mode 100644 src/lib/components/Widget/ecs-tree/types.ts delete mode 100644 src/lib/components/Widget/ecs-tree/utils.ts delete mode 100644 src/lib/components/Widget/ecs-tree/widget.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/component-list.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/component-param-list.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/component-param-row.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/component-row.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/dialog-component-selector.svelte delete mode 100644 src/lib/components/Widget/entity-inspector/index.ts delete mode 100644 src/lib/components/Widget/entity-inspector/widget.svelte delete mode 100644 src/lib/components/Widget/widgets.ts delete mode 100644 src/lib/components/dialogs/base-dialog.svelte delete mode 100644 src/lib/components/dialogs/delete-confirm-dialog.svelte delete mode 100644 src/lib/components/dialogs/index.ts delete mode 100644 src/lib/components/dialogs/input-dialog.svelte delete mode 100644 src/lib/components/forms/Form.svelte delete mode 100644 src/lib/components/forms/FormField.svelte delete mode 100644 src/lib/components/forms/form.svelte.ts delete mode 100644 src/lib/components/forms/form.type.ts delete mode 100644 src/lib/components/forms/inputs/FormFieldInput.svelte delete mode 100644 src/lib/components/forms/inputs/FormFieldMultiSelect.svelte delete mode 100644 src/lib/components/forms/inputs/FormFieldSelect.svelte delete mode 100644 src/lib/components/forms/inputs/FormFieldSwitch.svelte delete mode 100644 src/lib/components/marketplace/index.ts delete mode 100644 src/lib/components/marketplace/marketplace-dialog.svelte delete mode 100644 src/lib/components/marketplace/marketplace-footer.svelte delete mode 100644 src/lib/components/marketplace/marketplace-package-details.svelte delete mode 100644 src/lib/components/marketplace/marketplace-package-item.svelte delete mode 100644 src/lib/components/marketplace/marketplace-package-list.svelte delete mode 100644 src/lib/components/marketplace/marketplace-package-tabs.svelte delete mode 100644 src/lib/components/marketplace/marketplace-search.svelte delete mode 100644 src/lib/components/marketplace/marketplace.context.ts delete mode 100644 src/lib/components/project-loader/full-page-project-spinner.svelte delete mode 100644 src/lib/components/project-loader/index.ts delete mode 100644 src/lib/components/project-loader/progress-bar.svelte delete mode 100644 src/lib/components/project-loader/project-spinner.svelte delete mode 100644 src/lib/components/ui/badge/badge.svelte delete mode 100644 src/lib/components/ui/badge/index.ts delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-item.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-link.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-list.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-page.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte delete mode 100644 src/lib/components/ui/breadcrumb/breadcrumb.svelte delete mode 100644 src/lib/components/ui/breadcrumb/index.ts delete mode 100644 src/lib/components/ui/button/button.svelte delete mode 100644 src/lib/components/ui/button/index.ts delete mode 100644 src/lib/components/ui/card/card-action.svelte delete mode 100644 src/lib/components/ui/card/card-content.svelte delete mode 100644 src/lib/components/ui/card/card-description.svelte delete mode 100644 src/lib/components/ui/card/card-footer.svelte delete mode 100644 src/lib/components/ui/card/card-header.svelte delete mode 100644 src/lib/components/ui/card/card-title.svelte delete mode 100644 src/lib/components/ui/card/card.svelte delete mode 100644 src/lib/components/ui/card/index.ts delete mode 100644 src/lib/components/ui/checkbox/checkbox.svelte delete mode 100644 src/lib/components/ui/checkbox/index.ts delete mode 100644 src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-content.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-group-heading.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-group.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-item.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-label.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-portal.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-radio-group.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-radio-item.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-separator.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-shortcut.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-sub-content.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-sub.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu-trigger.svelte delete mode 100644 src/lib/components/ui/context-menu/context-menu.svelte delete mode 100644 src/lib/components/ui/context-menu/index.ts delete mode 100644 src/lib/components/ui/dialog/dialog-close.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-content.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-description.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-footer.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-header.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-overlay.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-portal.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-title.svelte delete mode 100644 src/lib/components/ui/dialog/dialog-trigger.svelte delete mode 100644 src/lib/components/ui/dialog/dialog.svelte delete mode 100644 src/lib/components/ui/dialog/index.ts delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu.svelte delete mode 100644 src/lib/components/ui/dropdown-menu/index.ts delete mode 100644 src/lib/components/ui/form/form-button.svelte delete mode 100644 src/lib/components/ui/form/form-description.svelte delete mode 100644 src/lib/components/ui/form/form-element-field.svelte delete mode 100644 src/lib/components/ui/form/form-field-errors.svelte delete mode 100644 src/lib/components/ui/form/form-field.svelte delete mode 100644 src/lib/components/ui/form/form-fieldset.svelte delete mode 100644 src/lib/components/ui/form/form-label.svelte delete mode 100644 src/lib/components/ui/form/form-legend.svelte delete mode 100644 src/lib/components/ui/form/index.ts delete mode 100644 src/lib/components/ui/input-group/index.ts delete mode 100644 src/lib/components/ui/input-group/input-group-addon.svelte delete mode 100644 src/lib/components/ui/input-group/input-group-button.svelte delete mode 100644 src/lib/components/ui/input-group/input-group-input.svelte delete mode 100644 src/lib/components/ui/input-group/input-group-text.svelte delete mode 100644 src/lib/components/ui/input-group/input-group-textarea.svelte delete mode 100644 src/lib/components/ui/input-group/input-group.svelte delete mode 100644 src/lib/components/ui/input/index.ts delete mode 100644 src/lib/components/ui/input/input.svelte delete mode 100644 src/lib/components/ui/kbd/index.ts delete mode 100644 src/lib/components/ui/kbd/kbd-group.svelte delete mode 100644 src/lib/components/ui/kbd/kbd.svelte delete mode 100644 src/lib/components/ui/label/index.ts delete mode 100644 src/lib/components/ui/label/label.svelte delete mode 100644 src/lib/components/ui/loading-button/index.ts delete mode 100644 src/lib/components/ui/loading-button/loading-button.svelte delete mode 100644 src/lib/components/ui/multi-select/index.ts delete mode 100644 src/lib/components/ui/multi-select/multi-select.svelte delete mode 100644 src/lib/components/ui/progress/index.ts delete mode 100644 src/lib/components/ui/progress/progress.svelte delete mode 100644 src/lib/components/ui/scroll-area/index.ts delete mode 100644 src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte delete mode 100644 src/lib/components/ui/scroll-area/scroll-area.svelte delete mode 100644 src/lib/components/ui/select/index.ts delete mode 100644 src/lib/components/ui/select/select-content.svelte delete mode 100644 src/lib/components/ui/select/select-group-heading.svelte delete mode 100644 src/lib/components/ui/select/select-group.svelte delete mode 100644 src/lib/components/ui/select/select-item.svelte delete mode 100644 src/lib/components/ui/select/select-label.svelte delete mode 100644 src/lib/components/ui/select/select-portal.svelte delete mode 100644 src/lib/components/ui/select/select-scroll-down-button.svelte delete mode 100644 src/lib/components/ui/select/select-scroll-up-button.svelte delete mode 100644 src/lib/components/ui/select/select-separator.svelte delete mode 100644 src/lib/components/ui/select/select-trigger.svelte delete mode 100644 src/lib/components/ui/select/select.svelte delete mode 100644 src/lib/components/ui/separator/index.ts delete mode 100644 src/lib/components/ui/separator/separator.svelte delete mode 100644 src/lib/components/ui/skeleton/index.ts delete mode 100644 src/lib/components/ui/skeleton/skeleton.svelte delete mode 100644 src/lib/components/ui/sonner/index.ts delete mode 100644 src/lib/components/ui/sonner/sonner.svelte delete mode 100644 src/lib/components/ui/spinner/index.ts delete mode 100644 src/lib/components/ui/spinner/spinner.svelte delete mode 100644 src/lib/components/ui/switch/index.ts delete mode 100644 src/lib/components/ui/switch/switch.svelte delete mode 100644 src/lib/components/ui/tabs/index.ts delete mode 100644 src/lib/components/ui/tabs/tabs-content.svelte delete mode 100644 src/lib/components/ui/tabs/tabs-list.svelte delete mode 100644 src/lib/components/ui/tabs/tabs-trigger.svelte delete mode 100644 src/lib/components/ui/tabs/tabs.svelte delete mode 100644 src/lib/components/ui/textarea/index.ts delete mode 100644 src/lib/components/ui/textarea/textarea.svelte delete mode 100644 src/lib/components/ui/tooltip-text/index.ts delete mode 100644 src/lib/components/ui/tooltip-text/tooltip-text.svelte delete mode 100644 src/lib/components/ui/tooltip/index.ts delete mode 100644 src/lib/components/ui/tooltip/tooltip-content.svelte delete mode 100644 src/lib/components/ui/tooltip/tooltip-portal.svelte delete mode 100644 src/lib/components/ui/tooltip/tooltip-provider.svelte delete mode 100644 src/lib/components/ui/tooltip/tooltip-trigger.svelte delete mode 100644 src/lib/components/ui/tooltip/tooltip.svelte delete mode 100644 src/lib/components/ui/tristate-switch/index.ts delete mode 100644 src/lib/components/ui/tristate-switch/tristate-switch.svelte delete mode 100644 src/lib/server/actions/project/archive/create.action.ts delete mode 100644 src/lib/server/actions/project/complete.action.ts delete mode 100644 src/lib/server/actions/project/fs/readdir.action.ts delete mode 100644 src/lib/server/actions/project/gateway.action.ts delete mode 100644 src/lib/server/actions/project/info.action.ts delete mode 100644 src/lib/server/actions/project/library/install-library.action.ts delete mode 100644 src/lib/server/actions/project/load.action.ts delete mode 100644 src/lib/server/actions/project/loader/build.action.ts delete mode 100644 src/lib/server/actions/project/loader/env.action.ts delete mode 100644 src/lib/server/actions/project/loader/manifest.action.ts delete mode 100644 src/lib/server/actions/project/new.action.ts delete mode 100644 src/lib/server/actions/project/package/create-component.action.ts delete mode 100644 src/lib/server/actions/project/package/create-system.action.ts delete mode 100644 src/lib/server/actions/project/package/get-assets.action.ts delete mode 100644 src/lib/server/actions/project/package/get-components.action.ts delete mode 100644 src/lib/server/actions/project/package/get-systems.action.ts delete mode 100644 src/lib/server/actions/project/package/install-packages.action.ts delete mode 100644 src/lib/server/actions/project/package/search-packages.action.ts delete mode 100644 src/lib/server/actions/project/save/get-save.action.ts delete mode 100644 src/lib/server/actions/project/save/set-save.action.ts delete mode 100644 src/lib/server/api/base.repository.ts delete mode 100644 src/lib/server/api/client.ts delete mode 100644 src/lib/server/api/index.ts delete mode 100644 src/lib/server/api/middlewares/refresh-token.middleware.ts delete mode 100644 src/lib/server/api/repositories/auth.repository.ts delete mode 100644 src/lib/server/api/repositories/package.repository.ts delete mode 100644 src/lib/server/api/repositories/projects.repository.ts delete mode 100644 src/lib/server/api/repositories/registry.repository.ts delete mode 100644 src/lib/server/api/types/auth.type.ts delete mode 100644 src/lib/server/api/types/index.ts delete mode 100644 src/lib/server/api/types/package.type.ts delete mode 100644 src/lib/server/api/types/paginate.type.ts delete mode 100644 src/lib/server/api/types/project.type.ts delete mode 100644 src/lib/server/api/types/registry.type.ts delete mode 100644 src/lib/server/cli/cli-defaults.ts delete mode 100644 src/lib/server/cli/cli-error.ts delete mode 100644 src/lib/server/cli/cli.ts delete mode 100644 src/lib/server/cli/cli.type.ts delete mode 100644 src/lib/server/cli/index.ts delete mode 100644 src/lib/server/file-system/archive.ts delete mode 100644 src/lib/server/file-system/file-system-error.ts delete mode 100644 src/lib/server/file-system/file-system.functions.ts delete mode 100644 src/lib/server/file-system/file-system.ts delete mode 100644 src/lib/server/file-system/index.ts delete mode 100644 src/lib/server/file-system/project-directory.ts delete mode 100644 src/lib/server/file-system/project-file.ts delete mode 100644 src/lib/server/git/git.ts delete mode 100644 src/lib/server/git/index.ts delete mode 100644 src/lib/server/project/index.ts delete mode 100644 src/lib/server/project/library/index.ts delete mode 100644 src/lib/server/project/library/library-handler.ts delete mode 100644 src/lib/server/project/library/library.type.ts delete mode 100644 src/lib/server/project/load-project.ts delete mode 100644 src/lib/server/project/loader/env.ts delete mode 100644 src/lib/server/project/loader/index.ts delete mode 100644 src/lib/server/project/loader/loader.ts delete mode 100644 src/lib/server/project/loader/types.ts delete mode 100644 src/lib/server/project/package/index.ts delete mode 100644 src/lib/server/project/package/manifest/manifest-handler.ts delete mode 100644 src/lib/server/project/package/manifest/manifest-resolver.ts delete mode 100644 src/lib/server/project/package/manifest/manifest.type.ts delete mode 100644 src/lib/server/project/package/package-handler.ts delete mode 100644 src/lib/server/project/package/package.const.ts delete mode 100644 src/lib/server/project/package/package.enum.ts delete mode 100644 src/lib/server/project/package/package.type.ts delete mode 100644 src/lib/server/project/project-handler.ts delete mode 100644 src/lib/server/project/project-manager.ts delete mode 100644 src/lib/server/project/project.type.ts delete mode 100644 src/lib/server/project/save/save-handler.ts delete mode 100644 src/lib/server/session/index.ts delete mode 100644 src/lib/server/session/project/project-functions.ts delete mode 100644 src/lib/server/session/project/project.store.ts delete mode 100644 src/lib/server/session/project/project.type.ts delete mode 100644 src/lib/server/session/session-functions.ts delete mode 100644 src/lib/server/session/session-handle.ts delete mode 100644 src/lib/server/session/session.const.ts delete mode 100644 src/lib/server/session/session.store.ts delete mode 100644 src/lib/server/session/session.type.ts delete mode 100644 src/lib/server/utils/cookies/index.ts delete mode 100644 src/lib/server/utils/request-handler/action-handler.ts delete mode 100644 src/lib/server/utils/request-handler/body.ts delete mode 100644 src/lib/server/utils/request-handler/context.ts delete mode 100644 src/lib/server/utils/request-handler/handler.ts delete mode 100644 src/lib/server/utils/request-handler/index.ts delete mode 100644 src/lib/server/utils/request-handler/request-handler.ts delete mode 100644 src/lib/server/utils/request-handler/request.policy.ts delete mode 100644 src/lib/server/utils/request-handler/types.ts delete mode 100644 src/lib/server/utils/string.ts delete mode 100644 src/lib/server/utils/validators/index.ts delete mode 100644 src/lib/server/utils/validators/is-string-or-false.validator.ts delete mode 100644 src/lib/utils/const.ts delete mode 100644 src/lib/utils/exception.ts delete mode 100644 src/lib/utils/file.ts delete mode 100644 src/lib/utils/format.ts delete mode 100644 src/lib/utils/http/client.ts delete mode 100644 src/lib/utils/http/index.ts delete mode 100644 src/lib/utils/http/query.ts delete mode 100644 src/lib/utils/null.ts delete mode 100644 src/lib/utils/object.ts delete mode 100644 src/lib/utils/string.ts delete mode 100644 src/lib/utils/types/base.type.ts delete mode 100644 src/lib/utils/types/class.type.ts delete mode 100644 src/lib/utils/types/index.ts delete mode 100644 src/lib/utils/types/part.type.ts delete mode 100644 src/lib/utils/types/promise.type.ts delete mode 100644 src/lib/utils/types/save.type.ts delete mode 100644 src/lib/utils/ui.ts delete mode 100644 src/routes/+layout.svelte delete mode 100644 src/routes/+page.svelte delete mode 100644 src/routes/actions/project/+page.server.ts delete mode 100644 src/routes/actions/project/archive/+page.server.ts delete mode 100644 src/routes/actions/project/fs/+page.server.ts delete mode 100644 src/routes/actions/project/library/+page.server.ts delete mode 100644 src/routes/actions/project/loader/+page.server.ts delete mode 100644 src/routes/actions/project/package/+page.server.ts delete mode 100644 src/routes/actions/project/save/+page.server.ts delete mode 100644 src/routes/components/cache-project-list-skeleton.svelte delete mode 100644 src/routes/components/cache-project.svelte delete mode 100644 src/routes/components/create-project-dialog.svelte delete mode 100644 src/routes/components/create-project-form.svelte delete mode 100644 src/routes/components/header.svelte delete mode 100644 src/routes/components/index.ts delete mode 100644 src/routes/components/offline-project-buttons.svelte delete mode 100644 src/routes/components/online-project-buttons.svelte delete mode 100644 src/routes/components/open-project-dialog.svelte delete mode 100644 src/routes/components/open-project-form.svelte delete mode 100644 src/routes/dashboard/+page.svelte delete mode 100644 src/routes/fs/archive/[slug]/+server.ts delete mode 100644 src/routes/fs/build/+server.ts delete mode 100644 src/routes/fs/project/+server.ts delete mode 100644 src/routes/load/+page.svelte delete mode 100644 src/routes/load/components/complete-project-form.svelte delete mode 100644 svelte.config.js create mode 100644 tooling/example-plugin/.prettierignore create mode 100644 tooling/example-plugin/LICENSE create mode 100644 tooling/example-plugin/README.md create mode 100644 tooling/example-plugin/nanoforge.manifest.json create mode 100644 tooling/example-plugin/package.json create mode 100644 tooling/example-plugin/src/index.ts create mode 100644 tooling/example-plugin/vite.config.ts create mode 100644 tooling/vite-plugin/.cliff-jumperrc.json create mode 100644 tooling/vite-plugin/.prettierignore create mode 100644 tooling/vite-plugin/CHANGELOG.md create mode 100644 tooling/vite-plugin/LICENSE create mode 100644 tooling/vite-plugin/README.md create mode 100644 tooling/vite-plugin/cliff.toml create mode 100644 tooling/vite-plugin/package.json create mode 100644 tooling/vite-plugin/src/index.ts create mode 100644 tooling/vite-plugin/src/manifest/build-manifest.ts create mode 100644 tooling/vite-plugin/src/plugin/nanoforge-editor-plugin.ts create mode 100644 tooling/vite-plugin/src/rewrite/rewrite-shared-imports.ts create mode 100644 tooling/vite-plugin/src/shared-module/shared-modules.ts create mode 100644 tooling/vite-plugin/test/manifest/build-manifest.spec.ts create mode 100644 tooling/vite-plugin/test/rewrite/rewrite-shared-imports.spec.ts create mode 100644 tooling/vite-plugin/tsconfig.json create mode 100644 tooling/vite-plugin/tsdown.config.ts create mode 100644 turbo.json delete mode 100644 uno.config.ts delete mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..51a4595b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/dist +**/.svelte-kit +**/.turbo +**/coverage +.git +.idea +.env diff --git a/.env.example b/.env.example index 296d687c..bea778d9 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,32 @@ -# Set to production +# production | development NODE_ENV=development -# OFFLINE or ONLINE, change editor mode beetween local execution and online execution +# OFFLINE: local projects on disk (binds to 127.0.0.1). ONLINE: hosted, projects synced with git gateways. PUBLIC_MODE=OFFLINE -# Urls -PUBLIC_PM_URL=https://projects.nanoforge.eu -PUBLIC_DOCS_URL=https://docs.nanoforge.eu -PUBLIC_LANDING_URL=https://nanoforge.eu - -# Api params (required if PUBLIC_MODE=ONLINE) -API_URL=http://localhost:3000 -API_KEY=test +# Server +# HOST=127.0.0.1 +PORT=3000 -# Fs root dir read/write projects (leave empty to select current directory) (if you use relative path, it will be /) +# Projects must live under this directory (default: current directory) FS_ROOT= -# Archive root dir read/write archive (leave empty to select current directory) (if you use relative path, it will be /) -# Shouldn't be in FS_ROOT to avoid name conflicts -ARCHIVE_ROOT= +# Editor data (recent projects, installed plugins). Default: ~/.nanoforge/editor +DATA_DIR= -# Leaving this empty will generate a new unique random session secret at start +# NanoForge API (API_KEY required when PUBLIC_MODE=ONLINE) +API_URL=https://api.nanoforge.eu +API_KEY= +# Where ONLINE users sign in (the website sets the auth cookies) +PUBLIC_PM_URL=https://projects.nanoforge.eu + +# Leave empty to generate a random secret at start (sessions are lost on restart) SESSION_SECRET= -# Change if your nf cli executable isn't in the path -NF_CLI_PATH=nf +# Change if the binaries are not in the PATH (nf: the project's own node_modules/.bin is tried first) +NF_CLI_PATH= +GIT_PATH= + +# Comma separated list of local plugin folders under development (their dist/ is served, hot reloaded) +DEV_PLUGINS= +# Extra origins allowed to call the server (reverse proxies) +ALLOWED_ORIGINS= diff --git a/.github/ISSUE_TEMPLATE/01-command_bug_report.yml b/.github/ISSUE_TEMPLATE/01-packages_bug_report.yml similarity index 76% rename from .github/ISSUE_TEMPLATE/01-command_bug_report.yml rename to .github/ISSUE_TEMPLATE/01-packages_bug_report.yml index f75ac748..4dc72529 100644 --- a/.github/ISSUE_TEMPLATE/01-command_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/01-packages_bug_report.yml @@ -5,9 +5,42 @@ body: - type: markdown attributes: value: | - Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/editor/discussions. + Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/Editor/discussions. This issue form is for Nanoforge Editor. + - type: dropdown + id: package + attributes: + label: Which package is this bug report for? + options: + - editor + - code + - history + - kernel + - layout + - meta + - project + - protocol + - registry + - rpc + - runtime + - sdk + - server-core + - settings + - ui + - code-editor + - command-palette + - console + - ecs + - file-manager + - git + - history-panel + - inspectors + - packages + - scene + - settings-ui + - viewport + - other validations: required: true - type: textarea @@ -41,7 +74,7 @@ body: description: List necessary versions here. This includes your package version, runtime version, operating system etc. placeholder: | - @nanoforge-dev/editor 1.0.0 (`npm ls @nanoforge-dev/editor`) - - Node.js 25.x.x (`node --version`) + - Node.js 26 (`node --version`) - TypeScript 5.9.3 (`npm ls typescript` if you use it) - macOS Ventura 13.3.1 validations: diff --git a/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml new file mode 100644 index 00000000..9b7dbbc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report on tooling +description: Report an issue with Nanoforge Editor tooling. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/Editor/discussions. + + This issue form is for Nanoforge Editor tooling. + - type: dropdown + id: package + attributes: + label: Which tooling is this bug report for? + options: + - example-plugin + - vite-plugin + - other + validations: + required: true + - type: textarea + id: description + attributes: + label: Issue description + description: Describe the issue in as much detail as possible. + placeholder: | + Steps to reproduce with below code sample: + 1. Do thing + 2. Build your plugin + 3. Observe behavior + 4. See error logs below + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: List necessary versions here. This includes your package version, runtime version, operating system etc. + placeholder: | + - @nanoforge-dev/editor-vite-plugin 1.0.0 (`npm ls @nanoforge-dev/editor-vite-plugin`) + - Node.js 26 (`node --version`) + - TypeScript 6.0.3 (`npm ls typescript` if you use it) + - macOS Ventura 13.3.1 + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Issue priority + description: Please be realistic. If you need to elaborate on your reasoning, please use the issue description field above. + options: + - Low (slightly annoying) + - Medium (should be fixed soon) + - High (immediate attention needed) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/02-feature_request.yml b/.github/ISSUE_TEMPLATE/03-feature_request.yml similarity index 60% rename from .github/ISSUE_TEMPLATE/02-feature_request.yml rename to .github/ISSUE_TEMPLATE/03-feature_request.yml index bea2ce33..3a5aed83 100644 --- a/.github/ISSUE_TEMPLATE/02-feature_request.yml +++ b/.github/ISSUE_TEMPLATE/03-feature_request.yml @@ -6,6 +6,43 @@ body: attributes: value: | Thank you for submitting an idea ! + - type: dropdown + id: application_or_package + attributes: + label: Which package or tooling is this feature request for? + options: + - editor + - code + - history + - kernel + - layout + - meta + - project + - protocol + - registry + - rpc + - runtime + - sdk + - server-core + - settings + - ui + - code-editor + - command-palette + - console + - ecs + - file-manager + - git + - history-panel + - inspectors + - packages + - scene + - settings-ui + - viewport + - example-plugin + - vite-plugin + - other + validations: + required: true - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/03-documentation_issue.yml b/.github/ISSUE_TEMPLATE/04-documentation_issue.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/03-documentation_issue.yml rename to .github/ISSUE_TEMPLATE/04-documentation_issue.yml diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 442c2e92..02af1ec1 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -17,10 +17,14 @@ runs: name: Install Node.js uses: actions/setup-node@v6 with: - node-version: 25 + node-version: 26 package-manager-cache: false registry-url: https://registry.npmjs.org + # The editor server build and the game runtime (tests included) run on Bun. + - name: Install Bun + uses: oven-sh/setup-bun@v2 + - if: inputs.pnpm-install == 'true' name: Install dependencies uses: ./.github/actions/pnpm-install diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index e69de29b..c315c747 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -0,0 +1,86 @@ +apps:editor: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\neditor\\n" + +packages:code: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncode\\n" + +packages:history: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nhistory\\n" + +packages:kernel: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nkernel\\n" + +packages:layout: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nlayout\\n" + +packages:meta: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nmeta\\n" + +packages:project: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nproject\\n" + +packages:protocol: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nprotocol\\n" + +packages:registry: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nregistry\\n" + +packages:rpc: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nrpc\\n" + +packages:runtime: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nruntime\\n" + +packages:sdk: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsdk\\n" + +packages:server-core: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nserver-core\\n" + +packages:settings: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsettings\\n" + +packages:ui: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nui\\n" + +plugins:code-editor: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncode-editor\\n" + +plugins:command-palette: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncommand-palette\\n" + +plugins:console: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nconsole\\n" + +plugins:ecs: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\necs\\n" + +plugins:file-manager: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nfile-manager\\n" + +plugins:git: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ngit\\n" + +plugins:history-panel: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nhistory-panel\\n" + +plugins:inspectors: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ninspectors\\n" + +plugins:packages: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\npackages\\n" + +plugins:scene: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nscene\\n" + +plugins:settings-ui: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsettings-ui\\n" + +plugins:viewport: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nviewport\\n" + +tooling:example-plugin: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-plugin\\n" + +tooling:vite-plugin: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nvite-plugin\\n" diff --git a/.github/labeler.yml b/.github/labeler.yml index 539fefc4..929ca27f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,3 +3,177 @@ documentation: - any-glob-to-any-file: - docs/* - docs/**/* + +apps:editor: + - changed-files: + - any-glob-to-any-file: + - apps/editor/* + - apps/editor/**/* + +packages:code: + - changed-files: + - any-glob-to-any-file: + - packages/code/* + - packages/code/**/* + +packages:history: + - changed-files: + - any-glob-to-any-file: + - packages/history/* + - packages/history/**/* + +packages:kernel: + - changed-files: + - any-glob-to-any-file: + - packages/kernel/* + - packages/kernel/**/* + +packages:layout: + - changed-files: + - any-glob-to-any-file: + - packages/layout/* + - packages/layout/**/* + +packages:meta: + - changed-files: + - any-glob-to-any-file: + - packages/meta/* + - packages/meta/**/* + +packages:project: + - changed-files: + - any-glob-to-any-file: + - packages/project/* + - packages/project/**/* + +packages:protocol: + - changed-files: + - any-glob-to-any-file: + - packages/protocol/* + - packages/protocol/**/* + +packages:registry: + - changed-files: + - any-glob-to-any-file: + - packages/registry/* + - packages/registry/**/* + +packages:rpc: + - changed-files: + - any-glob-to-any-file: + - packages/rpc/* + - packages/rpc/**/* + +packages:runtime: + - changed-files: + - any-glob-to-any-file: + - packages/runtime/* + - packages/runtime/**/* + +packages:sdk: + - changed-files: + - any-glob-to-any-file: + - packages/sdk/* + - packages/sdk/**/* + +packages:server-core: + - changed-files: + - any-glob-to-any-file: + - packages/server-core/* + - packages/server-core/**/* + +packages:settings: + - changed-files: + - any-glob-to-any-file: + - packages/settings/* + - packages/settings/**/* + +packages:ui: + - changed-files: + - any-glob-to-any-file: + - packages/ui/* + - packages/ui/**/* + +plugins:code-editor: + - changed-files: + - any-glob-to-any-file: + - plugins/code-editor/* + - plugins/code-editor/**/* + +plugins:command-palette: + - changed-files: + - any-glob-to-any-file: + - plugins/command-palette/* + - plugins/command-palette/**/* + +plugins:console: + - changed-files: + - any-glob-to-any-file: + - plugins/console/* + - plugins/console/**/* + +plugins:ecs: + - changed-files: + - any-glob-to-any-file: + - plugins/ecs/* + - plugins/ecs/**/* + +plugins:file-manager: + - changed-files: + - any-glob-to-any-file: + - plugins/file-manager/* + - plugins/file-manager/**/* + +plugins:git: + - changed-files: + - any-glob-to-any-file: + - plugins/git/* + - plugins/git/**/* + +plugins:history-panel: + - changed-files: + - any-glob-to-any-file: + - plugins/history-panel/* + - plugins/history-panel/**/* + +plugins:inspectors: + - changed-files: + - any-glob-to-any-file: + - plugins/inspectors/* + - plugins/inspectors/**/* + +plugins:packages: + - changed-files: + - any-glob-to-any-file: + - plugins/packages/* + - plugins/packages/**/* + +plugins:scene: + - changed-files: + - any-glob-to-any-file: + - plugins/scene/* + - plugins/scene/**/* + +plugins:settings-ui: + - changed-files: + - any-glob-to-any-file: + - plugins/settings-ui/* + - plugins/settings-ui/**/* + +plugins:viewport: + - changed-files: + - any-glob-to-any-file: + - plugins/viewport/* + - plugins/viewport/**/* + +tooling:example-plugin: + - changed-files: + - any-glob-to-any-file: + - tooling/example-plugin/* + - tooling/example-plugin/**/* + +tooling:vite-plugin: + - changed-files: + - any-glob-to-any-file: + - tooling/vite-plugin/* + - tooling/vite-plugin/**/* diff --git a/.github/labels.yml b/.github/labels.yml index d771bce7..d22ddaa6 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,3 +1,7 @@ +- name: 'apps:editor' + description: 'Related to Editor app' + color: '5319e7' + - name: 'blocked' description: 'This is blocked' color: 'e92f1d' @@ -42,6 +46,110 @@ description: "This doesn't seem right" color: 'e4e669' +- name: 'packages:code' + description: 'Related to Code package' + color: 'aaa3dc' + +- name: 'packages:history' + description: 'Related to History package' + color: 'aaa3dc' + +- name: 'packages:kernel' + description: 'Related to Kernel package' + color: 'aaa3dc' + +- name: 'packages:layout' + description: 'Related to Layout package' + color: 'aaa3dc' + +- name: 'packages:meta' + description: 'Related to Meta package' + color: 'aaa3dc' + +- name: 'packages:project' + description: 'Related to Project package' + color: 'aaa3dc' + +- name: 'packages:protocol' + description: 'Related to Protocol package' + color: 'aaa3dc' + +- name: 'packages:registry' + description: 'Related to Registry package' + color: 'aaa3dc' + +- name: 'packages:rpc' + description: 'Related to RPC package' + color: 'aaa3dc' + +- name: 'packages:runtime' + description: 'Related to Runtime package' + color: 'aaa3dc' + +- name: 'packages:sdk' + description: 'Related to SDK package' + color: 'aaa3dc' + +- name: 'packages:server-core' + description: 'Related to Server core package' + color: 'aaa3dc' + +- name: 'packages:settings' + description: 'Related to Settings package' + color: 'aaa3dc' + +- name: 'packages:ui' + description: 'Related to UI package' + color: 'aaa3dc' + +- name: 'plugins:code-editor' + description: 'Related to Code editor plugin' + color: '1c3f78' + +- name: 'plugins:command-palette' + description: 'Related to Command palette plugin' + color: '1c3f78' + +- name: 'plugins:console' + description: 'Related to Console plugin' + color: '1c3f78' + +- name: 'plugins:ecs' + description: 'Related to ECS plugin' + color: '1c3f78' + +- name: 'plugins:file-manager' + description: 'Related to File manager plugin' + color: '1c3f78' + +- name: 'plugins:git' + description: 'Related to Git plugin' + color: '1c3f78' + +- name: 'plugins:history-panel' + description: 'Related to History panel plugin' + color: '1c3f78' + +- name: 'plugins:inspectors' + description: 'Related to Inspectors plugin' + color: '1c3f78' + +- name: 'plugins:packages' + description: 'Related to Packages plugin' + color: '1c3f78' + +- name: 'plugins:scene' + description: 'Related to Scene plugin' + color: '1c3f78' + +- name: 'plugins:settings-ui' + description: 'Related to Settings ui plugin' + color: '1c3f78' + +- name: 'plugins:viewport' + description: 'Related to Viewport plugin' + color: '1c3f78' + - name: 'question' description: 'Further information is requested' color: 'd876e3' @@ -54,6 +162,14 @@ description: 'Adding missing tests or correcting existing tests' color: '7f8f92' +- name: 'tooling:example-plugin' + description: 'Related to Example plugin tooling' + color: '606bdf' + +- name: 'tooling:vite-plugin' + description: 'Related to Vite plugin tooling' + color: '606bdf' + - name: 'wontfix' description: 'This will not be worked on' color: 'ffffff' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8867c894 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,36 @@ +name: E2E + +on: + schedule: + - cron: '0 3 * * *' + pull_request: + branches: + - main + paths: + - 'apps/**' + - 'packages/**' + - 'tooling/**' + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Prepare + uses: ./.github/actions/prepare + + - name: Install browsers + run: pnpm --filter @nanoforge-dev/editor exec playwright install --with-deps chromium + + - name: Run end-to-end tests + run: pnpm --filter @nanoforge-dev/editor test:e2e + + - name: Upload traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: apps/editor/test-results diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa53bb07..071ae1a4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,5 +28,11 @@ jobs: - name: Run linter run: pnpm lint + - name: Run type checks + run: pnpm typecheck + + - name: Install browsers for component tests + run: pnpm --filter @nanoforge-dev/editor-ui exec playwright install --with-deps chromium + - name: Run tests run: pnpm test:unit diff --git a/.gitignore b/.gitignore index f984847d..aad4424e 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,25 @@ dist test-results/ public/assets/fonts/ + +### Turborepo +.turbo + +# Spike fixture copy (phase 1.1) +apps/editor/static/__spike/ + +# Temporary fixture copies of tests +packages/server-core/test/.tmp-* + +# e2e workspace and reports +apps/editor/e2e/.workspace/ +apps/editor/e2e/.workspace-*/ +apps/editor/test-results/ +apps/editor/playwright-report/ + +# SvelteKit client build (bundled into dist/ by the build script) +apps/editor/build/ + +# The example editor's data (pnpm example) +examples/.data/ +examples/node_modules diff --git a/.husky/pre-push b/.husky/pre-push index 64e3d4e1..d5cc1185 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,3 @@ pnpm lint -pnpm build +pnpm typecheck pnpm test:unit diff --git a/.nvmrc b/.nvmrc index a682cfb9..4aa0e0a7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v25 +v26 diff --git a/.prettierignore b/.prettierignore index 9976d76a..bae5a534 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,21 +1,26 @@ .idea/ .vscode/ +.turbo/ -.svelte-kit/ -dist/ +**/.svelte-kit/ +**/dist/ +**/build/ node_modules/ pnpm-lock.yaml pnpm-workspace.yaml -CHANGELOG.md +apps/ +packages/ +plugins/ +tooling/ -coverage/ -test-results/ +**/CHANGELOG.md -project.inlang/cache/ -project.inlang/.gitignore -project.inlang/.meta.json -project.inlang/README.md +**/coverage/ +**/test-results/ -src/lib/paraglide/ +**/test/fixtures/ + +# Written by the editor in a project it opens (the example). +**/.nanoforge/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3b672cb9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,281 @@ +# NanoForge Editor v2 — notes for agents + +The NanoForge editor, rewritten from scratch. The full plan, with status per phase, is `docs/rewrite-plan.md`. Specs per plugin are in `docs/specs/`, design decisions in `docs/adr/`. Read the plan section of the phase you work on before touching code. + +## Working rules + +- **Where to work.** + - This is the worktree `G-EIP-600/editor-v2`, branch `rewrite/v2`. + - Never commit on the user's own branches or checkouts (`G-EIP-600/editor`, `engine`, `loader`, `cli`): use a worktree. + - Never push or publish. That is the user's call. +- **Phases.** + - The user advances phases explicitly ("move to phase N"). Don't start the next phase on your own. + - Plugins (phases 9–11) always start with a **requirements session**: ask the user, write `docs/specs/.md`, then build. + - When a phase item is done, mark it ✅ in `docs/rewrite-plan.md` with a short summary of what was built. +- **Open questions** (see "Open design questions" below): + - the `nf_modules` dependency model (install, imports, collisions: see ADR 0003's open questions). + + Ask the user before building anything that depends on them. + +- **Repository standards follow the engine** (`../engine`, `origin/main`): + - `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12; + - every workspace has the engine's `README.md` (badges, About, Links, Contributing, Help), `LICENSE` and `.prettierignore`; published ones (`apps/editor`, `packages/sdk`, `tooling/vite-plugin`) also `CHANGELOG.md`, `cliff.toml` and `.cliff-jumperrc.json`; + - each workspace has a `:` label (`.github/labels.yml`, `labeler.yml`, `issue-labeler.yml`) and a choice in the issue forms. A new workspace gets all of them; + - the editor keeps single quotes (its Prettier override of the engine config). +- **Commits.** + - Conventional commits, with the package or plugin name as scope: `feat(settings-ui): …`, `fix(ui): …`. + - Commitlint rejects sentence case, and a subject starting with an acronym such as "JSON" counts as sentence case: write "json". + - End the message with the `Co-Authored-By` line. + - Split work into logical commits: core/SDK changes first, then the plugin. + - Husky runs Prettier and ESLint on staged files. +- **Shell.** + - `cp` is aliased interactive: use `command cp -f`. + - Never `pkill -f` (it matches and kills its own shell): kill by PID. + - zsh doesn't word-split `$var`: use `${=var}` in loops. + - The shell cwd resets after each command: `cd` inside each command. +- **Ports.** + - 5174 is the user's own dev server: don't touch it. Use `EDITOR_API_PORT` for another dev server. + - The e2e tests use port 4790. + +## Commands + +```sh +pnpm install +pnpm turbo build --filter=@nanoforge-dev/editor... # editor + every package and plugin it bundles +pnpm lint # prettier --check . + eslint in every package +pnpm typecheck +CHROME=/usr/bin/google-chrome-stable pnpm test # unit + Vitest browser tests (packages/ui needs CHROME) +pnpm dev # editor + built-in plugins in watch mode (hot reload) + +# E2E: against the production build. Build first, and rebuild after every change. +cd apps/editor +CHROME=/usr/bin/google-chrome-stable \ +NANOFORGE_ENGINE=/home/exelo/workspace/epitech/G6/G-EIP-600/engine-editor-bridge \ +NANOFORGE_CLI=/home/exelo/workspace/epitech/G6/G-EIP-600/cli-editor \ +npx playwright test [spec…] --reporter=line +``` + +- **Run e2e with `E2E_WORKSPACE=.workspace-agent`** (any folder name under `e2e/`). The default `e2e/.workspace` is wiped at each run, and the user's dev server (`FS_ROOT=apps/editor`) may have its projects open. Specs take their paths from `e2e/workspace.ts`. +- Without `NANOFORGE_ENGINE`, the tests that play the game (runtime, the viewport's "while playing", some code-editor tests) are **skipped**, not failed. Check the skipped count. +- `NANOFORGE_CLI` (a built CLI checkout) is linked into the played game as its own CLI. Without it, the console test skips its source-map check (the game's published CLI writes no source maps). +- Contract tests (`test/contract` in `server-core` and `registry`) run in `pnpm test` against the stand-ins, and against a real API with `CONTRACT_*` variables (`docs/api/README.md`). Never run them against a real API yourself. +- `ANALYZE=1 npx vite build` (in `apps/editor`) prints the bundle analysis; `BENCH=1 npx playwright test` runs the startup measurements of `e2e/*.bench.ts` instead of the tests (`docs/performance.md`). +- `E2E_EXTRA_PLUGINS=` adds a dev plugin to the e2e editor. +- SDK public API snapshot: after adding SDK exports, run `cd packages/sdk && npx vitest run -u`, and check that the diff shows only what you added. +- E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error. Known noise goes in its `IGNORED` list. +- To debug a failed e2e test, read `test-results//error-context.md`. For a screenshot, unzip `trace.zip` and open the last frame in `screencast/`. +- The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`. + +## Architecture in one screen + +- **Monorepo:** pnpm + turbo. The app is a SvelteKit static SPA plus a Bun server, in Svelte 5 with runes only. Schemas use zod v4. +- `apps/editor`: + - the shell (routes `/`, `/load?path=|gatewayId=`, `/project/[id]`, `/play`); + - the server (`scripts/build-server.ts` bundles `dist/`, including `dist/plugins`); + - the e2e tests. +- **`packages/*`** are source-only (`exports` → `src/index.ts`). Only `sdk` is really built. + - `kernel`: DI (`createToken`, `Container`), `Disposable`, `Emitter`, `ObservableValue`, extension points, commands, context keys / `when`, plugin and package manifest schemas, resolver, `PluginHost` (`PluginHostToken`; `update()` swaps the plugin set when a project opens or closes). + - `sdk`: **the only package plugins import**: `@nanoforge-dev/editor-sdk`, plus `@nanoforge-dev/editor-sdk/ui`. + - `settings`: scopes default < account < machine < project < projectLocal; `AccountSyncStore` (`AccountSyncToken`). + - `history`: per-context stacks, `goTo`, serialization, IndexedDB persistence of `file:*` contexts. + - `layout`: a pure layout model and its operations. + - `ui`: the kit (bits-ui), theme tokens, workbench renderer, extension points. + - `code`: code worker, `DocumentService`, `JSON_SCHEMAS`. + - `runtime`: build, client runner, server runner. + - `protocol`: the engine bridge protocol, and the RPC contracts between the editor and its server. + - `rpc`, `project`, `server-core` (fs, `.nfignore`, `nf_modules` read-only, plugin sources and package suggestions, git, cli, registry contract). + - `registry` (`@nanoforge-dev/registry`, **not** an `editor-*` package): registry client, folder registry, installer, the project's packages list and lock. It is meant to move to the CLI repository: ESLint keeps `@nanoforge-dev/editor-*` imports out of it. Its HTTP side follows the npm registry's routes (`src/wire/`, contract in `docs/api/registry.md`). Protocol imports only its `/types` (zod schemas, no Node code). +- **`plugins/*`** are the built-in plugins, each with a `nanoforge.manifest.json` of `type: "plugin"`: + - `code-editor` (Monaco) + - `file-manager` + - `viewport` (Game and Scene screens) + - `history-panel` + - `settings-ui` + - `ecs` + - `console` (Console and Problems panels) + - `command-palette` (the palette, and the Keyboard shortcuts page of Settings) + - `inspectors` (Profiler, Network and World panels) + - `git` (the VCS menu, the Commit and Git panels after IntelliJ's, status bar branch, file status marks) + - `packages` (the Packages dialog, _File › Packages…_; the plugin marketplace is in `settings-ui`) + - `scene` (the Scenes and Vars panels, for apps with `@nanoforge-dev/scene`; spec `docs/specs/scene.md`). It plugs into the ECS widgets through `ecs.sources`: they edit the selected scene's `setup` instead of `main` +- **`tooling/`**: + - `vite-plugin`: builds plugins; + - `example-plugin`: `@acme/hello`, the dev plugin loaded by the e2e tests; + - `spike-plugin`. +- **`examples/pong-game`**: a game project to try the editor on. `pnpm example` (`scripts/example.mjs`) links its engine libraries from `NANOFORGE_ENGINE` (default `../engine-editor-bridge`) and starts the built editor on port 4800 with `FS_ROOT=examples`. It has a shared library (`libs/shared`, `@pong-game/shared`). The ECS scene test has its own trimmed copy (`plugins/ecs/test/fixtures/pong-game`). +- **`examples/breakout`**: a client-only game made of scenes (`@nanoforge-dev/scene`, engine branch `feat/scene`): `NANOFORGE_ENGINE=../engine-scene pnpm example breakout`. Its entities and systems all belong to scenes (`GameScene`), so the Scene screen and Hierarchy show none: use Play and the World panel. +- **`examples/counter-plugin`**: a standalone plugin, outside the workspace, as `nf create plugin` writes it (the CLI command is in `../cli-editor`). +- **`docs/docs`**: the user documentation and the plugin authoring guide (Mintlify pages, copied to the docs site). + +### Manifests: packages and plugins (ADR 0003) + +Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/name`. There are two types, and they never mix: a package never holds a plugin. + +- **`type: "package"`** (schema: `PackageManifestSchema` in kernel; full draft in `docs/drafts/item-schema.ts`): + - installed in `/nf_modules/@scope/name`, read-only; + - `items`: source files whose exports are items (components, systems…), documented with TSDoc; + - `include`: sub-folders holding their own `package` manifest. Walk it, never scan folders. Includes stay inside the package (no `..`), 4 levels max; + - `suggestedPlugins`: `["@scope/name"]` or `{ "@scope/name": "^1.0.0" }`. When a project opens, the editor names the missing ones in a notification (`plugins.suggestions`); + - `libDependencies` (engine libs), `dependencies` (other packages), `npmDependencies`. +- **`type: "plugin"`** (schema: `PluginManifestSchema`, JSON schema `packages/kernel/schemas/plugin.schema.json`, regenerate with `pnpm --filter @nanoforge-dev/editor-kernel generate:schema`). Plugin sources, later ones shadowing earlier ones with the same name: + + | Source | Where | Loaded | + | ----------- | ------------------------------------------------------------------------- | -------------------------- | + | `bundled` | `apps/editor/dist/plugins/@scope/name` (built-in plugins) | always | + | `installed` | `~/.nanoforge/editor/plugins/@scope/name` | in every project | + | `project` | `/.nanoforge/plugins/@scope/name`, URL `/plugins/project//…` | while that project is open | + | `dev` | local folders (`/dist`), hot reloaded | always | + + A project plugin's server entry only runs in a local editor, never in a hosted one. + +- **Items** (components, systems…) never have a lib-specific type. Lib data goes in owner objects keyed by the owner plugin's name: `"@nanoforge/ecs": { "type": "component" }`. Only that plugin parses it. Core formats (manifest, item, element) must stay lib-agnostic. +- **Shared libraries** (ADR 0004): `libs/` workspace members of `type: "lib"` (no manifest, never published) hold components and systems shared by client and server; side is per item. Apps import them by package name (`@pong-network/shared/components/player`) through the root `tsconfig.json` `paths`; installed packages the same way. An app **uses** a library when its `nanoforge.config` `libs` lists it (paths relative to the app, the CLI's field; `AppModel.libraries`). Every change also writes the app's `package.json` dependency; when they disagree `libs` wins and the project shows a warning. A library nobody mentions goes to every app. `libs` are never engine libraries (those come from `package.json`), and is only offered the items of the libraries it uses. The Project screen manages apps and libraries through `packages/project/src/workspace/` (add, rename, remove, "Used by"; one undo step each, history context `project`). In the UI say **Shared library**, **Installed package** and **Engine library**, never a bare "lib". +- **Meta** of items is generated by the editor into `.nanoforge/editor/cache/meta/`, never published, never written next to sources. (Planned for phase 10.3.) +- The CLI (`nf install`, `nf publish`) still uses `scope/name` and the old types: ignore it until the user asks. + +### Plugin mechanics (ADR 0001) + +- **Build.** Plugins are built with `tooling/vite-plugin`: `base: './'`, and shared modules (the Svelte runtime and the SDK) are rewritten to `globalThis.__nanoforge_editor_shared__.require(...)`. Plugins **must be in runes mode**. +- **CSS.** CSS is extracted to `.css` (`build.lib.cssFileName`). Load it in `activate()` with `context.resolveAsset(...)` + `StyleServiceToken.inject(context.name, css)`. +- **Settings keys** from the manifest are prefixed with the plugin name, e.g. `@nanoforge/viewport.maximizeOnPlay`. +- **Adding a built-in plugin:** + 1. copy the layout of an existing plugin (`package.json` with `build`/`dev`/`lint`/`typecheck`, `vite.config.ts`, `svelte.config.js`, `tsconfig.json`, manifest, `README.md`, `LICENSE`, `.prettierignore`); + 2. add it as a devDependency of `apps/editor` (it is then bundled and run as a dev plugin); + 3. its name is accepted as a commit scope automatically. +- **Plugins only import the SDK** (ESLint enforces it). If a plugin needs something from core, export it from the SDK: add a token in the owning package, provide it in `apps/editor/src/lib/editor/create-editor.ts`, re-export it, and update the snapshot. +- **Dialogs from a plugin:** mount a Svelte component with `mount`/`unmount` from a command (see `plugins/settings-ui/src/index.ts`). + +### Engine bridge (protocol v1) + +- **The game registers it** (engine 2.0 and later): `app.use(new EditorLibrary())` from `@nanoforge-dev/editor-lib` (engine `modules/editor`). The engine's own examples don't: the e2e `game` copy and `pnpm example` link the library from `NANOFORGE_ENGINE` and add it (`scripts/engine-bridge.mjs`). A game without it plays with no bridge, and the editor offers **Add the editor library** (`addEditorLibrary` in `packages/project/src/workspace/editor-library.ts`). +- **The hello comes in the game's first tick**, after its `main` returned: the runtime waits for it a little (`helloTimeoutMs`, 1.5 s) before calling a game bridgeless, and re-checks on a late hello. The runtime tests' fake game (`server-core/test/fixtures/runtime/bridge-game.js`) greets the same way. +- **The engine sends `ecs-world` only when the world changed**: `RuntimeService.lastWorlds()` keeps the last one per game, for views that start listening while the game is paused. +- **Handshake:** `hello` → `welcome`, with features `frameStats`, `logs` (`{ values: true }` adds the logged values and the call site to `log` events), `networkTrace`, `ecsWorld` and `viewport` (the client's `ViewportState`: how game units map to its container, for drawing over the game). +- **Messages:** + - commands: `pause`, `resume`, `step`, `stop`, `mute`; + - event: `state`. +- **Events plugins read** (`RuntimeService.onEvent`), each sent only when its feature is asked for with `useFeatures`: `frame-stats`, `ecs-world`, `ecs-system-stats` (`ecsSystemStats`), `network-trace` and `network-stats` (`networkTrace`, with `maxBytes` for payload bytes), `viewport`. A new feature also goes in `mergeFeatures`, or it is silently dropped. Ask while a view is on screen (`WidgetInstance.visible`: a tab shown once stays mounted while hidden), not from activation: `mergeFeatures` takes the most demanding request. +- The engine's `EditorBridge` (`modules/editor/src/internal/editor-bridge.ts`) buffers the game's console output until `welcome`. The runtime service checks compatibility (legacy, older or newer engine). +- **Logs:** everything shown in the Console goes through `LoggerService`. The source name gives the group: `game:client`, `game:server`, `build:`, `cli:`, a plugin's name, anything else is the editor. Logged values use the `LogValue` tree, made by kernel `serializeLogValue` only: the client runner hands it to the game (`RunOptions.editor.serializeLogValue`), and the engine calls it when a line is logged. The engine has no serializer of its own. +- **Moving entities in the Game screen** (ECS plugin, `GameMoveOverlay.svelte`): the _Move entities_ tool (`ecs.moveInGame`) draws the scene shapes of the client's live entities over the game with the `viewport` mapping, and a drag is a live edit (`live.setField`), like the Scene's. graphics-2d's old drag (`DrawableCircle2D`… emitting `move-component`) was removed from the engine (`11f4c98`, breaking). +- **Unpushed branches in other repos:** + + | Repo | Branch | Worktree | + | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | + | engine | `feat/core-editor-bridge` (one commit on engine 2.0.0, rebased by the user 2026-10-02; old base in `backup/core-editor-bridge-pre-rebase`) | `../engine-editor-bridge` | + | loader | `feat/server-editor-ipc` | `../loader-editor` | + | cli | `feat/editor-open-project` (`nf editor [path] --port`, `nf create plugin`) | `../cli-editor` | + | engine | `feat/scene` (`modules/scene`, `@nanoforge-dev/scene`; from `feat/core-editor-bridge`) | `../engine-scene` | + +## Conventions that matter + +- **Source layout (ADR 0006, after the engine and the CLI):** + - `src/` holds `index.ts` and kebab-case feature folders, each with its own `index.ts` in packages (none in plugins); + - files are named after their main export, with kind suffixes (`.type.ts`, `.enum.ts`, `.exception.ts`, `.const.ts`), and `..ts` for implementations (`memory.scope-store.ts`); + - tests go in `test/` mirroring `src/`, as `*.spec.ts`, with shared helpers in `test/fixtures/`; + - Svelte files are kebab-case when an `index.ts` re-exports them, PascalCase otherwise; + - split a file past about 300 lines (a Svelte file's ` diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts new file mode 100644 index 00000000..8546307d --- /dev/null +++ b/apps/editor/src/lib/editor/create-editor.ts @@ -0,0 +1,268 @@ +import { version as svelteVersion } from 'svelte/package.json'; + +import { + HistoryService, + HistoryServiceToken, + registerHistoryCommands, +} from '@nanoforge-dev/editor-history'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + LogLevel, + type Logger, + type Observable, + PluginHost, + PluginHostToken, + consoleSink, + createCoreContainer, + switchObservable, +} from '@nanoforge-dev/editor-kernel'; +import { ProjectService, ProjectServiceToken } from '@nanoforge-dev/editor-project'; +import { PluginsContract, SessionContract, type SessionInfo } from '@nanoforge-dev/editor-protocol'; +import { RpcClient, RpcClientToken } from '@nanoforge-dev/editor-rpc'; +import { RuntimeSettings } from '@nanoforge-dev/editor-runtime'; +import { version as sdkVersion } from '@nanoforge-dev/editor-sdk/package.json'; +import { + AccountSyncToken, + CoreSettings, + type SettingsService, + followCurrentProject, + settingsContributionHandler, + setupSettings, +} from '@nanoforge-dev/editor-settings'; +import { + KeybindingServiceToken, + LAYOUT_HISTORY_CONTEXT, + NotificationServiceToken, + PromptServiceToken, + type Shell, + StyleServiceToken, + ThemeServiceToken, + setupShell, +} from '@nanoforge-dev/editor-ui'; + +import { installSharedModules } from '$lib/plugin/shared-modules'; +import { registerCorePanels } from '$lib/project/register-core-panels'; + +import { followProjectCode } from '../code/follow-project-code'; +import { followProjectPlugins, toDescriptor } from '../plugin/follow-project-plugins'; +import { followProjectRuntime } from '../runtime/follow-project-runtime'; +import { WorkspaceActions, WorkspaceActionsToken } from '../workspace/workspace-actions'; +import { registerAppContributions } from './register-app-contributions'; + +export interface Editor extends Disposable { + readonly services: Container; + readonly rpc: RpcClient; + readonly plugins: PluginHost; + readonly projects: ProjectService; + readonly settings: SettingsService; + readonly history: HistoryService; + readonly shell: Shell; + /** Id of the project whose settings scopes are attached. */ + readonly projectSettings: Observable; + readonly session: SessionInfo; + readonly logger: Logger; +} + +/** + * Boots the editor in the browser: shared modules for plugins, the kernel container with the + * core client services, then the plugin host. + */ +export const createEditor = async ( + onProgress: (step: string, done: number, total: number, description?: string) => void = () => + undefined, +): Promise => { + const TOTAL = 4; + onProgress( + 'Connecting to the editor server', + 0, + TOTAL, + 'Checking your session with the editor server.', + ); + installSharedModules(); + const store = new DisposableStore(); + const services = store.add(createCoreContainer()); + const logs = services.get(CoreServices.Logger); + logs.level = LogLevel.Debug; + store.add(logs.addSink((entry) => entry.level >= LogLevel.Info && consoleSink(entry))); + const logger = logs.getLogger('editor'); + + const rpc = store.add(new RpcClient({ baseUrl: location.origin })); + const projects = store.add(new ProjectService(rpc)); + services.provide(RpcClientToken, rpc); + services.provide(ProjectServiceToken, projects); + services.provide(CoreServices.EngineLibs, { + apps: switchObservable( + { + get: () => projects.current.get()?.apps, + subscribe: (run) => projects.current.subscribe((project) => run(project?.apps)), + }, + [], + ), + }); + + const session = await rpc.api(SessionContract).info(null); + onProgress( + 'Loading settings', + 1, + TOTAL, + session.mode === 'ONLINE' + ? 'Reading your preferences and syncing your NanoForge account settings.' + : 'Reading your editor preferences.', + ); + const settingsSetup = store.add( + await setupSettings({ + services, + rpc, + mode: session.mode, + userId: session.user?.id ?? 'anonymous', + }), + ); + store.add(settingsSetup.registry.register(...Object.values(RuntimeSettings))); + const projectSettings = store.add(followCurrentProject(settingsSetup, projects.current)); + const { settings } = settingsSetup; + services.provide(AccountSyncToken, settingsSetup.account); + + const history = store.add( + new HistoryService({ + limit: settings.observe(CoreSettings.historyLimit), + mergeWindowMs: settings.observe(CoreSettings.historyMergeWindow), + localOrigin: { kind: 'user', id: session.user?.id ?? 'anonymous' }, + }), + ); + services.provide(HistoryServiceToken, history); + store.add( + registerHistoryCommands( + services.get(CoreServices.Commands), + services.get(CoreServices.ContextKeys), + history, + { fallbackContext: LAYOUT_HISTORY_CONTEXT }, + ), + ); + + onProgress('Loading plugins', 2, TOTAL, 'Finding built-in, installed and local plugins.'); + const listings = await rpc.api(PluginsContract).list(null); + const plugins = store.add( + new PluginHost({ + services, + editorVersion: __EDITOR_VERSION__, + runtimeVersions: { svelte: svelteVersion, sdk: sdkVersion }, + disabled: new Set(settings.get(CoreSettings.pluginsDisabled)), + }), + ); + store.add( + plugins.registerContributionHandler(settingsContributionHandler(settingsSetup.registry)), + ); + services.provide(PluginHostToken, plugins); + + const shell = store.add( + setupShell({ + extensions: services.get(CoreServices.Extensions), + commands: services.get(CoreServices.Commands), + contextKeys: services.get(CoreServices.ContextKeys), + settings, + logger: logs.getLogger('ui'), + plugins, + }), + ); + services.provide(NotificationServiceToken, shell.notifications); + services.provide(PromptServiceToken, shell.prompts); + services.provide(KeybindingServiceToken, shell.keybindings); + services.provide(StyleServiceToken, shell.styles); + services.provide(ThemeServiceToken, shell.themes); + store.add(registerCorePanels(services.get(CoreServices.Extensions))); + const code = store.add( + followProjectCode({ + services, + projects, + rpc, + plugins, + notifications: shell.notifications, + history, + logger: logs.getLogger('editor'), + }), + ); + store.add( + followProjectRuntime({ + services, + projects, + rpc, + settings, + logs, + notifications: shell.notifications, + enabled: session.mode === 'OFFLINE', + }), + ); + const workspace = store.add( + new WorkspaceActions({ + services, + projects, + rpc, + history, + notifications: shell.notifications, + prompts: shell.prompts, + local: session.mode === 'OFFLINE', + }), + ); + services.provide(WorkspaceActionsToken, workspace); + store.add(registerAppContributions(services, session)); + store.add( + history.onDidInvalidate(({ contextId, reason }) => { + if (reason === 'external-change') { + shell.notifications.notify( + 'warning', + `Undo history of ${history.get(contextId)?.label ?? contextId} was cleared`, + { + detail: 'The file changed outside of the editor.', + }, + ); + } + }), + ); + + onProgress( + 'Starting plugins', + 3, + TOTAL, + listings.length + ? `Checking and activating ${listings.length} plugin${listings.length > 1 ? 's' : ''}.` + : 'No plugin to activate.', + ); + await plugins.start(listings.flatMap((listing) => toDescriptor(listing, logger) ?? [])); + + store.add( + followProjectPlugins({ rpc, projects, plugins, notifications: shell.notifications, logger }), + ); + + store.add( + rpc.subscribe(PluginsContract, 'devChanges', null, ({ name, listing }) => { + const descriptor = toDescriptor(listing, logger); + if (descriptor?.manifest.entry.worker) { + code.reloadWorkerPlugin( + name, + new URL(descriptor.manifest.entry.worker, descriptor.baseUrl).href, + ); + } + if (descriptor) + plugins + .reload(name, descriptor) + .catch((error: unknown) => logger.error(`Reload of ${name} failed`, error)); + }), + ); + + return { + services, + rpc, + plugins, + projects, + settings, + history, + shell, + projectSettings: projectSettings.attached, + session, + logger, + dispose: () => store.dispose(), + }; +}; diff --git a/apps/editor/src/lib/editor/editor-context.ts b/apps/editor/src/lib/editor/editor-context.ts new file mode 100644 index 00000000..63bcd732 --- /dev/null +++ b/apps/editor/src/lib/editor/editor-context.ts @@ -0,0 +1,18 @@ +import { getContext, setContext } from 'svelte'; + +import type { Editor } from './create-editor'; + +const KEY = Symbol('nanoforge-editor'); + +/** Filled once the editor booted; the root layout renders pages only after that. */ +export interface EditorRef { + current: Editor | undefined; +} + +export const provideEditor = (): EditorRef => setContext(KEY, { current: undefined }); + +export const getEditor = (): Editor => { + const editor = getContext(KEY)?.current; + if (!editor) throw new Error('The editor is not initialized'); + return editor; +}; diff --git a/apps/editor/src/lib/editor/error-description.ts b/apps/editor/src/lib/editor/error-description.ts new file mode 100644 index 00000000..c5d7be7c --- /dev/null +++ b/apps/editor/src/lib/editor/error-description.ts @@ -0,0 +1,21 @@ +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +export interface ErrorAction { + readonly label: string; + readonly run: () => void | Promise; + readonly primary?: boolean; +} + +export interface ErrorDescription { + readonly title: string; + readonly hint?: string; + /** Technical detail, shown small (the raw message). */ + readonly detail?: string; + readonly actions: readonly ErrorAction[]; +} + +export const errorCode = (reason: unknown): string | undefined => + reason instanceof RpcError ? reason.code : (reason as { code?: string } | null)?.code; + +export const errorMessage = (reason: unknown): string => + reason instanceof Error ? reason.message : String(reason); diff --git a/apps/editor/src/lib/editor/register-app-contributions.ts b/apps/editor/src/lib/editor/register-app-contributions.ts new file mode 100644 index 00000000..a0160b65 --- /dev/null +++ b/apps/editor/src/lib/editor/register-app-contributions.ts @@ -0,0 +1,79 @@ +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + derived, +} from '@nanoforge-dev/editor-kernel'; +import { ProjectServiceToken } from '@nanoforge-dev/editor-project'; +import type { SessionInfo } from '@nanoforge-dev/editor-protocol'; +import { MENU_ITEMS, MenuItemSchema, STATUS_BAR_ITEMS } from '@nanoforge-dev/editor-ui'; + +import { goto } from '$app/navigation'; + +const DOCS_URL = 'https://docs.nanoforge.eu'; + +/** Commands, menus and status bar items of the editor application itself. */ +export const registerAppContributions = (services: Container, session: SessionInfo): Disposable => { + const store = new DisposableStore(); + const commands = services.get(CoreServices.Commands); + const extensions = services.get(CoreServices.Extensions); + const projects = services.get(ProjectServiceToken); + + store.add( + commands.register({ + id: 'project.close', + title: 'Close project', + category: 'File', + handler: async () => { + projects.close(); + await goto('/'); + }, + }), + ); + store.add( + commands.register({ + id: 'help.docs', + title: 'Documentation', + category: 'Help', + handler: () => void window.open(DOCS_URL, '_blank', 'noopener'), + }), + ); + for (const item of [ + { menu: 'file', command: 'project.close', group: 'project' }, + { menu: 'view', command: 'workbench.focusNextPart', group: 'focus' }, + { menu: 'help', command: 'help.docs' }, + ]) { + store.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); + } + + store.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'core.project', + alignment: 'left', + order: 0, + text: derived([projects.current], (project) => project?.model.get().name ?? 'No project'), + tooltip: 'Close the project', + command: 'project.close', + }, + { owner: 'core' }, + ), + ); + store.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'core.mode', + alignment: 'right', + order: 10, + text: derived([], () => + session.mode === 'OFFLINE' ? 'Local' : `Online · ${session.user?.name ?? 'signed out'}`, + ), + }, + { owner: 'core' }, + ), + ); + return store; +}; diff --git a/apps/editor/src/lib/history/indexed-db-history-store.ts b/apps/editor/src/lib/history/indexed-db-history-store.ts new file mode 100644 index 00000000..4d9ca62a --- /dev/null +++ b/apps/editor/src/lib/history/indexed-db-history-store.ts @@ -0,0 +1,38 @@ +import type { HistoryStore, PersistedEntry } from '@nanoforge-dev/editor-history'; + +const DATABASE = 'nanoforge-history'; +const STORE = 'contexts'; + +const request = (operation: IDBRequest): Promise => + new Promise((resolve, reject) => { + operation.onsuccess = () => resolve(operation.result); + operation.onerror = () => reject(operation.error); + }); + +let database: Promise | undefined; +const open = () => + (database ??= new Promise((resolve) => { + if (typeof indexedDB === 'undefined') return resolve(undefined); + const opening = indexedDB.open(DATABASE, 1); + opening.onupgradeneeded = () => opening.result.createObjectStore(STORE); + opening.onsuccess = () => resolve(opening.result); + opening.onerror = () => resolve(undefined); + })); + +/** Histories kept in the browser (IndexedDB) per project, across reloads. */ +export const indexedDbHistoryStore = (projectId: string): HistoryStore => ({ + async load(contextId) { + const db = await open(); + if (!db) return undefined; + const store = db.transaction(STORE, 'readonly').objectStore(STORE); + return (await request(store.get(`${projectId}:${contextId}`))) as PersistedEntry[] | undefined; + }, + async save(contextId, entries) { + const db = await open(); + if (!db) return; + const store = db.transaction(STORE, 'readwrite').objectStore(STORE); + const key = `${projectId}:${contextId}`; + if (entries.length) await request(store.put(entries, key)); + else await request(store.delete(key)); + }, +}); diff --git a/apps/editor/src/lib/plugin/follow-project-plugins.ts b/apps/editor/src/lib/plugin/follow-project-plugins.ts new file mode 100644 index 00000000..92b49e7a --- /dev/null +++ b/apps/editor/src/lib/plugin/follow-project-plugins.ts @@ -0,0 +1,88 @@ +import { + type Disposable, + type Logger, + type PluginDescriptor, + type PluginHost, + parsePluginManifest, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import { type ProjectService } from '@nanoforge-dev/editor-project'; +import { type PluginListing, PluginsContract } from '@nanoforge-dev/editor-protocol'; +import { type RpcClient } from '@nanoforge-dev/editor-rpc'; +import type { NotificationService } from '@nanoforge-dev/editor-ui'; + +export const toDescriptor = ( + listing: PluginListing, + logger: Logger, +): PluginDescriptor | undefined => { + if (listing.error) { + logger.warn(`Plugin at ${listing.baseUrl} skipped: ${listing.error}`); + return undefined; + } + try { + return { + source: listing.source, + baseUrl: new URL(listing.baseUrl, location.origin).href, + manifest: parsePluginManifest(listing.manifest, listing.baseUrl), + }; + } catch (error) { + logger.error(`Invalid plugin manifest at ${listing.baseUrl}`, error); + return undefined; + } +}; + +/** + * The open project's own plugins (`.nanoforge/plugins`) join the plugin set while it is open. + * Plugins that its packages suggest and that aren't installed are named in a notification, once + * per project. + */ +export const followProjectPlugins = (options: { + rpc: RpcClient; + projects: ProjectService; + plugins: PluginHost; + notifications: NotificationService; + logger: Logger; +}): Disposable => { + const { rpc, plugins, logger } = options; + const api = rpc.api(PluginsContract); + + const suggested = new Set(); + let listedFor: string | undefined; + let queue = Promise.resolve(); + + const suggest = async (project: string) => { + if (suggested.has(project)) return; + suggested.add(project); + const missing = (await api.suggestions({ project })).filter( + (suggestion) => plugins.getPlugin(suggestion.name)?.status.kind !== 'ok', + ); + if (!missing.length) return; + const count = missing.length > 1 ? `${missing.length} plugins` : 'a plugin'; + options.notifications.notify('info', `Packages in this project suggest ${count}`, { + detail: `${missing + .map( + ({ name, suggestedBy }) => + `${name} (for ${suggestedBy.map((entry) => entry.package).join(', ')})`, + ) + .join( + ', ', + )}. Install a plugin in the project's .nanoforge/plugins, or in your home for every project.`, + timeout: 15_000, + }); + }; + + const follow = (project: string | undefined) => { + if (project === listedFor) return; + listedFor = project; + queue = queue + .then(async () => { + const listings = await api.list(project ? { project } : null); + if (listedFor !== project) return; + await plugins.update(listings.flatMap((listing) => toDescriptor(listing, logger) ?? [])); + if (project) await suggest(project); + }) + .catch((error: unknown) => logger.error('Could not load the project plugins', error)); + }; + + return toDisposable(options.projects.current.subscribe((project) => follow(project?.id))); +}; diff --git a/apps/editor/src/lib/plugin/shared-modules.ts b/apps/editor/src/lib/plugin/shared-modules.ts new file mode 100644 index 00000000..25eed393 --- /dev/null +++ b/apps/editor/src/lib/plugin/shared-modules.ts @@ -0,0 +1,52 @@ +import * as svelte from 'svelte'; +import * as svelteAnimate from 'svelte/animate'; +import * as svelteAttachments from 'svelte/attachments'; +import * as svelteEasing from 'svelte/easing'; +import * as svelteEvents from 'svelte/events'; +// eslint-disable-next-line svelte/no-svelte-internal +import * as svelteInternalClient from 'svelte/internal/client'; +import * as svelteLegacy from 'svelte/legacy'; +import * as svelteMotion from 'svelte/motion'; +import * as svelteReactivity from 'svelte/reactivity'; +import * as svelteReactivityWindow from 'svelte/reactivity/window'; +import * as svelteStore from 'svelte/store'; +import * as svelteTransition from 'svelte/transition'; + +import { type ModuleNamespace, SharedModuleRegistry } from '@nanoforge-dev/editor-kernel'; +import * as sdk from '@nanoforge-dev/editor-sdk'; +import * as sdkUi from '@nanoforge-dev/editor-sdk/ui'; + +/** + * Modules the editor shares with plugins, keyed by import specifier. + * Must list exactly `MAIN_SHARED_MODULES` of `@nanoforge-dev/editor-vite-plugin` (checked by a test). + */ +export const SHARED_MODULE_NAMESPACES: Readonly> = { + svelte, + 'svelte/animate': svelteAnimate, + 'svelte/attachments': svelteAttachments, + 'svelte/easing': svelteEasing, + 'svelte/events': svelteEvents, + 'svelte/internal/client': svelteInternalClient, + 'svelte/legacy': svelteLegacy, + 'svelte/motion': svelteMotion, + 'svelte/reactivity': svelteReactivity, + 'svelte/reactivity/window': svelteReactivityWindow, + 'svelte/store': svelteStore, + 'svelte/transition': svelteTransition, + '@nanoforge-dev/editor-sdk': sdk, + '@nanoforge-dev/editor-sdk/ui': sdkUi, +}; + +let registry: SharedModuleRegistry | undefined; + +/** Installs the shared module registry once, in the browser, before any plugin is imported. */ +export const installSharedModules = (): SharedModuleRegistry => { + if (registry) return registry; + registry = new SharedModuleRegistry(); + for (const [id, namespace] of Object.entries(SHARED_MODULE_NAMESPACES)) { + registry.register(id, namespace); + } + const uninstall = registry.install(); + import.meta.hot?.dispose(uninstall); + return registry; +}; diff --git a/apps/editor/src/lib/plugin/svelte-internal.d.ts b/apps/editor/src/lib/plugin/svelte-internal.d.ts new file mode 100644 index 00000000..f4d5d3b6 --- /dev/null +++ b/apps/editor/src/lib/plugin/svelte-internal.d.ts @@ -0,0 +1,2 @@ +// `svelte/internal/client` ships without types; it is only re-exposed to plugins, never used directly. +declare module 'svelte/internal/client'; diff --git a/apps/editor/src/lib/project/AddAppDialog.svelte b/apps/editor/src/lib/project/AddAppDialog.svelte new file mode 100644 index 00000000..06fc4bf9 --- /dev/null +++ b/apps/editor/src/lib/project/AddAppDialog.svelte @@ -0,0 +1,117 @@ + + + +
{ + event.preventDefault(); + if (folderError || nameError) return; + const source = sameType.find((app) => app.id === from); + oncreate({ type, folder, name, ...(source && { from: source }) }); + close(); + }} + > +

+ {type === 'client' ? 'A game the players run.' : 'A game server.'} It is created in + apps/{folder}. +

+ + + + + {#if apps.length} +
+ Used by + {#each apps as app (app.id)} + { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, not mutated + const next = new Set(users); + if (checked) next.add(app.id); + else next.delete(app.id); + users = next; + }} + /> + {/each} +
+ {/if} +
+ + +
+
+
+ + diff --git a/apps/editor/src/lib/project/ProjectOverview.svelte b/apps/editor/src/lib/project/ProjectOverview.svelte new file mode 100644 index 00000000..0df4a811 --- /dev/null +++ b/apps/editor/src/lib/project/ProjectOverview.svelte @@ -0,0 +1,346 @@ + + +{#if $model} +
+

{$model.name}

+

{$model.location}

+ + {#if $model.diagnostics.length} +
+ {#each $model.diagnostics as diagnostic, index (index)} +

+ {diagnostic.path || '.'} + {diagnostic.message} +

+ {/each} +
+ {/if} + +
+

Apps

+ {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +
+
+ {#each apps as app (app.id)} +
+

+ {app.name} + {app.type} + {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +

+

{app.root || '.'}

+
    + {#each Object.entries(app.engineLibs) as [name, version] (name)} +
  • + {name.replace('@nanoforge-dev/', '')} + {#if /^\d/.test(version)}{version}{/if} +
  • + {:else} +
  • No engine library
  • + {/each} +
+ {#if app.libraries.length} +

Uses {app.libraries.join(', ')}

+ {/if} +
+ {/each} +
+ +
+

Shared libraries

+ {#if actions} + + {/if} +
+ {#if libraries.length} +
+ {#each libraries as library (library.id)} + {@const count = itemsOf(library)} +
+

+ {library.name} + {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +

+

+ {library.root} · {count} + {count === 1 ? 'component or system' : 'components and systems'} +

+
+ Used by + {#each apps as app (app.id)} + void actions?.setUse(app, library, checked)} + /> + {/each} +
+ {#if library.unclaimed} +

+ No app lists it in the libs of its nanoforge.config yet, so every app gets it. + Untick one to choose. +

+ {/if} +
+ {/each} +
+ {:else} +

+ A shared library holds the components and systems that several apps use: a position the + client draws and the server computes, for example. +

+ {/if} +
+ {#if actions && $request?.kind === 'library'} + void actions.addLibrary(library)} + onclose={() => actions.ask(undefined)} + /> + {:else if actions && $request?.kind === 'app'} + void actions.addApp(app)} + onclose={() => actions.ask(undefined)} + /> + {:else if actions && $request?.kind === 'remove'} + {@const target = $request.app} + actions.ask(undefined)} + > +

+ The folder {target.root} goes to the editor's trash. Undo (Ctrl+Z on this screen) + brings it back. +

+ {#snippet footer()} + + + {/snippet} +
+ {/if} +{:else} + +{/if} + + diff --git a/apps/editor/src/lib/project/register-core-panels.ts b/apps/editor/src/lib/project/register-core-panels.ts new file mode 100644 index 00000000..fc8e64a6 --- /dev/null +++ b/apps/editor/src/lib/project/register-core-panels.ts @@ -0,0 +1,47 @@ +import type { Component } from 'svelte'; + +import { + type Disposable, + DisposableStore, + type ExtensionRegistry, +} from '@nanoforge-dev/editor-kernel'; +import { + WIDGETS, + WIDGET_VIEWS, + WidgetDescriptorSchema, + type WidgetInstance, +} from '@nanoforge-dev/editor-ui'; + +import ProjectOverview from './ProjectOverview.svelte'; + +const PANELS = [ + { + id: 'core.project', + title: 'Project', + icon: 'layers', + kind: 'screen', + order: 100, + component: ProjectOverview, + }, +] as const; + +/** + * Panels of the editor itself. Plugins of later phases replace or complement them + * (file manager, history panel…). + */ +export const registerCorePanels = (extensions: ExtensionRegistry): Disposable => { + const store = new DisposableStore(); + for (const { component, ...descriptor } of PANELS) { + store.add( + extensions.contribute(WIDGETS, WidgetDescriptorSchema.parse(descriptor), { owner: 'core' }), + ); + store.add( + extensions.contribute( + WIDGET_VIEWS, + { id: descriptor.id, component: component as Component<{ instance: WidgetInstance }> }, + { owner: 'core' }, + ), + ); + } + return store; +}; diff --git a/apps/editor/src/lib/runtime/follow-project-runtime.ts b/apps/editor/src/lib/runtime/follow-project-runtime.ts new file mode 100644 index 00000000..df5c340a --- /dev/null +++ b/apps/editor/src/lib/runtime/follow-project-runtime.ts @@ -0,0 +1,392 @@ +import type { z } from 'zod'; + +import { + type CodeDiagnostic, + DiagnosticsServiceToken, + DocumentServiceToken, +} from '@nanoforge-dev/editor-code'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + type LoggerService, + MutableDisposable, + derived, + observe, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectService } from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { + type PlayMode, + RuntimeService, + RuntimeServiceToken, + RuntimeSettings, + isPlaying, +} from '@nanoforge-dev/editor-runtime'; +import type { SettingsService } from '@nanoforge-dev/editor-settings'; +import { + KEYBINDINGS, + MENU_BAR, + MENU_ITEMS, + MenuItemSchema, + type NotificationService, + STATUS_BAR_ITEMS, + TOOLBAR_ITEMS, +} from '@nanoforge-dev/editor-ui'; + +import { WorkspaceActionsToken } from '../workspace/workspace-actions'; +import { LOG_LEVELS, TOOLBAR } from './runtime-toolbar.const'; + +/** + * The game runtime of the open project: play controls (commands, toolbar, Run menu, + * shortcuts), context keys (`runtime.state`, `runtime.active`, `runtime.controllable`), game, + * build and task output for the console, and build errors as problems. + */ +export const followProjectRuntime = (options: { + services: Container; + projects: ProjectService; + rpc: RpcClient; + settings: SettingsService; + logs: LoggerService; + notifications: NotificationService; + /** Local editors only: hosted editors never run project code. */ + enabled: boolean; +}): Disposable => { + const { services, settings, logs } = options; + const store = new DisposableStore(); + const commands = services.get(CoreServices.Commands); + const contextKeys = services.get(CoreServices.ContextKeys); + const extensions = services.get(CoreServices.Extensions); + const current = store.add(new MutableDisposable()); + let runtime: RuntimeService | undefined; + + const setKeys = (state: string, controllable: boolean) => { + contextKeys.set('runtime.state', state); + contextKeys.set('runtime.active', isPlaying(state as never)); + contextKeys.set('runtime.controllable', controllable); + }; + setKeys('idle', false); + + if (options.enabled) { + store.add({ + dispose: options.projects.current.subscribe((project) => { + runtime = undefined; + current.clear(); + setKeys('idle', false); + if (!project) return; + const scope = new DisposableStore(); + const service = scope.add( + new RuntimeService({ + projectId: project.id, + rpc: options.rpc, + apps: () => project.model.get().apps, + logger: logs.getLogger('runtime'), + playMode: () => settings.get(RuntimeSettings.playMode), + selectedApp: (type) => + settings.get( + type === 'client' ? RuntimeSettings.clientApp : RuntimeSettings.serverApp, + ) || undefined, + envOverrides: () => settings.get(RuntimeSettings.env), + }), + ); + scope.add(services.provide(RuntimeServiceToken, service)); + const choices = scope.add(new DisposableStore()); + const refreshChoices = () => { + choices.clear(); + for (const [type, title] of [ + ['client', 'Client app'], + ['server', 'Server app'], + ] as const) { + const apps = service.playable(type); + if (apps.length < 2) continue; + const add = (item: z.input) => + choices.add( + extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' }), + ); + const current = service.appFor(type); + add({ menu: 'run', submenu: `run.${type}`, title, group: 'apps', order: 0 }); + for (const app of apps) { + add({ + menu: `run.${type}`, + command: 'runtime.selectApp', + args: [type, app.id], + title: app.name, + ...(app === current && { icon: 'check' }), + }); + } + } + }; + scope.add({ dispose: project.model.subscribe(refreshChoices) }); + scope.add(observe(settings.observe(RuntimeSettings.clientApp), refreshChoices)); + scope.add(observe(settings.observe(RuntimeSettings.serverApp), refreshChoices)); + scope.add({ + dispose: service.session.subscribe(({ state, controllable }) => + setKeys(state, controllable), + ), + }); + scope.add( + service.onLog(({ source, app, level, text, values, location }) => + logs.write({ + level: LOG_LEVELS[level], + source: + source === 'build' + ? `build:${app}` + : source === 'cli' + ? `cli:${app}` + : `game:${source}`, + message: text, + args: [], + time: Date.now(), + ...(values && { values }), + ...(location && { location }), + }), + ), + ); + const diagnostics = services.tryGet(DiagnosticsServiceToken); + if (diagnostics) { + const reported = new Set(); + scope.add({ + dispose: service.builds.subscribe((builds) => { + for (const [app, status] of builds) { + if (status.state === 'building') continue; + const source = `build:${app}`; + reported.add(source); + diagnostics.clear(source); + const files = new Map(); + for (const diagnostic of status.diagnostics) { + const path = diagnostic.path ?? ''; + files.set(path, [ + ...(files.get(path) ?? []), + { + path, + start: -1, + length: 0, + ...(diagnostic.line !== undefined && { line: diagnostic.line }), + ...(diagnostic.column !== undefined && { column: diagnostic.column }), + message: diagnostic.message, + severity: diagnostic.severity, + source, + }, + ]); + } + for (const [path, list] of files) diagnostics.set(source, path, list); + } + }), + }); + scope.add({ + dispose: () => { + for (const source of reported) diagnostics.clear(source); + }, + }); + } + const notified = new Set(); + scope.add({ + dispose: service.session.subscribe(({ state, compatibility }) => { + if (state !== 'running') return; + for (const check of compatibility) { + const key = `${check.source}:${check.status}`; + if (!check.message || notified.has(key)) continue; + notified.add(key); + const app = + check.status === 'legacy' + ? project.model.get().apps.find((candidate) => candidate.id === check.app) + : undefined; + const workspace = services.tryGet(WorkspaceActionsToken); + options.notifications.notify( + 'warning', + check.status === 'legacy' + ? 'The game has no editor bridge' + : 'The game engine needs an update', + { + detail: check.message, + ...(app && + workspace && { + actions: [ + { + title: 'Add the editor library', + run: () => void workspace.addEditorLibrary(app), + }, + ], + timeout: 0, + }), + }, + ); + } + }), + }); + scope.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'runtime.frameStats', + alignment: 'right', + order: 0, + when: 'runtime.active', + tooltip: 'Ticks per second and average tick duration of the running games', + text: derived([service.frameStats], (stats) => + [...stats] + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([source, { tps, tick }]) => + `${source} ${Math.round(tps)} tps · ${tick.avg.toFixed(1)} ms`, + ) + .join(' '), + ), + }, + { owner: 'core' }, + ), + ); + runtime = service; + current.value = scope; + }), + }); + } + + const withRuntime = + (run: (runtime: RuntimeService, ...args: unknown[]) => unknown) => + (_services: unknown, ...args: unknown[]) => { + if (!runtime) { + options.notifications.notify('info', 'Games can only be run from a local editor'); + return; + } + return run(runtime, ...args); + }; + + const play = async (target: RuntimeService, mode?: PlayMode) => { + const resolved = mode ?? target.defaultMode(); + const documents = services.tryGet(DocumentServiceToken); + if (documents && settings.get(RuntimeSettings.saveBeforePlay)) { + const failed = await documents.saveAll(); + if (failed.length) { + options.notifications.notify('error', `Could not save ${failed[0]!.uri} before playing`, { + detail: String(failed[0]!.error), + }); + return; + } + } + await target.play(resolved); + const session = target.session.get(); + if (session.state === 'crashed') { + const first = session.diagnostics?.[0]; + options.notifications.notify('error', session.error ?? 'The game crashed', { + ...(first && { + detail: + `${first.path ?? ''}${first.line ? `:${first.line}` : ''} ${first.message}`.trim(), + }), + }); + } + }; + + store.add( + commands.register({ + id: 'runtime.selectApp', + title: 'Choose the app Play starts', + category: 'Run', + palette: false, + handler: async (_services, type: unknown, app: unknown) => { + if ((type !== 'client' && type !== 'server') || typeof app !== 'string') return; + await settings.set( + type === 'client' ? RuntimeSettings.clientApp : RuntimeSettings.serverApp, + app, + settings.hasStore('projectLocal') ? 'projectLocal' : 'project', + ); + }, + }), + ); + + for (const [id, title, handler] of [ + ['runtime.play', 'Play', withRuntime((target, mode) => play(target, mode as PlayMode))], + ['runtime.playClient', 'Play client only', withRuntime((target) => play(target, 'client'))], + ['runtime.playServer', 'Play server only', withRuntime((target) => play(target, 'server'))], + ['runtime.pause', 'Pause', withRuntime((target) => target.pause())], + ['runtime.resume', 'Resume', withRuntime((target) => target.resume())], + ['runtime.step', 'Step one frame', withRuntime((target) => target.step())], + ['runtime.stop', 'Stop', withRuntime((target) => target.stop())], + ['runtime.restart', 'Restart', withRuntime((target) => target.restart())], + [ + 'runtime.reload', + 'Reload editor runtime', + withRuntime(async (target) => { + await target.stop(); + location.reload(); + }), + ], + [ + 'runtime.openClient', + 'Open another client', + withRuntime(() => { + const project = options.projects.current.get(); + const app = runtime?.appFor('client'); + if (!project || !app) return; + const url = new URL(`/play/${project.id}`, location.origin); + url.searchParams.set('app', app.id); + const env = settings.get(RuntimeSettings.env); + if (Object.keys(env).length) url.hash = `env=${btoa(JSON.stringify(env))}`; + window.open(url, '_blank', 'noopener'); + }), + ], + ] as const) { + store.add(commands.register({ id, title, category: 'Run', handler })); + } + + for (const item of TOOLBAR) + store.add(extensions.contribute(TOOLBAR_ITEMS, item, { owner: 'core' })); + store.add( + extensions.contribute(MENU_BAR, { id: 'run', title: 'Run', order: 3 }, { owner: 'core' }), + ); + for (const item of [ + { menu: 'run', command: 'runtime.play', group: 'play', order: 0, when: '!runtime.active' }, + { + menu: 'run', + command: 'runtime.playClient', + group: 'play', + order: 1, + when: '!runtime.active', + }, + { + menu: 'run', + command: 'runtime.playServer', + group: 'play', + order: 2, + when: '!runtime.active', + }, + { + menu: 'run', + command: 'runtime.pause', + group: 'control', + order: 0, + when: "runtime.state == 'running' && runtime.controllable", + }, + { + menu: 'run', + command: 'runtime.resume', + group: 'control', + order: 1, + when: "runtime.state == 'paused'", + }, + { + menu: 'run', + command: 'runtime.step', + group: 'control', + order: 2, + when: "runtime.state == 'paused'", + }, + { menu: 'run', command: 'runtime.restart', group: 'control', order: 3, when: 'runtime.active' }, + { menu: 'run', command: 'runtime.stop', group: 'control', order: 4, when: 'runtime.active' }, + { menu: 'run', command: 'runtime.openClient', group: 'z-clients' }, + { menu: 'run', command: 'runtime.reload', group: 'z-reload' }, + ]) { + store.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); + } + for (const binding of [ + { key: 'F5', command: 'runtime.play', when: '!runtime.active', args: [] }, + { key: 'F5', command: 'runtime.resume', when: "runtime.state == 'paused'", args: [] }, + { key: 'Shift+F5', command: 'runtime.stop', when: 'runtime.active', args: [] }, + { key: 'Mod+Shift+F5', command: 'runtime.restart', when: 'runtime.active', args: [] }, + ]) { + store.add(extensions.contribute(KEYBINDINGS, binding, { owner: 'core' })); + } + + return store; +}; diff --git a/apps/editor/src/lib/runtime/runtime-toolbar.const.ts b/apps/editor/src/lib/runtime/runtime-toolbar.const.ts new file mode 100644 index 00000000..b6863ae4 --- /dev/null +++ b/apps/editor/src/lib/runtime/runtime-toolbar.const.ts @@ -0,0 +1,59 @@ +import { LogLevel } from '@nanoforge-dev/editor-kernel'; + +export const LOG_LEVELS = { + debug: LogLevel.Debug, + info: LogLevel.Info, + warn: LogLevel.Warn, + error: LogLevel.Error, +} as const; + +export const TOOLBAR = [ + { + id: 'runtime.play', + icon: 'play', + title: 'Play (F5)', + command: 'runtime.play', + order: 0, + when: '!runtime.active', + }, + { + id: 'runtime.pause', + icon: 'pause', + title: 'Pause', + command: 'runtime.pause', + order: 1, + when: "runtime.state == 'running' && runtime.controllable", + }, + { + id: 'runtime.resume', + icon: 'play', + title: 'Resume (F5)', + command: 'runtime.resume', + order: 1, + when: "runtime.state == 'paused'", + }, + { + id: 'runtime.step', + icon: 'step-forward', + title: 'Step one frame', + command: 'runtime.step', + order: 2, + when: "runtime.state == 'paused'", + }, + { + id: 'runtime.restart', + icon: 'rotate-ccw', + title: 'Restart (Ctrl+Shift+F5)', + command: 'runtime.restart', + order: 3, + when: 'runtime.active', + }, + { + id: 'runtime.stop', + icon: 'square', + title: 'Stop (Shift+F5)', + command: 'runtime.stop', + order: 4, + when: 'runtime.active', + }, +]; diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts new file mode 100644 index 00000000..1ee0c4e0 --- /dev/null +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -0,0 +1,313 @@ +import { DocumentServiceToken } from '@nanoforge-dev/editor-code'; +import type { HistoryContext, HistoryService } from '@nanoforge-dev/editor-history'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + EDITOR_LIBRARY, + type NewApp, + type NewLibrary, + type ProjectService, + type Undo, + WorkspaceError, + type WorkspaceIo, + addEditorLibrary, + createApp, + createLibrary, + removeApp, + removeLibrary, + renameApp, + renameLibrary, + runnableApps, + setLibraryUse, + validatePackageName, + workspaceIo, +} from '@nanoforge-dev/editor-project'; +import { type AppModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import type { NotificationService, PromptService } from '@nanoforge-dev/editor-ui'; + +/** Undo and redo target of the Project screen: changes of the workspace. */ +export const PROJECT_HISTORY_CONTEXT = 'project'; + +/** A dialog the Project screen is asked to show. */ +export type WorkspaceRequest = + | { readonly kind: 'library' } + | { readonly kind: 'app'; readonly type: 'client' | 'server' } + | { readonly kind: 'remove'; readonly app: AppModel }; + +export interface WorkspaceActionsOptions { + readonly services: Container; + readonly projects: ProjectService; + readonly rpc: RpcClient; + readonly history: HistoryService; + readonly notifications: NotificationService; + readonly prompts: PromptService; + /** Local editors install dependencies; hosted ones never run project tools. */ + readonly local: boolean; +} + +/** + * What the Project screen does to the workspace: add, rename and remove apps and shared + * libraries, and choose which apps use a library. Each action is one step of the Project + * history; failures are reported, never thrown. + */ +export class WorkspaceActions implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _request = new ObservableValue(undefined); + private readonly _stack: HistoryContext['stack']; + private readonly _hasSteps = new ObservableValue(false); + + constructor(private readonly _options: WorkspaceActionsOptions) { + const context = this._store.add( + _options.history.registerContext({ + id: PROJECT_HISTORY_CONTEXT, + label: 'Project', + owner: 'core', + }), + ); + this._stack = context.stack; + this._store.add( + context.stack.onDidChange(({ canUndo, canRedo }) => this._hasSteps.set(canUndo || canRedo)), + ); + const commands = _options.services.get(CoreServices.Commands); + const open = (request: WorkspaceRequest) => async () => { + await commands.execute('workbench.openWidget', 'core.project'); + this._request.set(request); + }; + for (const [id, title, request] of [ + ['project.addLibrary', 'Add shared library…', { kind: 'library' }], + ['project.addClientApp', 'Add client app…', { kind: 'app', type: 'client' }], + ['project.addServerApp', 'Add server app…', { kind: 'app', type: 'server' }], + ] as const) + this._store.add( + commands.register({ id, title, category: 'Project', handler: open(request) }), + ); + } + + /** + * Whether the Project history has a step to undo or redo. The screen is the target of undo + * only then: with none, `Ctrl+Z` there keeps undoing the layout, as on a screen without history. + */ + get hasSteps(): Observable { + return this._hasSteps.readonly(); + } + + /** The dialog asked for (by a command, or a button of the screen). */ + get request(): Observable { + return this._request.readonly(); + } + + ask(request: WorkspaceRequest | undefined): void { + this._request.set(request); + } + + get local(): boolean { + return this._options.local; + } + + /** The apps a new library is used by, and that can use one: the clients and servers. */ + apps(): AppModel[] { + const project = this._project(); + return project ? runnableApps(project.model.get()) : []; + } + + addLibrary(library: NewLibrary): Promise { + return this._run(`Add shared library ${library.packageName}`, (io) => + createLibrary(io, library), + ); + } + + async addApp(app: NewApp): Promise { + const added = await this._run(`Add ${app.type} app ${app.name}`, (io, project) => + createApp(io, project.model.get(), app), + ); + if (added && !app.from) this._offerInstall(app.name); + return added; + } + + /** + * Makes an app use a library, or stop. A library no app lists yet is given to them all: the + * first choice writes it down for every app that keeps it. + */ + setUse(app: AppModel, library: AppModel, used: boolean): Promise { + return this._run( + `${used ? 'Use' : 'Stop using'} ${library.name} in ${app.name}`, + async (io, project) => { + const undos: Undo[] = []; + if (library.unclaimed && !used) { + for (const other of runnableApps(project.model.get())) + if (other.id !== app.id) undos.push(await setLibraryUse(io, other, library, true)); + } + undos.push(await setLibraryUse(io, app, library, used)); + return async () => { + for (const undo of undos.reverse()) await undo(); + }; + }, + ); + } + + /** Asks for the new package name of an app or a library, then renames it. */ + async rename(app: AppModel): Promise { + const project = this._project(); + if (!project) return false; + const name = await this._options.prompts.ask({ + title: `Rename ${app.name}`, + label: app.type === 'lib' ? 'Package name (how apps import it)' : 'Name', + value: app.name, + confirm: 'Rename', + validate: (value) => + validatePackageName(value) ?? + (value !== app.name && project.model.get().apps.some((other) => other.name === value) + ? `${value} is already the name of an app or library.` + : undefined), + }); + if (!name || name === app.name) return false; + if (app.type === 'lib') { + const failed = await this._options.services.tryGet(DocumentServiceToken)?.saveAll(); + if (failed?.length) { + this._options.notifications.notify('error', `Could not save ${failed[0]!.uri}`, { + detail: 'Save or revert it, then rename the library again.', + }); + return false; + } + } + return this._run(`Rename ${app.name} to ${name}`, (io) => + app.type === 'lib' + ? renameLibrary(io, project.model.get(), app, name) + : renameApp(io, project.model.get(), app, name), + ); + } + + remove(app: AppModel): Promise { + return this._run(`Remove ${app.name}`, (io, project) => + app.type === 'lib' + ? removeLibrary(io, project.model.get(), app) + : removeApp(io, project.model.get(), app), + ); + } + + /** + * Registers the engine's editor library in an app (its `package.json` and entry file), so + * the editor can pause it and show it live. It takes effect at the next Play. + */ + async addEditorLibrary(app: AppModel): Promise { + const added = await this._run(`Add the editor library to ${app.name}`, (io) => + addEditorLibrary(io, app), + ); + if (!added) return false; + if (EDITOR_LIBRARY in app.engineLibs) { + this._options.notifications.notify('info', `${app.name} registers the editor library`, { + detail: 'Play again to use it.', + }); + } else + this._offerInstall( + app.name, + 'It now depends on @nanoforge-dev/editor-lib: install it, then play again.', + ); + return true; + } + + /** Whether the folder of an app can be shown in the machine's file manager. */ + get canReveal(): boolean { + return ( + this._options.local && + this._options.services.get(CoreServices.Commands).has('fileManager.reveal') + ); + } + + reveal(app: AppModel): void { + void this._options.services.get(CoreServices.Commands).execute('fileManager.reveal', app.root); + } + + /** Runs the project's package manager; its output goes to the Console. */ + async install(): Promise { + const project = this._project(); + if (!project) return; + const { notifications } = this._options; + try { + const { manager, ok } = await this._options.rpc + .api(ProjectsContract) + .install({ id: project.id }); + if (!manager) { + notifications.notify('info', 'Install the dependencies yourself', { + detail: + "The project has no lockfile, so the editor can't tell which package manager it uses. Run its install command in the project's folder.", + timeout: 0, + }); + } else if (ok) notifications.notify('info', `Dependencies installed with ${manager}`); + else + notifications.notify('error', `${manager} install failed`, { + detail: 'Its output is in the Console, under Tasks.', + }); + } catch (error) { + notifications.notify('error', 'Could not install the dependencies', { + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + dispose(): void { + this._store.dispose(); + } + + private _project(): ClientProject | undefined { + return this._options.projects.current.get(); + } + + private _offerInstall( + name: string, + detail = 'A new app has no node_modules yet: install them before playing it.', + ): void { + if (!this._options.local) return; + this._options.notifications.notify('info', `${name} needs its dependencies`, { + detail, + actions: [{ title: 'Install dependencies', run: () => this.install() }], + timeout: 0, + }); + } + + /** One undoable step of the Project history; a refusal is shown with the files in the way. */ + private async _run( + label: string, + operation: (io: WorkspaceIo, project: ClientProject) => Promise, + ): Promise { + const project = this._project(); + if (!project) return false; + const io = workspaceIo(project); + let undo: Undo | undefined; + try { + await this._stack.push({ + label, + do: async () => { + undo = await operation(io, project); + await project.fs.refresh(); + }, + undo: async () => { + await undo?.(); + await project.fs.refresh(); + }, + }); + return true; + } catch (error) { + const paths = error instanceof WorkspaceError ? error.paths : []; + this._options.notifications.notify('error', `${label}: not possible`, { + detail: `${error instanceof Error ? error.message : String(error)}${ + paths.length + ? ` (${paths.slice(0, 5).join(', ')}${paths.length > 5 ? `, and ${paths.length - 5} more` : ''})` + : '' + }`, + }); + return false; + } + } +} + +export const WorkspaceActionsToken = createToken('editor.workspaceActions'); diff --git a/apps/editor/src/routes/(editor)/+layout.svelte b/apps/editor/src/routes/(editor)/+layout.svelte new file mode 100644 index 00000000..dd95efa6 --- /dev/null +++ b/apps/editor/src/routes/(editor)/+layout.svelte @@ -0,0 +1,64 @@ + + + + NanoForge Editor + + +{#if editor} + {@render children()} +{:else} + +{/if} diff --git a/apps/editor/src/routes/(editor)/+page.svelte b/apps/editor/src/routes/(editor)/+page.svelte new file mode 100644 index 00000000..99d88360 --- /dev/null +++ b/apps/editor/src/routes/(editor)/+page.svelte @@ -0,0 +1,383 @@ + + +
+
+

NanoForge

+

+ {#if session.user}{session.user.name}{:else}Not signed in{/if} + {session.mode === 'OFFLINE' ? 'Local editor' : 'Online editor'} {session.version} +

+
+ + {#if session.mode === 'ONLINE' && !session.user} +
+

Sign in to open your projects

+

Your projects live on your NanoForge account.

+ +
+ {:else} +
+ {#if session.mode === 'OFFLINE'} +
{ + event.preventDefault(); + if (path.trim()) void open({ path: path.trim() }); + }} + > + + +
+ + {/if} +
+ + {#if busy}

{busy}…

{/if} + {#if error}{/if} + + {#if gateways.length} +
+

Your projects

+
    + {#each gateways as project (project.gatewayId)} +
  • + +
  • + {/each} +
+
+ {/if} + +
+

Recent

+ {#if recent.length} +
    + {#each recent as project (project.id)} +
  • + + void forget(project)} + /> +
  • + {/each} +
+ {:else} +

Projects you open appear here.

+ {/if} +
+ {/if} +
+ + +
{ + event.preventDefault(); + if (draft.name && !nameError) void create(); + }} + > + + {#if nameError}

{nameError}

{/if} + +
+ + Multiplayer (adds a server app) +
+ +
+ + +
+ +
+ + diff --git a/apps/editor/src/routes/(editor)/load/+page.svelte b/apps/editor/src/routes/(editor)/load/+page.svelte new file mode 100644 index 00000000..9c4e7b47 --- /dev/null +++ b/apps/editor/src/routes/(editor)/load/+page.svelte @@ -0,0 +1,126 @@ + + +{#if error} + +{:else} + +{/if} diff --git a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte new file mode 100644 index 00000000..2cf5618d --- /dev/null +++ b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte @@ -0,0 +1,200 @@ + + +{#if workbench} + + {#snippet brand()} + { + event.preventDefault(); + void goto('/'); + }}>NanoForge + {/snippet} + +{:else if error} + +{:else} + +{/if} + + diff --git a/apps/editor/src/routes/+layout.svelte b/apps/editor/src/routes/+layout.svelte new file mode 100644 index 00000000..d487f580 --- /dev/null +++ b/apps/editor/src/routes/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} + + diff --git a/apps/editor/src/routes/+layout.ts b/apps/editor/src/routes/+layout.ts new file mode 100644 index 00000000..83addb7e --- /dev/null +++ b/apps/editor/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = false; diff --git a/apps/editor/src/routes/play/[id]/+page.svelte b/apps/editor/src/routes/play/[id]/+page.svelte new file mode 100644 index 00000000..faec046a --- /dev/null +++ b/apps/editor/src/routes/play/[id]/+page.svelte @@ -0,0 +1,76 @@ + + + + {app ? `${app.split('/').at(-1)} · ` : ''}NanoForge game + + +
+ {#if error} + + {/if} +
+ + diff --git a/public/favicon.svg b/apps/editor/static/favicon.svg similarity index 100% rename from public/favicon.svg rename to apps/editor/static/favicon.svg diff --git a/public/robots.txt b/apps/editor/static/robots.txt similarity index 100% rename from public/robots.txt rename to apps/editor/static/robots.txt diff --git a/apps/editor/svelte.config.js b/apps/editor/svelte.config.js new file mode 100644 index 00000000..ec36c1c5 --- /dev/null +++ b/apps/editor/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** + * The UI is a static single page app (served by the editor's Bun server, see server/main.ts): + * all server logic lives behind the RPC endpoint, never in SvelteKit. + */ +/** @type {import('@sveltejs/kit').Config} */ +export default { + preprocess: vitePreprocess(), + compilerOptions: { runes: true }, + kit: { + adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', strict: false }), + }, +}; diff --git a/apps/editor/test/plugin/shared-modules.spec.ts b/apps/editor/test/plugin/shared-modules.spec.ts new file mode 100644 index 00000000..ac4b9678 --- /dev/null +++ b/apps/editor/test/plugin/shared-modules.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SHARED_GLOBAL as KERNEL_GLOBAL } from '@nanoforge-dev/editor-kernel'; +import { MAIN_SHARED_MODULES, SHARED_GLOBAL } from '@nanoforge-dev/editor-vite-plugin'; + +import { SHARED_MODULE_NAMESPACES } from '../../src/lib/plugin/shared-modules'; + +vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({})); + +describe('shared modules', () => { + it('provides exactly the modules plugins are built against', () => { + expect(Object.keys(SHARED_MODULE_NAMESPACES).sort()).toEqual([...MAIN_SHARED_MODULES].sort()); + }); + + it('uses the global name the plugin build rewrites to', () => { + expect(KERNEL_GLOBAL).toBe(SHARED_GLOBAL); + }); +}); diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json new file mode 100644 index 00000000..bff00c5c --- /dev/null +++ b/apps/editor/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "bundler", + "skipLibCheck": true, + "types": ["bun"] + } +} diff --git a/apps/editor/vite.config.ts b/apps/editor/vite.config.ts new file mode 100644 index 00000000..089254dc --- /dev/null +++ b/apps/editor/vite.config.ts @@ -0,0 +1,93 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { type ChildProcess, spawn } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { type Plugin, defineConfig, searchForWorkspaceRoot } from 'vite'; + +import pkg from './package.json' with { type: 'json' }; + +const API_PORT = Number(process.env.EDITOR_API_PORT ?? 5174); + +/** Built-in plugins run as dev plugins in dev: rebuilt by `vite build --watch`, hot reloaded. */ +const PLUGINS_ROOT = join(import.meta.dirname, '../../plugins'); +const builtInPlugins = existsSync(PLUGINS_ROOT) + ? readdirSync(PLUGINS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(PLUGINS_ROOT, entry.name)) + : []; + +/** + * Dev only: runs the editor's Bun server (RPC, WebSocket, plugins) next to Vite, which proxies + * those routes to it. `bun --watch` restarts it when server code changes. + */ +const editorServer = (): Plugin => { + let child: ChildProcess | undefined; + return { + name: 'nanoforge-editor-server', + apply: 'serve', + configureServer(server) { + child = spawn('bun', ['--watch', 'server/main.ts'], { + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: 'development', + PORT: String(API_PORT), + EDITOR_NO_STATIC: '1', + FS_ROOT: process.env.FS_ROOT ?? process.env.INIT_CWD ?? process.cwd(), + DEV_PLUGINS: [...builtInPlugins, process.env.DEV_PLUGINS ?? ''].filter(Boolean).join(','), + }, + }); + server.httpServer?.once('close', () => child?.kill()); + }, + }; +}; + +/** + * `ANALYZE=1 pnpm build`: prints, for each big chunk of the client build, its heaviest modules, + * and how TypeScript got into a chunk when it did (it belongs to the code worker only). + */ +const analyzeBundle = (): Plugin => ({ + name: 'nanoforge-analyze-bundle', + apply: 'build', + generateBundle(_options, bundle) { + const short = (id: string) => + id.replace(/^.*\/node_modules\/(\.pnpm\/[^/]+\/node_modules\/)?/, ''); + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk' || chunk.code.length < 200_000) continue; + const modules = Object.entries(chunk.modules) + .map(([id, module]) => ({ id, size: module.renderedLength })) + .sort((a, b) => b.size - a.size); + console.log(`\n${chunk.fileName}: ${(chunk.code.length / 1024).toFixed(0)} KiB`); + for (const module of modules.slice(0, 6)) + console.log(` ${(module.size / 1024).toFixed(0).padStart(6)} KiB ${short(module.id)}`); + const compiler = modules.find(({ id }) => /node_modules\/(typescript|@ts-morph)\//.test(id)); + if (!compiler || chunk.fileName.includes('worker')) continue; + const chain = [compiler.id]; + for (let id = compiler.id; chain.length < 12;) { + const importer = this.getModuleInfo(id)?.importers.find((next) => !chain.includes(next)); + if (!importer) break; + chain.push(importer); + id = importer; + } + console.log( + ` TypeScript is in this chunk through:\n ${chain.map(short).join('\n <- ')}`, + ); + } + }, +}); + +export default defineConfig({ + plugins: [sveltekit(), editorServer(), ...(process.env.ANALYZE ? [analyzeBundle()] : [])], + define: { __EDITOR_VERSION__: JSON.stringify(pkg.version) }, + worker: { format: 'es' }, + server: { + fs: { allow: [searchForWorkspaceRoot(process.cwd())] }, + proxy: { + '/rpc/ws': { target: `ws://127.0.0.1:${API_PORT}`, ws: true }, + '/rpc': { target: `http://127.0.0.1:${API_PORT}` }, + '/plugins': { target: `http://127.0.0.1:${API_PORT}` }, + '/runtime': { target: `http://127.0.0.1:${API_PORT}` }, + '/healthz': { target: `http://127.0.0.1:${API_PORT}` }, + }, + }, +}); diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 50fe89a0..00b811e2 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,4 +1,34 @@ +const PACKAGES = [ + 'editor', + 'kernel', + 'sdk', + 'rpc', + 'history', + 'settings', + 'layout', + 'meta', + 'ui', + 'project', + 'code', + 'runtime', + 'server-core', + 'registry', +]; + +const { existsSync, readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +/** Built-in plugins (`plugins/`) are scopes too. */ +const PLUGINS_DIR = join(__dirname, 'plugins'); +const PLUGINS = existsSync(PLUGINS_DIR) ? readdirSync(PLUGINS_DIR) : []; + module.exports = { extends: ['@commitlint/config-conventional'], - rules: {}, + rules: { + 'scope-enum': [ + 1, + 'always', + [...PACKAGES, ...PLUGINS, 'deps', 'ci', 'release', 'docs', 'tooling', 'specs'], + ], + }, }; diff --git a/components.json b/components.json deleted file mode 100644 index a74b7b46..00000000 --- a/components.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://shadcn-svelte.com/schema.json", - "style": "nova", - "aliases": { - "lib": "$lib", - "components": "$lib/components/ui", - "hooks": "$lib/hooks", - "utils": "@utils/ui" - }, - "tailwind": { - "baseColor": "neutral", - "css": "src/app.css" - }, - "typescript": true -} diff --git a/docs/adr/0001-plugin-ui-runtime.md b/docs/adr/0001-plugin-ui-runtime.md new file mode 100644 index 00000000..07e2b57c --- /dev/null +++ b/docs/adr/0001-plugin-ui-runtime.md @@ -0,0 +1,59 @@ +# ADR 0001 — Plugin UI runtime: shared modules through a build-time rewrite + +- Status: accepted (phase 1.1 spike, GO) +- Date: 2026-09-26 + +## Context + +Editor plugins are built separately from the editor (bundled, marketplace or local-dev plugins) +and render Svelte 5 widgets inside the editor. A compiled Svelte component imports +`svelte/internal/client`. If a plugin bundled its own copy of the runtime, its widgets would not +share reactivity, contexts or effect scheduling with the host. + +The plan's first idea was an **import map** that points `svelte/*` to the host's copy. It does +not work reliably: in a SvelteKit/Vite production build, the runtime is inlined into hashed +chunks with no stable URL. In dev it is served from `.vite/deps` with a version hash. Shims +re-exporting the runtime would need the exact export names of every module. + +## Decision + +Plugins are built with `@nanoforge-dev/editor-vite-plugin` (`tooling/vite-plugin`): + +- Shared modules (`SHARED_MODULES`: the Svelte client modules and `@nanoforge-dev/editor-sdk`) + are externalized. +- In `renderChunk`, every import of a shared module is rewritten into a lookup: + `globalThis.__nanoforge_editor_shared__.require('')`. This covers namespace, + named, default and dynamic imports. Re-exports are rejected. +- Side-effect-only modules the host already evaluated (`disclose-version`, `flags/tracing`) are + dropped. +- Modules that would flip global runtime flags (`flags/legacy`, `flags/async`) are a build + error, so **plugins must compile in runes mode**. + +The host fills a `SharedModuleRegistry` (kernel) with its own module namespaces and installs it +before importing any plugin (`apps/editor/src/lib/plugin/shared-modules.ts`). Plugin output +stays plain ESM with relative chunks, and nothing depends on the host's chunk names. + +## Verification + +_Update (phase 5): the spike route, fixture and check script were replaced by the end-to-end test `apps/editor/e2e/workbench.spec.ts` ("runs a local plugin built against the shared runtime"), which loads `tooling/example-plugin` in the real editor._ + +`tooling/spike-plugin` (an externally built widget) is rendered by `/__spike` as a child +component of a host component. `apps/editor/e2e/plugin-runtime.check.mjs` checks all of these, +in **dev (vite) and prod (bun build)**, and both pass: + +- a context set by the host is readable by the plugin (`getContext`); +- the host's `$state` passed as a prop drives the plugin's `$derived`, and the plugin's mutations + are seen by the host; +- the plugin's local `$state` works; +- scoped CSS applies; +- there are no console errors. + +## Consequences + +- Compiled Svelte output depends on the runtime's internal API. A plugin compiled with a newer + Svelte than the host may call internals the host lacks. The build will record the Svelte + version in the built manifest, and the plugin host will require + `same major && compiled <= host` (phase 1.13). +- Plugins must be built with the NanoForge vite plugin (`nf create plugin` sets this up). +- Fallback B from the plan (framework-agnostic `mount(el)` widgets) stays supported for + non-Svelte widgets. It is not needed as a fallback. diff --git a/docs/adr/0002-editor-server.md b/docs/adr/0002-editor-server.md new file mode 100644 index 00000000..62e6b6b7 --- /dev/null +++ b/docs/adr/0002-editor-server.md @@ -0,0 +1,51 @@ +# ADR 0002 — Editor server: Bun RPC server + static SvelteKit UI + +- Status: accepted (phase 2) +- Date: 2026-09-26 + +## Context + +The previous editor ran all server logic through SvelteKit form actions and hooks +(`svelte-adapter-bun`). The rewrite needs: + +- a typed RPC API shared with plugins' server entries; +- a WebSocket for file changes, model updates and dev plugin reloads; +- plugin file serving. + +`nf editor` must still start one process (`bun dist/index.js`). + +## Decision + +- **UI**: SvelteKit builds a static single-page app (`@sveltejs/adapter-static`, SPA fallback, + `ssr = false`). SvelteKit only handles routing and rendering. +- **Server**: `apps/editor/server/main.ts` runs `Bun.serve` around `createEditorServer` + (`packages/server-core`). The server is transport-agnostic and testable in Node. It serves: + - `POST /rpc/.`: typed calls, zod-validated, binary-safe JSON; + - `GET /rpc/ws`: one multiplexed WebSocket for stream subscriptions; + - `GET /plugins//@///*`: plugin files; + - the built UI with an SPA fallback, and `/healthz`. +- **Dev**: `vite dev` starts the Bun server (`bun --watch`) through a Vite plugin and proxies + `/rpc`, `/rpc/ws` and `/plugins` to it. +- **Contracts**: `packages/protocol` holds the contracts shared by client and server. Plugins + implement their own under the namespace `plugin.`. + +## Security + +- Every RPC call and WebSocket upgrade is checked against the `Origin` header, so a web page on + another origin cannot drive a local editor. OFFLINE editors bind to `127.0.0.1` by default. +- Project paths are jailed to `FS_ROOT`, and file paths to the project root, including through + symlinks. Deletes go to `.nanoforge/editor/trash`. +- **Config loading**: + - OFFLINE executes `nanoforge.config.*` with `unrun`, like the CLI does. Everything but Node + built-ins is bundled (unrun imports from `/node_modules/.unrun`), and + `@nanoforge-dev/config` is a virtual module, so fresh clones load. + - ONLINE never executes project code: configs are read statically, literal values only. +- ONLINE sessions reuse the `accessToken`/`refreshToken` cookies set by the NanoForge projects + website. The API client refreshes them through `POST /auth/refresh-token`. + +## Consequences + +- SvelteKit server features (load functions, form actions) are not used. +- Any server feature is an RPC contract, which plugins can also provide. +- Hosted (ONLINE) deployments still run `nf` CLI commands on project code, as the previous + editor did. They must isolate projects (containers) until a sandbox exists. diff --git a/docs/adr/0003-component-and-system-docs.md b/docs/adr/0003-component-and-system-docs.md new file mode 100644 index 00000000..6d300051 --- /dev/null +++ b/docs/adr/0003-component-and-system-docs.md @@ -0,0 +1,168 @@ +# ADR 0003 — Items: TSDoc in the source, editor-generated meta, owner-keyed data + +- Status: accepted (2026-09-27). The field-level schema is a draft and may change. +- Schema: `docs/drafts/item-schema.ts` (zod, every value commented) +- Visual summary: https://claude.ai/artifact/RNu5aEEUCBcPodtAnbtdNJ +- Example: `engine/example/pong-network/nf_modules/@nanoforge/` and its + `.nanoforge/editor/cache/meta/` + +## Context + +The editor needs to know, for each thing a package provides (today: ECS components and systems): +its name, what it does, its parameters (types, defaults), which side it runs on, and the data a +specific lib needs (for ECS: the registry name, the components a system queries, the `ctx` libs it +uses). + +The previous plan read `EDITOR_COMPONENT_MANIFEST` / `EDITOR_SYSTEM_MANIFEST` literals +(`engine/modules/ecs/src/shared/editor-manifest.type.ts`). They duplicate the code and drift from it. + +Dependencies will be packages installed in the read-only `nf_modules/`. The editor must read them +without scanning folders. + +What the real code forces: + +- Components are classes with **positional constructors** (`new Velocity(x, y)`) and a + `name = "X"` field that the ECS registry keys on. +- Parameters can be **engine objects** (`Rect`, `Circle` from graphics-2d) or **engine enums** + (`InputEnum`), not just primitives. +- Client and server sets differ: every item has a **side**. + +## Decision + +### Names + +Every name in our registry is `@scope/name`, like a scoped npm package: packages, editor plugins, +owner keys, and the prefix of plugin settings (`@nanoforge/viewport.zoom`). The editor enforces it +(`PLUGIN_NAME_PATTERN`). The CLI is not changed yet. + +### One shape at every level + +The manifest, each item, and each element (a parameter or a field) have three parts: + +1. **Core fields**, understood by the editor with no plugin: names, source, description, + TypeScript types, `side`. +2. **`requires`**: the npm libs needed to use it (manifest: `libDependencies`, `npmDependencies`; + item: the libs its source imports; element: implied by `ref` / `enumRef`). +3. **Owner objects**: extra keys named after an editor plugin (`"@nanoforge/ecs": { … }`). Only that + plugin parses them. Core fields never contain `/`, so they can't clash with owner keys, and any + other unknown key is an error. + +Nothing in the core is shaped for one lib. There is no `component` or `system` type: an ECS +component is an item with `"@nanoforge/ecs": { "type": "component", "name": "Position" }`. + +The core never looks inside an owner object. An item whose owner plugin isn't installed stays in +the catalog as a generic item. + +### Manifests + +`nanoforge.manifest.json`: + +- `type`: `package` or `plugin`. The two are separate: a package never holds a plugin. +- `items`: source files, relative to the manifest, whose exports can be items. +- `include`: sub-folders holding their own `package` manifest. The catalog walks `include`, it + never scans. An include must stay inside the package (no `..`, no absolute path), and nesting + stops at 4 levels. +- `suggestedPlugins`: editor plugins that make the package easier to use, as `["@scope/name"]` or + `{ "@scope/name": "^1.0.0" }`. +- `dependencies` (our registry), `libDependencies`, `npmDependencies`, `publish` as before. +- Owner objects for package-level data. + +**Where things are installed.** + +| What | Where | Loaded | +| -------- | ---------------------------------------------- | -------------------------- | +| Packages | `/nf_modules/@scope/name` (read-only) | by the catalog | +| Plugins | `~/.nanoforge/editor/plugins/@scope/name` | in every project | +| Plugins | `/.nanoforge/plugins/@scope/name` | while that project is open | + +A project's plugin shadows the same plugin installed in the home folder (then come dev plugins). +Its server entry only runs in a local editor: a hosted editor never runs project code on the +server. + +**Suggested plugins.** When a project opens, the editor collects the `suggestedPlugins` of the +packages in `nf_modules` (following `include`) and names the ones that aren't installed in a +notification, once per project. Installing is manual for now. + +An editor plugin that owns a key declares it in its manifest, so the editor can suggest it when it +meets one of its tags: + +```json +"contributes": { "itemOwner": { "schema": 1, "tags": ["component", "system", "query", "uses"] } } +``` + +### Docs are TSDoc in the source + +The `.ts` file is the only source of truth. + +- **Core tags:** the summary, `@remarks`, `@param`, `@example`, `@side client|server|shared` + (default `shared`), `@asset [param] [.ext…]`, `@deprecated`, `@internal`. +- **Core layout tags** (how params look in the inspector, see `docs/specs/ecs.md`, _Param layout_): + on the item, `@group [color=] [hidden] - `; on a param (its inline doc, + or a field's doc), `@group `, `@preset [.]`, `@label `, `@color `, + `@hidden`. They are core, not owner tags: any owner plugin's inspector can use them. +- **Owner tags** are declared by the owner plugin and read only by it. `@nanoforge/ecs` declares + `@component`, `@system`, and `@query` / `@uses` (overrides when the analyzer can't infer them). + Two installed plugins claiming the same tag is an error that names both. +- **Inferred from code:** types, defaults, optional, string literal unions (`enum`), enum types + (`enumRef`), lib classes (`ref`), imports (`requires`). The ECS plugin infers the registry `name`, + `query` (each `registry.getZipper([...])`) and `uses` (each `ctx.`). +- An owner **claims** members it describes itself, which are then left out of `params` / `fields`: + ECS claims a component's `name` field and a system's `(registry, ctx)` parameters. + +An export is an item when it's in a file listed in `items`, or, in the project's apps (no manifest), +when an owner tag is on it. + +### Elements + +Core types describe TypeScript, not a lib: `string` (`enum`, `enumRef`), `number`, `boolean`, +`asset` (a project file path, `accept`), `array` (`items`), `object` (`properties`), `ref` +(`"@nanoforge-dev/graphics-2d#Rect"`), `unknown` (`tsType`, edited as code). The default is +`default` when it's a JSON value, `defaultCode` (source text) otherwise. A `ref` is edited by the +plugin that contributes an editor for it; otherwise it's shown as code. + +### The editor generates the meta, into its cache only + +Owner data can only come from owner plugins, and they run in the editor. So the editor is the +only producer of metadata: + +- **Extraction runs in the code worker**, in its own package (`@nanoforge-dev/editor-meta`) so it + can later run without the UI. The core extractor produces the core parts; each owner plugin + registers, from its `entry.worker`, its key, tags, claimed members, a zod schema and `extract()`. +- **The meta lives only in `.nanoforge/editor/cache/meta/`** (git-ignored), one file per source: + `modules/@scope/name.json` (`nf_modules`), `packages/@scope/name.json` (the project's own + packages), `apps/.json`. It is never published and never written next to the sources. + `nf_modules` stays untouched. +- **Generated at init when missing, regenerated when stale:** when `sourceHash`, `metaVersion`, + `generator.version` or the installed owners (added, removed, other `version` or `schema`) differ. +- **Owner plugin missing:** its tags are kept raw in the item's `unclaimedTags`, with a warning. A + regeneration without the plugin doesn't produce its owner object, so the object disappears. It + comes back when the plugin is installed (the owners changed, so the meta is regenerated). +- **`nf publish`** has nothing to do with the meta. + +### Catalog + +Core owns a generic, reactive **catalog**: items by `ItemRef` (`@nanoforge/motion#Position`, or +`app:client#Paddle` for an app), from providers (`nf_modules`, project packages, project apps). +Plugins build views on it: the ECS plugin's component and system registries are the items whose +`@nanoforge/ecs.type` is `component` or `system`. + +## Open questions + +1. ~~**Imports into `nf_modules`.**~~ Decided in ADR 0004: by package name, through the root `tsconfig.json` `paths`. + **Was:** The example uses relative paths across packages + (`../../motion/components/position`). Should there be an alias (`@nf///…`), set up + by `nf install` in tsconfig and the build? +2. ~~**Name collisions.**~~ Decided in ADR 0005: plain names stay, the app's item wins and the editor warns. + **Was:** The ECS registry keys on `name`: `@nanoforge/motion#Position` and the + app's own `Position` would clash. Namespaced names, or a rule that forbids it? +3. ~~**Root index.**~~ Decided in ADR 0005: `nanoforge.packages.json` and its lock list the packages; the catalog still reads `nf_modules/@//`. + **Was:** Should `nf install` write a list of the installed packages (like a lockfile), so + the catalog has one entry point, or does the catalog read `nf_modules/@//`? +4. ~~**Sub-manifests.**~~ Decided in ADR 0005: every package is published and installable alone; a bigger one lists the others in `dependencies`. + **Was:** Can `@nanoforge/shapes` be installed alone, or only as part of `render-2d`? +5. **Engine libs' own items.** Do graphics-2d or network ship items the same way? +6. ~~**Editor hints.**~~ Partly decided: layout tags are core (groups, presets, labels, colors, + hidden). Still open: value hints such as `@min`, `@max`, `@step`. + **Was:** Tags for the inspector (`@min`, `@max`, `@step`, `@color`): core, or owner + tags? +7. **Headless generation** (later): the `@nanoforge-dev/editor-meta` package run by a command. diff --git a/docs/adr/0004-shared-libraries.md b/docs/adr/0004-shared-libraries.md new file mode 100644 index 00000000..ae4d47b3 --- /dev/null +++ b/docs/adr/0004-shared-libraries.md @@ -0,0 +1,141 @@ +# ADR 0004 — Shared libraries: components and systems shared by several apps + +- Status: accepted (2026-09-27) +- Related: ADR 0003 (items, packages, plugins), `docs/specs/ecs.md` + +## Context + +A NanoForge project has several apps (usually `client` and `server`), each with its own +`src/components` and `src/systems`. A game needs components that both sides use (`Position`, +`Velocity`, a `Player` with its id). Today they are copied into each app, and the copies drift. + +The CLI already knows a workspace member of `type: "lib"` in `nanoforge.config.ts`: a folder with +`dir.components`, `dir.systems`, `dir.assets`, `dir.scenes` and no entry file. The editor's project +discovery already lists libs, and the build already skips them (their code is bundled into the apps +that import it). + +The user wants, in the editor, to create components and systems shared between client and server. +Publishing is out of scope: a lib stays in its project. + +## Decision + +### A shared library is a lib workspace member + +``` +nanoforge.config.ts { type: "workspace", packages: ["apps/*", "libs/*"] } +libs/shared/ + nanoforge.config.ts { type: "lib", dir: { components: "src/components", … } } ← the CLI defaults are shared/… + package.json { "name": "@pong-network/shared", "private": true } + src/components/player.ts /** @component @side shared */ + src/systems/score.ts /** @system @side server */ +``` + +- No `nanoforge.manifest.json`: a lib isn't published, so it needs no registry identity. Its name + is its `package.json` name. +- Items are found like in an app: the exports of `dir.components` / `dir.systems` that an owner tag + claims (`@component`, `@system`). +- **Side is per item**, not per lib: one lib holds client, server and shared items. An app is offered + the items whose `@side` matches it and whose required engine libraries it has. +- A lib may import engine libraries (`@nanoforge-dev/*`), installed packages (`nf_modules`) and other + libs. It never imports an app. The editor reports an import of an app, and a cycle between libs. + +### Imports use the package name + +Apps import items from a lib, and from an installed package, by package name: + +```ts +import { Position } from '@nanoforge/motion/components/position'; +import { Player } from '@pong-network/shared/components/player'; +``` + +- The project's root `tsconfig.json` maps them with `paths`: + `"@pong-network/shared/*": ["libs/shared/src/*"]` and + `"@nanoforge/motion/*": ["nf_modules/@nanoforge/motion/*"]`. The editor writes the lib entry when it + creates a lib; package entries come with installing (not built yet). +- The code worker resolves through the same `paths`, so the editor's diagnostics, completion and the + codegen's import edits all agree. +- Moving an item between a lib and a package never changes the importing code's shape. +- This answers ADR 0003's open question 1 (imports into `nf_modules`). +- To check while building: that `nf build` honors `paths`. If it doesn't, fall back to a + `workspace:*` dependency plus an install run by the editor. + +### Items and catalog + +- Item references: `#`, e.g. `@pong-network/shared#Player`. Apps keep + `app:#`. +- The meta of a lib is cached like a project package's: + `.nanoforge/editor/cache/meta/libs/.json`. +- The catalog has a `lib` provider next to `app` and `module` (`nf_modules`). Lib items are editable; + package items are read-only. +- ECS name collisions: the ECS plugin warns when two items that the same app can use share a + registry `name` (an app's `Position` and a lib's `Position`). + +### Words in the UI + +"Lib" is already used in the editor for engine libraries (`engineLibs`), so the UI always says which +kind it means. Code and config keep `lib`, as the CLI does. + +| Thing | UI name | Example | +| -------------------------------------------- | --------------------- | ------------------------------------- | +| An app (`apps/client`) | **App** | App _client_ | +| A lib workspace member (`libs/shared`) | **Shared library** | Shared library _shared_ | +| An installed package (`nf_modules`) | **Installed package** | Installed package _@nanoforge/motion_ | +| An npm engine library (`@nanoforge-dev/ecs`) | **Engine library** | Engine library _ecs_ | + +The component and system browser groups items under those headings: the selected app first, then +each shared library, then installed packages (with a lock). + +### Editor features (phase 10, ECS plugin and core) + +- **New shared library** (command, and a button in the browser): asks for a folder name (default + `shared`) and the package name (default `@/`). It creates `libs//` + (`nanoforge.config.ts`, `package.json`, `src/components`, `src/systems`), adds `libs/*` to the + workspace `packages` when missing, and adds the `paths` entry. One undoable step. +- **New component / New system** ask where: the selected app (default) or a shared library. In a + shared library, `@side` defaults to `shared`. +- **Move to shared library**: moves a component or system from an app into a lib, rewrites every + import in the project, one undoable step. +- **Copy to…** on an installed package's item copies it into an app or a shared library. +- Adding a lib item to an entity or an app adds the package-name import. +- While playing, a change in a lib rebuilds every app that imports it (the build orchestrator + follows imports). + +### Which apps use a library, and managing them (2026-10-03) + +- **An app uses a library when its `nanoforge.config` lists it in `libs`**: paths relative to + the app, the field the CLI's config defines for this (`libs: ["../../libs/shared"]`). + `AppModel.libraries` holds the project's libraries an app uses. + - The editor keeps the app's `package.json` in step (`"@pong-network/shared": "workspace:*"`, + for package managers): every change writes both. Where they disagree (an edit by hand), `libs` + wins and the project shows a warning that says what to add. + - A library's config has no `libs`: a library uses another through its `package.json`. + - Until 2026-10-03 the editor read `libs` as engine library names, which the CLI never meant. + The editor reads engine libraries from `package.json` only. +- An app is only offered the items of the libraries it uses (`itemFitsApp`). An app that imports a + library it does not use is reported, with the fix named. +- **A library that no app lists is given to every app**, so a project made before this rule keeps + working. The Project screen says so; the first tick or untick writes `libs` and the dependencies down. +- `nf build` (Bun) honors the root `paths`, checked on `examples/pong-game`: the dependency is a + declaration, and nothing needs installing for a library to build and play. +- The **Project screen** manages the workspace: add, rename and remove client apps, server apps and + shared libraries, and tick the apps that use each library. Every change is one step of the + _Project_ history. The operations are `packages/project/src/workspace/` (in the SDK), which + the ECS plugin's _New shared library…_ uses too. + - Renaming a library rewrites its `paths` entry, the dependencies on it and every import of it. + - Removing a library is refused while a file imports it; the files are named. + - A new app is empty (engine libraries at the versions of another app) or a copy of an app of + its type. An empty app needs its dependencies installed: the editor offers to run the + project's package manager, found from its lockfile (`projects.install`, local editors only). + - The last app of a project can't be removed. +- **Several clients or servers**: Play starts one client and one server. With two or more of a + type, the Run menu gets a _Client app_ or _Server app_ submenu (`runtime.clientApp`, + `runtime.serverApp`, kept with the project for this machine). With one of each, the menu is + unchanged. +- Not built: two servers of one project running at once, several clients side by side in the + editor (a second client opens in its own window), publishing a library as a package. + +## Consequences + +- Client and server share one definition of each shared component, which network code needs. +- No publishing: turning a lib into a package later means adding a `nanoforge.manifest.json`. +- The root `tsconfig.json` becomes something the editor edits (only its `paths` entries). diff --git a/docs/adr/0005-package-installs.md b/docs/adr/0005-package-installs.md new file mode 100644 index 00000000..c053163d --- /dev/null +++ b/docs/adr/0005-package-installs.md @@ -0,0 +1,18 @@ +# ADR 0005: installing packages and plugins + +Date: 2026-10-01. Status: accepted. It settles the install questions ADR 0003 left open. + +## Decisions + +1. **Who installs.** The editor server, with a package that has no editor dependency (`@nanoforge-dev/registry`). It is meant to move to the CLI repository, where `nf install` will use it too. +2. **The project's list.** `nanoforge.packages.json` holds the packages a project asks for, with version ranges. `nanoforge.packages.lock.json` holds what is installed: exact versions, archive hashes, and each package's own dependencies. `nf_modules` can be restored from the lock, so it may be left out of git. The editor does not edit the project's `.gitignore`: that choice is the project's. +3. **Sub-packages.** Each package is published on its own and can be installed alone. A package that needs others lists them in `dependencies` (`{ "@scope/name": "^1.0.0" }`); they are installed side by side in `nf_modules/@scope/name`, one version each. `include` stays for folders inside one published package. +4. **Version conflicts.** One version of a package per project. When two ranges have no version in common, the install is refused and names both. +5. **Name collisions of items.** Plain names stay. The app's own item wins over a package's and the editor warns. Refusing installs on collisions between two packages is left for when the registry knows item names. +6. **The registry.** Its HTTP contract is in `docs/api/registry.md`, with routes after the npm registry's. Until the backend exists, the editor runs on a registry read from a folder. + +## Still open + +- Engine libraries: npm, or the registry. +- Inspector value hints (`@min`, `@max`, `@step`). +- Headless meta generation. diff --git a/docs/adr/0006-source-layout.md b/docs/adr/0006-source-layout.md new file mode 100644 index 00000000..d1a3b5ea --- /dev/null +++ b/docs/adr/0006-source-layout.md @@ -0,0 +1,127 @@ +# ADR 0006 — Source layout and naming + +- Status: accepted (2026-10-04), piloted on `packages/settings` and `plugins/git` +- Related: the engine (`../engine`, `origin/main`) and the CLI (`../cli`, `origin/main`), whose + layout this follows + +## Context + +The editor grew one phase at a time, and each workspace got its own habits: + +- tests next to the code (`packages/kernel/src/**/*.test.ts`), or in `test/` with flat names; +- flat `src/` folders of 15 files (`packages/settings`, `plugins/git`), or feature folders + (`packages/server-core`); +- Svelte components at the root of a plugin's `src/`, or in `widgets/`; +- files of 500 to 800 lines holding several classes or views. + +The engine and the CLI already share one layout: feature folders with an `index.ts`, kebab-case +files named after what they export, a suffix for each kind of file, and tests in `test/` named +`.spec.ts`. The editor follows them. + +## Decision + +### Workspace + +``` +/ + src/ + index.ts the entry: a barrel (packages), or definePlugin (plugins) + / + index.ts re-exports the feature's public files + .ts + /..ts a family of files of one kind + test/ + /.spec.ts mirrors src/ + fixtures/ +``` + +- **Feature folders.** `src/` holds `index.ts` and folders. Each folder is one feature, named in + kebab-case and in the singular (`store`, `account-sync`, `definition`), like the engine's + `application`, `library-registry`, `exception`. +- **Barrels.** A folder's `index.ts` re-exports what leaves the folder. A package's `src/index.ts` only + re-exports its folders (`export * from './store'`), with no code of its own. Files inside a + workspace import each other by path, never through their own package's barrel. +- **Subpath entries** (`editor-code/engine`, `editor-meta/pure`, `editor-sdk/ui`, registry `/types`) + are files at the root of `src/` next to `index.ts`, named after the subpath. +- **Families.** Implementations of one interface are named `..ts` for a class + ``, like the CLI's `build.action.ts` and the engine's `tcp.client.network.ts`: + `MemoryScopeStore` → `memory.scope-store.ts`, `IndexedDbKeyValueStore` → + `indexed-db.key-value-store.ts`. They stay in their feature folder; a plural sub-folder + (`actions/`) only when the family is large and the folder holds other files. + +### Files + +- **kebab-case**, named after the main export: `SettingsService` → `settings-service.ts`, + `createActions` → `create-actions.ts`. One main export per file; small helpers that only it uses + stay with it. +- **Kind suffixes**, after the engine and the CLI: + + | Suffix | Holds | + | --------------- | --------------------------------------------------------------- | + | `.type.ts` | types and interfaces only, no values | + | `.enum.ts` | an enum, or an `as const` list and its union type | + | `.exception.ts` | an `Error` subclass (several go in `exceptions/`) | + | `.abstract.ts` | an abstract class | + | `.factory.ts` | a function that picks and builds one of several implementations | + | `.const.ts` | constants shared by several files of the feature | + | `..ts` | a member of a family: `.scope-store.ts`, `.contract.ts`… | + +- **Tokens** (`createToken`) live in the file of the class or interface they inject. +- **Svelte components** are kebab-case when an `index.ts` re-exports them (the kit: + `button.svelte`), PascalCase otherwise (a plugin's `CommitView.svelte`). Rune modules are + `.svelte.ts`. +- **Size.** A file that passes about 300 lines (a Svelte file's ` diff --git a/examples/counter-plugin/src/counter.ts b/examples/counter-plugin/src/counter.ts new file mode 100644 index 00000000..3639b1e6 --- /dev/null +++ b/examples/counter-plugin/src/counter.ts @@ -0,0 +1,4 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; + +/** The plugin's state, shared by its command and its panel. */ +export const count = new ObservableValue(0); diff --git a/examples/counter-plugin/src/index.ts b/examples/counter-plugin/src/index.ts new file mode 100644 index 00000000..c53f32f5 --- /dev/null +++ b/examples/counter-plugin/src/index.ts @@ -0,0 +1,52 @@ +import type { Component } from 'svelte'; + +import { HistoryServiceToken, SettingsServiceToken, definePlugin } from '@nanoforge-dev/editor-sdk'; +import { StyleServiceToken, WIDGET_VIEWS, type WidgetInstance } from '@nanoforge-dev/editor-sdk/ui'; + +import Panel from './Panel.svelte'; +import { count } from './counter'; + +/** Undo and redo target of the panel (see Panel.svelte). */ +export const HISTORY = 'counter'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + + // The panel's styles are built into counter.css: the plugin injects them when it starts. + const css = await fetch(context.resolveAsset('counter.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + + // The view of the widget declared in the manifest. + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id: 'counter.panel', + component: Panel as Component<{ instance: WidgetInstance }>, + }), + ); + + // A history of its own: each addition can be undone with Ctrl+Z while the panel has focus. + const history = services.get(HistoryServiceToken).registerContext({ + id: HISTORY, + label: 'Counter', + owner: context.name, + }); + context.subscriptions.add(history); + + // The command declared in the manifest. Settings keys carry the plugin's name. + const settings = services.get(SettingsServiceToken); + context.subscriptions.add( + context.registerCommand('counter.add', async () => { + const step = Number(settings.get('@acme/counter.step')) || 1; + await history.stack.push({ + label: `Add ${step}`, + do: () => count.set(count.get() + step), + undo: () => count.set(count.get() - step), + }); + context.logger.info(`Counter: ${count.get()}`); + }), + ); + }, +}); diff --git a/examples/counter-plugin/svelte.config.js b/examples/counter-plugin/svelte.config.js new file mode 100644 index 00000000..ebe8f7ab --- /dev/null +++ b/examples/counter-plugin/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode, like the editor. */ +export default { compilerOptions: { runes: true } }; diff --git a/examples/counter-plugin/tsconfig.json b/examples/counter-plugin/tsconfig.json new file mode 100644 index 00000000..f391075b --- /dev/null +++ b/examples/counter-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/examples/counter-plugin/vite.config.ts b/examples/counter-plugin/vite.config.ts new file mode 100644 index 00000000..9696c802 --- /dev/null +++ b/examples/counter-plugin/vite.config.ts @@ -0,0 +1,12 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +// Builds dist/: index.js, counter.css and the manifest. The editor loads that folder. +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'counter' }, + }, +}); diff --git a/messages/en.json b/messages/en.json deleted file mode 100644 index 97efb220..00000000 --- a/messages/en.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from en!" -} diff --git a/messages/fr.json b/messages/fr.json deleted file mode 100644 index 45f5fefe..00000000 --- a/messages/fr.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from fr!" -} diff --git a/package.json b/package.json index 9b4f40fa..4dfb8c0a 100644 --- a/package.json +++ b/package.json @@ -1,139 +1,65 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@nanoforge-dev/editor", - "version": "0.1.5", - "description": "NanoForge Editor", - "keywords": [ - "nanoforge", - "game", - "editor" - ], + "name": "@nanoforge-dev/editor-workspace", + "version": "0.0.0", + "description": "NanoForge Editor monorepo", "homepage": "https://github.com/NanoForge-dev/Editor#readme", "bugs": "https://github.com/NanoForge-dev/Editor/issues", "license": "MIT", "contributors": [ - "Bill ", - "Exelo ", + "Exelo ", "Fexkoser ", - "Tchips " - ], - "files": [ - "dist" + "Harper ", + "Josephine ", + "Leo " ], - "main": "./dist/index.js", - "module": "./dist/index.js", - "exports": { - "./package.json": "./package.json" - }, "type": "module", - "directories": { - "lib": "src" - }, "repository": { "type": "git", "url": "git+https://github.com/NanoForge-dev/Editor.git" }, "funding": "https://github.com/NanoForge-dev/Editor?sponsor", "scripts": { - "dev": "bun --bun run vite dev", - "build": "bun --bun run vite build", - "preview": "bun --bun run vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint --format=pretty src", - "format": "prettier --write . && eslint --fix --format=pretty src", - "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r . --include-path '.'", - "release": "cliff-jumper", - "test": "pnpm run test:unit && pnpm run test:e2e", - "test:unit": "vitest run -c vite.config.ts", - "test:unit:watch": "vitest -c vite.config.ts", - "test:e2e": "vitest run -c vite.config.e2e.ts", - "test:e2e:watch": "vitest -c vite.config.e2e.ts", - "test:coverage": "pnpm run test:unit --coverage && pnpm run test:e2e --coverage", + "dev": "turbo run dev --filter=@nanoforge-dev/editor --filter='./plugins/*'", + "build": "turbo run build", + "typecheck": "turbo run typecheck", + "lint": "prettier --check . && turbo run lint", + "format": "prettier --write . && turbo run format", + "test": "pnpm run test:unit", + "test:unit": "turbo run test:unit", + "example": "node scripts/example.mjs", "prepare": "husky" }, - "dependencies": { - "bun": "catalog:core", - "class-transformer": "catalog:libs-back", - "class-validator": "catalog:libs-back", - "dotenv": "catalog:libs-back", - "micromatch": "catalog:libs-back", - "tar": "catalog:libs-back", - "typescript": "catalog:build" - }, "devDependencies": { - "@alexanderniebuhr/prettier-plugin-unocss": "catalog:css", "@commitlint/cli": "catalog:ci", "@commitlint/config-conventional": "catalog:ci", - "@favware/cliff-jumper": "catalog:ci", - "@iconify-json/clarity": "catalog:icons", - "@iconify-json/ic": "catalog:icons", - "@iconify-json/icomoon-free": "^1.2.1", - "@iconify-json/material-icon-theme": "catalog:icons", - "@iconify-json/solar": "catalog:icons", - "@inlang/paraglide-js": "catalog:i18n", - "@internationalized/date": "catalog:libs-front", - "@lucide/svelte": "catalog:icons", "@nanoforge-dev/actions": "catalog:ci", - "@nanoforge-dev/ecs-lib": "catalog:core", "@nanoforge-dev/utils-eslint-config": "catalog:lint", "@nanoforge-dev/utils-prettier-config": "catalog:lint", - "@playwright/test": "catalog:test", - "@sveltejs/kit": "catalog:core", - "@sveltejs/vite-plugin-svelte": "catalog:core", - "@tanstack/svelte-query": "catalog:libs-front", "@trivago/prettier-plugin-sort-imports": "catalog:lint", - "@tsconfig/svelte": "catalog:build", - "@types/bun": "catalog:core", - "@types/micromatch": "^4.0.10", - "@unocss/extractor-svelte": "catalog:css", - "@unocss/preset-icons": "catalog:css", - "@unocss/preset-web-fonts": "catalog:css", - "@unocss/preset-wind4": "catalog:css", - "@vitest/browser-playwright": "catalog:test", + "@types/node": "catalog:core", "@vitest/coverage-v8": "catalog:test", - "bits-ui": "catalog:components", - "clsx": "catalog:css", "eslint": "catalog:lint", "eslint-plugin-svelte": "catalog:lint", - "formsnap": "catalog:libs-front", "globals": "catalog:lint", "husky": "catalog:ci", "lint-staged": "catalog:ci", - "mode-watcher": "catalog:libs-front", - "monaco-editor": "catalog:components", - "playwright": "catalog:test", "prettier": "catalog:lint", "prettier-plugin-svelte": "catalog:lint", "svelte": "catalog:core", - "svelte-adapter-bun": "catalog:build", - "svelte-check": "catalog:core", - "svelte-kit-sessions": "catalog:core", - "svelte-sonner": "catalog:libs-front", - "sveltekit-superforms": "catalog:libs-front", - "tailwind-merge": "catalog:css", - "tailwind-variants": "catalog:css", - "tailwindcss": "catalog:css", + "turbo": "catalog:build", + "typescript": "catalog:build", "typescript-eslint": "catalog:lint", - "unocss": "catalog:css", - "vite": "catalog:core", - "vitest": "catalog:test", - "vitest-browser-svelte": "catalog:test", - "zod": "catalog:libs-front" + "vitest": "catalog:test" }, - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@12.0.0", "engines": { - "node": "25" - }, - "publishConfig": { - "access": "public" + "node": "26" }, + "private": true, "lint-staged": { "**": [ "prettier --ignore-unknown --write" - ], - "src/**/*.{ts,svelte}": [ - "eslint --fix" ] } } diff --git a/packages/code/.prettierignore b/packages/code/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/code/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/code/LICENSE b/packages/code/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/code/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/code/README.md b/packages/code/README.md new file mode 100644 index 00000000..7bbed539 --- /dev/null +++ b/packages/code/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-code` is an internal package of the NanoForge Editor. + +Code service: TypeScript analysis and AST transforms in a web worker. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/code +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/code/package.json b/packages/code/package.json new file mode 100644 index 00000000..44015cd1 --- /dev/null +++ b/packages/code/package.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-code", + "version": "0.0.0", + "description": "NanoForge Editor - Code service: TypeScript analysis and AST transforms in a web worker, documents, diagnostics", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./worker": "./src/worker.ts", + "./engine": "./src/engine/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/code" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-meta": "workspace:*", + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "ts-morph": "catalog:code", + "zod": "catalog:libs" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "@types/node": "catalog:core", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/code/src/catalog/catalog-service.ts b/packages/code/src/catalog/catalog-service.ts new file mode 100644 index 00000000..8772c2d8 --- /dev/null +++ b/packages/code/src/catalog/catalog-service.ts @@ -0,0 +1,415 @@ +import { + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + type PluginHost, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import type { MetaDiagnostic } from '@nanoforge-dev/editor-meta'; +import { + type ItemMeta, + type ItemRef, + META_GENERATOR, + META_NOTICE, + META_VERSION, + MetaFile, + type MetaSource, + metaCachePath, + sourceHash, + sourceRefBase, +} from '@nanoforge-dev/editor-meta/pure'; +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import type { MetaRoot, MetaSourceFile } from '../engine/extract-meta'; +import { isCodeFile } from '../service/code-service'; +import type { CatalogItem, CatalogOptions, CatalogState } from './catalog.type'; +import { appRefName, itemFitsApp, within } from './item-fits-app'; + +interface Source { + readonly source: MetaSource; + /** Folder of the source; its files are the code files under it. */ + readonly root: string; + readonly files: readonly MetaSourceFile[]; +} + +const MANIFEST = 'nanoforge.manifest.json'; +const MODULES = 'nf_modules'; +const EDITOR_GITIGNORE = '.nanoforge/editor/.gitignore'; +const EXPECTED_OWNERS_TIMEOUT_MS = 5000; + +/** + * Items (components, systems…) of the open project and its installed packages (ADR 0003). Each + * source's meta is cached in `.nanoforge/editor/cache/meta/`, used while its sources and owners + * are unchanged, and extracted again by the code worker otherwise. + */ +export class CatalogService implements Disposable { + private readonly _state = new ObservableValue({ + items: [], + diagnostics: [], + loading: true, + }); + private readonly _store = new DisposableStore(); + private readonly _bySource = new Map< + string, + { items: CatalogItem[]; diagnostics: MetaDiagnostic[] } + >(); + private readonly _dirty = new Set(); + private _sources: Source[] = []; + private _timer: ReturnType | undefined; + private _running: Promise = Promise.resolve(); + private _disposed = false; + /** The catalog starts (and starts the code worker) when first read. */ + private _active = false; + + constructor(private readonly _options: CatalogOptions) { + const { project, documents, plugins } = _options; + this._store.add({ + dispose: project.model.subscribe(() => this._scheduleAll()), + }); + this._store.add( + project.fs.onDidChange((changes) => { + for (const change of changes) { + if (change.path.endsWith(MANIFEST) && within(change.path, MODULES)) this._scheduleAll(); + else if (isCodeFile(change.path) && change.type !== 'changed') this._scheduleAll(); + else if (isCodeFile(change.path)) this._schedulePath(change.path); + } + }), + ); + this._store.add(documents.onDidChange(({ uri }) => this._schedulePath(uri))); + if (plugins) { + let owners = ownerPlugins(plugins); + this._store.add( + plugins.onDidChange(() => { + const next = ownerPlugins(plugins); + if (next === owners) return; + owners = next; + this._scheduleAll(); + }), + ); + } + } + + get state(): Observable { + this._activate(); + return this._state.readonly(); + } + + get items(): readonly CatalogItem[] { + this._activate(); + return this._state.get().items; + } + + get(ref: ItemRef): CatalogItem | undefined { + return this.items.find((item) => item.ref === ref); + } + + /** The items an app can use: its own, shared libraries' and installed packages'. */ + forApp(app: AppModel): CatalogItem[] { + return this.items.filter((item) => itemFitsApp(item, app)); + } + + /** The item a source file's export is, if any. */ + itemAt(path: string, exportName: string): CatalogItem | undefined { + return this.items.find((item) => item.meta.source === path && item.meta.export === exportName); + } + + /** Extracts everything again (after an owner plugin changed, or on demand). */ + refresh(): Promise { + this._active = true; + this._scheduleAll(0); + return this.whenIdle(); + } + + /** Resolves once pending extractions are done. */ + async whenIdle(): Promise { + while (this._timer !== undefined || this._dirty.size) { + await new Promise((resolve) => setTimeout(resolve, 20)); + await this._running; + } + await this._running; + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._timer); + this._store.dispose(); + } + + private _activate(): void { + if (this._active) return; + this._active = true; + this._scheduleAll(0); + } + + private _scheduleAll(delay = this._options.delayMs ?? 300): void { + this._dirty.add('*'); + this._schedule(delay); + } + + private _schedulePath(path: string): void { + if (!isCodeFile(path)) return; + const source = this._sources.find((candidate) => within(path, candidate.root)); + if (!source && !within(path, MODULES)) { + return; + } + this._dirty.add(source ? sourceRefBase(source.source) : '*'); + this._schedule(this._options.delayMs ?? 300); + } + + private _schedule(delay: number): void { + if (!this._active) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => { + this._timer = undefined; + this._running = this._running + .then(() => this._run()) + .catch((error: unknown) => this._options.logger?.error('Catalog update failed', error)); + }, delay); + } + + private async _run(): Promise { + if (this._disposed || !this._dirty.size) return; + const all = this._dirty.has('*'); + const dirty = new Set(this._dirty); + this._dirty.clear(); + if (all) this._sources = await this._discover(); + await this._expectedOwners(); + const owners = await this._options.code.itemOwners(); + const roots: MetaRoot[] = this._sources.map((source) => ({ + path: source.root, + ref: sourceRefBase(source.source), + })); + const known = new Set(this._sources.map((source) => sourceRefBase(source.source))); + for (const key of this._bySource.keys()) if (!known.has(key)) this._bySource.delete(key); + for (const source of this._sources) { + if (this._disposed) return; + const key = sourceRefBase(source.source); + if (!all && !dirty.has(key) && this._bySource.has(key)) continue; + try { + this._bySource.set(key, await this._load(source, roots, owners)); + } catch (error) { + this._options.logger?.warn(`Items of ${key} could not be read`, error); + } + } + this._publish(); + } + + private _publish(): void { + const entries = [...this._bySource.values()]; + this._state.set({ + items: entries.flatMap((entry) => entry.items), + diagnostics: entries.flatMap((entry) => entry.diagnostics), + loading: false, + }); + } + + /** Waits (a few seconds at most) for the worker to register the owners plugins declare. */ + private async _expectedOwners(): Promise { + const plugins = this._options.plugins; + if (!plugins) return; + const expected = ownerPlugins(plugins).split(',').filter(Boolean); + const deadline = Date.now() + EXPECTED_OWNERS_TIMEOUT_MS; + while (Date.now() < deadline) { + const registered = new Set( + (await this._options.code.itemOwners()).map((owner) => owner.name), + ); + if (expected.every((name) => registered.has(name))) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + this._options.logger?.warn(`Item owners not registered in time: ${expected.join(', ')}`); + } + + /** Apps, shared libraries and installed packages (walking `include`), with their files. */ + private async _discover(): Promise { + const { project } = this._options; + const entries = project.fs.entries; + const codeFiles = (root: string) => + entries + .filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path) && within(entry.path, root), + ) + .map((entry) => entry.path); + const sources: Source[] = []; + + for (const app of project.model.get().apps) { + const folderOf = (path: string): MetaSourceFile['folder'] => + within(path, app.dirs.components) + ? 'components' + : within(path, app.dirs.systems) + ? 'systems' + : undefined; + const source: MetaSource = + app.type === 'lib' + ? { kind: 'lib', name: app.name, path: app.root } + : { kind: 'app', name: appRefName(app), path: app.root }; + sources.push({ + source, + root: app.root, + files: codeFiles(app.root) + .filter((path) => !within(path, MODULES)) + .map((path) => { + const folder = folderOf(path); + return { path, ...(folder && { folder }) }; + }), + }); + } + + const visit = async (dir: string, depth: number) => { + const manifestPath = `${dir}/${MANIFEST}`; + if (!project.fs.entry(manifestPath) || depth > 4) return; + let manifest: { + type?: string; + name?: string; + version?: string; + items?: unknown; + include?: unknown; + }; + try { + manifest = JSON.parse((await project.fs.readText(manifestPath)).text) as typeof manifest; + } catch { + return; + } + if (manifest.type !== 'package' || !manifest.name) return; + const listed = Array.isArray(manifest.items) + ? manifest.items.filter((item): item is string => typeof item === 'string') + : []; + sources.push({ + source: { + kind: 'module', + name: manifest.name, + version: manifest.version ?? '0.0.0', + path: dir, + }, + root: dir, + files: listed + .filter((item) => !item.split('/').includes('..')) + .map((item) => ({ path: `${dir}/${item}`, listed: true })), + }); + const includes = Array.isArray(manifest.include) ? manifest.include : []; + for (const include of includes) + if (typeof include === 'string' && !include.split('/').includes('..')) + await visit(`${dir}/${include}`, depth + 1); + }; + const scopes = entries.filter( + (entry) => entry.kind === 'directory' && /^nf_modules\/@[^/]+$/.test(entry.path), + ); + for (const scope of scopes) { + const names = entries.filter( + (entry) => + entry.kind === 'directory' && + entry.path.startsWith(`${scope.path}/`) && + !entry.path.slice(scope.path.length + 1).includes('/'), + ); + for (const name of names) await visit(name.path, 0); + } + return sources; + } + + /** A source's items: from the cache when still valid, else extracted and cached. */ + private async _load( + source: Source, + roots: readonly MetaRoot[], + owners: readonly { name: string; version: string; schema: number }[], + ): Promise<{ items: CatalogItem[]; diagnostics: MetaDiagnostic[] }> { + const { project, documents, code } = this._options; + const cachePath = metaCachePath(source.source); + const texts = await Promise.all( + source.files.map(async (file) => ({ + path: file.path, + text: await documents.getText(file.path), + })), + ); + const hash = await sourceHash(texts); + const ownerTable = Object.fromEntries( + owners.map((owner) => [owner.name, { version: owner.version, schema: owner.schema }]), + ); + + const cached = await this._readCache(cachePath); + const fresh = + cached && + cached.sourceHash === hash && + cached.generator.version === META_GENERATOR.version && + JSON.stringify(sortKeys(cached.owners)) === JSON.stringify(sortKeys(ownerTable)); + let items: ItemMeta[]; + let diagnostics: MetaDiagnostic[] = []; + if (fresh) { + items = cached.items; + } else { + const result = await code.extractMeta({ + source: source.source, + files: source.files, + roots, + texts, + }); + items = result.items; + diagnostics = result.diagnostics; + const file: MetaFile = { + generated: META_NOTICE, + metaVersion: META_VERSION, + generator: { ...META_GENERATOR }, + source: source.source, + sourceHash: result.sourceHash, + owners: result.owners, + items, + }; + await this._ensureIgnored(); + await project.fs + .write(cachePath, `${JSON.stringify(file, null, 2)}\n`) + .catch((error: unknown) => + this._options.logger?.warn(`Could not cache ${cachePath}`, error), + ); + } + const base = sourceRefBase(source.source); + return { + items: items.map((meta) => ({ + ref: `${base}#${meta.export}`, + source: source.source, + meta, + readonly: source.source.kind === 'module', + })), + diagnostics, + }; + } + + private async _readCache(path: string): Promise { + if (!this._options.project.fs.entry(path)) return undefined; + try { + const parsed = MetaFile.safeParse( + JSON.parse((await this._options.project.fs.readText(path)).text), + ); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } + } + + /** The cache is never committed: `cache/` in `.nanoforge/editor/.gitignore`. */ + private async _ensureIgnored(): Promise { + const fs = this._options.project.fs; + const current = fs.entry(EDITOR_GITIGNORE) ? await fs.readText(EDITOR_GITIGNORE) : undefined; + const lines = current?.text.split('\n').map((line) => line.trim()) ?? []; + if (lines.includes('cache/')) return; + const text = `${current?.text.trimEnd() ?? ''}${current?.text.trim() ? '\n' : ''}cache/\n`; + await fs.write(EDITOR_GITIGNORE, text).catch(() => undefined); + } +} + +/** Plugins that declare an item owner and have a worker entry, as `name@version,…`. */ +const ownerPlugins = (plugins: PluginHost): string => + plugins + .getPlugins() + .filter( + (plugin) => + plugin.status.kind === 'ok' && + plugin.descriptor.manifest.entry.worker && + (plugin.descriptor.manifest.contributes as Record).itemOwner, + ) + .map((plugin) => plugin.name) + .sort() + .join(','); + +const sortKeys = (value: Record) => + Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + +export const CatalogServiceToken = createToken('code.catalog'); diff --git a/packages/code/src/catalog/catalog.type.ts b/packages/code/src/catalog/catalog.type.ts new file mode 100644 index 00000000..13fa9909 --- /dev/null +++ b/packages/code/src/catalog/catalog.type.ts @@ -0,0 +1,33 @@ +import type { Logger, PluginHost } from '@nanoforge-dev/editor-kernel'; +import type { MetaDiagnostic } from '@nanoforge-dev/editor-meta'; +import type { ItemMeta, ItemRef, MetaSource } from '@nanoforge-dev/editor-meta/pure'; +import type { ClientProject } from '@nanoforge-dev/editor-project'; + +import type { DocumentService } from '../document/document-service'; +import type { CodeService } from '../service/code-service'; + +/** An item of the catalog: its metadata and where it comes from. */ +export interface CatalogItem { + readonly ref: ItemRef; + readonly source: MetaSource; + readonly meta: ItemMeta; + /** Items of installed packages can't be changed. */ + readonly readonly: boolean; +} + +export interface CatalogState { + readonly items: readonly CatalogItem[]; + readonly diagnostics: readonly MetaDiagnostic[]; + /** True until the first extraction of every source is done. */ + readonly loading: boolean; +} + +export interface CatalogOptions { + readonly project: ClientProject; + readonly code: CodeService; + readonly documents: DocumentService; + readonly plugins?: PluginHost; + readonly logger?: Logger; + /** Delay before extracting again after a change. */ + readonly delayMs?: number; +} diff --git a/packages/code/src/catalog/index.ts b/packages/code/src/catalog/index.ts new file mode 100644 index 00000000..15a058ae --- /dev/null +++ b/packages/code/src/catalog/index.ts @@ -0,0 +1,3 @@ +export * from './catalog.type'; +export { appRefName, itemFitsApp } from './item-fits-app'; +export * from './catalog-service'; diff --git a/packages/code/src/catalog/item-fits-app.ts b/packages/code/src/catalog/item-fits-app.ts new file mode 100644 index 00000000..a6fedb59 --- /dev/null +++ b/packages/code/src/catalog/item-fits-app.ts @@ -0,0 +1,29 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import type { CatalogItem } from './catalog.type'; + +/** The name in an app's item references: its folder (`apps/client` → `client`). */ +export const appRefName = (app: AppModel): string => + (app.root.split('/').pop() || app.name).replace(/[^\w.-]/g, '-'); + +export const within = (path: string, folder: string) => + folder === '' || path === folder || path.startsWith(`${folder}/`); + +/** + * Whether an item can go in an app: its side, the engine libraries it imports and, for an item + * of a shared library, that the app uses that library. + */ +export const itemFitsApp = (item: CatalogItem, app: AppModel): boolean => { + if (item.source.kind === 'app' && item.source.path !== app.root) return false; + if ( + item.source.kind === 'lib' && + item.source.path !== app.root && + !app.libraries.includes(item.source.name) + ) + return false; + const side = item.meta.side; + if (side !== 'shared' && !(app.type === side)) return false; + return item.meta.requires + .filter((name) => name.startsWith('@nanoforge-dev/')) + .every((name) => name in app.engineLibs); +}; diff --git a/packages/code/src/codegen/codegen-target.ts b/packages/code/src/codegen/codegen-target.ts new file mode 100644 index 00000000..99c23a72 --- /dev/null +++ b/packages/code/src/codegen/codegen-target.ts @@ -0,0 +1,36 @@ +import type { HistoryCommand } from '@nanoforge-dev/editor-history'; +import { + type ExtensionRegistry, + type Observable, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** + * Where the editor reads and writes what a visual editor shows (entities, scenes…) as code. + * Core defines the point only: the ECS plugin provides the default "entry file" target and a + * scene plugin can take over with a higher priority. + */ +export interface CodegenTarget { + readonly id: string; + readonly label: string; + /** Highest wins when several targets apply to an app. */ + readonly priority?: number; + appliesTo(app: AppModel): boolean; + /** Live model of the app, re-read when its files change. */ + load(app: AppModel): Observable; + /** An undoable command performing the operation (code edits through the DocumentService). */ + apply(app: AppModel, op: Op): Promise; +} + +export const CODEGEN_TARGETS = defineExtensionPoint('code.codegenTargets'); + +/** The target used for an app, if any provider applies. */ +export const selectTarget = ( + extensions: ExtensionRegistry, + app: AppModel, +): CodegenTarget | undefined => + extensions + .getValues(CODEGEN_TARGETS) + .filter((target) => target.appliesTo(app)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; diff --git a/packages/code/src/codegen/index.ts b/packages/code/src/codegen/index.ts new file mode 100644 index 00000000..27c3b44a --- /dev/null +++ b/packages/code/src/codegen/index.ts @@ -0,0 +1 @@ +export * from './codegen-target'; diff --git a/packages/code/src/diagnostics/diagnostics-service.ts b/packages/code/src/diagnostics/diagnostics-service.ts new file mode 100644 index 00000000..a0018193 --- /dev/null +++ b/packages/code/src/diagnostics/diagnostics-service.ts @@ -0,0 +1,58 @@ +import { type Observable, ObservableValue, createToken } from '@nanoforge-dev/editor-kernel'; + +import type { CodeDiagnostic } from '../engine/engine.type'; + +/** Problems per file and source (TypeScript, analyzers, build…), for the Problems panel. */ +export class DiagnosticsService { + private readonly _all = new ObservableValue>( + new Map(), + ); + private readonly _bySource = new Map>(); + + /** Every problem, by file. */ + get all(): Observable> { + return this._all.readonly(); + } + + forUri(uri: string): readonly CodeDiagnostic[] { + return this._all.get().get(uri) ?? []; + } + + /** Replaces the problems of a source for these files. */ + set(source: string, uri: string, diagnostics: readonly CodeDiagnostic[]): void { + const files = this._bySource.get(source) ?? new Map(); + if (diagnostics.length) files.set(uri, diagnostics); + else files.delete(uri); + this._bySource.set(source, files); + this._recompute(); + } + + /** Files a source reported problems for. */ + uris(source: string): string[] { + return [...(this._bySource.get(source)?.keys() ?? [])]; + } + + clear(source: string, uri?: string): void { + const files = this._bySource.get(source); + if (!files) return; + if (uri) files.delete(uri); + else files.clear(); + this._recompute(); + } + + private _recompute(): void { + const merged = new Map(); + for (const files of this._bySource.values()) { + for (const [uri, list] of files) merged.set(uri, [...(merged.get(uri) ?? []), ...list]); + } + for (const list of merged.values()) { + list.sort( + (a, b) => + (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0) || a.start - b.start, + ); + } + this._all.set(merged); + } +} + +export const DiagnosticsServiceToken = createToken('code.diagnostics'); diff --git a/packages/code/src/diagnostics/index.ts b/packages/code/src/diagnostics/index.ts new file mode 100644 index 00000000..bc288c5a --- /dev/null +++ b/packages/code/src/diagnostics/index.ts @@ -0,0 +1 @@ +export * from './diagnostics-service'; diff --git a/packages/code/src/document/document-changed.exception.ts b/packages/code/src/document/document-changed.exception.ts new file mode 100644 index 00000000..ae9ad481 --- /dev/null +++ b/packages/code/src/document/document-changed.exception.ts @@ -0,0 +1,6 @@ +export class DocumentChangedError extends Error { + constructor(readonly uri: string) { + super(`${uri} changed since this edit`); + this.name = 'DocumentChangedError'; + } +} diff --git a/packages/code/src/document/document-contributions.ts b/packages/code/src/document/document-contributions.ts new file mode 100644 index 00000000..c723fde1 --- /dev/null +++ b/packages/code/src/document/document-contributions.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +import type { DocumentEditor, JsonSchemaContribution } from './document.type'; + +export const DocumentEditorSchema = z.object({ + pattern: z.string().min(1), + widget: z.string().min(1), + command: z.string().min(1).optional(), + title: z.string().optional(), + priority: z.number().optional(), +}); + +export const DOCUMENT_EDITORS = defineExtensionPoint('code.documentEditors'); + +export const JSON_SCHEMAS = defineExtensionPoint('code.jsonSchemas'); + +/** Serialized type of document edit commands. */ +export const DOCUMENT_EDITS = 'document.edits'; diff --git a/packages/code/src/document/document-preview.ts b/packages/code/src/document/document-preview.ts new file mode 100644 index 00000000..81adc288 --- /dev/null +++ b/packages/code/src/document/document-preview.ts @@ -0,0 +1,28 @@ +import type { CommandPreview } from '@nanoforge-dev/editor-history'; + +const PREVIEW_CONTEXT = 1; +const PREVIEW_MAX_LINES = 12; + +/** The changed lines of a document (and one line around), before and after. */ +export const previewOf = (uri: string, before: string, after: string): CommandPreview => { + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start++; + let end = 0; + while ( + end < before.length - start && + end < after.length - start && + before[before.length - 1 - end] === after[after.length - 1 - end] + ) + end++; + const lines = (text: string, from: number, to: number) => { + const all = text.split('\n'); + const first = Math.max(0, text.slice(0, from).split('\n').length - 1 - PREVIEW_CONTEXT); + const last = Math.min(all.length, text.slice(0, to).split('\n').length + PREVIEW_CONTEXT); + return all.slice(first, Math.min(last, first + PREVIEW_MAX_LINES)).join('\n'); + }; + return { + title: uri, + before: lines(before, start, before.length - end), + after: lines(after, start, after.length - end), + }; +}; diff --git a/packages/code/src/document/document-service.ts b/packages/code/src/document/document-service.ts new file mode 100644 index 00000000..a35190aa --- /dev/null +++ b/packages/code/src/document/document-service.ts @@ -0,0 +1,272 @@ +import { + type CommandOrigin, + type CommandPreview, + type HistoryCommand, + type HistoryContext, + type HistoryService, + type TextEdit, + applyTextEdits, +} from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectFs } from '@nanoforge-dev/editor-project'; + +import { DOCUMENT_EDITS } from './document-contributions'; +import { previewOf } from './document-preview'; +import type { DocumentBackend, DocumentChange, OpenDocument } from './document.type'; + +export const projectFsBackend = (fs: ProjectFs): DocumentBackend => ({ + onDidChange: (listener, disposables) => + fs.onDidChange((changes) => listener(changes.map((change) => change.path)), disposables), + read: (uri) => fs.readText(uri), + write: (uri, text, expectedHash) => fs.write(uri, text, { expectedHash }), +}); + +/** FNV-1a fingerprint of a text (undo validity checks, not security). */ +export const fingerprint = (text: string): string => { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return `${(hash >>> 0).toString(16)}:${text.length}`; +}; + +/** + * The single path for editing documents: edits go to the open document when there is one + * (the editor stays in sync, no dirty-buffer conflicts), otherwise to the backend (files). + */ +export class DocumentService implements Disposable { + private readonly _open = new Map(); + private readonly _written = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _onDidChangeExternally = new Emitter(); + private readonly _onDidOpen = new Emitter(); + private readonly _store = new DisposableStore(); + + /** Every applied change, from open documents and file edits. */ + readonly onDidChange: Event = this._onDidChange.event; + /** A file the editor edited was changed by something else (hand edit, git…). */ + readonly onDidChangeExternally: Event = this._onDidChangeExternally.event; + readonly onDidOpen: Event = this._onDidOpen.event; + + private readonly _historyContexts = new Map(); + + constructor( + private readonly _backend: DocumentBackend, + /** Gives each document an undo context (`file:`) for `edit`. */ + private readonly _history?: HistoryService, + ) { + this._store.add(_backend.onDidChange((uris) => void this._checkExternal(uris))); + } + + register(document: OpenDocument): Disposable { + if (this._open.has(document.uri)) throw new Error(`${document.uri} is already open`); + this._open.set(document.uri, document); + const forward = document.onDidChange((change) => this._onDidChange.fire(change)); + this._onDidOpen.fire(document.uri); + return toDisposable(() => { + forward.dispose(); + if (this._open.get(document.uri) === document) this._open.delete(document.uri); + }); + } + + get(uri: string): OpenDocument | undefined { + return this._open.get(uri); + } + + get openUris(): string[] { + return [...this._open.keys()]; + } + + /** Where documents live when they are not open (editors read and write through it). */ + get backend(): DocumentBackend { + return this._backend; + } + + /** Open documents with unsaved changes. */ + get dirtyUris(): string[] { + return [...this._open.values()].filter((doc) => doc.dirty.get()).map((doc) => doc.uri); + } + + /** Saves an open document if it has unsaved changes (a closed one is always saved). */ + async save(uri: string): Promise { + const open = this._open.get(uri); + if (open?.dirty.get()) await open.save(); + } + + /** Saves every open document with unsaved changes; resolves with the ones that failed. */ + async saveAll(): Promise<{ uri: string; error: unknown }[]> { + const failed: { uri: string; error: unknown }[] = []; + for (const uri of this.dirtyUris) { + await this._open + .get(uri) + ?.save() + .catch((error: unknown) => failed.push({ uri, error })); + } + return failed; + } + + async getText(uri: string): Promise { + return this._open.get(uri)?.getText() ?? (await this._backend.read(uri)).text; + } + + /** Applies edits once (not undoable); returns their inverse. */ + async applyEdits( + uri: string, + edits: readonly TextEdit[], + origin?: CommandOrigin, + ): Promise { + if (!edits.length) return []; + const open = this._open.get(uri); + if (open) return open.applyEdits(edits, origin); + const current = await this._backend.read(uri); + const { text, inverse } = applyTextEdits(current.text, edits); + const { hash } = await this._backend.write(uri, text, current.hash); + this._written.set(uri, hash); + this._onDidChange.fire({ uri, edits, origin }); + return inverse; + } + + /** The undo context of a document (`file:`), created on first use. */ + historyContext(uri: string): HistoryContext | undefined { + if (!this._history) return undefined; + let context = this._historyContexts.get(uri); + if (!context) { + const id = documentHistoryId(uri); + context = + this._history.get(id) ?? + this._history.registerContext({ id, label: uri.split('/').at(-1) ?? uri }); + this._historyContexts.set(uri, context); + } + return context; + } + + /** Applies edits as one undo step of the document's history context. */ + async edit( + uri: string, + edits: readonly TextEdit[], + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): Promise { + const context = this.historyContext(uri); + const command = this.editCommand(uri, edits, options); + if (context) await context.stack.push(command); + else await command.do(); + } + + /** + * An undoable edit. Undo/redo check that the document still has the content the edit + * produced; otherwise the step is dropped by the history. It carries a preview of the changed + * lines and can be serialized (history kept across reloads, see `restoreEditCommand`). + */ + editCommand( + uri: string, + edits: readonly TextEdit[], + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): HistoryCommand { + return this._editCommand( + { uri, forward: [...edits], inverse: [], expected: '' }, + options, + false, + ); + } + + /** Rebuilds an edit command from `serialize()` data (already applied). */ + restoreEditCommand(data: unknown, label: string): HistoryCommand | undefined { + const state = data as Partial | undefined; + if (!state?.uri || !Array.isArray(state.forward) || !Array.isArray(state.inverse)) + return undefined; + return this._editCommand( + { + uri: state.uri, + forward: state.forward, + inverse: state.inverse, + expected: state.expected ?? '', + ...(state.preview && { preview: state.preview }), + }, + { label }, + true, + ); + } + + dispose(): void { + for (const context of this._historyContexts.values()) context.dispose(); + this._historyContexts.clear(); + this._store.dispose(); + this._onDidChange.dispose(); + this._onDidChangeExternally.dispose(); + this._onDidOpen.dispose(); + } + + private _editCommand( + state: EditState, + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + applied: boolean, + ): HistoryCommand { + const run = async (operations: readonly TextEdit[]) => { + const before = await this.getText(state.uri); + const result = await this.applyEdits(state.uri, operations, options.origin); + const after = await this.getText(state.uri); + state.expected = fingerprint(after); + return { result, before, after }; + }; + const command: HistoryCommand & { preview?: CommandPreview } = { + label: options.label, + ...(options.origin && { origin: options.origin }), + ...(options.mergeKey && { mergeKey: options.mergeKey }), + sizeBytes: state.forward.reduce((total, edit) => total + edit.text.length * 2 + 16, 0), + ...(state.preview && { preview: state.preview }), + do: async () => { + if (applied) return; + const { result, before, after } = await run(state.forward); + state.inverse = result; + state.preview = previewOf(state.uri, before, after); + (command as { preview?: CommandPreview }).preview = state.preview; + }, + undo: async () => { + applied = false; + state.forward = (await run(state.inverse)).result; + }, + redo: async () => { + state.inverse = (await run(state.forward)).result; + }, + isValid: async () => fingerprint(await this.getText(state.uri)) === state.expected, + serialize: () => ({ type: DOCUMENT_EDITS, label: options.label, data: { ...state } }), + }; + return command; + } + + private async _checkExternal(uris: readonly string[]): Promise { + for (const uri of uris) { + const written = this._written.get(uri); + if (!written || this._open.has(uri)) continue; + const current = await this._backend.read(uri).then( + (file) => file.hash, + () => null, + ); + if (current === written) continue; + this._written.delete(uri); + this._onDidChangeExternally.fire(uri); + } + } +} + +export const DocumentServiceToken = createToken('code.documents'); + +/** Id of a document's undo context. */ +export const documentHistoryId = (uri: string): string => `file:${uri}`; + +interface EditState { + uri: string; + forward: TextEdit[]; + inverse: TextEdit[]; + /** Fingerprint of the document after the command (undo validity). */ + expected: string; + preview?: CommandPreview; +} diff --git a/packages/code/src/document/document.type.ts b/packages/code/src/document/document.type.ts new file mode 100644 index 00000000..46463d7e --- /dev/null +++ b/packages/code/src/document/document.type.ts @@ -0,0 +1,51 @@ +import type { CommandOrigin, TextEdit } from '@nanoforge-dev/editor-history'; +import type { Event, Observable } from '@nanoforge-dev/editor-kernel'; + +export interface DocumentChange { + readonly uri: string; + readonly edits: readonly TextEdit[]; + readonly origin: CommandOrigin | undefined; +} + +/** A document opened by an editor (e.g. a Monaco model): the source of truth while open. */ +export interface OpenDocument { + readonly uri: string; + readonly kind: 'text' | 'binary' | 'custom'; + readonly onDidChange: Event; + readonly dirty: Observable; + /** Current content (text documents; custom documents serialize their model). */ + getText(): string; + /** Applies edits and returns their inverse. */ + applyEdits(edits: readonly TextEdit[], origin: CommandOrigin | undefined): TextEdit[]; + save(): Promise; + revert(): Promise; +} + +/** Where documents are stored when they are not open. */ +export interface DocumentBackend { + readonly onDidChange: Event; + read(uri: string): Promise<{ text: string; hash: string }>; + write(uri: string, text: string, expectedHash: string | null): Promise<{ hash: string }>; +} + +/** Which editor widget opens which files ("open with"). */ +export interface DocumentEditor { + /** Glob on project paths: `**\/*.ts`, `**\/*.{png,jpg}`. */ + readonly pattern: string; + readonly widget: string; + /** + * Command opening a file in this editor (`command(path, options)`); declared in the plugin + * manifest, running it activates the plugin. Without one, the widget is opened. + */ + readonly command?: string; + readonly title?: string; + readonly priority?: number; +} + +/** A JSON schema for files matching globs (completion and validation in the code editor). */ +export interface JsonSchemaContribution { + /** Globs on project paths, e.g. `.nanoforge/editor/settings.json`. */ + readonly fileMatch: readonly string[]; + /** The schema, or a function computing it when it is applied (e.g. from a registry). */ + readonly schema: object | (() => object); +} diff --git a/packages/code/src/document/index.ts b/packages/code/src/document/index.ts new file mode 100644 index 00000000..f7c34ca2 --- /dev/null +++ b/packages/code/src/document/index.ts @@ -0,0 +1,6 @@ +export * from './document.type'; +export * from './document-contributions'; +export * from './match-glob'; +export * from './document-changed.exception'; +export * from './document-service'; +export * from './document-preview'; diff --git a/packages/code/src/document/match-glob.ts b/packages/code/src/document/match-glob.ts new file mode 100644 index 00000000..b3247bea --- /dev/null +++ b/packages/code/src/document/match-glob.ts @@ -0,0 +1,45 @@ +import type { ExtensionRegistry } from '@nanoforge-dev/editor-kernel'; + +import { DOCUMENT_EDITORS } from './document-contributions'; +import type { DocumentEditor } from './document.type'; + +const globToRegex = (glob: string): RegExp => { + let source = ''; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]!; + if (char === '*' && glob[i + 1] === '*') { + if (glob[i + 2] === '/') { + source += '(?:.*/)?'; + i += 2; + } else { + source += '.*'; + i += 1; + } + } else if (char === '*') source += '[^/]*'; + else if (char === '?') source += '[^/]'; + else if (char === '{') source += '(?:'; + else if (char === '}') source += ')'; + else if (char === ',') source += '|'; + else source += /[.+^$()|[\]\\]/.test(char) ? `\\${char}` : char; + } + return new RegExp(`^${source}$`); +}; + +const globs = new Map(); + +/** Whether a project path matches a glob (`**\/*.{png,jpg}`, `src/*.ts`). */ +export const matchesGlob = (pattern: string, path: string): boolean => { + let regex = globs.get(pattern); + if (!regex) globs.set(pattern, (regex = globToRegex(pattern))); + return regex.test(path); +}; + +/** Editors able to open a file, best first. */ +export const editorsFor = ( + extensions: Pick, + uri: string, +): DocumentEditor[] => + extensions + .getValues(DOCUMENT_EDITORS) + .filter((editor) => matchesGlob(editor.pattern, uri)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); diff --git a/packages/code/src/engine/code-engine.ts b/packages/code/src/engine/code-engine.ts new file mode 100644 index 00000000..6de6a2d4 --- /dev/null +++ b/packages/code/src/engine/code-engine.ts @@ -0,0 +1,376 @@ +import { type Node, Project, ts } from 'ts-morph'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import type { AnalyzeContext, Analyzer, CodeEngineOptions, Transformer } from './code-engine.type'; +import { CodeError } from './code.exception'; +import { EditBuilder } from './edit-builder'; +import type { CodeDiagnostic, CodeSymbol, SourceText, TextEdit } from './engine.type'; +import { type MetaRequest, type MetaResponse, extractMeta } from './extract-meta'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './literals'; +import { joinPosix, matchesPathPattern, moduleSpecifierFor } from './module-specifier'; + +const ROOT = '/project/'; + +const toFsPath = (path: string) => ROOT + path; +const toProjectPath = (fsPath: string) => + fsPath.startsWith(ROOT) ? fsPath.slice(ROOT.length) : fsPath; + +/** + * TypeScript analysis of a project mirrored in memory. Runs in a web worker; plugins register + * analyzers (source → model) and transformers (operation → text edits). + */ +export class CodeEngine { + readonly project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + checkJs: false, + strict: true, + skipLibCheck: true, + noEmit: true, + experimentalDecorators: true, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + }, + }); + + private readonly _analyzers = new Map(); + private readonly _transformers = new Map(); + /** + * Per package, where its declarations were found (the folders holding the `node_modules` they + * are in) and the folders asked from. Each app of a workspace has its own `node_modules`: a + * package loaded for the client is not visible from the server's files. + */ + private readonly _typeRoots = new Map }>(); + private readonly _itemOwners = new Map(); + private _paths: Readonly> = {}; + /** + * One program per app, for diagnostics, once the project has several apps with their own + * `node_modules` (by app root; files outside of every app are in each of them). In one program the + * client's type augmentations (`declare module`, globals) would apply to the server's code + * and report errors `tsc` never shows. Built on demand, kept in step with `project`. + */ + private _scopes: Map | undefined; + + constructor(private readonly _options: CodeEngineOptions = {}) {} + + setFiles(files: readonly SourceText[]): void { + for (const { path, text } of files) { + const existing = this.project.getSourceFile(toFsPath(path)); + if (existing) { + if (existing.getFullText() !== text) existing.replaceWithText(text); + } else { + this.project.createSourceFile(toFsPath(path), text, { overwrite: true }); + } + this._syncScopes(path, text); + } + } + + deleteFiles(paths: readonly string[]): void { + for (const path of paths) { + const file = this.project.getSourceFile(toFsPath(path)); + if (file) this.project.removeSourceFile(file); + this._syncScopes(path, undefined); + } + } + + text(path: string): string | undefined { + return this.project.getSourceFile(toFsPath(path))?.getFullText(); + } + + get paths(): string[] { + return this.project.getSourceFiles().map((file) => toProjectPath(file.getFilePath())); + } + + registerAnalyzer(id: string, run: Analyzer, owner = 'core'): () => void { + if (this._analyzers.has(id)) throw new Error(`Analyzer "${id}" is already registered`); + this._analyzers.set(id, { owner, run }); + return () => this._analyzers.delete(id); + } + + registerTransformer(id: string, run: Transformer, owner = 'core'): () => void { + if (this._transformers.has(id)) throw new Error(`Transformer "${id}" is already registered`); + this._transformers.set(id, { owner, run }); + return () => this._transformers.delete(id); + } + + /** Registers an owner of item data (ADR 0003), e.g. `@nanoforge/ecs` from the ECS plugin. */ + registerItemOwner(owner: ItemOwner, plugin = 'core'): () => void { + if (this._itemOwners.has(owner.name)) + throw new Error(`Item owner "${owner.name}" is already registered`); + this._itemOwners.set(owner.name, { plugin, owner }); + return () => this._itemOwners.delete(owner.name); + } + + /** Names, versions and schemas of the registered item owners. */ + get itemOwners(): { name: string; version: string; schema: number }[] { + return [...this._itemOwners.values()].map(({ owner }) => ({ + name: owner.name, + version: owner.version, + schema: owner.schema, + })); + } + + /** Items of a source (ADR 0003), with every registered owner. */ + async extractMeta(request: MetaRequest): Promise { + if (request.texts?.length) this.setFiles(request.texts); + for (const file of request.files) await this._loadTypes(file.path); + return extractMeta( + this.project, + [...this._itemOwners.values()].map(({ owner }) => owner), + request, + ); + } + + /** + * Applies the root `tsconfig.json`'s `paths` (package-name imports of shared libraries and + * installed packages, ADR 0004). Other options stay the engine's. + */ + setTsconfig(text: string | undefined): void { + const parsed = text ? ts.parseConfigFileTextToJson(`${ROOT}tsconfig.json`, text) : undefined; + const options = (parsed?.config as { compilerOptions?: { paths?: unknown; baseUrl?: unknown } }) + ?.compilerOptions; + const paths: Record = {}; + if (options?.paths && typeof options.paths === 'object') { + const base = typeof options.baseUrl === 'string' ? options.baseUrl : '.'; + for (const [pattern, targets] of Object.entries(options.paths as Record)) { + if (!Array.isArray(targets)) continue; + paths[pattern] = targets + .filter((target): target is string => typeof target === 'string') + .map((target) => joinPosix(base, target)); + } + } + this._paths = paths; + this.project.compilerOptions.set({ baseUrl: ROOT, paths }); + this._scopes = undefined; + } + + /** Removes every analyzer and transformer of a plugin (hot reload, disable). */ + unregisterOwner(owner: string): void { + for (const [name, entry] of this._itemOwners) + if (entry.plugin === owner) this._itemOwners.delete(name); + for (const [id, entry] of this._analyzers) + if (entry.owner === owner) this._analyzers.delete(id); + for (const [id, entry] of this._transformers) + if (entry.owner === owner) this._transformers.delete(id); + } + + analyze(path: string, analyzerId: string, args?: unknown): unknown { + const analyzer = this._analyzers.get(analyzerId); + if (!analyzer) throw new CodeError(`Unknown analyzer "${analyzerId}"`); + return analyzer.run(this._context(path), args); + } + + /** Edits a transformer wants to make; the file itself is not changed here. */ + transform(path: string, transformerId: string, op: unknown): TextEdit[] { + const transformer = this._transformers.get(transformerId); + if (!transformer) throw new CodeError(`Unknown transformer "${transformerId}"`); + const context = this._context(path); + const edit = new EditBuilder(context.text); + transformer.run({ ...context, edit }, op); + return edit.build(); + } + + /** Edits sorting and removing unused imports of a file (TypeScript's organize imports). */ + async organizeImports(path: string): Promise { + await this._loadTypes(path); + const changes = this.project + .getLanguageService() + .compilerObject.organizeImports({ type: 'file', fileName: toFsPath(path) }, {}, {}); + return changes + .filter((change) => change.fileName === toFsPath(path)) + .flatMap((change) => + change.textChanges.map((edit) => ({ + start: edit.span.start, + end: edit.span.start + edit.span.length, + text: edit.newText, + })), + ); + } + + /** Named declarations of a file, in source order (TypeScript's navigation tree). */ + symbols(path: string): CodeSymbol[] { + const file = this.project.getSourceFile(toFsPath(path))?.compilerNode; + if (!file) return []; + const tree = this.project.getLanguageService().compilerObject.getNavigationTree(toFsPath(path)); + const result: CodeSymbol[] = []; + const visit = (node: ts.NavigationTree, container?: string) => { + const span = node.nameSpan ?? node.spans[0]; + const named = node.kind !== 'script' && node.kind !== 'module' && !node.text.startsWith('<'); + if (named && span) { + const position = file.getLineAndCharacterOfPosition(span.start); + result.push({ + name: node.text, + kind: node.kind, + ...(container && { container }), + line: position.line + 1, + column: position.character + 1, + }); + } + for (const child of node.childItems ?? []) visit(child, named ? node.text : container); + }; + visit(tree); + return result.sort((a, b) => a.line - b.line || a.column - b.column); + } + + /** TypeScript diagnostics of files (type declarations of bare imports are loaded first). */ + async diagnostics(paths: readonly string[]): Promise { + for (const path of paths) await this._loadTypes(path); + const programs = new Map(); + const result: CodeDiagnostic[] = []; + for (const path of paths) { + const checked = this._checked(path); + let program = programs.get(checked); + if (!program) programs.set(checked, (program = checked.getProgram().compilerObject)); + const file = program.getSourceFile(toFsPath(path)); + if (!file) continue; + for (const diagnostic of [ + ...program.getSyntacticDiagnostics(file), + ...program.getSemanticDiagnostics(file), + ]) { + const position = file.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + result.push({ + path, + start: diagnostic.start ?? 0, + length: diagnostic.length ?? 0, + line: position.line + 1, + column: position.character + 1, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + severity: + diagnostic.category === ts.DiagnosticCategory.Error + ? 'error' + : diagnostic.category === ts.DiagnosticCategory.Warning + ? 'warning' + : 'info', + code: diagnostic.code, + source: 'typescript', + }); + } + } + return result; + } + + /** Roots of the apps that have their own `node_modules` (where type declarations were found). */ + private _scopeRoots(): string[] { + const roots = new Set(); + for (const { found } of this._typeRoots.values()) + for (const root of found) if (root) roots.add(root); + return [...roots].sort(); + } + + private _scopeOf(path: string, roots: readonly string[]): string { + return roots.find((root) => path === root || path.startsWith(`${root}/`)) ?? ''; + } + + /** The program a file is checked in: its app's, with the files outside of every app. */ + private _checked(path: string): Project { + const roots = this._scopeRoots(); + if (roots.length < 2) return this.project; + const scope = this._scopeOf(path, roots); + if (!scope) return this.project; + this._scopes ??= new Map(); + let project = this._scopes.get(scope); + if (!project) { + project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: this.project.getCompilerOptions(), + }); + for (const file of this.project.getSourceFiles()) { + const owner = this._scopeOf(toProjectPath(file.getFilePath()), roots); + if (owner === '' || owner === scope) + project.createSourceFile(file.getFilePath(), file.getFullText(), { overwrite: true }); + } + this._scopes.set(scope, project); + } + return project; + } + + private _syncScopes(path: string, text: string | undefined): void { + if (!this._scopes) return; + const roots = this._scopeRoots(); + const owner = this._scopeOf(path, roots); + for (const [scope, project] of this._scopes) { + if (owner !== '' && owner !== scope) continue; + const existing = project.getSourceFile(toFsPath(path)); + if (text === undefined) { + if (existing) project.removeSourceFile(existing); + } else if (existing) { + if (existing.getFullText() !== text) existing.replaceWithText(text); + } else project.createSourceFile(toFsPath(path), text, { overwrite: true }); + } + } + + private _context(path: string): AnalyzeContext { + const file = this.project.getSourceFile(toFsPath(path)); + if (!file) throw new CodeError(`File not loaded: ${path}`, path); + return { + path, + file, + project: this.project, + text: file.getFullText(), + ref: (node) => { + const kind = node.getKindName(); + return { + id: `${path}:${kind}@${node.getStart()}`, + path, + kind, + start: node.getStart(), + end: node.getEnd(), + }; + }, + resolve: (ref) => { + const node = file.getDescendantAtPos(ref.start); + for (let current: Node | undefined = node; current; current = current.getParent()) { + if (current.getStart() === ref.start && current.getKindName() === ref.kind) + return current; + if (current.getStart() < ref.start) break; + } + return undefined; + }, + literals: { literalToValue, valueToLiteral, readExportedLiteral, quoteStyle }, + moduleSpecifier: (targetPath, fromPath = path) => + moduleSpecifierFor(fromPath, targetPath, this._paths), + }; + } + + private async _loadTypes(path: string): Promise { + const resolve = this._options.resolveTypes; + const file = this.project.getSourceFile(toFsPath(path)); + if (!resolve || !file) return; + const modules = new Set(); + for (const declaration of file.getImportDeclarations()) + modules.add(declaration.getModuleSpecifierValue()); + for (const declaration of file.getExportDeclarations()) { + const specifier = declaration.getModuleSpecifierValue(); + if (specifier) modules.add(specifier); + } + for (const module of modules) { + if (module.startsWith('.') || module.startsWith('/') || module.startsWith('node:')) continue; + if (Object.keys(this._paths).some((pattern) => matchesPathPattern(pattern, module))) continue; + const packageName = module.startsWith('@') + ? module.split('/').slice(0, 2).join('/') + : module.split('/')[0]!; + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + let roots = this._typeRoots.get(packageName); + if (!roots) this._typeRoots.set(packageName, (roots = { found: [], asked: new Set() })); + const visible = roots.found.some( + (root) => root === '' || dir === root || dir.startsWith(`${root}/`), + ); + if (visible || roots.asked.has(dir)) continue; + roots.asked.add(dir); + try { + const files = await resolve(packageName, path); + const marker = files[0]?.path.indexOf('node_modules/') ?? -1; + if (marker >= 0) { + const root = files[0]!.path.slice(0, marker).replace(/\/$/, ''); + if (root && !this._scopeRoots().includes(root)) this._scopes = undefined; + roots.found.push(root); + } + this.setFiles(files); + } catch {} + } + } +} diff --git a/packages/code/src/engine/code-engine.type.ts b/packages/code/src/engine/code-engine.type.ts new file mode 100644 index 00000000..c8204906 --- /dev/null +++ b/packages/code/src/engine/code-engine.type.ts @@ -0,0 +1,45 @@ +import { type Node, type Project, type SourceFile } from 'ts-morph'; + +import { type EditBuilder } from './edit-builder'; +import type { NodeRef, SourceText } from './engine.type'; +import { + type literalToValue, + type quoteStyle, + type readExportedLiteral, + type valueToLiteral, +} from './literals'; + +export interface AnalyzeContext { + readonly literals: { + readonly literalToValue: typeof literalToValue; + readonly valueToLiteral: typeof valueToLiteral; + readonly readExportedLiteral: typeof readExportedLiteral; + readonly quoteStyle: typeof quoteStyle; + }; + readonly path: string; + readonly file: SourceFile; + readonly project: Project; + readonly text: string; + /** Stable reference of a node for models returned to the main thread. */ + ref(node: Node): NodeRef; + /** Node at a reference, if the file still has it. */ + resolve(ref: NodeRef): Node | undefined; + /** + * How this file (or `fromPath`) imports another project file: by package name when a + * `tsconfig.json` `paths` pattern maps it (`@me/shared/components/player`), else a relative + * path. No extension. + */ + moduleSpecifier(targetPath: string, fromPath?: string): string; +} + +export interface TransformContext extends AnalyzeContext { + readonly edit: EditBuilder; +} + +export type Analyzer = (context: AnalyzeContext, args: unknown) => unknown; +export type Transformer = (context: TransformContext, op: unknown) => void; + +export interface CodeEngineOptions { + /** Fetches type declarations (`node_modules//…`) of a bare import, once per module. */ + readonly resolveTypes?: (module: string, from: string) => Promise; +} diff --git a/packages/code/src/engine/code-file.const.ts b/packages/code/src/engine/code-file.const.ts new file mode 100644 index 00000000..a81d53f9 --- /dev/null +++ b/packages/code/src/engine/code-file.const.ts @@ -0,0 +1,2 @@ +/** Files the code engine reads as code. Kept apart from the engine: the main thread needs it too. */ +export const CODE_FILE = /\.(?:[cm]?[jt]sx?|d\.ts)$/; diff --git a/packages/code/src/engine/code.exception.ts b/packages/code/src/engine/code.exception.ts new file mode 100644 index 00000000..02ed4bf3 --- /dev/null +++ b/packages/code/src/engine/code.exception.ts @@ -0,0 +1,11 @@ +/** Thrown by transformers/analyzers for problems users should see (not bugs). */ +export class CodeError extends Error { + constructor( + message: string, + readonly path?: string, + readonly start?: number, + ) { + super(message); + this.name = 'CodeError'; + } +} diff --git a/packages/code/src/engine/edit-builder.ts b/packages/code/src/engine/edit-builder.ts new file mode 100644 index 00000000..3dd4ab56 --- /dev/null +++ b/packages/code/src/engine/edit-builder.ts @@ -0,0 +1,85 @@ +import type { Node } from 'ts-morph'; + +import { CodeError } from './code.exception'; +import type { TextEdit } from './engine.type'; + +type Target = Node | { readonly start: number; readonly end: number }; + +const range = (target: Target) => + 'getStart' in target ? { start: target.getStart(), end: target.getEnd() } : target; + +/** + * Collects edits as replacements of node ranges. Code outside the edited ranges is never + * touched, so formatting and comments elsewhere are preserved by construction. + */ +export class EditBuilder { + private readonly _edits: TextEdit[] = []; + + constructor(private readonly _text: string) {} + + replace(target: Target, text: string): this { + const { start, end } = range(target); + return this._push({ start, end, text }); + } + + insertBefore(target: Target, text: string): this { + const { start } = range(target); + return this._push({ start, end: start, text }); + } + + insertAfter(target: Target, text: string): this { + const { end } = range(target); + return this._push({ start: end, end, text }); + } + + insertAt(offset: number, text: string): this { + return this._push({ start: offset, end: offset, text }); + } + + remove(target: Target): this { + const { start, end } = range(target); + return this._push({ start, end, text: '' }); + } + + /** Removes whole lines covered by the node (with its indentation and line break). */ + removeLines(target: Target): this { + const { start, end } = range(target); + const lineStart = this._text.lastIndexOf('\n', start - 1) + 1; + const newline = this._text.indexOf('\n', end); + const lineEnd = newline < 0 ? this._text.length : newline + 1; + const before = this._text.slice(lineStart, start); + const after = this._text.slice(end, newline < 0 ? this._text.length : newline); + return before.trim() === '' && after.trim().replace(/^;/, '') === '' + ? this._push({ start: lineStart, end: lineEnd, text: '' }) + : this._push({ start, end, text: '' }); + } + + /** Indentation of the line where the node starts. */ + indentationOf(target: Target): string { + const { start } = range(target); + const lineStart = this._text.lastIndexOf('\n', start - 1) + 1; + return /^[ \t]*/.exec(this._text.slice(lineStart, start))![0]; + } + + /** Edits sorted by position; overlapping edits are a programming error. */ + build(): TextEdit[] { + const sorted = [...this._edits].sort((a, b) => a.start - b.start || a.end - b.end); + for (let i = 1; i < sorted.length; i++) { + const previous = sorted[i - 1]!; + if (sorted[i]!.start < previous.end) { + throw new CodeError( + `Overlapping edits at ${previous.start}-${previous.end} and ${sorted[i]!.start}`, + ); + } + } + return sorted; + } + + private _push(edit: TextEdit): this { + if (edit.start < 0 || edit.end > this._text.length || edit.start > edit.end) { + throw new CodeError(`Edit out of range: ${edit.start}-${edit.end}`); + } + if (edit.text !== this._text.slice(edit.start, edit.end)) this._edits.push(edit); + return this; + } +} diff --git a/packages/code/src/engine/engine.type.ts b/packages/code/src/engine/engine.type.ts new file mode 100644 index 00000000..0f639633 --- /dev/null +++ b/packages/code/src/engine/engine.type.ts @@ -0,0 +1,46 @@ +import type { TextEdit } from '@nanoforge-dev/editor-history'; + +export type { TextEdit }; + +/** A reference to a syntax node, stable while the file does not change around it. */ +export interface NodeRef { + /** `:@` */ + readonly id: string; + readonly path: string; + readonly kind: string; + readonly start: number; + readonly end: number; +} + +export interface CodeDiagnostic { + /** Project-relative file; empty for a problem that has no file (a failed build). */ + readonly path: string; + /** Offset in the file's text; -1 when only `line` and `column` are known (build errors). */ + readonly start: number; + readonly length: number; + /** 1-based position, when the reporter knows it. */ + readonly line?: number; + readonly column?: number; + readonly message: string; + readonly severity: 'error' | 'warning' | 'info'; + readonly code?: number | string; + /** `typescript` or the analyzer/plugin that reported it. */ + readonly source: string; +} + +/** A named declaration of a file, for outlines and "go to symbol". */ +export interface CodeSymbol { + readonly name: string; + /** TypeScript's kind: `class`, `function`, `method`, `property`, `const`, `interface`… */ + readonly kind: string; + /** The declaration that holds it (`Position` for a field of the class). */ + readonly container?: string; + /** 1-based position of the name. */ + readonly line: number; + readonly column: number; +} + +export interface SourceText { + readonly path: string; + readonly text: string; +} diff --git a/packages/code/src/engine/extract-meta.ts b/packages/code/src/engine/extract-meta.ts new file mode 100644 index 00000000..dc5863d5 --- /dev/null +++ b/packages/code/src/engine/extract-meta.ts @@ -0,0 +1,111 @@ +import { Node, type Project } from 'ts-morph'; + +import { + type ItemMeta, + type ItemOwner, + type MetaDiagnostic, + type MetaSource, + defaultModulePath, + extractItems, + sourceHash, +} from '@nanoforge-dev/editor-meta'; + +/** A file of a source to extract items from. */ +export interface MetaSourceFile { + /** Project path. */ + readonly path: string; + /** The item folder it is in (`dir.components`, `dir.systems`), if any. */ + readonly folder?: 'components' | 'systems'; + /** Listed in a manifest's `items`: each export is an item. */ + readonly listed?: boolean; +} + +/** The folder of a source and the base of its item references (`app:client`, `@me/shared`). */ +export interface MetaRoot { + readonly path: string; + readonly ref: string; +} + +export interface MetaRequest { + readonly source: MetaSource; + readonly files: readonly MetaSourceFile[]; + /** Every source of the project, so items of other sources get their reference. */ + readonly roots: readonly MetaRoot[]; + /** + * Current texts of the files (open documents included), applied before extracting so the + * items and the hash come from the same text. + */ + readonly texts?: readonly { readonly path: string; readonly text: string }[]; +} + +export interface MetaResponse { + readonly items: ItemMeta[]; + readonly diagnostics: MetaDiagnostic[]; + /** Owners that ran, with their version and schema (the meta file's `owners`). */ + readonly owners: Record; + /** Hash of the files' current text (the meta file's `sourceHash`). */ + readonly sourceHash: string; + /** Files that aren't loaded in the engine. */ + readonly missing: string[]; +} + +const ROOT = '/project/'; +const toProjectPath = (path: string) => (path.startsWith(ROOT) ? path.slice(ROOT.length) : path); + +/** Items of a source, extracted with the registered owners. */ +export const extractMeta = async ( + project: Project, + owners: readonly ItemOwner[], + request: MetaRequest, +): Promise => { + const roots = [...request.roots].sort((a, b) => b.path.length - a.path.length); + const refOf = (node: Node) => { + let symbol = node.getSymbol(); + if (symbol?.isAlias()) symbol = symbol.getAliasedSymbol(); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration) return undefined; + const exportable = Node.isVariableDeclaration(declaration) + ? declaration.getVariableStatement() + : declaration; + if (!exportable || !Node.isExportable(exportable) || !exportable.isExported()) return undefined; + const path = toProjectPath(declaration.getSourceFile().getFilePath()); + const root = roots.find( + (candidate) => + path === candidate.path || path.startsWith(`${candidate.path}/`) || candidate.path === '', + ); + return root ? `${root.ref}#${symbol.getName()}` : undefined; + }; + + const items: ItemMeta[] = []; + const diagnostics: MetaDiagnostic[] = []; + const texts: { path: string; text: string }[] = []; + const missing: string[] = []; + for (const entry of request.files) { + const file = project.getSourceFile(ROOT + entry.path); + if (!file) { + missing.push(entry.path); + continue; + } + texts.push({ path: entry.path, text: file.getFullText() }); + const result = extractItems(file, { + source: request.source, + path: entry.path, + owners, + ...(entry.folder && { folder: entry.folder }), + ...(entry.listed && { listed: true }), + modulePath: (declarationFile) => defaultModulePath(declarationFile), + refOf, + }); + items.push(...result.items); + diagnostics.push(...result.diagnostics); + } + return { + items, + diagnostics, + owners: Object.fromEntries( + owners.map((owner) => [owner.name, { version: owner.version, schema: owner.schema }]), + ), + sourceHash: await sourceHash(texts), + missing, + }; +}; diff --git a/packages/code/src/engine/index.ts b/packages/code/src/engine/index.ts new file mode 100644 index 00000000..57b16fc0 --- /dev/null +++ b/packages/code/src/engine/index.ts @@ -0,0 +1,10 @@ +export * from './code-engine.type'; +export * from './code-engine'; +export { moduleSpecifierFor } from './module-specifier'; +export * from './edit-builder'; +export * from './literals'; +export * from './extract-meta'; +export * from './engine.type'; +export * from './code.exception'; +export * from './worker-sdk'; +export * from './code-file.const'; diff --git a/packages/code/src/engine/literals.ts b/packages/code/src/engine/literals.ts new file mode 100644 index 00000000..5a448bd3 --- /dev/null +++ b/packages/code/src/engine/literals.ts @@ -0,0 +1,133 @@ +import { type Expression, Node, type SourceFile, SyntaxKind } from 'ts-morph'; + +import { CodeError } from './code.exception'; + +export type LiteralValue = + | string + | number + | boolean + | null + | readonly LiteralValue[] + | { readonly [key: string]: LiteralValue }; + +const unwrap = (node: Node): Node => { + let current = node; + while ( + Node.isParenthesizedExpression(current) || + Node.isAsExpression(current) || + Node.isSatisfiesExpression(current) || + Node.isTypeAssertion(current) + ) { + current = current.getExpression(); + } + return current; +}; + +/** Value of a literal expression; throws `CodeError` (with position) for anything else. */ +export const literalToValue = (expression: Node): LiteralValue => { + const node = unwrap(expression); + const fail = (): never => { + throw new CodeError( + `Only literal values are supported here, found \`${node.getText().slice(0, 60)}\``, + node.getSourceFile().getFilePath(), + node.getStart(), + ); + }; + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) + return node.getLiteralValue(); + if (Node.isNumericLiteral(node)) return node.getLiteralValue(); + if (Node.isTrueLiteral(node)) return true; + if (Node.isFalseLiteral(node)) return false; + if (Node.isNullLiteral(node)) return null; + if (Node.isPrefixUnaryExpression(node)) { + const operand = node.getOperand(); + if (!Node.isNumericLiteral(operand)) return fail(); + const value = operand.getLiteralValue(); + if (node.getOperatorToken() === SyntaxKind.MinusToken) return -value; + if (node.getOperatorToken() === SyntaxKind.PlusToken) return value; + } + if (Node.isArrayLiteralExpression(node)) + return node.getElements().map((element) => literalToValue(element)); + if (Node.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.getProperties()) { + if (!Node.isPropertyAssignment(property)) return fail(); + const name = property.getNameNode(); + const key = + Node.isIdentifier(name) || Node.isStringLiteral(name) || Node.isNumericLiteral(name) + ? String(Node.isIdentifier(name) ? name.getText() : name.getLiteralValue()) + : fail(); + result[key] = literalToValue(property.getInitializerOrThrow()); + } + return result; + } + return fail(); +}; + +export interface PrintOptions { + readonly quote?: '"' | "'"; + /** Indentation of the line the literal starts on. */ + readonly indent?: string; + /** Indentation unit. */ + readonly tab?: string; + /** Objects/arrays longer than this are printed on several lines. */ + readonly maxInline?: number; +} + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +/** Source code of a value (the inverse of `literalToValue`). */ +export const valueToLiteral = (value: LiteralValue, options: PrintOptions = {}): string => { + const quote = options.quote ?? "'"; + const tab = options.tab ?? ' '; + const maxInline = options.maxInline ?? 60; + const string = (text: string) => + quote + + text + .replace(/\\/g, '\\\\') + .replace(new RegExp(quote, 'g'), `\\${quote}`) + .replace(/\n/g, '\\n') + + quote; + const print = (current: LiteralValue, indent: string): string => { + if (current === null) return 'null'; + if (typeof current === 'string') return string(current); + if (typeof current === 'number') { + if (!Number.isFinite(current)) throw new CodeError(`Cannot write ${current} as a literal`); + return String(current); + } + if (typeof current === 'boolean') return String(current); + const inner = indent + tab; + if (Array.isArray(current)) { + const items = (current as readonly LiteralValue[]).map((item) => print(item, inner)); + const inline = `[${items.join(', ')}]`; + return inline.length <= maxInline && !inline.includes('\n') + ? inline + : `[\n${items.map((item) => inner + item).join(',\n')},\n${indent}]`; + } + const entries = Object.entries(current as Record).map( + ([key, item]) => `${IDENTIFIER.test(key) ? key : string(key)}: ${print(item, inner)}`, + ); + if (!entries.length) return '{}'; + const inline = `{ ${entries.join(', ')} }`; + return inline.length <= maxInline && !inline.includes('\n') + ? inline + : `{\n${entries.map((entry) => inner + entry).join(',\n')},\n${indent}}`; + }; + return print(value, options.indent ?? ''); +}; + +/** Quote style of a file (the first string literal), for code that blends in. */ +export const quoteStyle = (file: SourceFile): '"' | "'" => { + const first = file.getFirstDescendantByKind(SyntaxKind.StringLiteral); + return first?.getText().startsWith('"') ? '"' : "'"; +}; + +/** Value of `export const = ` (e.g. `EDITOR_COMPONENT_MANIFEST`). */ +export const readExportedLiteral = (file: SourceFile, name: string): LiteralValue | undefined => { + const declaration = file.getVariableDeclaration(name); + if (!declaration || !declaration.isExported()) return undefined; + const initializer = declaration.getInitializer(); + if (!initializer) + throw new CodeError(`${name} has no value`, file.getFilePath(), declaration.getStart()); + return literalToValue(initializer as Expression); +}; diff --git a/packages/code/src/engine/module-specifier.ts b/packages/code/src/engine/module-specifier.ts new file mode 100644 index 00000000..3fe4086b --- /dev/null +++ b/packages/code/src/engine/module-specifier.ts @@ -0,0 +1,56 @@ +/** `a/./b/../c` → `a/c`, without a leading `./`. */ +export const joinPosix = (base: string, target: string): string => { + const parts: string[] = []; + for (const part of `${base}/${target}`.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') parts.pop(); + else parts.push(part); + } + return parts.join('/'); +}; + +/** Whether a module matches a `paths` pattern (`@me/shared/*`). */ +export const matchesPathPattern = (pattern: string, module: string): boolean => { + const star = pattern.indexOf('*'); + if (star < 0) return pattern === module; + return module.startsWith(pattern.slice(0, star)) && module.endsWith(pattern.slice(star + 1)); +}; + +const withoutExtension = (path: string) => + path.replace(/(\.d)?\.[cm]?[jt]sx?$/, '').replace(/\/index$/, ''); + +/** Import specifier of `target` from `from` (project paths), through `paths` when one maps it. */ +export const moduleSpecifierFor = ( + from: string, + target: string, + paths: Readonly>, +): string => { + const bare = withoutExtension(target); + for (const [pattern, targets] of Object.entries(paths)) { + for (const mapped of targets) { + const star = mapped.indexOf('*'); + const patternStar = pattern.indexOf('*'); + if (star < 0 || patternStar < 0) { + if (withoutExtension(mapped) === bare) return pattern; + continue; + } + const prefix = mapped.slice(0, star); + const suffix = withoutExtension(mapped.slice(star + 1)); + if (!bare.startsWith(prefix) || !bare.endsWith(suffix)) continue; + const middle = bare.slice(prefix.length, bare.length - suffix.length); + return pattern.slice(0, patternStar) + middle + pattern.slice(patternStar + 1); + } + } + const fromParts = from.split('/').slice(0, -1); + const toParts = bare.split('/'); + let common = 0; + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) + common++; + const up = fromParts.length - common; + const relative = [...Array(up).fill('..'), ...toParts.slice(common)].join('/'); + return up === 0 ? `./${relative}` : relative; +}; diff --git a/packages/code/src/engine/worker-sdk.ts b/packages/code/src/engine/worker-sdk.ts new file mode 100644 index 00000000..ffe7b253 --- /dev/null +++ b/packages/code/src/engine/worker-sdk.ts @@ -0,0 +1,33 @@ +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import type { CodeEngine } from './code-engine'; +import type { Analyzer, Transformer } from './code-engine.type'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './literals'; + +/** + * What plugin worker entries (`entry.worker`) import as `@nanoforge-dev/editor-sdk/worker`. + * Registrations are owned by the plugin and removed when it unloads. + */ +export interface WorkerSdk { + readonly literals: { + readonly literalToValue: typeof literalToValue; + readonly valueToLiteral: typeof valueToLiteral; + readonly readExportedLiteral: typeof readExportedLiteral; + readonly quoteStyle: typeof quoteStyle; + }; + registerAnalyzer(id: string, analyzer: Analyzer): () => void; + registerTransformer(id: string, transformer: Transformer): () => void; + /** Registers an owner of item data (ADR 0003): its tags, inference, claims and `extract`. */ + registerItemOwner(owner: ItemOwner): () => void; +} + +export interface WorkerPluginModule { + activate?(sdk: WorkerSdk): void | Promise; +} + +export const createWorkerSdk = (engine: CodeEngine, owner: string): WorkerSdk => ({ + registerAnalyzer: (id, analyzer) => engine.registerAnalyzer(id, analyzer, owner), + registerTransformer: (id, transformer) => engine.registerTransformer(id, transformer, owner), + registerItemOwner: (itemOwner) => engine.registerItemOwner(itemOwner, owner), + literals: { literalToValue, valueToLiteral, readExportedLiteral, quoteStyle }, +}); diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts new file mode 100644 index 00000000..fa8c706d --- /dev/null +++ b/packages/code/src/index.ts @@ -0,0 +1,10 @@ +export * from './worker-rpc'; +export * from './service'; +export * from './catalog'; +export * from './codegen'; +export * from './diagnostics'; +export * from './document'; +export type * from './engine'; +export * from './engine/code-file.const'; +export * from './engine/engine.type'; +export * from './engine/code.exception'; diff --git a/packages/code/src/service/code-service.ts b/packages/code/src/service/code-service.ts new file mode 100644 index 00000000..2fa57256 --- /dev/null +++ b/packages/code/src/service/code-service.ts @@ -0,0 +1,357 @@ +import type { CommandOrigin, HistoryCommand } from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + type Logger, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectFs } from '@nanoforge-dev/editor-project'; +import { CodeContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { DiagnosticsService } from '../diagnostics/diagnostics-service'; +import type { DocumentService } from '../document/document-service'; +import { CODE_FILE } from '../engine/code-file.const'; +import type { CodeDiagnostic, CodeSymbol, TextEdit } from '../engine/engine.type'; +import type { MetaRequest, MetaResponse } from '../engine/extract-meta'; +import type { EngineApi, EngineHostApi } from '../worker-rpc/serve-engine'; +import { type MessageEndpoint, type Remote, expose, wrap } from '../worker-rpc/worker-rpc'; + +/** Installed packages are read-only: their problems are not the user's to fix. */ +const NOT_CHECKED = /(^|\/)nf_modules\//; +const CHECK_BATCH = 16; + +/** Folders never mirrored into the code worker. */ +const EXCLUDED = /(^|\/)(node_modules|dist|coverage|\.nanoforge|\.git|\.turbo)(\/|$)/; + +const TSCONFIG = 'tsconfig.json'; + +/** Marks a step in the performance timeline, and measures it from `since` (`:time`). */ +const mark = (name: string, since?: string): void => { + if (typeof performance === 'undefined' || !performance.mark) return; + performance.mark(name); + if (since && performance.getEntriesByName(since).length) + performance.measure(`${name}:time`, since, name); +}; + +export const isCodeFile = (path: string): boolean => CODE_FILE.test(path) && !EXCLUDED.test(path); + +export interface WorkerEndpoint extends MessageEndpoint { + terminate?(): void; +} + +export interface CodeServiceOptions { + readonly projectId: string; + readonly fs: ProjectFs; + readonly rpc: RpcClient; + readonly documents: DocumentService; + readonly diagnostics: DiagnosticsService; + /** Starts the code worker (a Web Worker in browsers, a MessagePort in tests). */ + readonly createWorker: () => WorkerEndpoint; + readonly logger?: Logger; + /** Delay before re-checking watched files after a change. */ + readonly diagnosticsDelayMs?: number; +} + +/** + * TypeScript services of the open project, computed in a worker started on first use. The + * worker mirrors the project's code files and the unsaved text of open documents. + */ +export class CodeService implements Disposable { + private _engine: Promise> | undefined; + private _endpoint: WorkerEndpoint | undefined; + private readonly _store = new DisposableStore(); + private readonly _watched = new Set(); + private readonly _pendingDiagnostics = new Set(); + private _diagnosticsTimer: ReturnType | undefined; + private readonly _plugins = new Map(); + private _projectChecks = 0; + private _projectCheckRun = 0; + private _projectCheckTimer: ReturnType | undefined; + + constructor(private readonly _options: CodeServiceOptions) {} + + /** The engine, started (and the project mirrored) on first call. */ + engine(): Promise> { + this._engine ??= this._start(); + return this._engine; + } + + async analyze(path: string, analyzerId: string, args?: unknown): Promise { + const engine = await this._current(path); + return (await engine.analyze(path, analyzerId, args)) as T; + } + + /** Edits a transformer wants, as offsets in the file's current text (open documents included). */ + async transform(path: string, transformerId: string, op: unknown): Promise { + return (await this._current(path)).transform(path, transformerId, op); + } + + /** + * An undoable command applying a transformer's edits through the DocumentService (the open + * editor if any). Returns undefined when the transformer changes nothing. + */ + async edit( + path: string, + transformerId: string, + op: unknown, + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): Promise { + const edits = await this.transform(path, transformerId, op); + return edits.length ? this._options.documents.editCommand(path, edits, options) : undefined; + } + + /** Checks files now and publishes TypeScript problems. */ + /** Edits organizing the imports of a code file (current content, open documents included). */ + async organizeImports(path: string): Promise { + const engine = await this.engine(); + await engine.setFiles([{ path, text: await this._options.documents.getText(path) }]); + return engine.organizeImports(path); + } + + /** Named declarations of a code file's current text (open documents included). */ + async symbols(path: string): Promise { + return (await this._current(path)).symbols(path); + } + + /** Type declarations of a package as resolved from a file (node_modules lookup). */ + typeFiles(module: string, from: string): Promise { + return this._options.rpc + .api(CodeContract) + .typeFiles({ project: this._options.projectId, from, module }); + } + + /** Formats a file's text with Prettier (see `code.format`). */ + format( + path: string, + text: string, + ): Promise<{ text: string; formatter: 'project' | 'bundled' | 'none' }> { + return this._options.rpc + .api(CodeContract) + .format({ project: this._options.projectId, path, text }); + } + + async refreshDiagnostics(paths: readonly string[]): Promise { + const diagnostics = await (await this.engine()).diagnostics(paths); + for (const path of paths) { + this._options.diagnostics.set( + 'typescript', + path, + diagnostics.filter((diagnostic) => diagnostic.path === path), + ); + } + return diagnostics; + } + + /** Keeps TypeScript problems of a file up to date while watched (open editors). */ + watchDiagnostics(path: string): Disposable { + this._watched.add(path); + this._scheduleDiagnostics(path, false); + return { + dispose: () => { + this._watched.delete(path); + if (!this._projectChecks) this._options.diagnostics.clear('typescript', path); + }, + }; + } + + /** + * Keeps the TypeScript problems of every code file of the project up to date, not only of + * watched files, until disposed (the Problems panel). Files are checked when it starts and + * again after changes: a change in one file can break another. + */ + checkProject(): Disposable { + if (this._projectChecks++ === 0) this._scheduleProjectCheck(0); + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + if (--this._projectChecks > 0) return; + clearTimeout(this._projectCheckTimer); + this._projectCheckRun++; + for (const path of this._options.diagnostics.uris('typescript')) { + if (!this._watched.has(path)) this._options.diagnostics.clear('typescript', path); + } + }, + }; + } + + /** Items of a source (ADR 0003), extracted by the worker with the registered owners. */ + async extractMeta(request: MetaRequest): Promise { + return (await this.engine()).extractMeta(request); + } + + /** Item owners registered by worker plugins (name, version, schema). */ + async itemOwners(): Promise<{ name: string; version: string; schema: number }[]> { + return (await this.engine()).itemOwners(); + } + + /** Loads a plugin's worker entry (`entry.worker`); reloading replaces its registrations. */ + async loadWorkerPlugin(owner: string, url: string): Promise { + this._plugins.set(owner, url); + if (this._engine) await (await this._engine).loadPlugin(owner, url); + } + + async unloadWorkerPlugin(owner: string): Promise { + this._plugins.delete(owner); + if (this._engine) (await this._engine).unloadPlugin(owner); + } + + dispose(): void { + clearTimeout(this._diagnosticsTimer); + clearTimeout(this._projectCheckTimer); + this._projectCheckRun++; + this._store.dispose(); + this._endpoint?.terminate?.(); + } + + /** The engine, with the file's current text mirrored (the change stream may lag). */ + private async _current(path: string): Promise> { + const engine = await this.engine(); + await engine.setFiles([{ path, text: await this._options.documents.getText(path) }]); + return engine; + } + + private async _start(): Promise> { + mark('nf:code:created'); + const endpoint = this._options.createWorker(); + this._endpoint = endpoint; + const host: EngineHostApi = { + resolveTypes: (module, from) => + this._options.rpc + .api(CodeContract) + .typeFiles({ project: this._options.projectId, from, module }), + }; + this._store.add({ dispose: expose(host, endpoint) }); + const engine = wrap(endpoint); + this._store.add({ dispose: () => engine.dispose() }); + + const syncTsconfig = async () => + engine.setTsconfig( + this._options.fs.entry(TSCONFIG) + ? await this._options.documents.getText(TSCONFIG).catch(() => undefined) + : undefined, + ); + await syncTsconfig(); + mark('nf:code:ready', 'nf:code:created'); + this._store.add( + this._options.fs.onDidChange((changes) => { + if (changes.some((change) => change.path === TSCONFIG)) + void syncTsconfig().catch((error: unknown) => + this._options.logger?.warn('tsconfig.json could not be applied', error), + ); + }), + ); + + const paths = this._options.fs.entries.filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path), + ); + for (let i = 0; i < paths.length; i += 32) { + const batch = await Promise.all( + paths.slice(i, i + 32).map(async (entry) => ({ + path: entry.path, + text: await this._options.documents.getText(entry.path), + })), + ); + await engine.setFiles(batch); + } + mark('nf:code:mirrored', 'nf:code:created'); + this._store.add( + this._options.fs.onDidChange((changes) => { + void (async () => { + const deleted = changes + .filter((change) => change.type === 'deleted' && isCodeFile(change.path)) + .map((change) => change.path); + if (deleted.length) await engine.deleteFiles(deleted); + const updated = changes.filter( + (change) => + change.type !== 'deleted' && + change.kind === 'file' && + isCodeFile(change.path) && + !this._options.documents.get(change.path), + ); + if (updated.length) { + await engine.setFiles( + await Promise.all( + updated.map(async (change) => ({ + path: change.path, + text: await this._options.documents.getText(change.path), + })), + ), + ); + } + for (const change of [...deleted.map((path) => ({ path })), ...updated]) + this._scheduleDiagnostics(change.path); + })().catch((error: unknown) => + this._options.logger?.warn('Code mirror update failed', error), + ); + }), + ); + this._store.add( + this._options.documents.onDidChange(({ uri }) => { + if (!isCodeFile(uri)) return; + void this._options.documents + .getText(uri) + .then((text) => engine.setFiles([{ path: uri, text }])) + .then(() => this._scheduleDiagnostics(uri)) + .catch((error: unknown) => + this._options.logger?.warn(`Code mirror of ${uri} failed`, error), + ); + }), + ); + for (const [owner, url] of this._plugins) { + await engine + .loadPlugin(owner, url) + .catch((error: unknown) => + this._options.logger?.error(`Worker plugin ${owner} failed`, error), + ); + } + return engine; + } + + private _scheduleProjectCheck(delayMs = 1500): void { + if (!this._projectChecks) return; + clearTimeout(this._projectCheckTimer); + this._projectCheckTimer = setTimeout(() => { + this._checkProject().catch((error: unknown) => + this._options.logger?.warn('Project check failed', error), + ); + }, delayMs); + } + + private async _checkProject(): Promise { + const run = ++this._projectCheckRun; + await this.engine(); + const paths = this._options.fs.entries + .filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path) && !NOT_CHECKED.test(entry.path), + ) + .map((entry) => entry.path); + const checked = new Set(paths); + for (const path of this._options.diagnostics.uris('typescript')) { + if (!checked.has(path) && !this._watched.has(path)) + this._options.diagnostics.clear('typescript', path); + } + for (let index = 0; index < paths.length; index += CHECK_BATCH) { + if (run !== this._projectCheckRun) return; + await this.refreshDiagnostics(paths.slice(index, index + CHECK_BATCH)); + } + } + + private _scheduleDiagnostics(path: string, changed = true): void { + if (changed) this._scheduleProjectCheck(); + if (!this._watched.has(path) && !(this._projectChecks && !NOT_CHECKED.test(path))) return; + this._pendingDiagnostics.add(path); + clearTimeout(this._diagnosticsTimer); + this._diagnosticsTimer = setTimeout(() => { + const paths = [...this._pendingDiagnostics]; + this._pendingDiagnostics.clear(); + this.refreshDiagnostics(paths).catch((error: unknown) => + this._options.logger?.warn('Diagnostics failed', error), + ); + }, this._options.diagnosticsDelayMs ?? 300); + } +} + +export const CodeServiceToken = createToken('code.service'); diff --git a/packages/code/src/service/index.ts b/packages/code/src/service/index.ts new file mode 100644 index 00000000..e8d55ae3 --- /dev/null +++ b/packages/code/src/service/index.ts @@ -0,0 +1 @@ +export * from './code-service'; diff --git a/packages/code/src/worker-rpc/create-browser-worker.ts b/packages/code/src/worker-rpc/create-browser-worker.ts new file mode 100644 index 00000000..49a846ce --- /dev/null +++ b/packages/code/src/worker-rpc/create-browser-worker.ts @@ -0,0 +1,5 @@ +import type { WorkerEndpoint } from '../service/code-service'; + +/** The code worker of browsers (bundled by Vite from this URL). */ +export const createBrowserWorker = (): WorkerEndpoint => + new Worker(new URL('../worker.ts', import.meta.url), { type: 'module', name: 'nanoforge-code' }); diff --git a/packages/code/src/worker-rpc/index.ts b/packages/code/src/worker-rpc/index.ts new file mode 100644 index 00000000..bf477cc4 --- /dev/null +++ b/packages/code/src/worker-rpc/index.ts @@ -0,0 +1,3 @@ +export * from './create-browser-worker'; +export type * from './serve-engine'; +export * from './worker-rpc'; diff --git a/packages/code/src/worker-rpc/serve-engine.ts b/packages/code/src/worker-rpc/serve-engine.ts new file mode 100644 index 00000000..788d42cf --- /dev/null +++ b/packages/code/src/worker-rpc/serve-engine.ts @@ -0,0 +1,70 @@ +import { CodeEngine } from '../engine/code-engine'; +import type { CodeDiagnostic, CodeSymbol, SourceText, TextEdit } from '../engine/engine.type'; +import type { MetaRequest, MetaResponse } from '../engine/extract-meta'; +import { type WorkerPluginModule, createWorkerSdk } from '../engine/worker-sdk'; +import { type MessageEndpoint, expose, wrap } from './worker-rpc'; + +/** API of the engine as seen from the main thread. */ +export interface EngineApi { + setFiles(files: readonly SourceText[]): void; + deleteFiles(paths: readonly string[]): void; + analyze(path: string, analyzerId: string, args?: unknown): unknown; + transform(path: string, transformerId: string, op: unknown): TextEdit[]; + diagnostics(paths: readonly string[]): Promise; + organizeImports(path: string): Promise; + symbols(path: string): CodeSymbol[]; + setTsconfig(text: string | undefined): void; + extractMeta(request: MetaRequest): Promise; + itemOwners(): { name: string; version: string; schema: number }[]; + /** Imports a plugin's `entry.worker` and calls its `activate(sdk)`. */ + loadPlugin(owner: string, url: string): Promise; + unloadPlugin(owner: string): void; +} + +/** Services the main thread offers to the worker. */ +export interface EngineHostApi { + resolveTypes(module: string, from: string): Promise; +} + +export interface ServeOptions { + /** How plugin modules are imported (tests inject a loader). */ + readonly importModule?: (url: string) => Promise; +} + +/** Runs a code engine behind a message endpoint (the worker's `self`, or a MessagePort). */ +export const serveEngine = ( + endpoint: MessageEndpoint, + options: ServeOptions = {}, +): (() => void) => { + const host = wrap(endpoint); + const engine = new CodeEngine({ + resolveTypes: (module, from) => host.resolveTypes(module, from), + }); + const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); + const api: EngineApi = { + setFiles: (files) => engine.setFiles(files), + deleteFiles: (paths) => engine.deleteFiles(paths), + analyze: (path, analyzerId, args) => engine.analyze(path, analyzerId, args), + transform: (path, transformerId, op) => engine.transform(path, transformerId, op), + diagnostics: (paths) => engine.diagnostics(paths), + symbols: (path) => engine.symbols(path), + organizeImports: (path) => engine.organizeImports(path), + setTsconfig: (text) => engine.setTsconfig(text), + extractMeta: (request) => engine.extractMeta(request), + itemOwners: () => engine.itemOwners, + loadPlugin: async (owner, url) => { + engine.unregisterOwner(owner); + const imported = (await importModule(url)) as WorkerPluginModule & { + default?: WorkerPluginModule; + }; + const plugin = imported.activate ? imported : (imported.default ?? {}); + await plugin.activate?.(createWorkerSdk(engine, owner)); + }, + unloadPlugin: (owner) => engine.unregisterOwner(owner), + }; + const stop = expose(api, endpoint); + return () => { + stop(); + host.dispose(); + }; +}; diff --git a/packages/code/src/worker-rpc/worker-rpc.ts b/packages/code/src/worker-rpc/worker-rpc.ts new file mode 100644 index 00000000..a7a7c43e --- /dev/null +++ b/packages/code/src/worker-rpc/worker-rpc.ts @@ -0,0 +1,105 @@ +/** + * Minimal typed RPC over `postMessage` (a web worker, a MessagePort): the other side's + * methods become async functions. Errors are rethrown with their message and name. + */ +export interface MessageEndpoint { + postMessage(message: unknown): void; + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + start?(): void; +} + +type AnyFunction = (...args: never[]) => unknown; + +/** The async view of an API object. */ +export type Remote = { + [K in keyof T as T[K] extends AnyFunction ? K : never]: T[K] extends (...args: infer A) => infer R + ? (...args: A) => Promise> + : never; +}; + +interface Request { + readonly rpc: 'call'; + readonly id: number; + readonly method: string; + readonly args: unknown[]; +} + +interface Response { + readonly rpc: 'result'; + readonly id: number; + readonly value?: unknown; + readonly error?: { name: string; message: string; code?: string; data?: unknown }; +} + +/** Serves `api` on the endpoint. Returns a function that stops serving. */ +export const expose = (api: object, endpoint: MessageEndpoint): (() => void) => { + const listener = async (event: MessageEvent) => { + const message = event.data as Request; + if (message?.rpc !== 'call') return; + const method = (api as Record)[message.method]; + try { + if (typeof method !== 'function') throw new Error(`Unknown method ${message.method}`); + const value: unknown = await method.apply(api, message.args); + endpoint.postMessage({ rpc: 'result', id: message.id, value } satisfies Response); + } catch (error) { + const failure = error as { name?: string; message?: string; code?: string; data?: unknown }; + endpoint.postMessage({ + rpc: 'result', + id: message.id, + error: { + name: failure.name ?? 'Error', + message: failure.message ?? String(error), + ...(failure.code !== undefined && { code: failure.code }), + ...(failure.data !== undefined && { data: failure.data }), + }, + } satisfies Response); + } + }; + endpoint.addEventListener('message', listener); + endpoint.start?.(); + return () => endpoint.removeEventListener('message', listener); +}; + +/** Calls the API exposed on the other side of the endpoint. */ +export const wrap = ( + endpoint: MessageEndpoint, +): Remote & { dispose(): void } => { + let nextId = 1; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + const listener = (event: MessageEvent) => { + const message = event.data as Response; + if (message?.rpc !== 'result') return; + const call = pending.get(message.id); + if (!call) return; + pending.delete(message.id); + if (message.error) { + call.reject(Object.assign(new Error(message.error.message), message.error)); + } else { + call.resolve(message.value); + } + }; + endpoint.addEventListener('message', listener); + endpoint.start?.(); + return new Proxy({} as Remote & { dispose(): void }, { + get: (_target, property) => { + if (property === 'dispose') { + return () => { + endpoint.removeEventListener('message', listener); + for (const call of pending.values()) call.reject(new Error('Worker connection closed')); + pending.clear(); + }; + } + if (typeof property !== 'string' || property === 'then') return undefined; + return (...args: unknown[]) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + endpoint.postMessage({ rpc: 'call', id, method: property, args } satisfies Request); + }); + }, + }); +}; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts new file mode 100644 index 00000000..d8f1e7ac --- /dev/null +++ b/packages/code/src/worker.ts @@ -0,0 +1,36 @@ +/// +/** + * Entry of the code worker. Plugin worker entries share this worker's ts-morph and the worker + * sdk helpers through the shared module registry (like the Svelte runtime on the main thread). + */ +import * as tsMorph from 'ts-morph'; + +import { SharedModuleRegistry } from '@nanoforge-dev/editor-kernel'; +import * as meta from '@nanoforge-dev/editor-meta'; + +import { CodeError } from './engine/code.exception'; +import { EditBuilder } from './engine/edit-builder'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './engine/literals'; +import { serveEngine } from './worker-rpc/serve-engine'; +import type { MessageEndpoint } from './worker-rpc/worker-rpc'; + +new SharedModuleRegistry() + .register('ts-morph', tsMorph) + .register('@nanoforge-dev/editor-sdk/worker', { + defineWorkerPlugin: (plugin: T) => plugin, + CodeError, + EditBuilder, + docOf: meta.docOf, + parseDocComment: meta.parseDocComment, + firstTag: meta.firstTag, + hasTag: meta.hasTag, + leadingDocComments: meta.leadingDocComments, + tagTexts: meta.tagTexts, + literalToValue, + valueToLiteral, + readExportedLiteral, + quoteStyle, + }) + .install(globalThis); + +serveEngine(self as unknown as MessageEndpoint); diff --git a/packages/code/test/catalog/catalog-service.spec.ts b/packages/code/test/catalog/catalog-service.spec.ts new file mode 100644 index 00000000..05fad469 --- /dev/null +++ b/packages/code/test/catalog/catalog-service.spec.ts @@ -0,0 +1,186 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; +import { + type ClientProject, + MemoryContentCache, + MemoryTreeCache, + ProjectService, +} from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { + CatalogService, + CodeService, + DiagnosticsService, + DocumentService, + type WorkerPluginModule, + projectFsBackend, +} from '../../src'; +import { serveEngine } from '../../src/worker-rpc/serve-engine'; + +const owner: ItemOwner = { + name: '@nanoforge/ecs', + version: '1.0.0', + schema: 1, + tags: ['component'], + infer: ({ context, kind }) => context.folder === 'components' && kind === 'class', + claims: () => ({ fields: ['name'] }), + extract: ({ declaration }) => ({ type: 'component', name: declaration.getName() }), +}; +const ecsPlugin: WorkerPluginModule = { activate: (sdk) => void sdk.registerItemOwner(owner) }; + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let project: ClientProject; + +const write = (path: string, text: string) => { + const file = join(workspace, 'pong', path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); +}; + +const setup = async () => { + const documents = new DocumentService(projectFsBackend(project.fs)); + const code = new CodeService({ + projectId: project.id, + fs: project.fs, + rpc, + documents, + diagnostics: new DiagnosticsService(), + createWorker: () => { + const channel = new MessageChannel(); + serveEngine(channel.port2 as never, { importModule: async () => ecsPlugin }); + return channel.port1 as never; + }, + }); + await code.loadWorkerPlugin('@nanoforge/ecs', 'memory://ecs'); + const catalog = new CatalogService({ project, code, documents, delayMs: 10 }); + return { code, catalog }; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-catalog-')); + cpSync( + join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { recursive: true }, + ); + write( + 'nf_modules/@nanoforge/motion/nanoforge.manifest.json', + JSON.stringify({ + type: 'package', + name: '@nanoforge/motion', + version: '0.1.0', + items: ['velocity.ts'], + }), + ); + write( + 'nf_modules/@nanoforge/motion/velocity.ts', + '/** Speed. @component @side shared */\nexport class Velocity { name = "Velocity"; constructor(public x = 0) {} }', + ); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ path: 'pong' }); +}); + +afterAll(() => { + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('CatalogService', () => { + it('lists app components (inferred) and installed package items, and caches them', async () => { + const { code, catalog } = await setup(); + await catalog.refresh(); + const refs = catalog.items.map((item) => item.ref); + expect(refs).toContain('app:client#Position'); + expect(refs).toContain('@nanoforge/motion#Velocity'); + expect(catalog.get('@nanoforge/motion#Velocity')).toMatchObject({ + readonly: true, + meta: { params: [{ name: 'x', type: 'number', default: 0 }] }, + }); + const client = project.model.get().apps.find((app) => app.type === 'client')!; + expect(catalog.forApp(client).some((item) => item.ref.startsWith('app:server#'))).toBe(false); + + const cache = join( + workspace, + 'pong/.nanoforge/editor/cache/meta/modules/@nanoforge/motion.json', + ); + expect(JSON.parse(readFileSync(cache, 'utf8'))).toMatchObject({ + metaVersion: 1, + owners: { '@nanoforge/ecs': { version: '1.0.0', schema: 1 } }, + }); + expect(readFileSync(join(workspace, 'pong/.nanoforge/editor/.gitignore'), 'utf8')).toContain( + 'cache/', + ); + catalog.dispose(); + code.dispose(); + }); + + it('reuses a valid cache and extracts again when a source changes', async () => { + const { code, catalog } = await setup(); + const extract = vi.spyOn(code, 'extractMeta'); + await catalog.refresh(); + expect(extract).not.toHaveBeenCalled(); + + const path = 'apps/client/src/components/components.ts'; + const { text } = await project.fs.readText(path); + await project.fs.write( + path, + `${text}\nexport class Health {\n name = "Health";\n constructor(public points = 3) {}\n}\n`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await catalog.whenIdle(); + expect(extract).toHaveBeenCalledTimes(1); + expect(extract.mock.calls[0]![0].source).toMatchObject({ kind: 'app', name: 'client' }); + expect(catalog.get('app:client#Health')!.meta.params).toEqual([ + { name: 'points', type: 'number', default: 3, optional: true }, + ]); + expect(existsSync(join(workspace, 'pong/.nanoforge/editor/cache/meta/apps/client.json'))).toBe( + true, + ); + catalog.dispose(); + code.dispose(); + }); +}); + +describe('CatalogService (new files)', () => { + it('lists an item of a file created after the first extraction', async () => { + const { code, catalog } = await setup(); + await catalog.refresh(); + await project.fs.write( + 'apps/client/src/components/score.ts', + '/** @component */\nexport class Score { name = "Score"; constructor(public value = 0) {} }\n', + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await catalog.whenIdle(); + expect(catalog.get('app:client#Score')).toBeDefined(); + catalog.dispose(); + code.dispose(); + }); +}); diff --git a/packages/code/test/catalog/item-fits-app.spec.ts b/packages/code/test/catalog/item-fits-app.spec.ts new file mode 100644 index 00000000..7a1459e8 --- /dev/null +++ b/packages/code/test/catalog/item-fits-app.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import type { CatalogItem } from '../../src/catalog/catalog.type'; +import { itemFitsApp } from '../../src/catalog/item-fits-app'; + +const app = (root: string, type: AppModel['type'], libraries: string[]): AppModel => + ({ id: root, root, name: root, type, libraries, engineLibs: {} }) as never; +const item = (source: CatalogItem['source'], side = 'shared'): CatalogItem => + ({ source, meta: { side, requires: [] } }) as never; + +describe('itemFitsApp', () => { + const position = item({ kind: 'lib', name: '@pong/shared', path: 'libs/shared' }); + + it('offers the items of a shared library to the apps that use it', () => { + expect(itemFitsApp(position, app('apps/client', 'client', ['@pong/shared']))).toBe(true); + expect(itemFitsApp(position, app('apps/server', 'server', []))).toBe(false); + expect(itemFitsApp(position, app('apps/server', 'server', ['@pong/other']))).toBe(false); + }); + + it('offers them to the library itself, and to a library that uses it', () => { + expect(itemFitsApp(position, app('libs/shared', 'lib', []))).toBe(true); + expect(itemFitsApp(position, app('libs/ui', 'lib', ['@pong/shared']))).toBe(true); + expect(itemFitsApp(position, app('libs/ui', 'lib', []))).toBe(false); + }); + + it('still keeps a client item of a used library out of a server', () => { + const sprite = item({ kind: 'lib', name: '@pong/shared', path: 'libs/shared' }, 'client'); + expect(itemFitsApp(sprite, app('apps/server', 'server', ['@pong/shared']))).toBe(false); + }); +}); diff --git a/packages/code/test/document/document-service.spec.ts b/packages/code/test/document/document-service.spec.ts new file mode 100644 index 00000000..be035b0e --- /dev/null +++ b/packages/code/test/document/document-service.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { HistoryService } from '@nanoforge-dev/editor-history'; +import { Emitter } from '@nanoforge-dev/editor-kernel'; + +import { DOCUMENT_EDITS } from '../../src/document/document-contributions'; +import { previewOf } from '../../src/document/document-preview'; +import { DocumentService, documentHistoryId } from '../../src/document/document-service'; +import { fingerprint } from '../../src/document/document-service'; +import type { DocumentBackend } from '../../src/document/document.type'; +import { matchesGlob } from '../../src/document/match-glob'; + +/** Files in memory, hashed by fingerprint. */ +const memoryBackend = (files: Record): DocumentBackend => ({ + onDidChange: new Emitter().event, + read: async (uri) => ({ text: files[uri] ?? '', hash: fingerprint(files[uri] ?? '') }), + write: async (uri, text) => { + files[uri] = text; + return { hash: fingerprint(text) }; + }, +}); + +describe('document history', () => { + it('edits in the document context, with a preview, and restores after a reload', async () => { + const files = { 'src/main.ts': 'const a = 1;\nconst b = 2;\nconst c = 3;\n' }; + const history = new HistoryService(); + const documents = new DocumentService(memoryBackend(files), history); + + await documents.edit('src/main.ts', [{ start: 23, end: 24, text: '20' }], { label: 'Set b' }); + expect(files['src/main.ts']).toBe('const a = 1;\nconst b = 20;\nconst c = 3;\n'); + const context = history.get(documentHistoryId('src/main.ts'))!; + const [entry] = context.stack.state.past; + expect(entry!.command.label).toBe('Set b'); + expect(entry!.command.preview).toEqual({ + title: 'src/main.ts', + before: 'const a = 1;\nconst b = 2;\nconst c = 3;', + after: 'const a = 1;\nconst b = 20;\nconst c = 3;', + }); + + const serialized = entry!.command.serialize!()!; + expect(serialized.type).toBe(DOCUMENT_EDITS); + const reloaded = new DocumentService(memoryBackend(files), new HistoryService()); + const restored = reloaded.restoreEditCommand( + JSON.parse(JSON.stringify(serialized.data)), + serialized.label, + )!; + expect(restored.preview?.after).toContain('const b = 20;'); + expect(await restored.isValid!()).toBe(true); + await restored.undo(); + expect(files['src/main.ts']).toBe('const a = 1;\nconst b = 2;\nconst c = 3;\n'); + }); + + it('saves one open document with unsaved changes', async () => { + const documents = new DocumentService(memoryBackend({}), new HistoryService()); + const saved: string[] = []; + const open = (uri: string, dirty: boolean) => + documents.register({ + uri, + kind: 'text', + onDidChange: () => ({ dispose: () => undefined }), + dirty: { get: () => dirty, subscribe: () => () => undefined }, + getText: () => '', + applyEdits: () => [], + save: async () => void saved.push(uri), + revert: async () => undefined, + }); + open('a.ts', true); + open('b.ts', true); + open('c.ts', false); + await documents.save('a.ts'); + await documents.save('c.ts'); + await documents.save('closed.ts'); + expect(saved).toEqual(['a.ts']); + }); + + it('previews only the changed lines and their neighbours', () => { + const before = Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n'); + const after = before.replace('line 15', 'line fifteen'); + expect(previewOf('a.ts', before, after)).toEqual({ + title: 'a.ts', + before: 'line 14\nline 15\nline 16', + after: 'line 14\nline fifteen\nline 16', + }); + }); +}); + +describe('matchesGlob', () => { + it('reads **/ as folders, never as part of a file name', async () => { + const dotfiles = '**/{.*,*rc,Dockerfile,LICENSE,Makefile}'; + for (const path of ['.env', 'apps/client/.eslintrc', 'Dockerfile', 'apps/server/.prettierrc']) + expect(matchesGlob(dotfiles, path), path).toBe(true); + for (const path of ['logo.png', 'apps/client/assets/logo.png', 'src/main.ts']) + expect(matchesGlob(dotfiles, path), path).toBe(false); + + expect(matchesGlob('**/*.{png,jpg}', 'logo.png')).toBe(true); + expect(matchesGlob('**/*.{png,jpg}', 'a/b/logo.jpg')).toBe(true); + expect(matchesGlob('src/**/*.ts', 'src/main.ts')).toBe(true); + expect(matchesGlob('src/**/*.ts', 'src/a/b/main.ts')).toBe(true); + expect(matchesGlob('src/*.ts', 'src/a/main.ts')).toBe(false); + }); +}); diff --git a/packages/code/test/engine/code-engine.spec.ts b/packages/code/test/engine/code-engine.spec.ts new file mode 100644 index 00000000..4aea5f66 --- /dev/null +++ b/packages/code/test/engine/code-engine.spec.ts @@ -0,0 +1,349 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { Node, SyntaxKind } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; + +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import { + CODE_FILE, + CodeEngine, + CodeError, + type TransformContext, + literalToValue, + valueToLiteral, +} from '../../src/engine'; + +const FIXTURE = join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'); + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +const pong = () => + walk(FIXTURE).map((file) => ({ + path: relative(FIXTURE, file).split('\\').join('/'), + text: readFileSync(file, 'utf8'), + })); + +/** Toy transformer: renames a top level const and all its references in the file. */ +const renameConst = ({ file, edit }: TransformContext, op: unknown) => { + const { from, to } = op as { from: string; to: string }; + const declaration = file.getVariableDeclarationOrThrow(from); + const name = declaration.getNameNode(); + edit.replace(name, to); + for (const identifier of file.getDescendantsOfKind(SyntaxKind.Identifier)) { + if ( + identifier !== name && + identifier.getText() === from && + identifier.getSymbol() === name.getSymbol() + ) { + edit.replace(identifier, to); + } + } +}; + +describe('CodeEngine', () => { + it('keeps every pong-network file byte-identical and a no-op transform yields no edit', () => { + const engine = new CodeEngine(); + const files = pong(); + expect(files.length).toBeGreaterThan(6); + engine.setFiles(files); + engine.registerTransformer('noop', () => undefined); + for (const { path, text } of files) { + expect(engine.text(path)).toBe(text); + expect(engine.transform(path, 'noop', null)).toEqual([]); + } + }); + + it('renames a const with edits limited to identifiers', () => { + const engine = new CodeEngine(); + const text = [ + '// keep this comment', + 'const speed = 2; // odd spacing is kept', + '', + 'export function move(x: number) {', + ' return x * speed + other.speed;', + '}', + 'const other = { speed: 1 };', + '', + ].join('\n'); + engine.setFiles([{ path: 'src/a.ts', text }]); + engine.registerTransformer('rename', renameConst); + const edits = engine.transform('src/a.ts', 'rename', { from: 'speed', to: 'velocity' }); + expect(edits.every((edit) => text.slice(edit.start, edit.end) === 'speed')).toBe(true); + expect(edits).toHaveLength(2); + expect(applyTextEdits(text, edits).text).toBe( + text.replace('const speed ', 'const velocity ').replace('x * speed', 'x * velocity'), + ); + }); + + it('lists the symbols of a file with their kind, container and position', () => { + const engine = new CodeEngine(); + engine.setFiles([ + { + path: 'src/shapes.ts', + text: [ + 'export class Position {', + ' x = 0;', + ' move(dx: number) {', + ' this.x += dx;', + ' }', + '}', + '', + 'export const SPEED = 2;', + 'export function update() {}', + 'interface Shape {', + ' size: number;', + '}', + '', + ].join('\n'), + }, + ]); + expect( + engine + .symbols('src/shapes.ts') + .map((symbol) => [symbol.name, symbol.kind, symbol.container, symbol.line, symbol.column]), + ).toEqual([ + ['Position', 'class', undefined, 1, 14], + ['x', 'property', 'Position', 2, 3], + ['move', 'method', 'Position', 3, 3], + ['SPEED', 'const', undefined, 8, 14], + ['update', 'function', undefined, 9, 17], + ['Shape', 'interface', undefined, 10, 11], + ['size', 'property', 'Shape', 11, 3], + ]); + expect(engine.symbols('src/missing.ts')).toEqual([]); + }); + + it('runs analyzers returning references that resolve back to nodes', () => { + const engine = new CodeEngine(); + engine.setFiles(pong()); + engine.registerAnalyzer('spawns', ({ file, ref }) => + file + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => call.getExpression().getText().endsWith('spawnEntity')) + .map((call) => ref(call)), + ); + const refs = engine.analyze('apps/client/src/main.ts', 'spawns') as { + id: string; + start: number; + kind: string; + }[]; + expect(refs.length).toBeGreaterThan(0); + engine.registerAnalyzer('resolve', ({ resolve }, target) => + resolve(target as never)?.getText(), + ); + expect(engine.analyze('apps/client/src/main.ts', 'resolve', refs[0])).toMatch( + /spawnEntity\(\)/, + ); + }); + + it('reports syntax and type errors', async () => { + const engine = new CodeEngine(); + engine.setFiles([{ path: 'src/bad.ts', text: 'const a: number = "x";\nconst b = ;\n' }]); + const diagnostics = await engine.diagnostics(['src/bad.ts']); + expect(diagnostics.map((d) => [d.severity, d.code])).toEqual( + expect.arrayContaining([ + ['error', 2322], + ['error', 1109], + ]), + ); + }); + + it('loads type declarations of bare imports on demand', async () => { + const requested: string[] = []; + const engine = new CodeEngine({ + resolveTypes: async (module) => { + requested.push(module); + return [ + { + path: 'node_modules/@acme/lib/package.json', + text: '{"name":"@acme/lib","types":"index.d.ts"}', + }, + { + path: 'node_modules/@acme/lib/index.d.ts', + text: 'export declare const answer: number;', + }, + ]; + }, + }); + engine.setFiles([ + { + path: 'src/a.ts', + text: "import { answer } from '@acme/lib';\nconst s: string = answer;\n", + }, + ]); + const diagnostics = await engine.diagnostics(['src/a.ts']); + expect(requested).toEqual(['@acme/lib']); + expect(diagnostics.map((d) => d.code)).toContain(2322); + }); + + it('loads the declarations of a package for each app that has its own node_modules', async () => { + const requested: string[] = []; + const engine = new CodeEngine({ + resolveTypes: async (module, from) => { + requested.push(`${module} from ${from}`); + const app = /^apps\/[^/]+/.exec(from)?.[0]; + if (!app) return []; + return [ + { + path: `${app}/node_modules/@acme/lib/package.json`, + text: '{"name":"@acme/lib","types":"index.d.ts"}', + }, + { + path: `${app}/node_modules/@acme/lib/index.d.ts`, + text: 'export declare const answer: number;', + }, + ]; + }, + }); + const text = "import { answer } from '@acme/lib';\nexport const value: number = answer;\n"; + engine.setFiles([ + { path: 'apps/client/src/main.ts', text }, + { path: 'apps/client/src/systems/move.ts', text }, + { path: 'apps/server/src/main.ts', text }, + { path: 'test/game.spec.ts', text }, + ]); + const paths = [ + 'apps/client/src/main.ts', + 'apps/client/src/systems/move.ts', + 'apps/server/src/main.ts', + 'test/game.spec.ts', + ]; + const diagnostics = await engine.diagnostics(paths); + expect(requested).toEqual([ + '@acme/lib from apps/client/src/main.ts', + '@acme/lib from apps/server/src/main.ts', + '@acme/lib from test/game.spec.ts', + ]); + expect(diagnostics.filter((d) => d.code === 2307).map((d) => d.path)).toEqual([ + 'test/game.spec.ts', + ]); + await engine.diagnostics(paths); + expect(requested).toHaveLength(3); + }); + + it('checks each app in a program of its own: type augmentations do not leak', async () => { + const engine = new CodeEngine({ + resolveTypes: async (_module, from) => { + const app = /^apps\/([^/]+)/.exec(from); + if (!app) return []; + return [ + { + path: `${app[0]}/node_modules/@acme/net/package.json`, + text: '{"name":"@acme/net","types":"index.d.ts"}', + }, + { + path: `${app[0]}/node_modules/@acme/net/index.d.ts`, + text: `export declare const side: '${app[1]}';\ndeclare global { interface Ctx { net: '${app[1]}' } }\n`, + }, + ]; + }, + }); + const main = (side: string) => + `import { side } from '@acme/net';\nimport { shared } from '../../../libs/shared/src/index';\ndeclare const ctx: Ctx;\nexport const net: '${side}' = ctx.net;\nexport const all = [side, shared];\n`; + engine.setFiles([ + { path: 'apps/client/src/main.ts', text: main('client') }, + { path: 'apps/server/src/main.ts', text: main('server') }, + { path: 'libs/shared/src/index.ts', text: 'export const shared: number = 1;\n' }, + ]); + const paths = [ + 'apps/client/src/main.ts', + 'apps/server/src/main.ts', + 'libs/shared/src/index.ts', + ]; + expect(await engine.diagnostics(paths)).toEqual([]); + + engine.setFiles([ + { path: 'apps/server/src/main.ts', text: main('client') }, + { path: 'libs/shared/src/index.ts', text: "export const shared: number = 'one';\n" }, + ]); + const after = await engine.diagnostics(paths); + expect(after.map((d) => [d.path, d.code])).toEqual([ + ['apps/server/src/main.ts', 2322], + ['libs/shared/src/index.ts', 2322], + ]); + engine.deleteFiles(['libs/shared/src/index.ts']); + expect((await engine.diagnostics(['apps/client/src/main.ts'])).map((d) => d.code)).toEqual([ + 2307, + ]); + }); + + it('refuses overlapping edits', () => { + const engine = new CodeEngine(); + engine.setFiles([{ path: 'a.ts', text: 'const a = 1;' }]); + engine.registerTransformer('overlap', ({ edit }) => + edit.replace({ start: 0, end: 5 }, 'x').replace({ start: 3, end: 8 }, 'y'), + ); + expect(() => engine.transform('a.ts', 'overlap', null)).toThrow(CodeError); + }); +}); + +describe('organizeImports', () => { + it('sorts imports and drops unused ones', async () => { + const engine = new CodeEngine(); + const text = "import { b } from './b';\nimport { z, a } from './a';\n\nconsole.log(a, b);\n"; + engine.setFiles([ + { path: 'src/main.ts', text }, + { path: 'src/a.ts', text: 'export const a = 1;\nexport const z = 2;\n' }, + { path: 'src/b.ts', text: 'export const b = 1;\n' }, + ]); + const edits = await engine.organizeImports('src/main.ts'); + expect(applyTextEdits(text, edits).text).toBe( + "import { a } from './a';\nimport { b } from './b';\n\nconsole.log(a, b);\n", + ); + }); +}); + +describe('literals', () => { + const engine = new CodeEngine(); + const valueOf = (source: string) => { + engine.setFiles([{ path: 'l.ts', text: `export const X = ${source};` }]); + const file = engine.project.getSourceFileOrThrow('/project/l.ts'); + return literalToValue(file.getVariableDeclarationOrThrow('X').getInitializerOrThrow()); + }; + + it('reads literals', () => { + expect( + valueOf( + `{ name: "Ball", params: [{ type: 'number', default: -2.5, optional: true }], "x-y": null } as const`, + ), + ).toEqual({ + name: 'Ball', + params: [{ type: 'number', default: -2.5, optional: true }], + 'x-y': null, + }); + }); + + it('rejects computed values with their position', () => { + try { + valueOf('{ a: Math.PI }'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CodeError); + expect((error as CodeError).message).toContain('Math.PI'); + expect((error as CodeError).start).toBe(22); + } + }); + + it('prints values that read back identically', () => { + const value = { + name: "Bob's ball", + size: [1, -2], + nested: { a: true, 'b-c': null }, + long: 'x'.repeat(70), + }; + const printed = valueToLiteral(value, { quote: "'", indent: ' ' }); + expect(printed).toContain("'Bob\\'s ball'"); + expect(printed).toContain("'b-c': null"); + expect(valueOf(printed)).toEqual(value); + expect(valueToLiteral({ x: 1, y: 2 })).toBe('{ x: 1, y: 2 }'); + }); + + it('ignores Node import (type-only check)', () => { + expect(typeof Node.isIdentifier).toBe('function'); + }); +}); diff --git a/packages/code/test/engine/extract-meta.spec.ts b/packages/code/test/engine/extract-meta.spec.ts new file mode 100644 index 00000000..529b487b --- /dev/null +++ b/packages/code/test/engine/extract-meta.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import { CodeEngine } from '../../src/engine'; + +const ecs: ItemOwner = { + name: '@nanoforge/ecs', + version: '0.0.0', + schema: 1, + tags: ['component', 'system'], + claims: ({ kind }) => (kind === 'function' ? { params: ['registry'] } : { fields: ['name'] }), + extract: ({ declaration, context, tags }) => { + if (tags.component) return { type: 'component', name: declaration.getName() }; + const query = declaration + .getDescendants() + .filter( + (node) => + node.getKindName() === 'Identifier' && + node.getParent()?.getKindName() === 'CallExpression' && + node.getText() !== 'use', + ) + .map((node) => context.refOf(node)); + return { type: 'system', query }; + }, +}; + +const engine = () => { + const code = new CodeEngine(); + code.registerItemOwner(ecs, '@nanoforge/ecs'); + code.setTsconfig( + '{ "compilerOptions": { "paths": { "@me/shared/*": ["./libs/shared/src/*"] } } }', + ); + code.setFiles([ + { + path: 'libs/shared/src/components/player.ts', + text: '/** A player. @component */\nexport class Player { name = "Player"; constructor(public id = "") {} }', + }, + { + path: 'apps/client/src/systems/greet.ts', + text: [ + 'import { Player } from "@me/shared/components/player";', + 'declare const use: (c: unknown) => void;', + '/** Greets players. @system @side client */', + 'export function greet(registry: unknown) { use(Player); }', + ].join('\n'), + }, + ]); + return code; +}; + +const roots = [ + { path: 'libs/shared', ref: '@me/shared' }, + { path: 'apps/client', ref: 'app:client' }, +]; + +describe('CodeEngine.extractMeta', () => { + it('extracts items with owners and resolves package-name imports through tsconfig paths', async () => { + const code = engine(); + const lib = await code.extractMeta({ + source: { kind: 'lib', name: '@me/shared', path: 'libs/shared' }, + files: [{ path: 'libs/shared/src/components/player.ts', folder: 'components' }], + roots, + }); + expect(lib.items).toMatchObject([ + { export: 'Player', fields: [], '@nanoforge/ecs': { type: 'component', name: 'Player' } }, + ]); + expect(lib.owners).toEqual({ '@nanoforge/ecs': { version: '0.0.0', schema: 1 } }); + expect(lib.sourceHash).toMatch(/^sha256-[0-9a-f]{64}$/); + + const app = await code.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [{ path: 'apps/client/src/systems/greet.ts', folder: 'systems' }], + roots, + }); + expect(app.items[0]).toMatchObject({ + export: 'greet', + side: 'client', + params: [], + requires: ['@me/shared'], + '@nanoforge/ecs': { type: 'system', query: ['@me/shared#Player'] }, + }); + expect(await code.diagnostics(['apps/client/src/systems/greet.ts'])).toEqual([]); + }); + + it('drops the owners of an unloaded plugin', () => { + const code = engine(); + code.unregisterOwner('@nanoforge/ecs'); + expect(code.itemOwners).toEqual([]); + }); +}); + +describe('moduleSpecifierFor', () => { + it('uses tsconfig paths, else a relative path', async () => { + const { moduleSpecifierFor } = await import('../../src/engine'); + const paths = { '@me/shared/*': ['libs/shared/src/*'] }; + expect( + moduleSpecifierFor('apps/client/src/main.ts', 'libs/shared/src/components/player.ts', paths), + ).toBe('@me/shared/components/player'); + expect( + moduleSpecifierFor( + 'apps/client/src/main.ts', + 'apps/client/src/components/components.ts', + paths, + ), + ).toBe('./components/components'); + expect(moduleSpecifierFor('apps/client/src/main.ts', 'nf_modules/@a/b/x.ts', paths)).toBe( + '../../../nf_modules/@a/b/x', + ); + }); +}); diff --git a/packages/code/test/service/code-service.spec.ts b/packages/code/test/service/code-service.spec.ts new file mode 100644 index 00000000..9dc35703 --- /dev/null +++ b/packages/code/test/service/code-service.spec.ts @@ -0,0 +1,217 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SyntaxKind } from 'ts-morph'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type CommandOrigin, + HistoryService, + type TextEdit, + applyTextEdits, +} from '@nanoforge-dev/editor-history'; +import { Emitter, ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + MemoryContentCache, + MemoryTreeCache, + ProjectService, +} from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { + CodeService, + DiagnosticsService, + type DocumentChange, + DocumentService, + type OpenDocument, + type WorkerPluginModule, + projectFsBackend, +} from '../../src'; +import { serveEngine } from '../../src/worker-rpc/serve-engine'; + +const MAIN = 'apps/client/src/main.ts'; + +/** Worker plugin: renames the first `const` declared in a file. */ +const renamePlugin: WorkerPluginModule = { + activate: (sdk) => { + sdk.registerTransformer('test.rename-first-const', ({ file, edit }, op) => { + const declaration = file.getFirstDescendantByKindOrThrow(SyntaxKind.VariableDeclaration); + edit.replace(declaration.getNameNode(), (op as { name: string }).name); + }); + }, +}; + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let project: ClientProject; + +const setup = () => { + const documents = new DocumentService(projectFsBackend(project.fs)); + const diagnostics = new DiagnosticsService(); + const code = new CodeService({ + projectId: project.id, + fs: project.fs, + rpc, + documents, + diagnostics, + diagnosticsDelayMs: 10, + createWorker: () => { + const channel = new MessageChannel(); + serveEngine(channel.port2 as never, { importModule: async () => renamePlugin }); + return channel.port1 as never; + }, + }); + return { documents, diagnostics, code, history: new HistoryService() }; +}; + +/** A Monaco-like open document. */ +const fakeDocument = (uri: string, initial: string) => { + let text = initial; + const changes = new Emitter(); + const document: OpenDocument & { text(): string } = { + uri, + kind: 'text', + onDidChange: changes.event, + dirty: new ObservableValue(false), + getText: () => text, + text: () => text, + applyEdits: (edits: readonly TextEdit[], origin: CommandOrigin | undefined) => { + const result = applyTextEdits(text, edits); + text = result.text; + changes.fire({ uri, edits, origin }); + return result.inverse; + }, + save: async () => undefined, + revert: async () => undefined, + }; + return document; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-code-')); + cpSync( + join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { recursive: true }, + ); + const lib = join(workspace, 'pong/node_modules/@acme/speed'); + mkdirSync(lib, { recursive: true }); + writeFileSync(join(lib, 'package.json'), '{"name":"@acme/speed","types":"index.d.ts"}'); + writeFileSync(join(lib, 'index.d.ts'), 'export declare const maxSpeed: number;'); + mkdirSync(join(workspace, 'pong/checks'), { recursive: true }); + writeFileSync(join(workspace, 'pong/checks/broken.ts'), 'export const n: number =\n "text";\n'); + mkdirSync(join(workspace, 'pong/nf_modules/@acme/pkg'), { recursive: true }); + writeFileSync( + join(workspace, 'pong/nf_modules/@acme/pkg/bad.ts'), + 'export const n: number = "x";\n', + ); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ + path: 'pong', + }); +}); + +afterAll(() => { + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('CodeService', () => { + it('sends edits to the open document instead of the file', async () => { + const { documents, code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const onDisk = readFileSync(join(workspace, 'pong', MAIN), 'utf8'); + const document = fakeDocument(MAIN, onDisk); + documents.register(document); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + + const command = await code.edit( + MAIN, + 'test.rename-first-const', + { name: 'renamed' }, + { label: 'Rename' }, + ); + await stack.push(command!); + expect(document.text()).toMatch(/const renamed/); + expect(readFileSync(join(workspace, 'pong', MAIN), 'utf8')).toBe(onDisk); + await stack.undo(); + expect(document.text()).toBe(onDisk); + code.dispose(); + }); + + it('edits closed files on disk, undoes, and drops undo after an outside change', async () => { + const { code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + const path = join(workspace, 'pong', MAIN); + const original = readFileSync(path, 'utf8'); + + await stack.push( + (await code.edit(MAIN, 'test.rename-first-const', { name: 'app' }, { label: 'Rename' }))!, + ); + expect(readFileSync(path, 'utf8')).toMatch(/const app\b/); + await stack.undo(); + expect(readFileSync(path, 'utf8')).toBe(original); + await stack.redo(); + + writeFileSync(path, `${readFileSync(path, 'utf8')}\n// hand edit\n`); + await new Promise((resolve) => setTimeout(resolve, 300)); // watcher → ProjectFs + expect(await stack.undo()).toBe(false); + expect(stack.state.canUndo).toBe(false); + code.dispose(); + }); + + it('publishes diagnostics of watched files with types from node_modules', async () => { + const { code, documents, diagnostics } = setup(); + const uri = 'apps/client/src/speed.ts'; + documents.register( + fakeDocument( + uri, + "import { maxSpeed } from '@acme/speed';\nexport const s: string = maxSpeed;\n", + ), + ); + await code.engine(); // mirror first, then the open document text is pushed on change + await (await code.engine()).setFiles([{ path: uri, text: await documents.getText(uri) }]); + const watch = code.watchDiagnostics(uri); + const start = Date.now(); + while (!diagnostics.forUri(uri).length && Date.now() - start < 8000) + await new Promise((r) => setTimeout(r, 25)); + expect(diagnostics.forUri(uri).map((d) => d.code)).toEqual([2322]); + watch.dispose(); + expect(diagnostics.forUri(uri)).toEqual([]); + code.dispose(); + }); + + it('checks every file of the project while asked to, read-only packages aside', async () => { + const { code, diagnostics } = setup(); + const uri = 'checks/broken.ts'; + const check = code.checkProject(); + const start = Date.now(); + while (!diagnostics.forUri(uri).length && Date.now() - start < 15_000) + await new Promise((r) => setTimeout(r, 25)); + expect(diagnostics.forUri(uri)).toMatchObject([ + { code: 2322, source: 'typescript', line: 1, column: 14 }, + ]); + expect(diagnostics.forUri('nf_modules/@acme/pkg/bad.ts')).toEqual([]); + code.watchDiagnostics(uri).dispose(); + expect(diagnostics.forUri(uri)).toHaveLength(1); + check.dispose(); + expect(diagnostics.forUri(uri)).toEqual([]); + code.dispose(); + }); +}); diff --git a/packages/code/tsconfig.json b/packages/code/tsconfig.json new file mode 100644 index 00000000..29b22ee4 --- /dev/null +++ b/packages/code/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext", "esnext.disposable", "DOM", "DOM.Iterable", "WebWorker.ImportScripts"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/history/.prettierignore b/packages/history/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/history/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/history/LICENSE b/packages/history/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/history/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/history/README.md b/packages/history/README.md new file mode 100644 index 00000000..6dffda5c --- /dev/null +++ b/packages/history/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-history` is an internal package of the NanoForge Editor. + +Undo/redo engine with per-context stacks. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/history +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/history/package.json b/packages/history/package.json new file mode 100644 index 00000000..e1903225 --- /dev/null +++ b/packages/history/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-history", + "version": "0.0.0", + "description": "NanoForge Editor - Undo/redo engine with per-context stacks", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/history" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*" + }, + "devDependencies": { + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-server-core": "workspace:*", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/history/src/command/composite-command.ts b/packages/history/src/command/composite-command.ts new file mode 100644 index 00000000..e900408f --- /dev/null +++ b/packages/history/src/command/composite-command.ts @@ -0,0 +1,50 @@ +import type { CommandOrigin, HistoryCommand } from './history-command.type'; + +export const sameOrigin = (a: CommandOrigin | undefined, b: CommandOrigin | undefined): boolean => + a?.kind === b?.kind && a?.id === b?.id; + +/** Runs children in order and undoes them in reverse. */ +export const compositeCommand = ( + label: string, + children: readonly HistoryCommand[], + origin?: CommandOrigin, +): HistoryCommand => ({ + label, + ...(origin && { origin }), + children, + sizeBytes: children.reduce((total, child) => total + (child.sizeBytes ?? 0), 0), + async do() { + for (const child of children) await child.do(); + }, + async undo() { + for (const child of [...children].reverse()) await child.undo(); + }, + async redo() { + for (const child of children) await (child.redo ?? child.do).call(child); + }, + async isValid() { + for (const child of children) if ((await child.isValid?.()) === false) return false; + return true; + }, + dispose() { + for (const child of children) child.dispose?.(); + }, +}); + +/** Default merge of two commands with the same merge key: first undo, last redo. */ +export const mergeCommands = (previous: HistoryCommand, next: HistoryCommand): HistoryCommand => ({ + label: next.label, + ...(previous.origin && { origin: previous.origin }), + sizeBytes: (previous.sizeBytes ?? 0) + (next.sizeBytes ?? 0), + ...(next.mergeKey && { mergeKey: next.mergeKey }), + do: () => next.do(), + undo: () => previous.undo(), + redo: () => (next.redo ?? next.do).call(next), + async isValid() { + return (await previous.isValid?.()) !== false && (await next.isValid?.()) !== false; + }, + dispose() { + previous.dispose?.(); + next.dispose?.(); + }, +}); diff --git a/packages/history/src/command/history-command.type.ts b/packages/history/src/command/history-command.type.ts new file mode 100644 index 00000000..c50d7e51 --- /dev/null +++ b/packages/history/src/command/history-command.type.ts @@ -0,0 +1,51 @@ +/** Who made a change: undo only ever targets changes of the local user. */ +export interface CommandOrigin { + readonly kind: 'user' | 'plugin' | 'remote'; + readonly id: string; +} + +type MaybePromise = T | Promise; + +/** An undoable change. `do` runs once when pushed; `redo` defaults to `do`. */ +export interface HistoryCommand { + readonly label: string; + /** Defaults to the local user of the history service. */ + readonly origin?: CommandOrigin; + /** Approximate memory held by the command (snapshots…), for the stack memory budget. */ + readonly sizeBytes?: number; + /** Commands with the same key pushed within the merge window become one step. */ + readonly mergeKey?: string; + /** What changed, for the history panel (e.g. the lines of a document edit). */ + readonly preview?: CommandPreview; + /** Steps of a grouped command (transactions). */ + readonly children?: readonly HistoryCommand[]; + do(): MaybePromise; + undo(): MaybePromise; + redo?(): MaybePromise; + /** + * Merges a following command into this one. Returns the merged command, or undefined when + * they cannot merge. Without it, commands with the same `mergeKey` merge by keeping this + * command's `undo` and the next one's `redo`. + */ + merge?(next: HistoryCommand): HistoryCommand | undefined; + /** Checked before undo/redo; false means the state it relies on changed (step dropped). */ + isValid?(): MaybePromise; + /** Called when the command leaves the history (trimmed, cleared, invalidated). */ + dispose?(): void; + /** Data to rebuild the command after a reload (see `HistoryService.registerDeserializer`). */ + serialize?(): SerializedCommand | undefined; +} + +/** Before/after text of a change (a few lines around it). */ +export interface CommandPreview { + readonly title?: string; + readonly before: string; + readonly after: string; +} + +export interface SerializedCommand { + /** Deserializer type (`document.edits`…). */ + readonly type: string; + readonly label: string; + readonly data: unknown; +} diff --git a/packages/history/src/command/index.ts b/packages/history/src/command/index.ts new file mode 100644 index 00000000..1c2c647e --- /dev/null +++ b/packages/history/src/command/index.ts @@ -0,0 +1,2 @@ +export * from './history-command.type'; +export * from './composite-command'; diff --git a/packages/history/src/file/apply-text-edits.ts b/packages/history/src/file/apply-text-edits.ts new file mode 100644 index 00000000..1f693467 --- /dev/null +++ b/packages/history/src/file/apply-text-edits.ts @@ -0,0 +1,36 @@ +/** Replacement of `[start, end)` (UTF-16 offsets) by `text`: a serializable document operation. */ +export interface TextEdit { + readonly start: number; + readonly end: number; + readonly text: string; +} + +/** Applies non-overlapping edits (any order) and returns the new text with the inverse edits. */ +export const applyTextEdits = ( + source: string, + edits: readonly TextEdit[], +): { text: string; inverse: TextEdit[] } => { + const sorted = [...edits].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i]!.start < sorted[i - 1]!.end) throw new Error('Overlapping text edits'); + } + let text = ''; + let cursor = 0; + let delta = 0; + const inverse: TextEdit[] = []; + for (const edit of sorted) { + if (edit.start < cursor || edit.end > source.length || edit.start > edit.end) { + throw new Error(`Text edit out of range: ${edit.start}-${edit.end}`); + } + text += source.slice(cursor, edit.start) + edit.text; + const start = edit.start + delta; + inverse.push({ + start, + end: start + edit.text.length, + text: source.slice(edit.start, edit.end), + }); + delta += edit.text.length - (edit.end - edit.start); + cursor = edit.end; + } + return { text: text + source.slice(cursor), inverse }; +}; diff --git a/packages/history/src/file/file-history-tracker.ts b/packages/history/src/file/file-history-tracker.ts new file mode 100644 index 00000000..7845f38e --- /dev/null +++ b/packages/history/src/file/file-history-tracker.ts @@ -0,0 +1,120 @@ +import { type Disposable, DisposableStore, type Event } from '@nanoforge-dev/editor-kernel'; + +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; +import type { HistoryStack } from '../stack/history-stack'; +import { type TextEdit, applyTextEdits } from './apply-text-edits'; + +/** The file operations history needs (ProjectFs satisfies it). */ +export interface HistoryFileSystem { + readonly onDidChange: Event; + readText(path: string): Promise<{ text: string; hash: string }>; + write( + path: string, + content: string, + options: { expectedHash: string | null }, + ): Promise<{ hash: string }>; +} + +/** + * Tracks the hash each file should have after the editor's own edits. When a tracked file + * changes to anything else (hand edit, git pull…), the history of that context is invalidated. + */ +export class FileHistoryTracker implements Disposable { + private readonly _expected = new Map(); + /** Writes in progress per path: changes seen meanwhile may be their own echo. */ + private readonly _writing = new Map>(); + private readonly _store = new DisposableStore(); + + constructor( + private readonly _fs: HistoryFileSystem, + private readonly _stack: HistoryStack, + ) { + this._store.add( + _fs.onDidChange((changes) => { + for (const change of changes) + if (this._expected.has(change.path)) void this._check(change.path, change.type); + }), + ); + } + + expect(path: string, hash: string): void { + this._expected.set(path, hash); + } + + expectedHash(path: string): string | undefined { + return this._expected.get(path); + } + + /** + * An undoable edit of a file. It fails (and the step is dropped) if the file no longer has + * the content the edit produced. + */ + edit( + path: string, + edits: readonly TextEdit[], + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): HistoryCommand { + let inverse: TextEdit[] = []; + let forward = edits; + const apply = (operations: readonly TextEdit[]) => { + const task = write(operations); + this._writing.set( + path, + task.catch(() => undefined), + ); + return task.finally(() => { + if (this._writing.get(path) === task) this._writing.delete(path); + }); + }; + const write = async (operations: readonly TextEdit[]) => { + const current = await this._fs.readText(path); + const expected = this._expected.get(path); + if (expected !== undefined && current.hash !== expected) { + throw new Error(`${path} changed outside of the editor`); + } + const result = applyTextEdits(current.text, operations); + const { hash } = await this._fs.write(path, result.text, { expectedHash: current.hash }); + this._expected.set(path, hash); + return result.inverse; + }; + return { + label: options.label, + ...(options.origin && { origin: options.origin }), + ...(options.mergeKey && { mergeKey: options.mergeKey }), + sizeBytes: edits.reduce((total, edit) => total + edit.text.length * 2 + 16, 0), + do: async () => { + inverse = await apply(forward); + }, + undo: async () => { + forward = await apply(inverse); + }, + redo: async () => { + inverse = await apply(forward); + }, + isValid: async () => { + const expected = this._expected.get(path); + return expected === undefined || (await this._fs.readText(path)).hash === expected; + }, + }; + } + + dispose(): void { + this._store.dispose(); + } + + private async _check(path: string, type: string): Promise { + await this._writing.get(path); + const expected = this._expected.get(path); + if (expected === undefined) return; + const hash = + type === 'deleted' + ? null + : await this._fs.readText(path).then( + (file) => file.hash, + () => null, + ); + if (hash === expected) return; // echo of our own write + this._expected.delete(path); + await this._stack.invalidate('external-change'); + } +} diff --git a/packages/history/src/file/index.ts b/packages/history/src/file/index.ts new file mode 100644 index 00000000..617d3c57 --- /dev/null +++ b/packages/history/src/file/index.ts @@ -0,0 +1,2 @@ +export * from './apply-text-edits'; +export * from './file-history-tracker'; diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts new file mode 100644 index 00000000..34942403 --- /dev/null +++ b/packages/history/src/index.ts @@ -0,0 +1,4 @@ +export * from './command'; +export * from './file'; +export * from './service'; +export * from './stack'; diff --git a/packages/history/src/service/history-context-keys.const.ts b/packages/history/src/service/history-context-keys.const.ts new file mode 100644 index 00000000..136ffb2d --- /dev/null +++ b/packages/history/src/service/history-context-keys.const.ts @@ -0,0 +1,4 @@ +/** Context key holding the history context of the focused widget. */ +export const HISTORY_CONTEXT_KEY = 'historyContext'; +/** Context key set while a text field or the code editor has focus (native undo). */ +export const TEXT_INPUT_FOCUS_KEY = 'textInputFocus'; diff --git a/packages/history/src/service/history-service.ts b/packages/history/src/service/history-service.ts new file mode 100644 index 00000000..778838c6 --- /dev/null +++ b/packages/history/src/service/history-service.ts @@ -0,0 +1,222 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Observable, + ObservableValue, + constant, + createToken, + observe, +} from '@nanoforge-dev/editor-kernel'; + +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; +import { HistoryStack } from '../stack/history-stack'; +import type { + ContextInvalidation, + Deserializer, + HistoryContext, + HistoryContextInfo, + HistoryContextOptions, + HistoryServiceOptions, + HistoryStore, + PersistedEntry, +} from './history-service.type'; + +/** One undo/redo stack per context (document, layout, settings…). */ +export class HistoryService implements Disposable { + private readonly _contexts = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _onDidInvalidate = new Emitter(); + private readonly _list = new ObservableValue([]); + private readonly _store = new DisposableStore(); + private readonly _limit: Observable; + private readonly _mergeWindow: Observable; + private _localOrigin: CommandOrigin; + private readonly _deserializers = new Map(); + private readonly _persisted: { match: (id: string) => boolean; store: HistoryStore }[] = []; + + /** Fires with the id of the context whose stack changed. */ + readonly onDidChange: Event = this._onDidChange.event; + readonly onDidInvalidate: Event = this._onDidInvalidate.event; + + constructor(private readonly _options: HistoryServiceOptions = {}) { + this._limit = _options.limit ?? constant(100); + this._mergeWindow = _options.mergeWindowMs ?? constant(500); + this._localOrigin = _options.localOrigin ?? { kind: 'user', id: 'local' }; + this._store.add( + observe( + this._limit, + () => { + for (const context of this._contexts.values()) void context.stack.trim(); + }, + { immediate: false }, + ), + ); + } + + get localOrigin(): CommandOrigin { + return this._localOrigin; + } + + set localOrigin(origin: CommandOrigin) { + this._localOrigin = origin; + } + + /** Every context with its stack state, for the history panel. */ + get contexts(): Observable { + return this._list.readonly(); + } + + registerContext(options: HistoryContextOptions): HistoryContext { + if (this._contexts.has(options.id)) + throw new Error(`History context "${options.id}" already exists`); + const stack = new HistoryStack({ + localOrigin: () => this._localOrigin, + limit: () => this._limit.get(), + mergeWindowMs: () => this._mergeWindow.get(), + budgetBytes: () => this._options.budgetBytes ?? 64 * 1024 * 1024, + ...(this._options.now && { now: this._options.now }), + }); + const subscriptions = new DisposableStore(); + subscriptions.add(stack.onDidChange(() => this._changed(options.id))); + subscriptions.add( + stack.onDidInvalidate((reason) => + this._onDidInvalidate.fire({ contextId: options.id, reason }), + ), + ); + const context: HistoryContext = { + id: options.id, + label: options.label, + owner: options.owner ?? 'core', + stack, + dispose: () => { + if (this._contexts.get(options.id) !== context) return; + this._contexts.delete(options.id); + subscriptions.dispose(); + stack.dispose(); + this._changed(options.id); + }, + }; + this._contexts.set(options.id, context); + this._changed(options.id); + const persisted = this._persisted.find((candidate) => candidate.match(options.id)); + if (persisted) subscriptions.add(this._persistContext(context, persisted.store)); + return context; + } + + /** Rebuilds commands of a type from their serialized data (history kept across reloads). */ + registerDeserializer(type: string, deserialize: Deserializer): Disposable { + this._deserializers.set(type, deserialize); + return { dispose: () => this._deserializers.delete(type) }; + } + + /** + * Keeps the history of the matching contexts in a store: restored when such a context is + * registered, saved when it changes. Only histories whose steps are all serializable are + * saved. + */ + persist(match: (id: string) => boolean, store: HistoryStore): Disposable { + const entry = { match, store }; + this._persisted.push(entry); + return { + dispose: () => { + const index = this._persisted.indexOf(entry); + if (index >= 0) this._persisted.splice(index, 1); + }, + }; + } + + /** Undoes or redoes a context until the entry is its last applied step. */ + async goTo(contextId: string, entryId: number): Promise { + await this._contexts.get(contextId)?.stack.goTo(entryId); + } + + async clear(contextId: string): Promise { + await this._contexts.get(contextId)?.stack.clear(); + } + + get(id: string): HistoryContext | undefined { + return this._contexts.get(id); + } + + /** Undoes in the given context; returns false when there was nothing to undo. */ + async undo(contextId: string | undefined): Promise { + const context = contextId ? this._contexts.get(contextId) : undefined; + return context ? context.stack.undo() : false; + } + + async redo(contextId: string | undefined): Promise { + const context = contextId ? this._contexts.get(contextId) : undefined; + return context ? context.stack.redo() : false; + } + + dispose(): void { + for (const context of [...this._contexts.values()]) context.dispose(); + this._store.dispose(); + this._onDidChange.dispose(); + this._onDidInvalidate.dispose(); + } + + private _persistContext(context: HistoryContext, store: HistoryStore): Disposable { + let restored = false; + let timer: ReturnType | undefined; + const save = () => { + clearTimeout(timer); + timer = setTimeout(() => { + const entries: PersistedEntry[] = []; + for (const entry of context.stack.state.past) { + const command = entry.command.serialize?.(); + if (!command) return; // not all serializable: keep the stored history as is + entries.push({ command, origin: entry.origin, time: entry.time }); + } + void store.save(context.id, entries).catch(() => undefined); + }, 300); + }; + void store + .load(context.id) + .then(async (entries) => { + const commands = (entries ?? []).map((entry) => ({ + ...entry, + command: this._deserializers.get(entry.command.type)?.( + entry.command.data, + entry.command.label, + ), + })); + if ( + commands.length && + commands.every((entry) => entry.command) && + !context.stack.state.past.length + ) { + await context.stack.restore( + commands as { command: HistoryCommand; origin: CommandOrigin; time: number }[], + ); + } + }) + .catch(() => undefined) + .finally(() => (restored = true)); + const subscription = context.stack.onDidChange(() => { + if (restored) save(); + }); + return { + dispose: () => { + clearTimeout(timer); + subscription.dispose(); + }, + }; + } + + private _changed(id: string): void { + this._list.set( + [...this._contexts.values()].map((context) => ({ + id: context.id, + label: context.label, + owner: context.owner, + state: context.stack.state, + })), + ); + this._onDidChange.fire(id); + } +} + +export const HistoryServiceToken = createToken('history.service'); diff --git a/packages/history/src/service/history-service.type.ts b/packages/history/src/service/history-service.type.ts new file mode 100644 index 00000000..ea0c84ca --- /dev/null +++ b/packages/history/src/service/history-service.type.ts @@ -0,0 +1,60 @@ +import type { Disposable, Observable } from '@nanoforge-dev/editor-kernel'; + +import type { + CommandOrigin, + HistoryCommand, + SerializedCommand, +} from '../command/history-command.type'; +import { type HistoryStack } from '../stack/history-stack'; +import type { InvalidationReason, StackState } from '../stack/history-stack.type'; + +export interface HistoryContextOptions { + /** Stable id, e.g. `file:apps/client/src/main.ts`, `layout`, `settings`. */ + readonly id: string; + readonly label: string; + /** Plugin name or `core`. */ + readonly owner?: string; +} + +export interface HistoryContext extends Disposable { + readonly id: string; + readonly label: string; + readonly owner: string; + readonly stack: HistoryStack; +} + +/** A step of a persisted history (see `HistoryService.persist`). */ +export interface PersistedEntry { + readonly command: SerializedCommand; + readonly origin: CommandOrigin; + readonly time: number; +} + +/** Where persisted histories live (e.g. IndexedDB, per project). */ +export interface HistoryStore { + load(contextId: string): Promise; + save(contextId: string, entries: readonly PersistedEntry[]): Promise; +} + +export type Deserializer = (data: unknown, label: string) => HistoryCommand | undefined; + +export interface HistoryContextInfo { + readonly id: string; + readonly label: string; + readonly owner: string; + readonly state: StackState; +} + +export interface HistoryServiceOptions { + readonly limit?: Observable; + readonly mergeWindowMs?: Observable; + /** Memory budget per context (default 64 MiB). */ + readonly budgetBytes?: number; + readonly localOrigin?: CommandOrigin; + readonly now?: () => number; +} + +export interface ContextInvalidation { + readonly contextId: string; + readonly reason: InvalidationReason; +} diff --git a/packages/history/src/service/index.ts b/packages/history/src/service/index.ts new file mode 100644 index 00000000..cda32154 --- /dev/null +++ b/packages/history/src/service/index.ts @@ -0,0 +1,5 @@ +export * from './history-service.type'; +export * from './history-context-keys.const'; +export * from './history-service'; +export * from './undo-target'; +export * from './register-history-commands'; diff --git a/packages/history/src/service/register-history-commands.ts b/packages/history/src/service/register-history-commands.ts new file mode 100644 index 00000000..3ad0a9a8 --- /dev/null +++ b/packages/history/src/service/register-history-commands.ts @@ -0,0 +1,48 @@ +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, +} from '@nanoforge-dev/editor-kernel'; + +import { type HistoryService } from './history-service'; +import { resolveUndoTarget } from './undo-target'; + +/** + * Registers `history.undo` / `history.redo`. They return false when the keystroke should fall + * through to the browser (native undo), so the keybinding layer does not prevent it. + */ +export const registerHistoryCommands = ( + commands: CommandRegistry, + context: ContextKeyService, + history: HistoryService, + options: { + /** Context undone when the focused widget has none (e.g. `layout`). */ + fallbackContext?: string; + } = {}, +): Disposable => { + const store = new DisposableStore(); + const run = (action: 'undo' | 'redo') => async (): Promise => { + const target = resolveUndoTarget(context, options.fallbackContext); + if (target.kind !== 'context') return false; + await history[action](target.id); + return true; + }; + store.add( + commands.register({ + id: 'history.undo', + title: 'Undo', + category: 'Edit', + handler: run('undo'), + }), + ); + store.add( + commands.register({ + id: 'history.redo', + title: 'Redo', + category: 'Edit', + handler: run('redo'), + }), + ); + return store; +}; diff --git a/packages/history/src/service/undo-target.ts b/packages/history/src/service/undo-target.ts new file mode 100644 index 00000000..1036e11e --- /dev/null +++ b/packages/history/src/service/undo-target.ts @@ -0,0 +1,18 @@ +import type { ContextKeyService } from '@nanoforge-dev/editor-kernel'; + +import { HISTORY_CONTEXT_KEY, TEXT_INPUT_FOCUS_KEY } from './history-context-keys.const'; + +export type UndoTarget = + | { readonly kind: 'native' } + | { readonly kind: 'context'; readonly id: string } + | { readonly kind: 'none' }; + +/** + * Where Ctrl+Z goes: native undo inside text inputs and the code editor, otherwise the history + * context of the focused widget. + */ +export const resolveUndoTarget = (context: ContextKeyService, fallback?: string): UndoTarget => { + if (context.get(TEXT_INPUT_FOCUS_KEY)) return { kind: 'native' }; + const id = context.get(HISTORY_CONTEXT_KEY) ?? fallback; + return id ? { kind: 'context', id } : { kind: 'none' }; +}; diff --git a/packages/history/src/stack/history-stack.ts b/packages/history/src/stack/history-stack.ts new file mode 100644 index 00000000..37e3f9a1 --- /dev/null +++ b/packages/history/src/stack/history-stack.ts @@ -0,0 +1,284 @@ +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +import { compositeCommand, mergeCommands, sameOrigin } from '../command/composite-command'; +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; +import type { + HistoryEntry, + InvalidationReason, + StackOptions, + StackState, + Transaction, +} from './history-stack.type'; + +const SAVED_AT_START = 0; + +/** + * Undo/redo stack of one context. Operations are serialized; commands pushed inside a + * transaction go through the transaction handle. + */ +export class HistoryStack { + private _past: HistoryEntry[] = []; + private _future: HistoryEntry[] = []; + private _queue: Promise = Promise.resolve(); + private _nextId = 1; + /** Entry id at the savepoint (0: the initial state). */ + private _savedAt: number | null = SAVED_AT_START; + private readonly _onDidChange = new Emitter(); + private readonly _onDidInvalidate = new Emitter(); + + readonly onDidChange: Event = this._onDidChange.event; + readonly onDidInvalidate: Event = this._onDidInvalidate.event; + + constructor(private readonly _options: StackOptions) {} + + get state(): StackState { + const top = this._past.at(-1)?.id ?? SAVED_AT_START; + return { + past: [...this._past], + future: [...this._future], + canUndo: this._past.some((entry) => entry.undoable), + canRedo: this._future.length > 0, + dirty: this._savedAt !== top, + }; + } + + /** Executes a command and records it (merging with the previous one when allowed). */ + push(command: HistoryCommand): Promise { + return this._serial(async () => { + await command.do(); + this._add(command, true); + }); + } + + /** + * Records a change already applied elsewhere (e.g. by a collaborator). Changes of other + * origins are not undoable. + */ + record(command: HistoryCommand): Promise { + return this._serial(async () => this._add(command, false)); + } + + /** + * Groups the commands pushed through `tx` into one step. If `fn` throws, the commands already + * applied are undone in reverse order and the error is rethrown. + */ + transaction(label: string, fn: (tx: Transaction) => Promise): Promise { + return this._serial(async () => { + const collected: HistoryCommand[] = []; + const result = await runTransaction(collected, fn); + if (collected.length) + this._add(compositeCommand(label, collected, this._options.localOrigin()), true, false); + return result; + }); + } + + undo(): Promise { + return this._serial(() => this._undoOne()); + } + + redo(): Promise { + return this._serial(() => this._redoOne()); + } + + /** + * Undoes or redoes until the entry is the last applied step (0: before every step). Changes + * of other origins cannot be undone and stay applied. + */ + goTo(entryId: number): Promise { + return this._serial(async () => { + if (this._future.some((entry) => entry.id === entryId)) { + while (this._future.some((entry) => entry.id === entryId)) { + if (!(await this._redoOne())) return; + } + return; + } + for (;;) { + const last = this._past.findLast((entry) => entry.undoable); + if (!last || last.id <= entryId) return; + if (!(await this._undoOne())) return; + } + }); + } + + /** Replaces the stack with entries restored after a reload (already applied). */ + restore( + entries: readonly { command: HistoryCommand; origin: CommandOrigin; time: number }[], + ): Promise { + return this._serial(async () => { + this._drop([...this._past, ...this._future]); + const local = this._options.localOrigin(); + this._past = entries.map((entry) => ({ + id: this._nextId++, + command: entry.command, + origin: entry.origin, + undoable: sameOrigin(entry.origin, local), + time: entry.time, + })); + this._future = []; + this._savedAt = null; + this._trim(); + this._changed(); + }); + } + + /** Marks the current state as saved (dirty tracking). */ + markSaved(): void { + this._savedAt = this._past.at(-1)?.id ?? SAVED_AT_START; + this._changed(); + } + + /** Drops every step: the state they relied on changed. */ + invalidate(reason: InvalidationReason = 'manual'): Promise { + return this._serial(async () => this._invalidate(reason)); + } + + clear(): Promise { + return this._serial(async () => { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._savedAt = SAVED_AT_START; + this._changed(); + }); + } + + /** Re-applies limits (after the limit settings changed). */ + trim(): Promise { + return this._serial(async () => { + this._trim(); + this._changed(); + }); + } + + dispose(): void { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._onDidChange.dispose(); + this._onDidInvalidate.dispose(); + } + + private async _undoOne(): Promise { + const index = this._past.findLastIndex((entry) => entry.undoable); + if (index < 0) return false; + const entry = this._past[index]!; + if ((await entry.command.isValid?.()) === false) { + this._invalidate('invalid-command'); + return false; + } + await entry.command.undo(); + this._past.splice(index, 1); + this._future.push(entry); + this._changed(); + return true; + } + + private async _redoOne(): Promise { + const entry = this._future.at(-1); + if (!entry) return false; + if ((await entry.command.isValid?.()) === false) { + this._invalidate('invalid-command'); + return false; + } + await (entry.command.redo ?? entry.command.do).call(entry.command); + this._future.pop(); + this._past.push(entry); + this._changed(); + return true; + } + + private _add(command: HistoryCommand, local: boolean, mergeable = true): void { + const origin = command.origin ?? this._options.localOrigin(); + const undoable = local && sameOrigin(origin, this._options.localOrigin()); + const now = (this._options.now ?? Date.now)(); + if (undoable) { + this._drop(this._future); + this._future = []; + } + const previous = this._past.at(-1); + if ( + mergeable && + undoable && + previous?.undoable && + command.mergeKey !== undefined && + previous.command.mergeKey === command.mergeKey && + now - previous.time <= this._options.mergeWindowMs() && + this._savedAt !== previous.id + ) { + const merged = previous.command.merge + ? previous.command.merge(command) + : mergeCommands(previous.command, command); + if (merged) { + this._past[this._past.length - 1] = { ...previous, command: merged, time: now }; + this._trim(); + this._changed(); + return; + } + } + this._past.push({ id: this._nextId++, command, origin, undoable, time: now }); + this._trim(); + this._changed(); + } + + private _trim(): void { + const limit = Math.max(1, this._options.limit()); + const budget = this._options.budgetBytes(); + const size = () => + [...this._past, ...this._future].reduce( + (total, entry) => total + (entry.command.sizeBytes ?? 0), + 0, + ); + while (this._past.length > limit || (this._past.length > 1 && size() > budget)) { + const dropped = this._past.shift()!; + if (this._savedAt === dropped.id) this._savedAt = null; // savepoint no longer reachable + dropped.command.dispose?.(); + } + while (this._future.length && size() > budget) this._future.shift()!.command.dispose?.(); + } + + private _invalidate(reason: InvalidationReason): void { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._savedAt = null; + this._changed(); + this._onDidInvalidate.fire(reason); + } + + private _drop(entries: readonly HistoryEntry[]): void { + for (const entry of entries) entry.command.dispose?.(); + } + + private _changed(): void { + this._onDidChange.fire(this.state); + } + + private _serial(task: () => Promise): Promise { + const result = this._queue.then(task, task); + this._queue = result.catch(() => undefined); + return result; + } +} + +const runTransaction = async ( + collected: HistoryCommand[], + fn: (tx: Transaction) => Promise, +): Promise => { + const start = collected.length; + const tx: Transaction = { + push: async (command) => { + await command.do(); + collected.push(command); + }, + transaction: (_label, inner) => runTransaction(collected, inner), + }; + try { + return await fn(tx); + } catch (error) { + for (const command of collected.splice(start).reverse()) { + await command.undo(); + command.dispose?.(); + } + throw error; + } +}; diff --git a/packages/history/src/stack/history-stack.type.ts b/packages/history/src/stack/history-stack.type.ts new file mode 100644 index 00000000..07a48345 --- /dev/null +++ b/packages/history/src/stack/history-stack.type.ts @@ -0,0 +1,36 @@ +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; + +export interface HistoryEntry { + readonly id: number; + readonly command: HistoryCommand; + readonly origin: CommandOrigin; + /** False for changes of other origins: skipped by undo. */ + readonly undoable: boolean; + readonly time: number; +} + +export interface StackOptions { + /** Local user: the only origin undo/redo may target. */ + readonly localOrigin: () => CommandOrigin; + readonly limit: () => number; + /** Memory budget of the stack in bytes (sum of `sizeBytes`). */ + readonly budgetBytes: () => number; + readonly mergeWindowMs: () => number; + readonly now?: () => number; +} + +export interface StackState { + readonly past: readonly HistoryEntry[]; + readonly future: readonly HistoryEntry[]; + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly dirty: boolean; +} + +export type InvalidationReason = 'external-change' | 'invalid-command' | 'manual'; + +/** Collects commands of a transaction; `push` executes a command inside the transaction. */ +export interface Transaction { + push(command: HistoryCommand): Promise; + transaction(label: string, fn: (tx: Transaction) => Promise): Promise; +} diff --git a/packages/history/src/stack/index.ts b/packages/history/src/stack/index.ts new file mode 100644 index 00000000..2d040bf5 --- /dev/null +++ b/packages/history/src/stack/index.ts @@ -0,0 +1,2 @@ +export * from './history-stack.type'; +export * from './history-stack'; diff --git a/packages/history/test/file/apply-text-edits.spec.ts b/packages/history/test/file/apply-text-edits.spec.ts new file mode 100644 index 00000000..5780254c --- /dev/null +++ b/packages/history/test/file/apply-text-edits.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { applyTextEdits } from '../../src'; + +describe('applyTextEdits', () => { + it('applies unordered edits and returns their inverse', () => { + const source = 'const a = 1;\nconst b = 2;\n'; + const { text, inverse } = applyTextEdits(source, [ + { start: 23, end: 24, text: '20' }, + { start: 10, end: 11, text: '10' }, + ]); + expect(text).toBe('const a = 10;\nconst b = 20;\n'); + expect(applyTextEdits(text, inverse).text).toBe(source); + expect(() => + applyTextEdits(source, [ + { start: 0, end: 5, text: '' }, + { start: 3, end: 6, text: '' }, + ]), + ).toThrow(/Overlapping/); + }); +}); diff --git a/packages/history/test/file/file-history-tracker.spec.ts b/packages/history/test/file/file-history-tracker.spec.ts new file mode 100644 index 00000000..90c4be50 --- /dev/null +++ b/packages/history/test/file/file-history-tracker.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Emitter } from '@nanoforge-dev/editor-kernel'; + +import { FileHistoryTracker, type HistoryFileSystem } from '../../src'; +import { setup } from '../fixtures/history'; + +describe('FileHistoryTracker', () => { + const memoryFs = (initial: Record) => { + const files = new Map(Object.entries(initial)); + const hashOf = (text: string) => `h${text.length}:${text}`; + const changes = new Emitter<{ path: string; type: string }[]>(); + const fs: HistoryFileSystem & { + files: Map; + external(path: string, text: string): void; + } = { + files, + readText: async (path) => ({ text: files.get(path)!, hash: hashOf(files.get(path)!) }), + write: async (path, content, { expectedHash }) => { + if (hashOf(files.get(path)!) !== expectedHash) + throw Object.assign(new Error('conflict'), { code: 'CONFLICT' }); + files.set(path, content); + changes.fire([{ path, type: 'changed' }]); // echo, like the watcher + return { hash: hashOf(content) }; + }, + onDidChange: changes.event, + external(path, text) { + files.set(path, text); + changes.fire([{ path, type: 'changed' }]); + }, + }; + return fs; + }; + + it('undoes file edits and survives the echo of its own writes', async () => { + const fs = memoryFs({ 'main.ts': 'let x = 1;' }); + const { stack } = setup(); + const tracker = new FileHistoryTracker(fs, stack); + await stack.push( + tracker.edit('main.ts', [{ start: 8, end: 9, text: '42' }], { label: 'x = 42' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fs.files.get('main.ts')).toBe('let x = 42;'); + await stack.undo(); + expect(fs.files.get('main.ts')).toBe('let x = 1;'); + await stack.redo(); + expect(fs.files.get('main.ts')).toBe('let x = 42;'); + }); + + it('invalidates the context when the file is changed from outside', async () => { + const fs = memoryFs({ 'main.ts': 'let x = 1;' }); + const { history, stack } = setup(); + const invalidated = vi.fn(); + history.onDidInvalidate(invalidated); + const tracker = new FileHistoryTracker(fs, stack); + await stack.push( + tracker.edit('main.ts', [{ start: 8, end: 9, text: '2' }], { label: 'x = 2' }), + ); + fs.external('main.ts', 'let x = 2; // hand edit'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(stack.state.canUndo).toBe(false); + expect(invalidated).toHaveBeenCalledWith({ contextId: 'scene', reason: 'external-change' }); + expect(fs.files.get('main.ts')).toBe('let x = 2; // hand edit'); + }); +}); diff --git a/packages/history/test/file/project-fs.spec.ts b/packages/history/test/file/project-fs.spec.ts new file mode 100644 index 00000000..18f88139 --- /dev/null +++ b/packages/history/test/file/project-fs.spec.ts @@ -0,0 +1,62 @@ +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { MemoryContentCache, MemoryTreeCache, ProjectService } from '@nanoforge-dev/editor-project'; +import { createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { FileHistoryTracker, HistoryService } from '../../src'; + +const workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-history-')); +cpSync( + join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { + recursive: true, + }, +); +const editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', +}); +const rpc = createLoopbackClient(editor); +afterAll(() => { + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('file history over ProjectFs', () => { + it('undoes edits of a real project file, then drops history on an outside edit', async () => { + const project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ path: 'pong' }); + const path = 'apps/client/src/main.ts'; + const history = new HistoryService(); + const { stack } = history.registerContext({ id: `file:${path}`, label: path }); + const tracker = new FileHistoryTracker(project.fs, stack); + + const original = (await project.fs.readText(path)).text; + await stack.push( + tracker.edit(path, [{ start: 0, end: 0, text: '// edited\n' }], { label: 'comment' }), + ); + await new Promise((resolve) => setTimeout(resolve, 300)); // watcher echo + expect(stack.state.canUndo).toBe(true); + await stack.undo(); + expect((await project.fs.readText(path)).text).toBe(original); + + await stack.redo(); + await new Promise((resolve) => setTimeout(resolve, 300)); + writeFileSync(join(workspace, 'pong', path), '// replaced by git pull\n'); + const start = Date.now(); + while (stack.state.canUndo && Date.now() - start < 2000) + await new Promise((r) => setTimeout(r, 10)); + expect(stack.state.canUndo).toBe(false); + }); +}); diff --git a/packages/history/test/fixtures/history.ts b/packages/history/test/fixtures/history.ts new file mode 100644 index 00000000..7533a6bf --- /dev/null +++ b/packages/history/test/fixtures/history.ts @@ -0,0 +1,34 @@ +import { ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { type HistoryCommand, HistoryService } from '../../src'; + +/** A value with undoable "set" commands. */ +export const cell = (initial: number) => { + const state = { value: initial }; + const set = (value: number, extra: Partial = {}): HistoryCommand => { + let previous = 0; + return { + label: `set ${value}`, + do: () => { + previous = state.value; + state.value = value; + }, + undo: () => { + state.value = previous; + }, + ...extra, + }; + }; + return { state, set }; +}; + +export const setup = (options: { limit?: number; now?: () => number; budgetBytes?: number } = {}) => { + const limit = new ObservableValue(options.limit ?? 100); + const history = new HistoryService({ + limit, + mergeWindowMs: new ObservableValue(500), + ...(options.now && { now: options.now }), + ...(options.budgetBytes !== undefined && { budgetBytes: options.budgetBytes }), + }); + const context = history.registerContext({ id: 'scene', label: 'Scene' }); + return { history, context, stack: context.stack, limit }; +}; diff --git a/packages/history/test/service/history-service.spec.ts b/packages/history/test/service/history-service.spec.ts new file mode 100644 index 00000000..24932c04 --- /dev/null +++ b/packages/history/test/service/history-service.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type HistoryCommand, HistoryService } from '../../src'; +import { cell, setup } from '../fixtures/history'; + +describe('history panel support', () => { + it('jumps to any step, backward and forward', async () => { + const { context, history } = setup(); + const { state, set } = cell(0); + for (const value of [1, 2, 3, 4]) await context.stack.push(set(value)); + const ids = context.stack.state.past.map((entry) => entry.id); + + await history.goTo('scene', ids[1]!); + expect(state.value).toBe(2); + expect(context.stack.state.future).toHaveLength(2); + + await history.goTo('scene', ids[3]!); + expect(state.value).toBe(4); + await history.goTo('scene', 0); + expect(state.value).toBe(0); + + await history.clear('scene'); + expect(context.stack.state.past).toHaveLength(0); + expect(context.stack.state.future).toHaveLength(0); + }); + + it('exposes the steps of transactions', async () => { + const { context } = setup(); + const { set } = cell(0); + await context.stack.transaction('Drag', async (tx) => { + await tx.push(set(1)); + await tx.push(set(2)); + }); + expect(context.stack.state.past[0]!.command.children?.map((child) => child.label)).toEqual([ + 'set 1', + 'set 2', + ]); + }); + + it('keeps serializable histories of matching contexts across reloads', async () => { + const saved = new Map(); + const store = { + load: async (id: string) => saved.get(id) as never, + save: async (id: string, entries: unknown) => void saved.set(id, entries), + }; + const values = { current: 0 }; + const command = (value: number, previous: number): HistoryCommand => ({ + label: `set ${value}`, + do: () => void (values.current = value), + undo: () => void (values.current = previous), + serialize: () => ({ type: 'set', label: `set ${value}`, data: { value, previous } }), + }); + + const first = new HistoryService(); + first.persist((id) => id.startsWith('file:'), store); + const context = first.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + await context.stack.push(command(1, 0)); + await context.stack.push(command(2, 1)); + await vi.waitFor(() => expect(saved.get('file:a.ts')).toHaveLength(2)); + first.dispose(); + + const second = new HistoryService(); + second.registerDeserializer('set', (data) => { + const { value, previous } = data as { value: number; previous: number }; + return command(value, previous); + }); + second.persist((id) => id.startsWith('file:'), store); + const restored = second.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + await vi.waitFor(() => expect(restored.stack.state.past).toHaveLength(2)); + await restored.stack.undo(); + expect(values.current).toBe(1); + const other = second.registerContext({ id: 'layout', label: 'Layout' }); + await other.stack.push(command(9, 0)); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(saved.has('layout')).toBe(false); + }); +}); diff --git a/packages/history/test/service/undo-target.spec.ts b/packages/history/test/service/undo-target.spec.ts new file mode 100644 index 00000000..061d0ee4 --- /dev/null +++ b/packages/history/test/service/undo-target.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { CommandRegistry, Container, ContextKeyService } from '@nanoforge-dev/editor-kernel'; + +import { + HISTORY_CONTEXT_KEY, + HistoryService, + TEXT_INPUT_FOCUS_KEY, + registerHistoryCommands, +} from '../../src'; +import { cell } from '../fixtures/history'; + +describe('undo routing', () => { + it('targets the focused context and leaves text inputs to the browser', async () => { + const history = new HistoryService(); + const scene = history.registerContext({ id: 'scene', label: 'Scene' }).stack; + const layout = history.registerContext({ id: 'layout', label: 'Layout' }).stack; + const a = cell(0); + const b = cell(0); + await scene.push(a.set(1)); + await layout.push(b.set(1)); + + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + registerHistoryCommands(commands, context, history); + + context.set(HISTORY_CONTEXT_KEY, 'layout'); + expect(await commands.execute('history.undo')).toBe(true); + expect([a.state.value, b.state.value]).toEqual([1, 0]); + + context.set(TEXT_INPUT_FOCUS_KEY, true); + expect(await commands.execute('history.undo')).toBe(false); // native undo + context.set(TEXT_INPUT_FOCUS_KEY, false); + context.set(HISTORY_CONTEXT_KEY, 'scene'); + await commands.execute('history.undo'); + expect(a.state.value).toBe(0); + context.delete(HISTORY_CONTEXT_KEY); + expect(await commands.execute('history.redo')).toBe(false); + }); + + it('falls back to a default context when the focused widget has none', async () => { + const history = new HistoryService(); + const layout = history.registerContext({ id: 'layout', label: 'Layout' }).stack; + const value = cell(0); + await layout.push(value.set(3)); + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + registerHistoryCommands(commands, context, history, { fallbackContext: 'layout' }); + await commands.execute('history.undo'); + expect(value.state.value).toBe(0); + }); +}); diff --git a/packages/history/test/stack/history-stack.spec.ts b/packages/history/test/stack/history-stack.spec.ts new file mode 100644 index 00000000..7797585a --- /dev/null +++ b/packages/history/test/stack/history-stack.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { HistoryCommand } from '../../src'; +import { cell, setup } from '../fixtures/history'; + +describe('HistoryStack', () => { + it('undoes and redoes, and a new change drops the redo steps', async () => { + const { stack } = setup(); + const { state, set } = cell(0); + await stack.push(set(1)); + await stack.push(set(2)); + await stack.undo(); + expect(state.value).toBe(1); + await stack.redo(); + expect(state.value).toBe(2); + await stack.undo(); + await stack.push(set(5)); + expect(stack.state.canRedo).toBe(false); + await stack.undo(); + expect(state.value).toBe(1); + }); + + it('trims to the limit, live when the setting changes', async () => { + const { stack, limit } = setup({ limit: 3 }); + const { state, set } = cell(0); + const disposed = vi.fn(); + for (let i = 1; i <= 5; i++) await stack.push(set(i, { dispose: disposed })); + expect(stack.state.past).toHaveLength(3); + expect(disposed).toHaveBeenCalledTimes(2); + limit.set(1); + await stack.trim(); + expect(stack.state.past).toHaveLength(1); + while (await stack.undo()); + expect(state.value).toBe(4); + }); + + it('trims to the memory budget', async () => { + const { stack } = setup({ budgetBytes: 1000 }); + const { set } = cell(0); + for (let i = 1; i <= 5; i++) await stack.push(set(i, { sizeBytes: 400 })); + expect(stack.state.past.map((entry) => entry.command.label)).toEqual(['set 4', 'set 5']); + }); + + it('merges same-key changes within the merge window only', async () => { + let now = 0; + const { stack } = setup({ now: () => now }); + const { state, set } = cell(0); + await stack.push(set(1, { mergeKey: 'x' })); + now = 300; + await stack.push(set(2, { mergeKey: 'x' })); + now = 700; + await stack.push(set(3, { mergeKey: 'x' })); + now = 1500; + await stack.push(set(4, { mergeKey: 'x' })); // too late: new step + expect(stack.state.past).toHaveLength(2); + await stack.undo(); + expect(state.value).toBe(3); + await stack.undo(); + expect(state.value).toBe(0); + await stack.redo(); + expect(state.value).toBe(3); + }); + + it('groups transactions and rolls back nested failures', async () => { + const { stack } = setup(); + const a = cell(0); + const b = cell(0); + await stack.transaction('move', async (tx) => { + await tx.push(a.set(1)); + await tx.transaction('inner', async (inner) => inner.push(b.set(1))); + }); + expect(stack.state.past).toHaveLength(1); + await expect( + stack.transaction('broken', async (tx) => { + await tx.push(a.set(2)); + await tx.transaction('inner', async (inner) => { + await inner.push(b.set(2)); + throw new Error('boom'); + }); + }), + ).rejects.toThrow('boom'); + expect([a.state.value, b.state.value]).toEqual([1, 1]); + expect(stack.state.past).toHaveLength(1); + await stack.undo(); + expect([a.state.value, b.state.value]).toEqual([0, 0]); + }); + + it('runs async operations in order', async () => { + const { stack } = setup(); + const log: string[] = []; + const slow = (name: string, ms: number): HistoryCommand => ({ + label: name, + do: () => + new Promise((resolve) => setTimeout(() => (log.push(`do ${name}`), resolve()), ms)), + undo: () => void log.push(`undo ${name}`), + }); + await Promise.all([ + stack.push(slow('a', 30)), + stack.push(slow('b', 0)), + stack.undo(), + stack.redo(), + ]); + expect(log).toEqual(['do a', 'do b', 'undo b', 'do b']); + }); + + it('never undoes changes of other origins', async () => { + const { stack } = setup(); + const mine = cell(0); + const theirs = cell(0); + await stack.push(mine.set(1)); + theirs.set(7).do(); + await stack.record({ ...theirs.set(7), origin: { kind: 'remote', id: 'bob' } }); + expect(stack.state.past.map((entry) => entry.undoable)).toEqual([true, false]); + await stack.undo(); + expect([mine.state.value, theirs.state.value]).toEqual([0, 7]); + expect(stack.state.canUndo).toBe(false); + }); + + it('tracks the savepoint for dirty state', async () => { + const { stack } = setup(); + const { set } = cell(0); + expect(stack.state.dirty).toBe(false); + await stack.push(set(1)); + stack.markSaved(); + expect(stack.state.dirty).toBe(false); + await stack.push(set(2)); + expect(stack.state.dirty).toBe(true); + await stack.undo(); + expect(stack.state.dirty).toBe(false); + }); + + it('drops the history when a command is no longer valid', async () => { + const { history, stack } = setup(); + const invalidated = vi.fn(); + history.onDidInvalidate(invalidated); + const { state, set } = cell(0); + await stack.push(set(1, { isValid: () => false })); + expect(await stack.undo()).toBe(false); + expect(state.value).toBe(1); + expect(stack.state.past).toHaveLength(0); + expect(invalidated).toHaveBeenCalledWith({ contextId: 'scene', reason: 'invalid-command' }); + }); +}); diff --git a/packages/history/tsconfig.json b/packages/history/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/history/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/kernel/.prettierignore b/packages/kernel/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/kernel/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/kernel/LICENSE b/packages/kernel/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/kernel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/kernel/README.md b/packages/kernel/README.md new file mode 100644 index 00000000..6de73bba --- /dev/null +++ b/packages/kernel/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-kernel` is an internal package of the NanoForge Editor. + +Plugin kernel: DI, disposables, events, extension points, commands, context keys, plugin host. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/kernel +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/kernel/package.json b/packages/kernel/package.json new file mode 100644 index 00000000..85268f47 --- /dev/null +++ b/packages/kernel/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-kernel", + "version": "0.0.0", + "description": "NanoForge Editor - Plugin kernel: DI, disposables, events, extension points, commands, context keys, plugin host", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/kernel" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "generate:schema": "bun ./scripts/generate-schema.ts" + }, + "dependencies": { + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/kernel/schemas/plugin.schema.json b/packages/kernel/schemas/plugin.schema.json new file mode 100644 index 00000000..95e5a8a2 --- /dev/null +++ b/packages/kernel/schemas/plugin.schema.json @@ -0,0 +1,222 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "type": "string", + "const": "plugin" + }, + "name": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "version": { + "type": "string" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "tags": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string", + "minLength": 1 + }, + "license": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "engines": { + "type": "object", + "properties": { + "editor": { + "type": "string" + } + }, + "required": ["editor"] + }, + "dependencies": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "optionalDependencies": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "engineLibs": { + "default": { + "required": {}, + "optional": {} + }, + "type": "object", + "properties": { + "required": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string" + } + }, + "optional": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string" + } + } + } + }, + "entry": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "server": { + "type": "string", + "minLength": 1 + }, + "worker": { + "type": "string", + "minLength": 1 + } + } + }, + "activation": { + "default": ["onStartup"], + "type": "array", + "items": { + "type": "string", + "pattern": "^(onStartup|on(Command|Screen|Widget|ProjectType|Language|Uri):.+)$" + } + }, + "contributes": { + "default": { + "commands": [] + }, + "type": "object", + "properties": { + "commands": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "when": { + "type": "string" + }, + "palette": { + "type": "boolean" + } + }, + "required": ["id", "title"] + } + }, + "itemOwner": { + "type": "object", + "properties": { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "tags": { + "default": [], + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][A-Za-z0-9]*$" + } + } + }, + "required": ["schema"] + } + }, + "additionalProperties": {} + }, + "build": { + "default": {}, + "type": "object", + "properties": { + "svelte": { + "type": "string" + }, + "sdk": { + "type": "string" + } + } + } + }, + "required": ["type", "name", "version", "engines", "entry"] +} diff --git a/packages/kernel/scripts/generate-schema.ts b/packages/kernel/scripts/generate-schema.ts new file mode 100644 index 00000000..732aab8c --- /dev/null +++ b/packages/kernel/scripts/generate-schema.ts @@ -0,0 +1,7 @@ +import { writeFileSync } from 'node:fs'; + +import { pluginManifestJsonSchema } from '../src/plugin/plugin-manifest'; + +const target = new URL('../schemas/plugin.schema.json', import.meta.url); +writeFileSync(target, `${JSON.stringify(pluginManifestJsonSchema(), null, 2)}\n`); +console.log(`Wrote ${target.pathname}`); diff --git a/packages/kernel/src/command/command-not-found.exception.ts b/packages/kernel/src/command/command-not-found.exception.ts new file mode 100644 index 00000000..0b856756 --- /dev/null +++ b/packages/kernel/src/command/command-not-found.exception.ts @@ -0,0 +1,6 @@ +export class CommandNotFoundError extends Error { + constructor(readonly id: string) { + super(`Command "${id}" not found`); + this.name = 'CommandNotFoundError'; + } +} diff --git a/packages/kernel/src/command/command-precondition.exception.ts b/packages/kernel/src/command/command-precondition.exception.ts new file mode 100644 index 00000000..7e54898a --- /dev/null +++ b/packages/kernel/src/command/command-precondition.exception.ts @@ -0,0 +1,9 @@ +export class CommandPreconditionError extends Error { + constructor( + readonly id: string, + readonly when: string, + ) { + super(`Command "${id}" is not enabled (${when})`); + this.name = 'CommandPreconditionError'; + } +} diff --git a/packages/kernel/src/command/command-registry.ts b/packages/kernel/src/command/command-registry.ts new file mode 100644 index 00000000..d772a356 --- /dev/null +++ b/packages/kernel/src/command/command-registry.ts @@ -0,0 +1,83 @@ +import type { ContextKeyService } from '../context/context-key-service'; +import { parseWhen } from '../context/parse-when'; +import type { ServiceAccessor } from '../di/container.type'; +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { CommandNotFoundError } from './command-not-found.exception'; +import { CommandPreconditionError } from './command-precondition.exception'; +import type { CommandActivator, CommandDescriptor, CommandExecution } from './command.type'; + +export class CommandRegistry implements Disposable { + private readonly _commands = new Map(); + private readonly _onDidRegister = new Emitter(); + private readonly _onDidExecute = new Emitter(); + private _activator: CommandActivator | undefined; + + readonly onDidRegister: Event = this._onDidRegister.event; + readonly onDidExecute: Event = this._onDidExecute.event; + + constructor( + private readonly _services: ServiceAccessor, + private readonly _context?: ContextKeyService, + ) {} + + /** Hook called before running an unknown command, so its plugin can activate. */ + setActivator(activator: CommandActivator | undefined): void { + this._activator = activator; + } + + register(descriptor: CommandDescriptor): Disposable { + if (this._commands.has(descriptor.id)) { + throw new Error(`Command "${descriptor.id}" is already registered`); + } + if (descriptor.when) parseWhen(descriptor.when); // fail fast on invalid clauses + this._commands.set(descriptor.id, descriptor as unknown as CommandDescriptor); + this._onDidRegister.fire(descriptor.id); + return toDisposable(() => { + if (this._commands.get(descriptor.id) === (descriptor as unknown)) { + this._commands.delete(descriptor.id); + } + }); + } + + has(id: string): boolean { + return this._commands.has(id); + } + + get(id: string): CommandDescriptor | undefined { + return this._commands.get(id); + } + + getAll(): CommandDescriptor[] { + return [...this._commands.values()]; + } + + isEnabled(id: string): boolean { + const command = this._commands.get(id); + return !!command && (this._context?.evaluate(command.when) ?? true); + } + + async execute(id: string, ...args: unknown[]): Promise { + if (!this._commands.has(id) && this._activator) await this._activator(id); + const command = this._commands.get(id); + if (!command) throw new CommandNotFoundError(id); + if (command.when && this._context && !this._context.evaluate(command.when)) { + throw new CommandPreconditionError(id, command.when); + } + const start = performance.now(); + try { + const result = (await command.handler(this._services, ...args)) as R; + this._onDidExecute.fire({ id, args, durationMs: performance.now() - start }); + return result; + } catch (error) { + this._onDidExecute.fire({ id, args, durationMs: performance.now() - start, error }); + throw error; + } + } + + dispose(): void { + this._commands.clear(); + this._onDidRegister.dispose(); + this._onDidExecute.dispose(); + } +} diff --git a/packages/kernel/src/command/command.type.ts b/packages/kernel/src/command/command.type.ts new file mode 100644 index 00000000..e83185d6 --- /dev/null +++ b/packages/kernel/src/command/command.type.ts @@ -0,0 +1,40 @@ +import type { ServiceAccessor } from '../di/container.type'; + +export type CommandHandler = ( + accessor: ServiceAccessor, + ...args: A +) => R | Promise; + +export interface CommandMetadata { + /** Human readable title (already localized, or a localization key resolved by the UI). */ + title?: string; + category?: string; + icon?: string; + /** Precondition: the command can only run when this clause is true. */ + when?: string; + /** + * `false` keeps the command out of the command palette and the keymap editor: it needs + * arguments (menu entries that give them are listed instead). + */ + palette?: boolean; +} + +export interface CommandDescriptor< + A extends unknown[] = unknown[], + R = unknown, +> extends CommandMetadata { + id: string; + handler: CommandHandler; + /** Plugin name or `core`. */ + owner?: string; +} + +export interface CommandExecution { + readonly id: string; + readonly args: readonly unknown[]; + readonly durationMs: number; + readonly error?: unknown; +} + +/** Resolves a lazily activated command (plugin activation on `onCommand:`). */ +export type CommandActivator = (id: string) => Promise; diff --git a/packages/kernel/src/command/index.ts b/packages/kernel/src/command/index.ts new file mode 100644 index 00000000..1a804a34 --- /dev/null +++ b/packages/kernel/src/command/index.ts @@ -0,0 +1,4 @@ +export * from './command.type'; +export * from './command-not-found.exception'; +export * from './command-precondition.exception'; +export * from './command-registry'; diff --git a/packages/kernel/src/context/context-key-service.ts b/packages/kernel/src/context/context-key-service.ts new file mode 100644 index 00000000..3007f9ef --- /dev/null +++ b/packages/kernel/src/context/context-key-service.ts @@ -0,0 +1,104 @@ +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { parseWhen } from './parse-when'; +import type { WhenExpression } from './when.type'; + +export interface ContextChange { + readonly keys: ReadonlySet; +} + +/** + * Hierarchical key/value context used by `when` clauses (focus, active screen, selection, + * installed engine libs…). A scope overlays its parent: its own keys shadow the parent's, and + * parent changes are forwarded for keys the scope does not shadow. + */ +export class ContextKeyService implements Disposable { + private readonly _values = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _parentSubscription: Disposable | undefined; + + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + readonly name = 'global', + private readonly _parent?: ContextKeyService, + ) { + this._parentSubscription = _parent?.onDidChange(({ keys }) => { + const visible = new Set([...keys].filter((key) => !this._values.has(key))); + if (visible.size) this._onDidChange.fire({ keys: visible }); + }); + } + + createScoped(name: string): ContextKeyService { + return new ContextKeyService(name, this); + } + + get(key: string): T | undefined { + if (this._values.has(key)) return this._values.get(key) as T; + return this._parent?.get(key); + } + + set(key: string, value: unknown): void { + if (this._values.has(key) && Object.is(this._values.get(key), value)) return; + this._values.set(key, value); + this._onDidChange.fire({ keys: new Set([key]) }); + } + + delete(key: string): void { + if (!this._values.delete(key)) return; + this._onDidChange.fire({ keys: new Set([key]) }); + } + + /** Sets a key for the lifetime of the returned disposable, then restores the previous value. */ + bind(key: string, value: unknown): Disposable { + const had = this._values.has(key); + const previous = this._values.get(key); + this.set(key, value); + return toDisposable(() => { + if (had) this.set(key, previous); + else this.delete(key); + }); + } + + evaluate(when: string | WhenExpression | undefined): boolean { + if (when === undefined) return true; + const expression = typeof when === 'string' ? parseWhen(when) : when; + return expression.evaluate((key) => this.get(key)); + } + + /** The value of a when clause, re-evaluated only when one of its keys changes. */ + observe(when: string | undefined): Observable { + if (when === undefined) { + return { + get: () => true, + subscribe: (run) => { + run(true); + return () => undefined; + }, + }; + } + const expression = parseWhen(when); + return { + get: () => this.evaluate(expression), + subscribe: (run) => { + let current = this.evaluate(expression); + run(current); + const subscription = this.onDidChange(({ keys }) => { + if (![...keys].some((key) => expression.keys.has(key))) return; + const next = this.evaluate(expression); + if (next === current) return; + current = next; + run(next); + }); + return () => subscription.dispose(); + }, + }; + } + + dispose(): void { + this._parentSubscription?.dispose(); + this._onDidChange.dispose(); + this._values.clear(); + } +} diff --git a/packages/kernel/src/context/index.ts b/packages/kernel/src/context/index.ts new file mode 100644 index 00000000..b902f88e --- /dev/null +++ b/packages/kernel/src/context/index.ts @@ -0,0 +1,4 @@ +export * from './context-key-service'; +export * from './when.type'; +export * from './when-parse.exception'; +export * from './parse-when'; diff --git a/packages/kernel/src/context/parse-when.ts b/packages/kernel/src/context/parse-when.ts new file mode 100644 index 00000000..c968f756 --- /dev/null +++ b/packages/kernel/src/context/parse-when.ts @@ -0,0 +1,134 @@ +import { WhenParseError } from './when-parse.exception'; +import { type Token, tokenize } from './when-tokenizer'; +import type { ContextReader, WhenExpression } from './when.type'; + +type Node = (read: ContextReader) => boolean; + +const includes = (container: unknown, value: unknown): boolean => { + if (Array.isArray(container)) return container.includes(value); + if (typeof container === 'string') return typeof value === 'string' && container.includes(value); + if (container && typeof container === 'object') { + return typeof value === 'string' && Object.hasOwn(container, value); + } + return false; +}; + +const parse = (source: string): { node: Node; keys: Set } => { + const tokens = tokenize(source); + const keys = new Set(); + let index = 0; + const peek = () => tokens[index]!; + const next = () => tokens[index++]!; + const fail = (token: Token, message: string): never => { + throw new WhenParseError(source, token.pos, message); + }; + const isOp = (token: Token, value: string) => token.kind === 'op' && token.value === value; + + const parseOr = (): Node => { + let left = parseAnd(); + while (isOp(peek(), '||')) { + next(); + const l = left; + const r = parseAnd(); + left = (read) => l(read) || r(read); + } + return left; + }; + + const parseAnd = (): Node => { + let left = parseUnary(); + while (isOp(peek(), '&&')) { + next(); + const l = left; + const r = parseUnary(); + left = (read) => l(read) && r(read); + } + return left; + }; + + const parseUnary = (): Node => { + if (isOp(peek(), '!')) { + next(); + const operand = parseUnary(); + return (read) => !operand(read); + } + return parsePrimary(); + }; + + const parseValue = (): unknown => { + const token = next(); + switch (token.kind) { + case 'string': + case 'number': + case 'bool': + return token.value; + case 'key': + return token.value; + default: + return fail(token, 'expected a value'); + } + }; + + const parsePrimary = (): Node => { + const token = next(); + if (isOp(token, '(')) { + const inner = parseOr(); + if (!isOp(next(), ')')) fail(tokens[index - 1]!, 'expected ")"'); + return inner; + } + if (token.kind === 'bool') { + const value = token.value; + return () => value; + } + if (token.kind !== 'key') return fail(token, 'expected a context key'); + + const key = token.value; + keys.add(key); + const operator = peek(); + if (isOp(operator, '==') || isOp(operator, '!=')) { + next(); + const expected = parseValue(); + const negate = operator.kind === 'op' && operator.value === '!='; + return (read) => { + const actual = read(key); + const equal = actual === expected || String(actual) === String(expected); + return negate ? !equal : equal; + }; + } + if (isOp(operator, '=~')) { + next(); + const regex = next(); + if (regex.kind !== 'regex') return fail(regex, 'expected a regular expression'); + const pattern = regex.value; + return (read) => { + const actual = read(key); + return typeof actual === 'string' && pattern.test(actual); + }; + } + if (isOp(operator, 'in')) { + next(); + const container = next(); + if (container.kind !== 'key') return fail(container, 'expected a context key after "in"'); + keys.add(container.value); + return (read) => includes(read(container.value), read(key)); + } + return (read) => Boolean(read(key)); + }; + + const node = parseOr(); + const end = peek(); + if (end.kind !== 'end') fail(end, 'unexpected token'); + return { node, keys }; +}; + +const cache = new Map(); + +/** Parses (and caches) a when clause. Throws `WhenParseError` on invalid syntax. */ +export const parseWhen = (source: string): WhenExpression => { + const cached = cache.get(source); + if (cached) return cached; + const { node, keys } = parse(source); + const expression: WhenExpression = { source, keys, evaluate: node }; + cache.set(source, expression); + return expression; +}; diff --git a/packages/kernel/src/context/when-parse.exception.ts b/packages/kernel/src/context/when-parse.exception.ts new file mode 100644 index 00000000..55fa9ccf --- /dev/null +++ b/packages/kernel/src/context/when-parse.exception.ts @@ -0,0 +1,10 @@ +export class WhenParseError extends Error { + constructor( + readonly source: string, + readonly position: number, + message: string, + ) { + super(`Invalid when clause "${source}" at ${position}: ${message}`); + this.name = 'WhenParseError'; + } +} diff --git a/packages/kernel/src/context/when-tokenizer.ts b/packages/kernel/src/context/when-tokenizer.ts new file mode 100644 index 00000000..bb1a52c8 --- /dev/null +++ b/packages/kernel/src/context/when-tokenizer.ts @@ -0,0 +1,83 @@ +import { WhenParseError } from './when-parse.exception'; + +export type Token = + | { kind: 'op'; value: '(' | ')' | '!' | '&&' | '||' | '==' | '!=' | '=~' | 'in'; pos: number } + | { kind: 'key'; value: string; pos: number } + | { kind: 'string'; value: string; pos: number } + | { kind: 'number'; value: number; pos: number } + | { kind: 'bool'; value: boolean; pos: number } + | { kind: 'regex'; value: RegExp; pos: number } + | { kind: 'end'; pos: number }; + +const KEY_START = /[A-Za-z_@$]/; +const KEY_CHAR = /[\w.:@/$-]/; + +export const tokenize = (source: string): Token[] => { + const tokens: Token[] = []; + const fail = (pos: number, message: string): never => { + throw new WhenParseError(source, pos, message); + }; + let i = 0; + while (i < source.length) { + const char = source[i]!; + const two = source.slice(i, i + 2); + if (/\s/.test(char)) { + i++; + } else if (two === '&&' || two === '||' || two === '==' || two === '!=') { + tokens.push({ kind: 'op', value: two, pos: i }); + i += 2; + } else if (two === '=~') { + tokens.push({ kind: 'op', value: '=~', pos: i }); + i += 2; + while (/\s/.test(source[i] ?? '')) i++; + if (source[i] !== '/') fail(i, 'expected a regular expression after =~'); + const start = i++; + let pattern = ''; + while (i < source.length && source[i] !== '/') { + if (source[i] === '\\') pattern += source[i++]; + pattern += source[i++] ?? ''; + } + if (source[i] !== '/') fail(start, 'unterminated regular expression'); + i++; + let flags = ''; + while (/[a-z]/.test(source[i] ?? '')) flags += source[i++]; + try { + tokens.push({ kind: 'regex', value: new RegExp(pattern, flags), pos: start }); + } catch (error) { + fail(start, String(error)); + } + } else if (char === '(' || char === ')' || char === '!') { + tokens.push({ kind: 'op', value: char, pos: i++ }); + } else if (char === "'" || char === '"') { + const start = i++; + let value = ''; + while (i < source.length && source[i] !== char) { + if (source[i] === '\\') i++; + value += source[i++] ?? ''; + } + if (source[i] !== char) fail(start, 'unterminated string'); + i++; + tokens.push({ kind: 'string', value, pos: start }); + } else if (/[\d-]/.test(char)) { + const match = /^-?\d+(\.\d+)?/.exec(source.slice(i)); + if (!match) fail(i, `unexpected "${char}"`); + tokens.push({ kind: 'number', value: Number(match![0]), pos: i }); + i += match![0].length; + } else if (KEY_START.test(char)) { + const start = i; + while (i < source.length && KEY_CHAR.test(source[i]!)) i++; + const word = source.slice(start, i); + if (word === 'true' || word === 'false') { + tokens.push({ kind: 'bool', value: word === 'true', pos: start }); + } else if (word === 'in') { + tokens.push({ kind: 'op', value: 'in', pos: start }); + } else { + tokens.push({ kind: 'key', value: word, pos: start }); + } + } else { + fail(i, `unexpected "${char}"`); + } + } + tokens.push({ kind: 'end', pos: source.length }); + return tokens; +}; diff --git a/packages/kernel/src/context/when.type.ts b/packages/kernel/src/context/when.type.ts new file mode 100644 index 00000000..f8cd84a1 --- /dev/null +++ b/packages/kernel/src/context/when.type.ts @@ -0,0 +1,20 @@ +/** + * `when` clause expressions, e.g. + * `editorFocus && screen == 'scene' && !engineLib:@nanoforge-dev/ecs`, + * `resourceName =~ /\.component\.ts$/`, `selection in selectableKinds`. + * + * Grammar: or := and ('||' and)* ; and := unary ('&&' unary)* ; unary := '!' unary | primary ; + * primary := '(' or ')' | operand (('=='|'!=') value | '=~' regex | 'in' key)? + * A bare word on the right of `==`/`!=` is a string, as in VS Code. + */ +export type ContextValue = + string | number | boolean | null | undefined | readonly unknown[] | object; + +export type ContextReader = (key: string) => unknown; + +export interface WhenExpression { + readonly source: string; + /** Keys read by the expression (to re-evaluate only on relevant changes). */ + readonly keys: ReadonlySet; + evaluate(read: ContextReader): boolean; +} diff --git a/packages/kernel/src/di/container.ts b/packages/kernel/src/di/container.ts new file mode 100644 index 00000000..523c4d0b --- /dev/null +++ b/packages/kernel/src/di/container.ts @@ -0,0 +1,271 @@ +import { Emitter, type Event } from '../event/emitter'; +import { + type Disposable, + DisposableStore, + isDisposable, + toDisposable, +} from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import type { + ProvideOptions, + ServiceAccessor, + ServiceDecorator, + ServiceFactory, +} from './container.type'; +import { CyclicDependencyError } from './cyclic-dependency.exception'; +import { ServiceNotFoundError } from './service-not-found.exception'; +import type { ServiceToken } from './service-token'; + +interface Provider { + readonly seq: number; + readonly priority: number; + readonly factory: ServiceFactory | undefined; + value: T | undefined; + created: boolean; +} + +interface Override { + readonly seq: number; + readonly priority: number; + readonly decorate: ServiceDecorator; +} + +let sequence = 0; +/** Tokens being resolved, across containers (resolution is synchronous). */ +const resolving: string[] = []; + +const byPriority = (a: { priority: number; seq: number }, b: { priority: number; seq: number }) => + a.priority - b.priority || a.seq - b.seq; + +/** + * Hierarchical service container. A child container resolves its own providers first and falls + * back to its parent; overrides registered anywhere in the chain decorate the resolved service, + * from the root down to the requesting container. + */ +export class Container implements ServiceAccessor, Disposable { + private readonly _providers = new Map[]>(); + private readonly _overrides = new Map[]>(); + private readonly _cache = new Map(); + private readonly _children = new Set(); + /** Provider and override handles, disposed with the container. */ + private readonly _registrations = new DisposableStore(); + private readonly _onDidChange = new Emitter>(); + private readonly _parentSubscription: Disposable | undefined; + private _disposed = false; + + /** Fires when the resolution of a token may have changed (here or in an ancestor). */ + readonly onDidChange: Event> = this._onDidChange.event; + + constructor( + readonly name = 'root', + private readonly _parent?: Container, + ) { + this._parentSubscription = _parent?.onDidChange((token) => this._changed(token)); + } + + get parent(): Container | undefined { + return this._parent; + } + + createChild(name: string): Container { + this._assertAlive(); + const child = new Container(name, this); + this._children.add(child); + return child; + } + + provide(token: ServiceToken, value: T, options: ProvideOptions = {}): Disposable { + return this._addProvider(token, { + seq: sequence++, + priority: options.priority ?? 0, + factory: undefined, + value, + created: false, + }); + } + + /** Registers a lazy singleton, created on first resolution with this container as accessor. */ + provideFactory( + token: ServiceToken, + factory: ServiceFactory, + options: ProvideOptions = {}, + ): Disposable { + return this._addProvider(token, { + seq: sequence++, + priority: options.priority ?? 0, + factory, + value: undefined, + created: false, + }); + } + + /** Decorates the resolved service; lower priorities wrap first (closest to the base). */ + override( + token: ServiceToken, + decorate: ServiceDecorator, + options: ProvideOptions = {}, + ): Disposable { + this._assertAlive(); + const entry: Override = { + seq: sequence++, + priority: options.priority ?? 0, + decorate: decorate as ServiceDecorator, + }; + const list = this._overrides.get(token.id) ?? []; + list.push(entry); + list.sort(byPriority); + this._overrides.set(token.id, list); + this._changed(token); + return this._track(() => { + const current = this._overrides.get(token.id); + if (!current) return; + current.splice(current.indexOf(entry), 1); + if (!current.length) this._overrides.delete(token.id); + this._changed(token); + }); + } + + has(token: ServiceToken): boolean { + return this._findProvider(token.id) !== undefined; + } + + get(token: ServiceToken): T { + this._assertAlive(); + if (this._cache.has(token.id)) return this._cache.get(token.id) as T; + if (resolving.includes(token.id)) throw new CyclicDependencyError([...resolving, token.id]); + resolving.push(token.id); + try { + const found = this._findProvider(token.id); + if (!found) throw new ServiceNotFoundError(token); + let value = found.owner._instantiate(found.provider) as T; + for (const entry of this._collectOverrides(token.id)) { + value = (entry.decorate as ServiceDecorator)(value, this); + } + this._cache.set(token.id, value); + return value; + } finally { + resolving.pop(); + } + } + + tryGet(token: ServiceToken): T | undefined { + return this.has(token) ? this.get(token) : undefined; + } + + /** Every provider's instance for the token, along the chain, lowest priority first. */ + getAll(token: ServiceToken): T[] { + this._assertAlive(); + const entries: { provider: Provider; owner: Container }[] = []; + for (const container of this._lineage()) { + for (const provider of container._providers.get(token.id) ?? []) { + entries.push({ provider, owner: container }); + } + } + return entries + .sort((a, b) => byPriority(a.provider, b.provider)) + .map(({ provider, owner }) => owner._instantiate(provider) as T); + } + + /** The current resolution of a token, updated when providers or overrides change. */ + observe(token: ServiceToken): Observable { + return { + get: () => this.tryGet(token), + subscribe: (run) => { + let current = this.tryGet(token); + run(current); + const subscription = this.onDidChange((changed) => { + if (changed.id !== token.id) return; + const next = this.tryGet(token); + if (next === current) return; + current = next; + run(next); + }); + return () => subscription.dispose(); + }, + }; + } + + dispose(): void { + if (this._disposed) return; + for (const child of [...this._children]) child.dispose(); + this._disposed = true; + this._parent?._children.delete(this); + this._parentSubscription?.dispose(); + this._registrations.dispose(); + this._providers.clear(); + this._overrides.clear(); + this._cache.clear(); + this._onDidChange.dispose(); + } + + private _addProvider(token: ServiceToken, provider: Provider): Disposable { + this._assertAlive(); + const list = this._providers.get(token.id) ?? []; + list.push(provider); + list.sort(byPriority); + this._providers.set(token.id, list); + this._changed(token); + return this._track(() => { + const current = this._providers.get(token.id); + if (!current || !current.includes(provider)) return; + current.splice(current.indexOf(provider), 1); + if (!current.length) this._providers.delete(token.id); + this._disposeInstance(provider); + this._changed(token); + }); + } + + private _findProvider(id: string): { provider: Provider; owner: Container } | undefined { + for (const container of this._lineage()) { + const providers = container._providers.get(id); + if (providers?.length) return { provider: providers.at(-1)!, owner: container }; + } + return undefined; + } + + private _collectOverrides(id: string): Override[] { + return this._lineage() + .reverse() + .flatMap((container) => container._overrides.get(id) ?? []); + } + + private _track(remove: () => void): Disposable { + const handle = toDisposable(() => { + this._registrations.delete(handle); + remove(); + }); + return this._registrations.add(handle); + } + + /** This container followed by its ancestors, up to the root. */ + private _lineage(): Container[] { + const lineage: Container[] = [this]; + for (let parent = this._parent; parent; parent = parent._parent) lineage.push(parent); + return lineage; + } + + private _instantiate(provider: Provider): unknown { + if (!provider.factory || provider.created) return provider.value; + provider.value = provider.factory(this); + provider.created = true; + return provider.value; + } + + private _disposeInstance(provider: Provider): void { + if (provider.factory && provider.created && isDisposable(provider.value)) { + provider.value.dispose(); + } + provider.created = false; + provider.value = undefined; + } + + private _changed(token: ServiceToken): void { + if (this._disposed) return; + this._cache.delete(token.id); + this._onDidChange.fire(token); + } + + private _assertAlive(): void { + if (this._disposed) throw new Error(`Container "${this.name}" is disposed`); + } +} diff --git a/packages/kernel/src/di/container.type.ts b/packages/kernel/src/di/container.type.ts new file mode 100644 index 00000000..ee6836c5 --- /dev/null +++ b/packages/kernel/src/di/container.type.ts @@ -0,0 +1,15 @@ +import type { ServiceToken } from './service-token'; + +export interface ServiceAccessor { + get(token: ServiceToken): T; + tryGet(token: ServiceToken): T | undefined; + getAll(token: ServiceToken): T[]; +} + +export type ServiceFactory = (accessor: ServiceAccessor) => T; +export type ServiceDecorator = (inner: T, accessor: ServiceAccessor) => T; + +export interface ProvideOptions { + /** Highest priority wins; on ties the latest registration wins. Default 0. */ + priority?: number; +} diff --git a/packages/kernel/src/di/cyclic-dependency.exception.ts b/packages/kernel/src/di/cyclic-dependency.exception.ts new file mode 100644 index 00000000..3bbd160c --- /dev/null +++ b/packages/kernel/src/di/cyclic-dependency.exception.ts @@ -0,0 +1,6 @@ +export class CyclicDependencyError extends Error { + constructor(readonly path: string[]) { + super(`Cyclic service dependency: ${path.join(' -> ')}`); + this.name = 'CyclicDependencyError'; + } +} diff --git a/packages/kernel/src/di/index.ts b/packages/kernel/src/di/index.ts new file mode 100644 index 00000000..7e7f1448 --- /dev/null +++ b/packages/kernel/src/di/index.ts @@ -0,0 +1,5 @@ +export * from './service-token'; +export * from './container.type'; +export * from './service-not-found.exception'; +export * from './cyclic-dependency.exception'; +export * from './container'; diff --git a/packages/kernel/src/di/service-not-found.exception.ts b/packages/kernel/src/di/service-not-found.exception.ts new file mode 100644 index 00000000..0552e0c7 --- /dev/null +++ b/packages/kernel/src/di/service-not-found.exception.ts @@ -0,0 +1,8 @@ +import type { ServiceToken } from './service-token'; + +export class ServiceNotFoundError extends Error { + constructor(readonly token: ServiceToken) { + super(`No provider for service "${token.id}"`); + this.name = 'ServiceNotFoundError'; + } +} diff --git a/packages/kernel/src/di/service-token.ts b/packages/kernel/src/di/service-token.ts new file mode 100644 index 00000000..8e699697 --- /dev/null +++ b/packages/kernel/src/di/service-token.ts @@ -0,0 +1,8 @@ +/** Typed key of a service. `T` only exists at the type level. */ +export interface ServiceToken { + readonly id: string; + /** @internal phantom type marker */ + readonly __type?: T; +} + +export const createToken = (id: string): ServiceToken => Object.freeze({ id }); diff --git a/packages/kernel/src/event/emitter.ts b/packages/kernel/src/event/emitter.ts new file mode 100644 index 00000000..fccddf59 --- /dev/null +++ b/packages/kernel/src/event/emitter.ts @@ -0,0 +1,70 @@ +import { type Disposable, type DisposableStore, toDisposable } from '../lifecycle/disposable'; + +export type Listener = (event: T) => void; + +/** Subscribes a listener; the returned disposable unsubscribes it. */ +export type Event = (listener: Listener, disposables?: DisposableStore) => Disposable; + +export type ListenerErrorHandler = (error: unknown) => void; + +let listenerErrorHandler: ListenerErrorHandler = (error) => { + console.error('[nanoforge-editor] Uncaught error in event listener', error); +}; + +/** Sets where errors thrown by listeners are reported (they never break `fire`). */ +export const setListenerErrorHandler = (handler: ListenerErrorHandler): void => { + listenerErrorHandler = handler; +}; + +export interface EmitterOptions { + /** Called when the first listener subscribes (lazy upstream subscription). */ + onFirstListenerAdd?: () => void; + /** Called when the last listener unsubscribes. */ + onLastListenerRemove?: () => void; +} + +export class Emitter implements Disposable { + private _listeners = new Set<{ fn: Listener }>(); + private _disposed = false; + private _event: Event | undefined; + + constructor(private readonly _options: EmitterOptions = {}) {} + + get event(): Event { + this._event ??= (listener, disposables) => { + if (this._disposed) return toDisposable(() => undefined); + const entry = { fn: listener }; + if (this._listeners.size === 0) this._options.onFirstListenerAdd?.(); + this._listeners.add(entry); + const subscription = toDisposable(() => { + if (!this._listeners.delete(entry)) return; + if (this._listeners.size === 0) this._options.onLastListenerRemove?.(); + }); + disposables?.add(subscription); + return subscription; + }; + return this._event; + } + + get hasListeners(): boolean { + return this._listeners.size > 0; + } + + fire(event: T): void { + for (const entry of [...this._listeners]) { + if (!this._listeners.has(entry)) continue; + try { + entry.fn(event); + } catch (error) { + listenerErrorHandler(error); + } + } + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + if (this._listeners.size) this._options.onLastListenerRemove?.(); + this._listeners.clear(); + } +} diff --git a/packages/kernel/src/event/event-bus.ts b/packages/kernel/src/event/event-bus.ts new file mode 100644 index 00000000..0306e765 --- /dev/null +++ b/packages/kernel/src/event/event-bus.ts @@ -0,0 +1,37 @@ +import type { Disposable } from '../lifecycle/disposable'; +import { Emitter, type Listener } from './emitter'; + +/** Maps event keys to payload types. */ +export type EventMap = object; + +type EventKey = keyof M & string; + +/** + * Typed publish/subscribe channel for loosely coupled editor parts. The editor-wide map is + * `EditorEvents` of the SDK, which plugins extend by module augmentation. + */ +export class EventBus> implements Disposable { + private readonly _emitters = new Map>(); + + on>(key: K, listener: Listener): Disposable { + return this._emitter(key).event(listener as Listener); + } + + emit>(key: K, payload: M[K]): void { + this._emitters.get(key)?.fire(payload); + } + + dispose(): void { + for (const emitter of this._emitters.values()) emitter.dispose(); + this._emitters.clear(); + } + + private _emitter(key: string): Emitter { + let emitter = this._emitters.get(key); + if (!emitter) { + emitter = new Emitter({ onLastListenerRemove: () => this._emitters.delete(key) }); + this._emitters.set(key, emitter); + } + return emitter; + } +} diff --git a/packages/kernel/src/event/events.ts b/packages/kernel/src/event/events.ts new file mode 100644 index 00000000..7bc7afc0 --- /dev/null +++ b/packages/kernel/src/event/events.ts @@ -0,0 +1,75 @@ +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import type { Event } from './emitter'; + +/** Combinators over `Event`. Derived events subscribe upstream lazily. */ +export const Events = { + None: ((): Disposable => toDisposable(() => undefined)) as Event, + + once(event: Event): Event { + return (listener, disposables) => { + let fired = false; + const subscription = event((value) => { + if (fired) return; + fired = true; + subscription.dispose(); + listener(value); + }, disposables); + if (fired) subscription.dispose(); + return subscription; + }; + }, + + map(event: Event, fn: (value: T) => U): Event { + return (listener, disposables) => event((value) => listener(fn(value)), disposables); + }, + + filter(event: Event, predicate: (value: T) => boolean): Event { + return (listener, disposables) => + event((value) => { + if (predicate(value)) listener(value); + }, disposables); + }, + + any(...events: Event[]): Event { + return (listener, disposables) => { + const store = new DisposableStore(); + for (const event of events) event(listener, store); + disposables?.add(store); + return store; + }; + }, + + /** Merges bursts of events: `merge` folds values until `delay` ms pass without a new one. */ + debounce( + event: Event, + merge: (last: R | undefined, value: T) => R, + delay: number, + ): Event { + return (listener, disposables) => { + let pending: R | undefined; + let timer: ReturnType | undefined; + const upstream = event((value) => { + pending = merge(pending, value); + clearTimeout(timer); + timer = setTimeout(() => { + const result = pending as R; + pending = undefined; + timer = undefined; + listener(result); + }, delay); + }); + const subscription = toDisposable(() => { + clearTimeout(timer); + upstream.dispose(); + }); + disposables?.add(subscription); + return subscription; + }; + }, + + toPromise(event: Event): Promise { + return new Promise((resolve) => { + Events.once(event)(resolve); + }); + }, +}; diff --git a/packages/kernel/src/event/index.ts b/packages/kernel/src/event/index.ts new file mode 100644 index 00000000..8bcd45a8 --- /dev/null +++ b/packages/kernel/src/event/index.ts @@ -0,0 +1,3 @@ +export * from './emitter'; +export * from './events'; +export * from './event-bus'; diff --git a/packages/kernel/src/extension/contribution.exception.ts b/packages/kernel/src/extension/contribution.exception.ts new file mode 100644 index 00000000..d29ca990 --- /dev/null +++ b/packages/kernel/src/extension/contribution.exception.ts @@ -0,0 +1,10 @@ +export class ContributionError extends Error { + constructor( + readonly point: string, + readonly owner: string, + cause: unknown, + ) { + super(`Invalid contribution of "${owner}" to "${point}": ${String(cause)}`, { cause }); + this.name = 'ContributionError'; + } +} diff --git a/packages/kernel/src/extension/define-extension-point.ts b/packages/kernel/src/extension/define-extension-point.ts new file mode 100644 index 00000000..d1c7d09f --- /dev/null +++ b/packages/kernel/src/extension/define-extension-point.ts @@ -0,0 +1,8 @@ +import type { ExtensionPoint, ExtensionPointOptions } from './extension-point.type'; + +/** Declares an extension point other plugins can contribute to. */ +export const defineExtensionPoint = ( + id: string, + options: ExtensionPointOptions = {}, +): ExtensionPoint => + Object.freeze({ id, validator: options.validator, multiple: options.multiple ?? true }); diff --git a/packages/kernel/src/extension/extension-point.type.ts b/packages/kernel/src/extension/extension-point.type.ts new file mode 100644 index 00000000..850ee1b6 --- /dev/null +++ b/packages/kernel/src/extension/extension-point.type.ts @@ -0,0 +1,31 @@ +/** Anything with a zod-like `parse`; zod schemas satisfy it. */ +export interface Validator { + parse(value: unknown): T; +} + +export interface ExtensionPoint { + readonly id: string; + readonly validator: Validator | undefined; + /** When false, at most one contribution is kept active: the highest priority one. */ + readonly multiple: boolean; + /** @internal phantom type marker */ + readonly __type?: T; +} + +export interface ExtensionPointOptions { + validator?: Validator; + multiple?: boolean; +} + +export interface Contribution { + readonly value: T; + /** Name of the contributing plugin (or `core`). */ + readonly owner: string; + readonly priority: number; + readonly seq: number; +} + +export interface ContributeOptions { + owner: string; + priority?: number; +} diff --git a/packages/kernel/src/extension/extension-registry.ts b/packages/kernel/src/extension/extension-registry.ts new file mode 100644 index 00000000..a27602bd --- /dev/null +++ b/packages/kernel/src/extension/extension-registry.ts @@ -0,0 +1,86 @@ +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { ContributionError } from './contribution.exception'; +import type { ContributeOptions, Contribution, ExtensionPoint } from './extension-point.type'; + +let sequence = 0; + +/** Contributions are ordered by descending priority, then registration order. */ +const compare = (a: Contribution, b: Contribution) => + b.priority - a.priority || a.seq - b.seq; + +export class ExtensionRegistry implements Disposable { + private readonly _contributions = new Map[]>(); + private readonly _onDidChange = new Emitter>(); + + readonly onDidChange: Event> = this._onDidChange.event; + + contribute(point: ExtensionPoint, value: T, options: ContributeOptions): Disposable { + let parsed = value; + if (point.validator) { + try { + parsed = point.validator.parse(value); + } catch (error) { + throw new ContributionError(point.id, options.owner, error); + } + } + const contribution: Contribution = { + value: parsed, + owner: options.owner, + priority: options.priority ?? 0, + seq: sequence++, + }; + const list = this._contributions.get(point.id) ?? []; + list.push(contribution); + list.sort(compare); + this._contributions.set(point.id, list); + this._onDidChange.fire(point); + + return toDisposable(() => { + const current = this._contributions.get(point.id); + const index = current?.indexOf(contribution) ?? -1; + if (index < 0) return; + current!.splice(index, 1); + this._onDidChange.fire(point); + }); + } + + /** Active contributions, highest priority first. */ + getContributions(point: ExtensionPoint): readonly Contribution[] { + const list = (this._contributions.get(point.id) ?? []) as Contribution[]; + return point.multiple ? [...list] : list.slice(0, 1); + } + + getValues(point: ExtensionPoint): T[] { + return this.getContributions(point).map((contribution) => contribution.value); + } + + observe(point: ExtensionPoint): Observable[]> { + return { + get: () => this.getContributions(point), + subscribe: (run) => { + run(this.getContributions(point)); + const subscription = this.onDidChange((changed) => { + if (changed.id === point.id) run(this.getContributions(point)); + }); + return () => subscription.dispose(); + }, + }; + } + + /** Removes every contribution of an owner (plugin unload safety net). */ + removeOwner(owner: string): void { + for (const [id, list] of this._contributions) { + const kept = list.filter((contribution) => contribution.owner !== owner); + if (kept.length === list.length) continue; + this._contributions.set(id, kept); + this._onDidChange.fire({ id, validator: undefined, multiple: true }); + } + } + + dispose(): void { + this._contributions.clear(); + this._onDidChange.dispose(); + } +} diff --git a/packages/kernel/src/extension/index.ts b/packages/kernel/src/extension/index.ts new file mode 100644 index 00000000..68e54f70 --- /dev/null +++ b/packages/kernel/src/extension/index.ts @@ -0,0 +1,4 @@ +export * from './extension-point.type'; +export * from './define-extension-point'; +export * from './contribution.exception'; +export * from './extension-registry'; diff --git a/packages/kernel/src/i18n/index.ts b/packages/kernel/src/i18n/index.ts new file mode 100644 index 00000000..9a6c8611 --- /dev/null +++ b/packages/kernel/src/i18n/index.ts @@ -0,0 +1,2 @@ +export * from './localization-service'; +export * from './message-format'; diff --git a/packages/kernel/src/i18n/localization-service.ts b/packages/kernel/src/i18n/localization-service.ts new file mode 100644 index 00000000..5ef2ddf1 --- /dev/null +++ b/packages/kernel/src/i18n/localization-service.ts @@ -0,0 +1,79 @@ +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { ObservableValue } from '../observable/observable-value'; +import { type MessageParams, formatMessage } from './message-format'; + +export type MessageBundle = Readonly>; + +export const FALLBACK_LOCALE = 'en'; + +/** + * Translations per namespace (a plugin name or `core`) and locale. Lookups fall back from + * `fr-CA` to `fr` to `en`, then to the key itself so missing strings stay visible. + */ +export class LocalizationService { + private readonly _bundles = new Map>(); + private readonly _locale = new ObservableValue(FALLBACK_LOCALE); + private readonly _revision = new ObservableValue(0); + + get locale(): Observable { + return this._locale.readonly(); + } + + /** Bumps whenever the locale or a bundle changes, for UIs to re-render translations. */ + get revision(): Observable { + return this._revision.readonly(); + } + + setLocale(locale: string): void { + if (locale === this._locale.get()) return; + this._locale.set(locale); + this._revision.update((n) => n + 1); + } + + registerBundle(namespace: string, locale: string, messages: MessageBundle): Disposable { + const locales = this._bundles.get(namespace) ?? new Map(); + const bundles = locales.get(locale) ?? []; + bundles.push(messages); + locales.set(locale, bundles); + this._bundles.set(namespace, locales); + this._revision.update((n) => n + 1); + return toDisposable(() => { + bundles.splice(bundles.indexOf(messages), 1); + this._revision.update((n) => n + 1); + }); + } + + /** Available locales across all namespaces. */ + get locales(): string[] { + const set = new Set(); + for (const locales of this._bundles.values()) + for (const locale of locales.keys()) set.add(locale); + return [...set].sort(); + } + + translate(namespace: string, key: string, params?: MessageParams): string { + const locale = this._locale.get(); + const message = this._lookup(namespace, key, locale); + return message === undefined ? key : formatMessage(message, params, locale); + } + + /** A translator bound to a namespace, e.g. `const t = i18n.scope('@nanoforge/ecs')`. */ + scope(namespace: string): (key: string, params?: MessageParams) => string { + return (key, params) => this.translate(namespace, key, params); + } + + private _lookup(namespace: string, key: string, locale: string): string | undefined { + const locales = this._bundles.get(namespace); + if (!locales) return undefined; + const candidates = [locale, locale.split('-')[0]!, FALLBACK_LOCALE]; + for (const candidate of candidates) { + const bundles = locales.get(candidate) ?? []; + for (let i = bundles.length - 1; i >= 0; i--) { + const message = bundles[i]![key]; + if (message !== undefined) return message; + } + } + return undefined; + } +} diff --git a/packages/kernel/src/i18n/message-format.ts b/packages/kernel/src/i18n/message-format.ts new file mode 100644 index 00000000..0b69f10d --- /dev/null +++ b/packages/kernel/src/i18n/message-format.ts @@ -0,0 +1,87 @@ +export type MessageParams = Readonly>; + +/** + * Minimal ICU message formatting: `{name}`, `{count, plural, one {# item} other {# items}}` + * (with `=N` exact matches) and `{kind, select, file {…} other {…}}`. `'{'` escapes a brace. + */ +export const formatMessage = (message: string, params: MessageParams = {}, locale = 'en'): string => + format(message, params, locale, undefined); + +const format = ( + message: string, + params: MessageParams, + locale: string, + pluralValue: number | undefined, +): string => { + let result = ''; + let i = 0; + while (i < message.length) { + const char = message[i]!; + if (char === "'" && message[i + 1] === '{') { + result += '{'; + i += 2; + } else if (char === '#' && pluralValue !== undefined) { + result += new Intl.NumberFormat(locale).format(pluralValue); + i++; + } else if (char === '{') { + const end = matchingBrace(message, i); + result += formatArgument(message.slice(i + 1, end), params, locale); + i = end + 1; + } else { + result += char; + i++; + } + } + return result; +}; + +const matchingBrace = (text: string, open: number): number => { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}' && --depth === 0) return i; + } + throw new Error(`Unbalanced braces in message "${text}"`); +}; + +const formatArgument = (argument: string, params: MessageParams, locale: string): string => { + const [name = '', type, ...rest] = argument.split(','); + const key = name.trim(); + const value = params[key]; + if (type === undefined) return value === undefined ? `{${key}}` : String(value); + + const options = parseOptions(rest.join(',')); + const kind = type.trim(); + if (kind === 'plural') { + const count = Number(value ?? 0); + const branch = + options.get(`=${count}`) ?? + options.get(new Intl.PluralRules(locale).select(count)) ?? + options.get('other') ?? + ''; + return format(branch, params, locale, count); + } + if (kind === 'select') { + const branch = options.get(String(value)) ?? options.get('other') ?? ''; + return format(branch, params, locale, undefined); + } + return value === undefined ? `{${key}}` : String(value); +}; + +/** `one {…} other {…}` → Map { one → …, other → … } */ +const parseOptions = (text: string): Map => { + const options = new Map(); + let i = 0; + while (i < text.length) { + while (/\s/.test(text[i] ?? '')) i++; + const start = i; + while (i < text.length && text[i] !== '{' && !/\s/.test(text[i]!)) i++; + const selector = text.slice(start, i); + while (/\s/.test(text[i] ?? '')) i++; + if (!selector || text[i] !== '{') break; + const end = matchingBrace(text, i); + options.set(selector, text.slice(i + 1, end)); + i = end + 1; + } + return options; +}; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts new file mode 100644 index 00000000..b92bc9e7 --- /dev/null +++ b/packages/kernel/src/index.ts @@ -0,0 +1,10 @@ +export * from './command'; +export * from './context'; +export * from './di'; +export * from './event'; +export * from './extension'; +export * from './i18n'; +export * from './lifecycle'; +export * from './log'; +export * from './observable'; +export * from './plugin'; diff --git a/packages/kernel/src/lifecycle/disposable-object.abstract.ts b/packages/kernel/src/lifecycle/disposable-object.abstract.ts new file mode 100644 index 00000000..3af7c84a --- /dev/null +++ b/packages/kernel/src/lifecycle/disposable-object.abstract.ts @@ -0,0 +1,18 @@ +import { type Disposable, DisposableStore } from './disposable'; + +/** Base class for services owning disposables through `this._register`. */ +export abstract class DisposableObject implements Disposable { + protected readonly _store = new DisposableStore(); + + get isDisposed(): boolean { + return this._store.isDisposed; + } + + dispose(): void { + this._store.dispose(); + } + + protected _register(disposable: T): T { + return this._store.add(disposable); + } +} diff --git a/packages/kernel/src/lifecycle/disposable.ts b/packages/kernel/src/lifecycle/disposable.ts new file mode 100644 index 00000000..c6e2c4e8 --- /dev/null +++ b/packages/kernel/src/lifecycle/disposable.ts @@ -0,0 +1,114 @@ +export interface Disposable { + dispose(): void; +} + +export interface DisposableTracker { + onCreate(disposable: Disposable): void; + onDispose(disposable: Disposable): void; +} + +let tracker: DisposableTracker | null = null; + +/** Installs a tracker notified of every kernel disposable (leak detection in tests). */ +export const setDisposableTracker = (value: DisposableTracker | null): void => { + tracker = value; +}; + +export const isDisposable = (value: unknown): value is Disposable => + typeof value === 'object' && + value !== null && + typeof (value as Partial).dispose === 'function'; + +class FunctionDisposable implements Disposable { + private _fn: (() => void) | null; + + constructor(fn: () => void) { + this._fn = fn; + tracker?.onCreate(this); + } + + dispose(): void { + const fn = this._fn; + if (!fn) return; + this._fn = null; + tracker?.onDispose(this); + fn(); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} + +/** Wraps a cleanup function; calling `dispose` more than once runs it only once. */ +export const toDisposable = (fn: () => void): Disposable => new FunctionDisposable(fn); + +/** Disposes every given disposable, collecting errors into an `AggregateError`. */ +export const disposeAll = (disposables: Iterable): void => { + const errors: unknown[] = []; + for (const disposable of disposables) { + try { + disposable.dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Errors while disposing'); +}; + +export const combineDisposables = (...disposables: Disposable[]): Disposable => + toDisposable(() => disposeAll(disposables)); + +/** + * A collection of disposables disposed together (LIFO). Adding to a disposed store disposes + * the new value immediately, so late registrations never leak. + */ +export class DisposableStore implements Disposable { + private readonly _items = new Set(); + private _disposed = false; + + constructor() { + tracker?.onCreate(this); + } + + get isDisposed(): boolean { + return this._disposed; + } + + get size(): number { + return this._items.size; + } + + add(disposable: T): T { + if (this._disposed) { + disposable.dispose(); + return disposable; + } + this._items.add(disposable); + return disposable; + } + + /** Removes without disposing. */ + delete(disposable: Disposable): void { + this._items.delete(disposable); + } + + /** Disposes the current items but keeps the store usable. */ + clear(): void { + const items = [...this._items].reverse(); + this._items.clear(); + disposeAll(items); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + tracker?.onDispose(this); + this.clear(); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} diff --git a/packages/kernel/src/lifecycle/index.ts b/packages/kernel/src/lifecycle/index.ts new file mode 100644 index 00000000..7adfe283 --- /dev/null +++ b/packages/kernel/src/lifecycle/index.ts @@ -0,0 +1,3 @@ +export * from './disposable'; +export * from './mutable-disposable'; +export * from './disposable-object.abstract'; diff --git a/packages/kernel/src/lifecycle/mutable-disposable.ts b/packages/kernel/src/lifecycle/mutable-disposable.ts new file mode 100644 index 00000000..9317cb57 --- /dev/null +++ b/packages/kernel/src/lifecycle/mutable-disposable.ts @@ -0,0 +1,32 @@ +import type { Disposable } from './disposable'; + +/** Holds at most one disposable; replacing or clearing it disposes the previous one. */ +export class MutableDisposable implements Disposable { + private _value: T | undefined; + private _disposed = false; + + get value(): T | undefined { + return this._value; + } + + set value(value: T | undefined) { + if (this._disposed) { + value?.dispose(); + return; + } + if (value === this._value) return; + const previous = this._value; + this._value = value; + previous?.dispose(); + } + + clear(): void { + this.value = undefined; + } + + dispose(): void { + if (this._disposed) return; + this.clear(); + this._disposed = true; + } +} diff --git a/packages/kernel/src/log/format-log-value.ts b/packages/kernel/src/log/format-log-value.ts new file mode 100644 index 00000000..a2ce0998 --- /dev/null +++ b/packages/kernel/src/log/format-log-value.ts @@ -0,0 +1,55 @@ +import type { LogValue } from './log-value.type'; + +const isPlain = (value: LogValue): value is string | number | boolean | null => + value === null || typeof value !== 'object'; + +/** + * One-line text of a logged value, as a console prints it: top-level strings are bare, nested + * ones quoted. + */ +export const formatLogValue = (value: LogValue, nested = false): string => { + if (isPlain(value)) + return typeof value === 'string' && nested ? JSON.stringify(value) : `${value}`; + switch (value.type) { + case 'undefined': + return 'undefined'; + case 'number': + case 'bigint': + case 'symbol': + case 'function': + case 'date': + case 'cut': + return value.text; + case 'circular': + return '[Circular]'; + case 'error': + return nested || !value.stack ? `${value.name}: ${value.message}` : value.stack; + case 'array': { + const items = value.items.map((item) => formatLogValue(item, true)); + if (value.more) items.push(`… ${value.more} more`); + const prefix = value.name ? `${value.name}(${value.length}) ` : ''; + return `${prefix}[${items.join(', ')}]`; + } + case 'map': { + const entries = value.entries.map( + ([key, item]) => `${formatLogValue(key, true)} => ${formatLogValue(item, true)}`, + ); + if (value.more) entries.push(`… ${value.more} more`); + return `Map(${value.size}) {${entries.join(', ')}}`; + } + case 'object': { + const entries = value.entries.map(([key, item]) => `${key}: ${formatLogValue(item, true)}`); + if (value.more) entries.push(`… ${value.more} more`); + const body = entries.length ? `{ ${entries.join(', ')} }` : '{}'; + return value.name ? `${value.name} ${body}` : body; + } + } +}; + +/** Whether a value has children to expand. */ +export const isExpandableLogValue = (value: LogValue): boolean => + !isPlain(value) && + ((value.type === 'object' && value.entries.length > 0) || + (value.type === 'array' && value.items.length > 0) || + (value.type === 'map' && value.entries.length > 0) || + (value.type === 'error' && !!value.stack)); diff --git a/packages/kernel/src/log/index.ts b/packages/kernel/src/log/index.ts new file mode 100644 index 00000000..5a1da216 --- /dev/null +++ b/packages/kernel/src/log/index.ts @@ -0,0 +1,6 @@ +export * from './log-level.enum'; +export * from './logger.type'; +export * from './logger-service'; +export * from './log-value.type'; +export * from './serialize-log-value'; +export * from './format-log-value'; diff --git a/packages/kernel/src/log/log-level.enum.ts b/packages/kernel/src/log/log-level.enum.ts new file mode 100644 index 00000000..f8011340 --- /dev/null +++ b/packages/kernel/src/log/log-level.enum.ts @@ -0,0 +1,7 @@ +export enum LogLevel { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, +} diff --git a/packages/kernel/src/log/log-value.type.ts b/packages/kernel/src/log/log-value.type.ts new file mode 100644 index 00000000..7b9802c4 --- /dev/null +++ b/packages/kernel/src/log/log-value.type.ts @@ -0,0 +1,56 @@ +/** + * A logged value as a JSON tree: a snapshot that can cross a bridge (game → editor) and be + * shown expanded. The engine's editor bridge writes the same format. + */ +export type LogValue = + | string + | number + | boolean + | null + | { readonly type: 'undefined' } + /** Numbers JSON cannot carry: `NaN`, `Infinity`, `-Infinity`, `-0`. */ + | { readonly type: 'number'; readonly text: string } + | { readonly type: 'bigint' | 'symbol' | 'function' | 'date'; readonly text: string } + | { + readonly type: 'error'; + readonly name: string; + readonly message: string; + readonly stack?: string; + } + | { + readonly type: 'object'; + /** Constructor name, for instances of classes. */ + readonly name?: string; + readonly entries: readonly (readonly [string, LogValue])[]; + /** Entries left out. */ + readonly more?: number; + } + | { + readonly type: 'array'; + /** `Set`, `Float32Array`… for array-likes that are not arrays. */ + readonly name?: string; + readonly length: number; + readonly items: readonly LogValue[]; + readonly more?: number; + } + | { + readonly type: 'map'; + readonly size: number; + readonly entries: readonly (readonly [LogValue, LogValue])[]; + readonly more?: number; + } + /** A value already shown higher in the tree. */ + | { readonly type: 'circular' } + /** A value deeper than the limit: only its summary is kept. */ + | { readonly type: 'cut'; readonly text: string }; + +export interface LogValueLimits { + /** Nesting kept below the logged value (default 4). */ + readonly depth?: number; + /** Entries kept per object, array or map (default 50). */ + readonly entries?: number; + /** Values kept in the whole tree (default 500). */ + readonly nodes?: number; + /** Characters kept per string (default 10 000). */ + readonly text?: number; +} diff --git a/packages/kernel/src/log/logger-service.ts b/packages/kernel/src/log/logger-service.ts new file mode 100644 index 00000000..a75e0636 --- /dev/null +++ b/packages/kernel/src/log/logger-service.ts @@ -0,0 +1,57 @@ +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { LogLevel } from './log-level.enum'; +import type { LogEntry, LogSink, Logger } from './logger.type'; + +export const consoleSink: LogSink = (entry) => { + const method = (['debug', 'debug', 'info', 'warn', 'error'] as const)[entry.level]; + console[method](`[${entry.source}] ${entry.message}`, ...entry.args); +}; + +/** + * Central logging: named loggers write entries to sinks (browser console, the Console plugin…). + * The last `bufferSize` entries are kept so a sink added late can replay history. + */ +export class LoggerService { + private readonly _sinks = new Set(); + private readonly _buffer: LogEntry[] = []; + + level: LogLevel = LogLevel.Info; + + constructor(private readonly _bufferSize = 500) {} + + addSink(sink: LogSink, options: { replay?: boolean } = {}): Disposable { + if (options.replay) for (const entry of this._buffer) sink(entry); + this._sinks.add(sink); + return toDisposable(() => this._sinks.delete(sink)); + } + + get history(): readonly LogEntry[] { + return this._buffer; + } + + getLogger(name: string): Logger { + const log = (level: LogLevel, message: string, args: unknown[]) => + this.write({ level, source: name, message, args, time: Date.now() }); + return { + name, + trace: (message, ...args) => log(LogLevel.Trace, message, args), + debug: (message, ...args) => log(LogLevel.Debug, message, args), + info: (message, ...args) => log(LogLevel.Info, message, args), + warn: (message, ...args) => log(LogLevel.Warn, message, args), + error: (message, ...args) => log(LogLevel.Error, message, args), + child: (child) => this.getLogger(`${name}.${child}`), + }; + } + + /** Writes a full entry: for output that comes with its own values or location. */ + write(entry: LogEntry): void { + if (entry.level < this.level) return; + this._buffer.push(entry); + if (this._buffer.length > this._bufferSize) this._buffer.shift(); + for (const sink of this._sinks) { + try { + sink(entry); + } catch {} + } + } +} diff --git a/packages/kernel/src/log/logger.type.ts b/packages/kernel/src/log/logger.type.ts new file mode 100644 index 00000000..a86dda5f --- /dev/null +++ b/packages/kernel/src/log/logger.type.ts @@ -0,0 +1,33 @@ +import { type LogLevel } from './log-level.enum'; +import type { LogValue } from './log-value.type'; + +export interface LogEntry { + readonly level: LogLevel; + /** Logger name, e.g. `kernel.plugins` or a plugin name. */ + readonly source: string; + readonly message: string; + readonly args: readonly unknown[]; + readonly time: number; + /** The message's parts already serialized (output of a game): shown instead of `message`. */ + readonly values?: readonly LogValue[]; + /** Where it was logged: a file (or the URL of a built bundle) and a 1-based position. */ + readonly location?: LogLocation; +} + +export interface LogLocation { + readonly file: string; + readonly line: number; + readonly column?: number; +} + +export type LogSink = (entry: LogEntry) => void; + +export interface Logger { + readonly name: string; + trace(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + child(name: string): Logger; +} diff --git a/packages/kernel/src/log/serialize-log-value.ts b/packages/kernel/src/log/serialize-log-value.ts new file mode 100644 index 00000000..837e73a9 --- /dev/null +++ b/packages/kernel/src/log/serialize-log-value.ts @@ -0,0 +1,125 @@ +import type { LogValue, LogValueLimits } from './log-value.type'; + +const constructorName = (value: object): string | undefined => { + try { + const name = (Object.getPrototypeOf(value) as { constructor?: { name?: unknown } } | null) + ?.constructor?.name; + return typeof name === 'string' && name && name !== 'Object' ? name : undefined; + } catch { + return undefined; + } +}; + +const summary = (value: object): string => { + if (Array.isArray(value)) return `Array(${value.length})`; + if (value instanceof Map) return `Map(${value.size})`; + if (value instanceof Set) return `Set(${value.size})`; + return constructorName(value) ?? 'Object'; +}; + +/** Snapshot of a value for the console, within limits (never throws). */ +export const serializeLogValue = (input: unknown, limits: LogValueLimits = {}): LogValue => { + const maxDepth = limits.depth ?? 4; + const maxEntries = limits.entries ?? 50; + const maxText = limits.text ?? 10_000; + let budget = limits.nodes ?? 500; + const ancestors = new Set(); + + const text = (value: string) => (value.length > maxText ? `${value.slice(0, maxText)}…` : value); + + const visit = (value: unknown, depth: number): LogValue => { + budget--; + switch (typeof value) { + case 'string': + return text(value); + case 'boolean': + return value; + case 'number': + return Number.isFinite(value) && !Object.is(value, -0) + ? value + : { type: 'number', text: Object.is(value, -0) ? '-0' : String(value) }; + case 'undefined': + return { type: 'undefined' }; + case 'bigint': + return { type: 'bigint', text: `${value}n` }; + case 'symbol': + return { type: 'symbol', text: String(value) }; + case 'function': + return { type: 'function', text: value.name ? `ƒ ${value.name}()` : 'ƒ ()' }; + } + if (value === null) return null; + const object = value as object; + if (ancestors.has(object)) return { type: 'circular' }; + try { + if (object instanceof Error) { + return { + type: 'error', + name: object.name, + message: text(object.message), + ...(object.stack && { stack: text(object.stack) }), + }; + } + if (object instanceof Date) { + return { + type: 'date', + text: Number.isNaN(object.getTime()) ? 'Invalid Date' : object.toISOString(), + }; + } + if (depth >= maxDepth || budget <= 0) return { type: 'cut', text: summary(object) }; + ancestors.add(object); + try { + if (object instanceof Map) { + const entries: [LogValue, LogValue][] = []; + for (const [key, item] of object) { + if (entries.length >= maxEntries || budget <= 0) break; + entries.push([visit(key, depth + 1), visit(item, depth + 1)]); + } + const more = object.size - entries.length; + return { type: 'map', size: object.size, entries, ...(more > 0 && { more }) }; + } + const list = Array.isArray(object) + ? (object as unknown[]) + : object instanceof Set + ? [...(object as Set)] + : ArrayBuffer.isView(object) && !(object instanceof DataView) + ? (object as unknown as ArrayLike) + : undefined; + if (list) { + const items: LogValue[] = []; + for (let index = 0; index < list.length; index++) { + if (items.length >= maxEntries || budget <= 0) break; + items.push(visit(list[index], depth + 1)); + } + const more = list.length - items.length; + return { + type: 'array', + ...(!Array.isArray(object) && { name: constructorName(object) ?? 'Array' }), + length: list.length, + items, + ...(more > 0 && { more }), + }; + } + const keys = Object.keys(object); + const entries: [string, LogValue][] = []; + for (const key of keys) { + if (entries.length >= maxEntries || budget <= 0) break; + let item: unknown; + try { + item = (object as Record)[key]; + } catch (error) { + item = `[threw ${String(error)}]`; + } + entries.push([key, visit(item, depth + 1)]); + } + const name = constructorName(object); + const more = keys.length - entries.length; + return { type: 'object', ...(name && { name }), entries, ...(more > 0 && { more }) }; + } finally { + ancestors.delete(object); + } + } catch { + return { type: 'cut', text: 'Object' }; + } + }; + return visit(input, 0); +}; diff --git a/packages/kernel/src/observable/index.ts b/packages/kernel/src/observable/index.ts new file mode 100644 index 00000000..d2d9731f --- /dev/null +++ b/packages/kernel/src/observable/index.ts @@ -0,0 +1,2 @@ +export * from './observable'; +export * from './observable-value'; diff --git a/packages/kernel/src/observable/observable-value.ts b/packages/kernel/src/observable/observable-value.ts new file mode 100644 index 00000000..eede0a20 --- /dev/null +++ b/packages/kernel/src/observable/observable-value.ts @@ -0,0 +1,44 @@ +import type { Listener } from '../event/emitter'; +import type { Equality, Observable } from './observable'; + +/** A writable observable value. */ +export class ObservableValue implements Observable { + private readonly _subscribers = new Set<{ run: Listener }>(); + + constructor( + private _value: T, + private readonly _equals: Equality = Object.is, + ) {} + + get(): T { + return this._value; + } + + set(value: T): void { + if (this._equals(this._value, value)) return; + this._value = value; + for (const subscriber of [...this._subscribers]) { + if (this._subscribers.has(subscriber)) subscriber.run(value); + } + } + + update(fn: (value: T) => T): void { + this.set(fn(this._value)); + } + + subscribe(run: Listener): () => void { + const subscriber = { run }; + this._subscribers.add(subscriber); + run(this._value); + return () => this._subscribers.delete(subscriber); + } + + get subscriberCount(): number { + return this._subscribers.size; + } + + /** A read-only view, to expose without handing out `set`. */ + readonly(): Observable { + return { get: () => this.get(), subscribe: (run) => this.subscribe(run) }; + } +} diff --git a/packages/kernel/src/observable/observable.ts b/packages/kernel/src/observable/observable.ts new file mode 100644 index 00000000..f0fb1ead --- /dev/null +++ b/packages/kernel/src/observable/observable.ts @@ -0,0 +1,121 @@ +import type { Event, Listener } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { ObservableValue } from './observable-value'; + +/** + * A readable reactive value. `subscribe` follows the Svelte store contract (called immediately, + * returns an unsubscriber), so `$observable` works in plugin templates without any adapter. + */ +export interface Observable { + get(): T; + subscribe(run: Listener): () => void; +} + +export type ObservableValues[]> = { + [K in keyof S]: S[K] extends Observable ? V : never; +}; + +/** Subscribes as a `Disposable`, optionally skipping the immediate call. */ +export const observe = ( + observable: Observable, + listener: Listener, + options: { immediate?: boolean } = {}, +): Disposable => { + let skip = options.immediate === false; + const unsubscribe = observable.subscribe((value) => { + if (skip) return; + listener(value); + }); + skip = false; + return toDisposable(unsubscribe); +}; + +/** Change event of an observable (no immediate call). */ +export const changes = + (observable: Observable): Event => + (listener, disposables) => { + const subscription = observe(observable, listener, { immediate: false }); + disposables?.add(subscription); + return subscription; + }; + +export type Equality = (a: T, b: T) => boolean; + +/** An observable that never changes. */ +export const constant = (value: T): Observable => ({ + get: () => value, + subscribe: (run) => { + run(value); + return () => undefined; + }, +}); + +/** + * Computes a value from other observables. Upstream subscriptions are held only while the + * derived observable itself has subscribers; otherwise `get` computes on demand. + */ +export const derived = [], T>( + sources: S, + compute: (...values: ObservableValues) => T, + equals: Equality = Object.is, +): Observable => { + const read = () => compute(...(sources.map((source) => source.get()) as ObservableValues)); + let cache: ObservableValue | undefined; + let upstream: (() => void)[] = []; + let listeners = 0; + + const start = () => { + cache = new ObservableValue(read(), equals); + let ready = false; + upstream = sources.map((source) => + source.subscribe(() => { + if (ready) cache!.set(read()); + }), + ); + ready = true; + }; + const stop = () => { + for (const unsubscribe of upstream) unsubscribe(); + upstream = []; + cache = undefined; + }; + + return { + get: () => (cache ? cache.get() : read()), + subscribe(run) { + if (listeners++ === 0) start(); + const unsubscribe = cache!.subscribe(run); + let active = true; + return () => { + if (!active) return; + active = false; + unsubscribe(); + if (--listeners === 0) stop(); + }; + }, + }; +}; + +/** + * Follows the observable currently held by `outer` (e.g. "apps of the current project"): + * re-subscribes when `outer` changes and yields `fallback` while it holds nothing. + */ +export const switchObservable = ( + outer: Observable | undefined>, + fallback: T, +): Observable => ({ + get: () => outer.get()?.get() ?? fallback, + subscribe(run) { + let inner: (() => void) | undefined; + const unsubscribeOuter = outer.subscribe((current) => { + inner?.(); + inner = undefined; + if (current) inner = current.subscribe(run); + else run(fallback); + }); + return () => { + inner?.(); + unsubscribeOuter(); + }; + }, +}); diff --git a/packages/kernel/src/plugin/command-metadata.ts b/packages/kernel/src/plugin/command-metadata.ts new file mode 100644 index 00000000..02c36927 --- /dev/null +++ b/packages/kernel/src/plugin/command-metadata.ts @@ -0,0 +1,7 @@ +import { defineExtensionPoint } from '../extension/define-extension-point'; +import type { ExtensionPoint } from '../extension/extension-point.type'; +import type { CommandMetadataContribution } from './plugin-host.type'; + +/** Commands declared by manifests (activated or not), for palettes and menus. */ +export const COMMAND_METADATA: ExtensionPoint = + defineExtensionPoint('core.commandMetadata'); diff --git a/packages/kernel/src/plugin/core-services.const.ts b/packages/kernel/src/plugin/core-services.const.ts new file mode 100644 index 00000000..51516042 --- /dev/null +++ b/packages/kernel/src/plugin/core-services.const.ts @@ -0,0 +1,20 @@ +import type { CommandRegistry } from '../command/command-registry'; +import type { ContextKeyService } from '../context/context-key-service'; +import { createToken } from '../di/service-token'; +import type { EventBus } from '../event/event-bus'; +import type { ExtensionRegistry } from '../extension/extension-registry'; +import type { LocalizationService } from '../i18n/localization-service'; +import type { LoggerService } from '../log/logger-service'; +import type { EngineLibProvider } from './engine-libs'; + +/** Well-known services every editor container provides. */ +export const CoreServices = { + Commands: createToken('core.commands'), + ContextKeys: createToken('core.contextKeys'), + /** Typed as `EventBus` by the SDK. */ + EventBus: createToken('core.eventBus'), + Extensions: createToken('core.extensions'), + Localization: createToken('core.localization'), + Logger: createToken('core.logger'), + EngineLibs: createToken('core.engineLibs'), +} as const; diff --git a/packages/kernel/src/plugin/create-core-container.ts b/packages/kernel/src/plugin/create-core-container.ts new file mode 100644 index 00000000..602e6d60 --- /dev/null +++ b/packages/kernel/src/plugin/create-core-container.ts @@ -0,0 +1,24 @@ +import { CommandRegistry } from '../command/command-registry'; +import { ContextKeyService } from '../context/context-key-service'; +import { Container } from '../di/container'; +import { EventBus } from '../event/event-bus'; +import { ExtensionRegistry } from '../extension/extension-registry'; +import { LocalizationService } from '../i18n/localization-service'; +import { LoggerService } from '../log/logger-service'; +import { CoreServices } from './core-services.const'; + +/** Creates the editor root container with every core kernel service registered. */ +export const createCoreContainer = (options: { logger?: LoggerService } = {}): Container => { + const container = new Container('editor'); + container.provideFactory(CoreServices.ContextKeys, () => new ContextKeyService()); + container.provideFactory(CoreServices.Extensions, () => new ExtensionRegistry()); + container.provideFactory(CoreServices.EventBus, () => new EventBus()); + container.provideFactory(CoreServices.Localization, () => new LocalizationService()); + if (options.logger) container.provide(CoreServices.Logger, options.logger); + else container.provideFactory(CoreServices.Logger, () => new LoggerService()); + container.provideFactory( + CoreServices.Commands, + (services) => new CommandRegistry(services, services.get(CoreServices.ContextKeys)), + ); + return container; +}; diff --git a/packages/kernel/src/plugin/create-plugin-context.ts b/packages/kernel/src/plugin/create-plugin-context.ts new file mode 100644 index 00000000..7c77485f --- /dev/null +++ b/packages/kernel/src/plugin/create-plugin-context.ts @@ -0,0 +1,54 @@ +import type { Container } from '../di/container'; +import type { Disposable, DisposableStore } from '../lifecycle/disposable'; +import { type Observable, constant } from '../observable/observable'; +import { CoreServices } from './core-services.const'; +import { type AppInfo, eligibleApps } from './engine-libs'; +import type { PluginContext } from './plugin-context.type'; +import type { PluginDescriptor } from './plugin-resolution.type'; + +/** + * The context handed to a plugin's `activate`: a child scope of `services`, and registrations + * owned by the plugin, undone when `subscriptions` is disposed. + */ +export const createPluginContext = ( + services: Container, + descriptor: PluginDescriptor, + subscriptions: DisposableStore, +): PluginContext => { + const { manifest } = descriptor; + const scope = subscriptions.add(services.createChild(`plugin:${manifest.name}`)); + const extensions = services.get(CoreServices.Extensions); + const commands = services.get(CoreServices.Commands); + const i18n = services.get(CoreServices.Localization); + const own = (disposable: T) => subscriptions.add(disposable); + const engineLibs = services.tryGet(CoreServices.EngineLibs); + const apps: Observable = engineLibs + ? eligibleApps(manifest, engineLibs.apps) + : constant([]); + + return { + name: manifest.name, + manifest, + source: descriptor.source, + subscriptions, + services: scope, + scope, + provide: (token, value, options) => own(services.provide(token, value, options)), + provideFactory: (token, factory, options) => + own(services.provideFactory(token, factory, options)), + override: (token, decorate, options) => own(services.override(token, decorate, options)), + contribute: (point, value, options) => + own(extensions.contribute(point, value, { owner: manifest.name, ...options })), + contributions: (point) => extensions.observe(point), + registerCommand: (id, handler, metadata) => + own(commands.register({ ...metadata, id, handler, owner: manifest.name })), + executeCommand: (id, ...args) => commands.execute(id, ...args), + contextKeys: services.get(CoreServices.ContextKeys), + logger: services.get(CoreServices.Logger).getLogger(manifest.name), + t: i18n.scope(manifest.name), + registerMessages: (locale, messages) => + own(i18n.registerBundle(manifest.name, locale, messages)), + eligibleApps: apps, + resolveAsset: (path) => new URL(path, descriptor.baseUrl).href, + }; +}; diff --git a/packages/kernel/src/plugin/describe-status.ts b/packages/kernel/src/plugin/describe-status.ts new file mode 100644 index 00000000..f6991e34 --- /dev/null +++ b/packages/kernel/src/plugin/describe-status.ts @@ -0,0 +1,24 @@ +import type { PluginStatus } from './plugin-resolution.type'; + +export const describeStatus = (status: PluginStatus): string => { + switch (status.kind) { + case 'ok': + return 'ok'; + case 'disabled': + return 'disabled'; + case 'shadowed': + return `shadowed by the ${status.by} version`; + case 'incompatible-editor': + return `requires editor ${status.required} (running ${status.actual})`; + case 'incompatible-runtime': + return `compiled with ${status.module} ${status.compiled}, editor provides ${status.actual}`; + case 'missing-dependency': + return `missing dependency ${status.dependency}@${status.range}`; + case 'dependency-version': + return `dependency ${status.dependency}@${status.actual} does not match ${status.range}`; + case 'dependency-failed': + return `dependency ${status.dependency} is not available`; + case 'cycle': + return `dependency cycle: ${status.cycle.join(' -> ')}`; + } +}; diff --git a/packages/kernel/src/plugin/engine-libs.ts b/packages/kernel/src/plugin/engine-libs.ts new file mode 100644 index 00000000..e2326aa5 --- /dev/null +++ b/packages/kernel/src/plugin/engine-libs.ts @@ -0,0 +1,90 @@ +import semver from 'semver'; + +import type { ContextKeyService } from '../context/context-key-service'; +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import { type Observable, derived } from '../observable/observable'; +import type { PluginManifest } from './plugin-manifest'; + +export type AppType = 'client' | 'server' | 'lib'; + +/** An app of the opened game workspace, as discovered by the project service. */ +export interface AppInfo { + readonly id: string; + readonly name: string; + readonly type: AppType; + /** Installed engine libraries and their versions, e.g. `{ '@nanoforge-dev/ecs': '1.4.2' }`. */ + readonly engineLibs: Readonly>; +} + +/** Provided by the project service (phase 2). */ +export interface EngineLibProvider { + readonly apps: Observable; +} + +export interface MissingEngineLib { + readonly name: string; + readonly range: string; + /** Installed version when present but out of range. */ + readonly installed?: string; +} + +/** Required engine libs of a plugin that an app lacks (empty = eligible). */ +export const missingEngineLibs = (manifest: PluginManifest, app: AppInfo): MissingEngineLib[] => + Object.entries(manifest.engineLibs.required).flatMap(([name, range]) => { + const installed = app.engineLibs[name]; + if (!installed) return [{ name, range }]; + const version = semver.valid(installed) ?? semver.coerce(installed)?.version; + if (!version || semver.satisfies(version, range)) return []; + return [{ name, range, installed }]; + }); + +export const isEligible = (manifest: PluginManifest, app: AppInfo): boolean => + missingEngineLibs(manifest, app).length === 0; + +export const eligibleApps = ( + manifest: PluginManifest, + apps: Observable, +): Observable => + derived( + [apps], + (list) => list.filter((app) => isEligible(manifest, app)), + (a, b) => a.length === b.length && a.every((app, i) => app === b[i]), + ); + +/** Context key holding the id of the app the user is working on. */ +export const ACTIVE_APP_KEY = 'activeApp'; +export const engineLibKey = (lib: string) => `engineLib:${lib}`; +export const pluginEligibleKey = (plugin: string) => `plugin.${plugin}.eligible`; + +/** + * Keeps `engineLib:` and `plugin..eligible` context keys in sync with the active app, + * so `when` clauses can gate features on installed engine libraries. + */ +export const bindEngineLibContext = ( + context: ContextKeyService, + apps: Observable, + plugins: () => readonly PluginManifest[], +): Disposable => { + const store = new DisposableStore(); + const bound = new DisposableStore(); + store.add(bound); + + const refresh = () => { + bound.clear(); + const activeId = context.get(ACTIVE_APP_KEY); + const app = apps.get().find((candidate) => candidate.id === activeId); + if (!app) return; + for (const lib of Object.keys(app.engineLibs)) bound.add(context.bind(engineLibKey(lib), true)); + for (const manifest of plugins()) { + bound.add(context.bind(pluginEligibleKey(manifest.name), isEligible(manifest, app))); + } + }; + + store.add(toDisposable(apps.subscribe(refresh))); + store.add( + context.onDidChange(({ keys }) => { + if (keys.has(ACTIVE_APP_KEY)) refresh(); + }), + ); + return store; +}; diff --git a/packages/kernel/src/plugin/index.ts b/packages/kernel/src/plugin/index.ts new file mode 100644 index 00000000..299e3428 --- /dev/null +++ b/packages/kernel/src/plugin/index.ts @@ -0,0 +1,17 @@ +export * from './shared-module-registry'; +export * from './create-core-container'; +export * from './engine-libs'; +export * from './plugin-manifest'; +export * from './package-manifest'; +export * from './plugin-manifest.exception'; +export * from './plugin-context.type'; +export * from './plugin-host.type'; +export * from './plugin-module-loader'; +export * from './command-metadata'; +export * from './plugin-activation.exception'; +export * from './plugin-host'; +export * from './describe-status'; +export * from './plugin-source.enum'; +export * from './plugin-resolution.type'; +export * from './resolve-plugins'; +export * from './core-services.const'; diff --git a/packages/kernel/src/plugin/manifest-fields.ts b/packages/kernel/src/plugin/manifest-fields.ts new file mode 100644 index 00000000..f02b6c97 --- /dev/null +++ b/packages/kernel/src/plugin/manifest-fields.ts @@ -0,0 +1,44 @@ +import semver from 'semver'; +import { z } from 'zod'; + +/** Registry package name: `@scope/name`, like a scoped npm package. */ +export const PLUGIN_NAME_PATTERN = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; + +export const PluginName = z + .string() + .regex(PLUGIN_NAME_PATTERN, 'must be "@scope/name" (lowercase letters, digits, dashes)'); + +export const Version = z + .string() + .refine((value) => semver.valid(value) !== null, 'invalid semver version'); + +export const Range = z + .string() + .refine((value) => semver.validRange(value) !== null, 'invalid semver range'); + +/** `["a/b"]` (any version, CLI registry style) or `{ "a/b": "^1.0.0" }`. */ +export const PluginDependencies = z + .union([ + z.array(PluginName).transform((names) => Object.fromEntries(names.map((name) => [name, '*']))), + z.record(PluginName, Range), + ]) + .default({}); + +export const NpmRanges = z.record(z.string().min(1), Range).default({}); + +export const ACTIVATION_EVENT_PATTERN = + /^(onStartup|on(Command|Screen|Widget|ProjectType|Language|Uri):.+)$/; + +export const ActivationEvent = z + .string() + .regex( + ACTIVATION_EVENT_PATTERN, + 'expected onStartup, onCommand:, onScreen:, onWidget:, onProjectType:, onLanguage: or onUri:', + ); + +export const EntryPath = z + .string() + .min(1) + .refine((path) => !path.startsWith('/') && !path.split('/').includes('..'), { + message: 'must be a relative path inside the plugin package', + }); diff --git a/packages/kernel/src/plugin/package-manifest.ts b/packages/kernel/src/plugin/package-manifest.ts new file mode 100644 index 00000000..b2fe0a61 --- /dev/null +++ b/packages/kernel/src/plugin/package-manifest.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { PluginDependencies, PluginName, Version } from './manifest-fields'; + +const SubFolder = z + .string() + .min(1) + .refine((path) => !path.startsWith('/') && !path.split(/[\\/]/).includes('..'), { + message: 'must be a sub-folder of the package', + }); + +/** + * `nanoforge.manifest.json` of a package of type `package`, installed in `nf_modules`: the fields + * the editor reads today. Items and owner objects (ADR 0003) are kept as they are. + * A package never holds a plugin: plugins are installed apart (`.nanoforge/plugins`). + */ +export const PackageManifestSchema = z + .object({ + $schema: z.string().optional(), + type: z.literal('package'), + name: PluginName, + version: Version, + description: z.string().optional(), + /** Sub-folders holding their own `package` manifest. Walked, never scanned. */ + include: z.array(SubFolder).default([]), + /** Editor plugins that make this package easier to use: the editor offers them. */ + suggestedPlugins: PluginDependencies, + }) + .loose(); + +export type PackageManifest = z.output; diff --git a/packages/kernel/src/plugin/plugin-activation.exception.ts b/packages/kernel/src/plugin/plugin-activation.exception.ts new file mode 100644 index 00000000..83b888c9 --- /dev/null +++ b/packages/kernel/src/plugin/plugin-activation.exception.ts @@ -0,0 +1,9 @@ +export class PluginActivationError extends Error { + constructor( + readonly plugin: string, + cause: unknown, + ) { + super(`Plugin "${plugin}" failed to activate: ${String(cause)}`, { cause }); + this.name = 'PluginActivationError'; + } +} diff --git a/packages/kernel/src/plugin/plugin-context.type.ts b/packages/kernel/src/plugin/plugin-context.type.ts new file mode 100644 index 00000000..28f639da --- /dev/null +++ b/packages/kernel/src/plugin/plugin-context.type.ts @@ -0,0 +1,77 @@ +import type { CommandHandler, CommandMetadata } from '../command/command.type'; +import type { ContextKeyService } from '../context/context-key-service'; +import type { Container } from '../di/container'; +import type { + ProvideOptions, + ServiceAccessor, + ServiceDecorator, + ServiceFactory, +} from '../di/container.type'; +import type { ServiceToken } from '../di/service-token'; +import type { Contribution, ExtensionPoint } from '../extension/extension-point.type'; +import type { MessageBundle } from '../i18n/localization-service'; +import type { MessageParams } from '../i18n/message-format'; +import type { Disposable, DisposableStore } from '../lifecycle/disposable'; +import type { Logger } from '../log/logger.type'; +import type { Observable } from '../observable/observable'; +import type { AppInfo } from './engine-libs'; +import type { PluginManifest } from './plugin-manifest'; +import type { PluginSourceKind } from './plugin-source.enum'; + +/** + * What a plugin receives on activation. Everything registered through it is tied to the plugin + * lifetime: deactivating the plugin disposes it all. + */ +export interface PluginContext { + readonly name: string; + readonly manifest: PluginManifest; + readonly source: PluginSourceKind; + /** Disposed when the plugin deactivates; add your own disposables here. */ + readonly subscriptions: DisposableStore; + + /** Resolves services: plugin-private ones first, then the editor's. */ + readonly services: ServiceAccessor; + /** Plugin-private container (not visible to other plugins). */ + readonly scope: Container; + readonly contextKeys: ContextKeyService; + readonly logger: Logger; + /** Apps of the project that have every engine lib this plugin requires. */ + readonly eligibleApps: Observable; + + /** Provides a service to the whole editor (other plugins included). */ + provide(token: ServiceToken, value: T, options?: ProvideOptions): Disposable; + provideFactory( + token: ServiceToken, + factory: ServiceFactory, + options?: ProvideOptions, + ): Disposable; + /** Decorates an editor service, e.g. to extend a core feature. */ + override( + token: ServiceToken, + decorate: ServiceDecorator, + options?: ProvideOptions, + ): Disposable; + + contribute(point: ExtensionPoint, value: T, options?: { priority?: number }): Disposable; + contributions(point: ExtensionPoint): Observable[]>; + + registerCommand( + id: string, + handler: CommandHandler, + metadata?: CommandMetadata, + ): Disposable; + executeCommand(id: string, ...args: unknown[]): Promise; + + /** Translates a key of this plugin's namespace. */ + t(key: string, params?: MessageParams): string; + registerMessages(locale: string, messages: MessageBundle): Disposable; + + /** Absolute URL of a file shipped in the plugin package. */ + resolveAsset(path: string): string; +} + +/** What a plugin's client entry exports (see `definePlugin` in the SDK). */ +export interface PluginModule { + activate?(context: PluginContext): void | Promise; + deactivate?(): void | Promise; +} diff --git a/packages/kernel/src/plugin/plugin-host.ts b/packages/kernel/src/plugin/plugin-host.ts new file mode 100644 index 00000000..d6dd02dc --- /dev/null +++ b/packages/kernel/src/plugin/plugin-host.ts @@ -0,0 +1,406 @@ +import { createToken } from '../di/service-token'; +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import type { Logger } from '../log/logger.type'; +import { type Observable } from '../observable/observable'; +import { COMMAND_METADATA } from './command-metadata'; +import { CoreServices } from './core-services.const'; +import { createPluginContext } from './create-plugin-context'; +import { describeStatus } from './describe-status'; +import { bindEngineLibContext } from './engine-libs'; +import { PluginActivationError } from './plugin-activation.exception'; +import type { PluginModule } from './plugin-context.type'; +import type { + PluginHostOptions, + PluginInfo, + PluginModuleLoader, + PluginState, + StaticContributionHandler, +} from './plugin-host.type'; +import type { CommandContribution } from './plugin-manifest'; +import { importPluginLoader } from './plugin-module-loader'; +import type { PluginDescriptor, PluginStatus, Resolution } from './plugin-resolution.type'; +import { resolvePlugins } from './resolve-plugins'; + +interface PluginRecord { + descriptor: PluginDescriptor; + status: PluginStatus; + state: PluginState; + error?: unknown; + generation: number; + module?: PluginModule; + context?: DisposableStore; + staticContributions: DisposableStore; + pending?: Promise; +} + +export class PluginHost implements Disposable { + private readonly _records = new Map(); + private readonly _handlers = new Map(); + private readonly _store = new DisposableStore(); + private readonly _onDidChange = new Emitter(); + private readonly _logger: Logger; + private readonly _loader: PluginModuleLoader; + private _resolution: Resolution | undefined; + + readonly onDidChange: Event = this._onDidChange.event; + + constructor(private readonly _options: PluginHostOptions) { + this._loader = _options.loader ?? importPluginLoader; + this._logger = this._options.services.get(CoreServices.Logger).getLogger('kernel.plugins'); + this._store.add( + this.registerContributionHandler({ + key: 'commands', + apply: (commands, plugin) => { + const store = new DisposableStore(); + for (const command of commands) { + store.add( + this._options.services + .get(CoreServices.Extensions) + .contribute( + COMMAND_METADATA, + { ...command, owner: plugin.manifest.name }, + { owner: plugin.manifest.name }, + ), + ); + } + return store; + }, + }), + ); + this._options.services + .get(CoreServices.Commands) + .setActivator((id) => this.activateByEvent(`onCommand:${id}`)); + } + + registerContributionHandler(handler: StaticContributionHandler): Disposable { + if (this._handlers.has(handler.key)) { + throw new Error(`A handler for contributes.${handler.key} is already registered`); + } + this._handlers.set(handler.key, handler as StaticContributionHandler); + for (const record of this._records.values()) { + if (record.status.kind === 'ok') + this._applyContribution(record, handler as StaticContributionHandler); + } + return toDisposable(() => this._handlers.delete(handler.key)); + } + + get resolution(): Resolution | undefined { + return this._resolution; + } + + getPlugin(name: string): PluginInfo | undefined { + const record = this._records.get(name); + return record && this._info(name, record); + } + + getPlugins(): PluginInfo[] { + return [...this._records].map(([name, record]) => this._info(name, record)); + } + + get plugins(): Observable { + return { + get: () => this.getPlugins(), + subscribe: (run) => { + run(this.getPlugins()); + const subscription = this.onDidChange(() => run(this.getPlugins())); + return () => subscription.dispose(); + }, + }; + } + + /** Resolves plugins, applies their static contributions and activates `onStartup` ones. */ + async start(descriptors: readonly PluginDescriptor[]): Promise { + if (this._resolution) throw new Error('Plugin host already started'); + this._resolution = resolvePlugins(descriptors, this._options); + for (const [name, resolved] of this._resolution.plugins) { + const record: PluginRecord = { + descriptor: resolved.descriptor, + status: resolved.status, + state: 'inactive', + generation: 0, + staticContributions: new DisposableStore(), + }; + this._records.set(name, record); + if (resolved.status.kind !== 'ok') { + this._logger.warn(`Plugin "${name}" not loaded: ${describeStatus(resolved.status)}`); + } + } + for (const descriptor of this._resolution.order) { + this._applyStaticContributions(this._records.get(descriptor.manifest.name)!); + } + const apps = this._options.services.tryGet(CoreServices.EngineLibs)?.apps; + if (apps) { + this._store.add( + bindEngineLibContext(this._options.services.get(CoreServices.ContextKeys), apps, () => + this._resolution!.order.map((d) => d.manifest), + ), + ); + } + this._onDidChange.fire(); + await this.activateByEvent('onStartup'); + } + + /** + * Replaces the set of plugins (a project with its own plugins was opened or closed). Plugins + * that left, changed source or stopped resolving are deactivated and removed; new ones get + * their static contributions. Plugins that were active and still resolve are re-activated, + * then `onStartup` plugins are activated. + */ + async update(descriptors: readonly PluginDescriptor[]): Promise { + if (!this._resolution) throw new Error('Plugin host not started'); + const next = resolvePlugins(descriptors, this._options); + const wasActive = new Set( + [...this._records].filter(([, record]) => record.state === 'active').map(([name]) => name), + ); + const changed = [...this._records].filter(([name, record]) => { + const resolved = next.plugins.get(name); + return ( + !resolved || + resolved.descriptor.baseUrl !== record.descriptor.baseUrl || + resolved.descriptor.manifest.version !== record.descriptor.manifest.version || + resolved.status.kind !== record.status.kind + ); + }); + for (const [name] of changed) await this.deactivate(name); + for (const [name, record] of changed) { + this._records.delete(name); + this._cleanup(name, record); + record.staticContributions.dispose(); + } + + this._resolution = next; + const fresh = new Set(); + for (const [name, resolved] of next.plugins) { + if (this._records.has(name)) continue; + fresh.add(name); + this._records.set(name, { + descriptor: resolved.descriptor, + status: resolved.status, + state: 'inactive', + generation: 0, + staticContributions: new DisposableStore(), + }); + if (resolved.status.kind !== 'ok') { + this._logger.warn(`Plugin "${name}" not loaded: ${describeStatus(resolved.status)}`); + } + } + for (const descriptor of next.order) { + if (fresh.has(descriptor.manifest.name)) + this._applyStaticContributions(this._records.get(descriptor.manifest.name)!); + } + this._onDidChange.fire(); + + const reactivate = [...wasActive].filter( + (name) => this._records.get(name)?.status.kind === 'ok', + ); + const results = await Promise.allSettled(reactivate.map((name) => this.activate(name))); + for (const result of results) { + if (result.status === 'rejected') this._logger.error(String(result.reason)); + } + await this.activateByEvent('onStartup'); + } + + /** Activates every inactive plugin listening to `event` (dependencies first). */ + async activateByEvent(event: string): Promise { + const targets = (this._resolution?.order ?? []) + .map((descriptor) => descriptor.manifest) + .filter((manifest) => manifest.activation.includes(event)) + .map((manifest) => manifest.name); + const results = await Promise.allSettled(targets.map((name) => this.activate(name))); + for (const result of results) { + if (result.status === 'rejected') this._logger.error(String(result.reason)); + } + } + + /** Activates a plugin and its dependencies. Concurrent calls share the same activation. */ + activate(name: string): Promise { + const record = this._records.get(name); + if (!record) return Promise.reject(new Error(`Unknown plugin "${name}"`)); + if (record.status.kind !== 'ok') { + return Promise.reject(new PluginActivationError(name, describeStatus(record.status))); + } + if (record.state === 'active') return Promise.resolve(); + if (record.state === 'failed') + return Promise.reject(new PluginActivationError(name, record.error)); + record.pending ??= this._activate(name, record).finally(() => { + record.pending = undefined; + }); + return record.pending; + } + + /** Deactivates a plugin after its active dependents. */ + async deactivate(name: string): Promise { + const record = this._records.get(name); + if (!record || (record.state !== 'active' && record.state !== 'failed')) return; + for (const dependent of this._dependents(name)) await this.deactivate(dependent); + if (record.state === 'failed') { + record.state = 'inactive'; + record.error = undefined; + this._onDidChange.fire(); + return; + } + record.state = 'deactivating'; + this._onDidChange.fire(); + try { + await record.module?.deactivate?.(); + } catch (error) { + this._logger.error(`Plugin "${name}" failed to deactivate`, error); + } + this._cleanup(name, record); + record.state = 'inactive'; + this._onDidChange.fire(); + } + + /** + * Hot reload (local dev plugins): deactivates the plugin and its dependents, re-applies its + * static contributions from the new manifest, re-imports its code and re-activates what was + * active before. + */ + async reload(name: string, descriptor?: PluginDescriptor): Promise { + const record = this._records.get(name); + if (!record) throw new Error(`Unknown plugin "${name}"`); + const wasActive = [name, ...this._dependentsDeep(name)].filter( + (plugin) => this._records.get(plugin)?.state === 'active', + ); + await this.deactivate(name); + if (descriptor) record.descriptor = descriptor; + record.generation++; + record.module = undefined; + record.state = 'inactive'; + record.staticContributions.clear(); + if (record.status.kind === 'ok') this._applyStaticContributions(record); + for (const plugin of wasActive) await this.activate(plugin); + } + + dispose(): void { + for (const [name, record] of this._records) { + if (record.state === 'active') { + try { + void record.module?.deactivate?.(); + } catch (error) { + this._logger.error(`Plugin "${name}" failed to deactivate`, error); + } + } + this._cleanup(name, record); + record.staticContributions.dispose(); + } + this._records.clear(); + this._options.services.get(CoreServices.Commands).setActivator(undefined); + this._store.dispose(); + this._onDidChange.dispose(); + } + + private async _activate(name: string, record: PluginRecord): Promise { + const { manifest } = record.descriptor; + for (const dependency of Object.keys(manifest.dependencies)) { + await this.activate(dependency); + } + for (const dependency of Object.keys(manifest.optionalDependencies)) { + if (this._records.get(dependency)?.status.kind === 'ok') { + await this.activate(dependency).catch(() => undefined); + } + } + record.state = 'activating'; + this._onDidChange.fire(); + const store = new DisposableStore(); + record.context = store; + try { + record.module ??= await this._loader.load(record.descriptor, record.generation); + const context = createPluginContext(this._options.services, record.descriptor, store); + await withTimeout( + Promise.resolve(record.module.activate?.(context)), + this._options.activationTimeoutMs ?? 10_000, + `activation of "${name}" timed out`, + ); + record.state = 'active'; + this._logger.info(`Plugin "${name}" activated`); + this._onDidChange.fire(); + } catch (error) { + this._cleanup(name, record); + record.state = 'failed'; + record.error = error; + this._logger.error(`Plugin "${name}" failed to activate`, error); + this._onDidChange.fire(); + throw new PluginActivationError(name, error); + } + } + + private _cleanup(name: string, record: PluginRecord): void { + try { + record.context?.dispose(); + } catch (error) { + this._logger.error(`Errors while disposing plugin "${name}"`, error); + } + record.context = undefined; + this._options.services.get(CoreServices.Extensions).removeOwner(name); + record.staticContributions.clear(); + if (record.status.kind === 'ok' && this._records.has(name)) { + this._applyStaticContributions(record); + } + } + + private _applyStaticContributions(record: PluginRecord): void { + const { contributes, name } = record.descriptor.manifest; + for (const key of Object.keys(contributes)) { + const handler = this._handlers.get(key); + if (handler) this._applyContribution(record, handler); + else this._logger.debug(`No handler yet for contributes.${key} of "${name}"`); + } + } + + private _applyContribution(record: PluginRecord, handler: StaticContributionHandler): void { + const { manifest } = record.descriptor; + const raw = (manifest.contributes as Record)[handler.key]; + if (raw === undefined) return; + try { + const value = handler.validator ? handler.validator.parse(raw) : raw; + record.staticContributions.add(handler.apply(value, record.descriptor)); + } catch (error) { + this._logger.error(`Invalid contributes.${handler.key} of "${manifest.name}"`, error); + } + } + + /** Active or activating plugins depending (required or optional) on `name`. */ + private _dependents(name: string): string[] { + return [...this._records] + .filter(([, record]) => { + const { dependencies, optionalDependencies } = record.descriptor.manifest; + return ( + (record.state === 'active' || record.state === 'failed') && + (name in dependencies || name in optionalDependencies) + ); + }) + .map(([dependent]) => dependent); + } + + private _dependentsDeep(name: string, seen = new Set()): string[] { + for (const dependent of this._dependents(name)) { + if (seen.has(dependent)) continue; + seen.add(dependent); + this._dependentsDeep(dependent, seen); + } + return [...seen]; + } + + private _info(name: string, record: PluginRecord): PluginInfo { + return { + name, + descriptor: record.descriptor, + status: record.status, + state: record.state, + ...(record.error === undefined ? {} : { error: record.error }), + }; + } +} + +const withTimeout = (promise: Promise, ms: number, message: string): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +/** The plugin host of the editor (plugin list, enable state), e.g. for a plugins page. */ +export const PluginHostToken = createToken('core.pluginHost'); diff --git a/packages/kernel/src/plugin/plugin-host.type.ts b/packages/kernel/src/plugin/plugin-host.type.ts new file mode 100644 index 00000000..e4b6b8cb --- /dev/null +++ b/packages/kernel/src/plugin/plugin-host.type.ts @@ -0,0 +1,44 @@ +import type { CommandMetadata } from '../command/command.type'; +import type { Container } from '../di/container'; +import type { Validator } from '../extension/extension-point.type'; +import type { Disposable } from '../lifecycle/disposable'; +import type { PluginModule } from './plugin-context.type'; +import type { PluginDescriptor, PluginStatus, ResolveOptions } from './plugin-resolution.type'; + +export type PluginState = 'inactive' | 'activating' | 'active' | 'deactivating' | 'failed'; + +export interface PluginInfo { + readonly name: string; + readonly descriptor: PluginDescriptor; + readonly status: PluginStatus; + readonly state: PluginState; + readonly error?: unknown; +} + +export interface PluginModuleLoader { + /** Imports the client entry. `generation` changes on hot reload to bypass module caches. */ + load(descriptor: PluginDescriptor, generation: number): Promise; +} + +/** + * Handles a static `contributes.` section of manifests. Static contributions are applied + * for every resolved plugin before activation, so the UI can show commands, widgets or settings + * of plugins that activate lazily. + */ +export interface StaticContributionHandler { + readonly key: string; + readonly validator?: Validator; + apply(value: T, plugin: PluginDescriptor): Disposable; +} + +export interface CommandMetadataContribution extends CommandMetadata { + readonly id: string; + readonly owner: string; +} + +export interface PluginHostOptions extends ResolveOptions { + /** Editor-wide container; plugin scopes are created as its children. */ + services: Container; + loader?: PluginModuleLoader; + activationTimeoutMs?: number; +} diff --git a/packages/kernel/src/plugin/plugin-manifest.exception.ts b/packages/kernel/src/plugin/plugin-manifest.exception.ts new file mode 100644 index 00000000..88aff76a --- /dev/null +++ b/packages/kernel/src/plugin/plugin-manifest.exception.ts @@ -0,0 +1,12 @@ +export class PluginManifestError extends Error { + constructor( + readonly issues: readonly { path: string; message: string }[], + readonly location?: string, + ) { + super( + `Invalid plugin manifest${location ? ` (${location})` : ''}:\n` + + issues.map((issue) => ` - ${issue.path || ''}: ${issue.message}`).join('\n'), + ); + this.name = 'PluginManifestError'; + } +} diff --git a/packages/kernel/src/plugin/plugin-manifest.ts b/packages/kernel/src/plugin/plugin-manifest.ts new file mode 100644 index 00000000..25fd842a --- /dev/null +++ b/packages/kernel/src/plugin/plugin-manifest.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { + ActivationEvent, + EntryPath, + NpmRanges, + PluginDependencies, + PluginName, + Range, + Version, +} from './manifest-fields'; +import { PluginManifestError } from './plugin-manifest.exception'; + +const CommandContribution = z.object({ + id: z.string().min(1), + title: z.string().min(1), + category: z.string().optional(), + icon: z.string().optional(), + when: z.string().optional(), + /** `false`: not listed in the command palette (the command needs arguments). */ + palette: z.boolean().optional(), +}); + +/** + * `nanoforge.manifest.json` of a package of type `plugin`. + * Contributions other than `commands` are validated by the service that owns them + * (settings, widgets, menus…) through contribution handlers. + */ +export const PluginManifestSchema = z.object({ + $schema: z.string().optional(), + type: z.literal('plugin'), + name: PluginName, + version: Version, + displayName: z.string().min(1).optional(), + description: z.string().optional(), + tags: z.array(z.string()).default([]), + icon: EntryPath.optional(), + license: z.string().optional(), + repository: z.string().optional(), + + engines: z.object({ editor: Range }), + dependencies: PluginDependencies, + optionalDependencies: PluginDependencies, + engineLibs: z + .object({ required: NpmRanges, optional: NpmRanges }) + .default({ required: {}, optional: {} }), + + entry: z + .object({ + client: EntryPath.optional(), + server: EntryPath.optional(), + worker: EntryPath.optional(), + }) + .refine((entry) => entry.client || entry.server || entry.worker, { + message: 'at least one of client, server or worker is required', + }), + /** Defaults to `["onStartup"]`. */ + activation: z.array(ActivationEvent).default(['onStartup']), + contributes: z + .object({ + commands: z.array(CommandContribution).default([]), + /** The plugin owns item data (ADR 0003): its tags, and the version of its owner object. */ + itemOwner: z + .object({ + schema: z.number().int().positive(), + tags: z.array(z.string().regex(/^[a-z][A-Za-z0-9]*$/)).default([]), + }) + .optional(), + }) + .catchall(z.unknown()) + .default({ commands: [] }), + + /** Filled by `@nanoforge-dev/editor-vite-plugin` at build time. */ + build: z.object({ svelte: Version.optional(), sdk: Version.optional() }).default({}), +}); + +export type PluginManifest = z.output; + +export type PluginManifestInput = z.input; +export type CommandContribution = z.output; + +export const parsePluginManifest = (value: unknown, location?: string): PluginManifest => { + const result = PluginManifestSchema.safeParse(value); + if (result.success) return result.data; + throw new PluginManifestError( + result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })), + location, + ); +}; + +/** JSON Schema of the manifest, for editor completion (`$schema` in manifests). */ +export const pluginManifestJsonSchema = (): unknown => + z.toJSONSchema(PluginManifestSchema, { io: 'input', unrepresentable: 'any' }); diff --git a/packages/kernel/src/plugin/plugin-module-loader.ts b/packages/kernel/src/plugin/plugin-module-loader.ts new file mode 100644 index 00000000..b1ab5e4e --- /dev/null +++ b/packages/kernel/src/plugin/plugin-module-loader.ts @@ -0,0 +1,24 @@ +import type { PluginModule } from './plugin-context.type'; +import type { PluginModuleLoader } from './plugin-host.type'; + +/** + * A plugin entry exports `activate`/`deactivate`, or a default export holding them + * (`export default definePlugin({...})`). + */ +export const normalizePluginModule = (imported: unknown): PluginModule => { + const module = (imported ?? {}) as PluginModule & { default?: unknown }; + if (module.activate || module.deactivate) return module; + const fallback = module.default as PluginModule | undefined; + return fallback && typeof fallback === 'object' ? fallback : {}; +}; + +/** Loads client entries with a dynamic `import()` of `/`. */ +export const importPluginLoader: PluginModuleLoader = { + async load(descriptor, generation) { + const entry = descriptor.manifest.entry.client; + if (!entry) return {}; + const url = new URL(entry, descriptor.baseUrl); + if (generation > 0) url.searchParams.set('v', String(generation)); + return normalizePluginModule(await import(/* @vite-ignore */ url.href)); + }, +}; diff --git a/packages/kernel/src/plugin/plugin-resolution.type.ts b/packages/kernel/src/plugin/plugin-resolution.type.ts new file mode 100644 index 00000000..8757e6b2 --- /dev/null +++ b/packages/kernel/src/plugin/plugin-resolution.type.ts @@ -0,0 +1,41 @@ +import type { PluginManifest } from './plugin-manifest'; +import type { PluginSourceKind } from './plugin-source.enum'; + +export interface PluginDescriptor { + readonly manifest: PluginManifest; + readonly source: PluginSourceKind; + /** URL of the plugin package root, entry paths are resolved against it. */ + readonly baseUrl: string; +} + +export type PluginStatus = + | { kind: 'ok' } + | { kind: 'disabled' } + | { kind: 'shadowed'; by: PluginSourceKind } + | { kind: 'incompatible-editor'; required: string; actual: string } + | { kind: 'incompatible-runtime'; module: string; compiled: string; actual: string } + | { kind: 'missing-dependency'; dependency: string; range: string } + | { kind: 'dependency-version'; dependency: string; range: string; actual: string } + | { kind: 'dependency-failed'; dependency: string } + | { kind: 'cycle'; cycle: readonly string[] }; + +export interface ResolveOptions { + editorVersion: string; + /** Versions of the shared runtime modules of the host, e.g. `{ svelte: '5.57.1' }`. */ + runtimeVersions?: Readonly>; + disabled?: ReadonlySet; +} + +export interface ResolvedPlugin { + readonly descriptor: PluginDescriptor; + readonly status: PluginStatus; +} + +export interface Resolution { + /** Every candidate by name (the winning source for shadowed names). */ + readonly plugins: ReadonlyMap; + /** Plugins shadowed by another source. */ + readonly shadowed: readonly ResolvedPlugin[]; + /** `ok` plugins in activation order: dependencies (and present optional ones) first. */ + readonly order: readonly PluginDescriptor[]; +} diff --git a/packages/kernel/src/plugin/plugin-source.enum.ts b/packages/kernel/src/plugin/plugin-source.enum.ts new file mode 100644 index 00000000..f116f32d --- /dev/null +++ b/packages/kernel/src/plugin/plugin-source.enum.ts @@ -0,0 +1,3 @@ +/** Where a plugin comes from. Later sources shadow earlier ones with the same name. */ +export const PLUGIN_SOURCE_ORDER = ['bundled', 'installed', 'project', 'dev'] as const; +export type PluginSourceKind = (typeof PLUGIN_SOURCE_ORDER)[number]; diff --git a/packages/kernel/src/plugin/resolve-plugins.ts b/packages/kernel/src/plugin/resolve-plugins.ts new file mode 100644 index 00000000..cb777a82 --- /dev/null +++ b/packages/kernel/src/plugin/resolve-plugins.ts @@ -0,0 +1,178 @@ +import semver from 'semver'; + +import type { + PluginDescriptor, + PluginStatus, + Resolution, + ResolveOptions, + ResolvedPlugin, +} from './plugin-resolution.type'; +import { PLUGIN_SOURCE_ORDER } from './plugin-source.enum'; + +const satisfies = (version: string, range: string) => + semver.satisfies(version, range, { includePrerelease: true }); + +/** + * A compiled Svelte component calls runtime internals: it must have been compiled with the same + * major and a version not newer than the host runtime (docs/adr/0001-plugin-ui-runtime.md). + */ +const runtimeCompatible = (compiled: string, actual: string) => + semver.major(compiled) === semver.major(actual) && semver.lte(compiled, actual); + +const optionalOk = (plugins: Map, name: string, range: string) => { + const dependency = plugins.get(name); + return ( + dependency?.status.kind === 'ok' && satisfies(dependency.descriptor.manifest.version, range) + ); +}; + +export const resolvePlugins = ( + descriptors: readonly PluginDescriptor[], + options: ResolveOptions, +): Resolution => { + const candidates = new Map(); + const shadowed: ResolvedPlugin[] = []; + const rank = (d: PluginDescriptor) => PLUGIN_SOURCE_ORDER.indexOf(d.source); + for (const descriptor of [...descriptors].sort((a, b) => rank(a) - rank(b))) { + const previous = candidates.get(descriptor.manifest.name); + if (previous) { + shadowed.push({ descriptor: previous, status: { kind: 'shadowed', by: descriptor.source } }); + } + candidates.set(descriptor.manifest.name, descriptor); + } + + const plugins = new Map(); + for (const [name, descriptor] of candidates) { + plugins.set(name, { descriptor, status: checkPlugin(descriptor, candidates, options) }); + } + + for (const cycle of findCycles(plugins)) { + for (const name of cycle) { + plugins.set(name, { + descriptor: plugins.get(name)!.descriptor, + status: { kind: 'cycle', cycle }, + }); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const [name, plugin] of plugins) { + if (plugin.status.kind !== 'ok') continue; + const failed = Object.keys(plugin.descriptor.manifest.dependencies).find( + (dependency) => plugins.get(dependency)?.status.kind !== 'ok', + ); + if (failed) { + plugins.set(name, { + descriptor: plugin.descriptor, + status: { kind: 'dependency-failed', dependency: failed }, + }); + changed = true; + } + } + } + + return { plugins, shadowed, order: activationOrder(plugins) }; +}; + +const checkPlugin = ( + descriptor: PluginDescriptor, + candidates: ReadonlyMap, + options: ResolveOptions, +): PluginStatus => { + const { manifest } = descriptor; + if (options.disabled?.has(manifest.name)) return { kind: 'disabled' }; + if (!satisfies(options.editorVersion, manifest.engines.editor)) { + return { + kind: 'incompatible-editor', + required: manifest.engines.editor, + actual: options.editorVersion, + }; + } + for (const [module, compiled] of Object.entries(manifest.build)) { + const actual = options.runtimeVersions?.[module]; + if (compiled && actual && !runtimeCompatible(compiled, actual)) { + return { kind: 'incompatible-runtime', module, compiled, actual }; + } + } + for (const [dependency, range] of Object.entries(manifest.dependencies)) { + const found = candidates.get(dependency); + if (!found) return { kind: 'missing-dependency', dependency, range }; + if (!satisfies(found.manifest.version, range)) { + return { kind: 'dependency-version', dependency, range, actual: found.manifest.version }; + } + } + return { kind: 'ok' }; +}; + +/** Edges used for ordering: required dependencies and valid optional ones. */ +const edges = (plugins: Map, plugin: ResolvedPlugin): string[] => { + const { dependencies, optionalDependencies } = plugin.descriptor.manifest; + return [ + ...Object.keys(dependencies).filter((name) => plugins.has(name)), + ...Object.entries(optionalDependencies) + .filter(([name, range]) => optionalOk(plugins, name, range)) + .map(([name]) => name), + ]; +}; + +/** Strongly connected components with more than one node (or a self loop), via Tarjan. */ +const findCycles = (plugins: Map): string[][] => { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const cycles: string[][] = []; + let counter = 0; + + const visit = (name: string) => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + for (const next of edges(plugins, plugins.get(name)!)) { + if (plugins.get(next)?.status.kind !== 'ok') continue; + if (!index.has(next)) { + visit(next); + low.set(name, Math.min(low.get(name)!, low.get(next)!)); + } else if (onStack.has(next)) { + low.set(name, Math.min(low.get(name)!, index.get(next)!)); + } + } + if (low.get(name) !== index.get(name)) return; + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== name); + const selfLoop = edges(plugins, plugins.get(name)!).includes(name); + if (component.length > 1 || selfLoop) cycles.push(component.sort()); + }; + + for (const [name, plugin] of [...plugins].sort(([a], [b]) => a.localeCompare(b))) { + if (plugin.status.kind === 'ok' && !index.has(name)) visit(name); + } + return cycles; +}; + +/** Kahn's algorithm over ok plugins; ties broken by name for a deterministic order. */ +const activationOrder = (plugins: Map): PluginDescriptor[] => { + const ok = [...plugins].filter(([, plugin]) => plugin.status.kind === 'ok'); + const pending = new Map(ok.map(([name, plugin]) => [name, new Set(edges(plugins, plugin))])); + const order: PluginDescriptor[] = []; + while (pending.size) { + const ready = [...pending] + .filter(([, deps]) => [...deps].every((dep) => !pending.has(dep))) + .map(([name]) => name) + .sort(); + if (!ready.length) break; // unreachable: cycles were removed + for (const name of ready) { + pending.delete(name); + order.push(plugins.get(name)!.descriptor); + } + } + return order; +}; diff --git a/packages/kernel/src/plugin/shared-module-registry.ts b/packages/kernel/src/plugin/shared-module-registry.ts new file mode 100644 index 00000000..aa1ad41a --- /dev/null +++ b/packages/kernel/src/plugin/shared-module-registry.ts @@ -0,0 +1,55 @@ +/** + * Global under which the host exposes its shared modules to editor plugins. + * Must match `SHARED_GLOBAL` of `@nanoforge-dev/editor-vite-plugin`. + */ +export const SHARED_GLOBAL = '__nanoforge_editor_shared__'; + +export type ModuleNamespace = Readonly>; + +export interface SharedModuleScope { + require(id: string): ModuleNamespace; +} + +/** + * Registry of the modules (Svelte runtime, editor SDK…) that plugins share with the host. + * Plugins built with the editor vite plugin resolve these imports through the installed + * registry instead of bundling their own copy. + */ +export class SharedModuleRegistry implements SharedModuleScope { + private readonly _modules = new Map(); + + register(id: string, namespace: ModuleNamespace): this { + if (this._modules.has(id)) throw new Error(`Shared module "${id}" is already registered`); + this._modules.set(id, namespace); + return this; + } + + has(id: string): boolean { + return this._modules.has(id); + } + + require(id: string): ModuleNamespace { + const namespace = this._modules.get(id); + if (!namespace) { + throw new Error(`Shared module "${id}" is not provided by this editor version`); + } + return namespace; + } + + get ids(): string[] { + return [...this._modules.keys()]; + } + + /** Exposes the registry to plugin code. Only one registry can be installed at a time. */ + install(target: object = globalThis): () => void { + const holder = target as Record; + if (holder[SHARED_GLOBAL]) { + throw new Error('Another shared module registry is already installed'); + } + const scope: SharedModuleScope = { require: (id) => this.require(id) }; + Object.defineProperty(holder, SHARED_GLOBAL, { value: scope, configurable: true }); + return () => { + Reflect.deleteProperty(holder, SHARED_GLOBAL); + }; + } +} diff --git a/packages/kernel/test/command/command-registry.spec.ts b/packages/kernel/test/command/command-registry.spec.ts new file mode 100644 index 00000000..f75a1cbe --- /dev/null +++ b/packages/kernel/test/command/command-registry.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CommandNotFoundError } from '../../src/command/command-not-found.exception'; +import { CommandPreconditionError } from '../../src/command/command-precondition.exception'; +import { CommandRegistry } from '../../src/command/command-registry'; +import { ContextKeyService } from '../../src/context/context-key-service'; +import { Container } from '../../src/di/container'; +import { createToken } from '../../src/di/service-token'; + +const Counter = createToken<{ value: number }>('counter'); + +const setup = () => { + const services = new Container(); + services.provide(Counter, { value: 0 }); + const context = new ContextKeyService(); + return { services, context, commands: new CommandRegistry(services, context) }; +}; + +describe('CommandRegistry', () => { + it('runs handlers with services and args', async () => { + const { commands } = setup(); + commands.register({ + id: 'counter.add', + handler: (s, n: number) => (s.get(Counter).value += n), + }); + await expect(commands.execute('counter.add', 2)).resolves.toBe(2); + }); + + it('rejects duplicates and unknown commands', async () => { + const { commands } = setup(); + const sub = commands.register({ id: 'a', handler: () => undefined }); + expect(() => commands.register({ id: 'a', handler: () => undefined })).toThrow(); + sub.dispose(); + await expect(commands.execute('a')).rejects.toThrow(CommandNotFoundError); + }); + + it('checks preconditions', async () => { + const { commands, context } = setup(); + commands.register({ id: 'runtime.pause', when: 'playing', handler: () => 'paused' }); + expect(commands.isEnabled('runtime.pause')).toBe(false); + await expect(commands.execute('runtime.pause')).rejects.toThrow(CommandPreconditionError); + context.set('playing', true); + await expect(commands.execute('runtime.pause')).resolves.toBe('paused'); + }); + + it('activates lazily and reports executions', async () => { + const { commands } = setup(); + const executions = vi.fn(); + commands.onDidExecute(executions); + commands.setActivator(async (id) => { + commands.register({ id, handler: () => 'late' }); + }); + await expect(commands.execute('plugin.cmd')).resolves.toBe('late'); + commands.register({ + id: 'fails', + handler: () => { + throw new Error('x'); + }, + }); + await expect(commands.execute('fails')).rejects.toThrow('x'); + expect(executions.mock.calls.map(([e]) => [e.id, 'error' in e])).toEqual([ + ['plugin.cmd', false], + ['fails', true], + ]); + }); +}); diff --git a/packages/kernel/test/context/context-key-service.spec.ts b/packages/kernel/test/context/context-key-service.spec.ts new file mode 100644 index 00000000..1e7f6c59 --- /dev/null +++ b/packages/kernel/test/context/context-key-service.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ContextKeyService } from '../../src/context/context-key-service'; + +describe('ContextKeyService', () => { + it('scopes shadow their parent', () => { + const global = new ContextKeyService(); + global.set('screen', 'scene'); + const widget = global.createScoped('widget'); + widget.set('focus', 'hierarchy'); + expect(widget.evaluate("screen == scene && focus == 'hierarchy'")).toBe(true); + expect(global.evaluate('focus')).toBe(false); + widget.set('screen', 'game'); + expect(widget.get('screen')).toBe('game'); + expect(global.get('screen')).toBe('scene'); + }); + + it('bind restores the previous value', () => { + const context = new ContextKeyService(); + context.set('mode', 'edit'); + const binding = context.bind('mode', 'play'); + expect(context.get('mode')).toBe('play'); + binding.dispose(); + expect(context.get('mode')).toBe('edit'); + const temp = context.bind('tmp', 1); + temp.dispose(); + expect(context.get('tmp')).toBeUndefined(); + }); + + it('observe re-evaluates only on relevant keys', () => { + const global = new ContextKeyService(); + const scoped = global.createScoped('s'); + const run = vi.fn(); + scoped.observe('playing && !paused').subscribe(run); + global.set('playing', true); + global.set('unrelated', 1); + scoped.set('paused', true); + global.set('paused', false); // shadowed by the scope: no change + expect(run.mock.calls).toEqual([[false], [true], [false]]); + }); + + it('undefined when clauses are always true', () => { + const context = new ContextKeyService(); + expect(context.evaluate(undefined)).toBe(true); + expect(context.observe(undefined).get()).toBe(true); + }); +}); diff --git a/packages/kernel/test/context/parse-when.spec.ts b/packages/kernel/test/context/parse-when.spec.ts new file mode 100644 index 00000000..03c1c312 --- /dev/null +++ b/packages/kernel/test/context/parse-when.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWhen } from '../../src/context/parse-when'; +import { WhenParseError } from '../../src/context/when-parse.exception'; + +const ctx: Record = { + editorFocus: true, + screen: 'scene', + count: 2, + 'engineLib:@nanoforge-dev/ecs': true, + 'plugin.@nanoforge/ecs.eligible': false, + resourceName: 'player.component.ts', + selection: 'entity', + selectable: ['entity', 'component'], + apps: { client: {}, server: {} }, + app: 'server', +}; +const run = (source: string) => parseWhen(source).evaluate((key) => ctx[key]); + +describe('when clauses', () => { + it.each([ + ['editorFocus', true], + ['!editorFocus', false], + ['missing', false], + ['true && !false', true], + ["screen == 'scene'", true], + ['screen == scene', true], + ['screen != "game"', true], + ['count == 2', true], + ['count == 3', false], + ['engineLib:@nanoforge-dev/ecs && !plugin.@nanoforge/ecs.eligible', true], + ['resourceName =~ /\\.component\\.ts$/', true], + ['resourceName =~ /\\.SYSTEM\\.ts$/i', false], + ['selection in selectable', true], + ['app in apps', true], + ['screen == game || (editorFocus && count == 2)', true], + ['!(editorFocus || missing)', false], + ])('%s → %s', (source, expected) => { + expect(run(source)).toBe(expected); + }); + + it('reports read keys', () => { + expect([...parseWhen('a && (b == x || !c) && d in e').keys]).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it.each(['a &&', '(a', 'a == ', "a == 'x", 'a =~ b', 'a b', '#'])('rejects %s', (source) => { + expect(() => parseWhen(source)).toThrow(WhenParseError); + }); +}); diff --git a/packages/kernel/test/di/container.spec.ts b/packages/kernel/test/di/container.spec.ts new file mode 100644 index 00000000..6cd2eff7 --- /dev/null +++ b/packages/kernel/test/di/container.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Container } from '../../src/di/container'; +import { CyclicDependencyError } from '../../src/di/cyclic-dependency.exception'; +import { ServiceNotFoundError } from '../../src/di/service-not-found.exception'; +import { createToken } from '../../src/di/service-token'; +import { toDisposable } from '../../src/lifecycle/disposable'; + +interface Greeter { + greet(): string; +} +const Greeter = createToken('greeter'); +const Name = createToken('name'); + +describe('Container', () => { + it('resolves values and lazy factories once', () => { + const container = new Container(); + const factory = vi.fn(() => ({ greet: () => 'hi' })); + container.provideFactory(Greeter, factory); + expect(factory).not.toHaveBeenCalled(); + expect(container.get(Greeter)).toBe(container.get(Greeter)); + expect(factory).toHaveBeenCalledOnce(); + expect(() => container.get(Name)).toThrow(ServiceNotFoundError); + expect(container.tryGet(Name)).toBeUndefined(); + }); + + it('picks the highest priority, then the latest registration', () => { + const container = new Container(); + container.provide(Name, 'default'); + const high = container.provide(Name, 'plugin', { priority: 10 }); + container.provide(Name, 'late'); + expect(container.get(Name)).toBe('plugin'); + high.dispose(); + expect(container.get(Name)).toBe('late'); + expect(container.getAll(Name)).toEqual(['default', 'late']); + }); + + it('applies overrides from the root down to the requesting scope', () => { + const root = new Container(); + root.provide(Name, 'base'); + root.override(Name, (inner) => `${inner}+root`); + const child = root.createChild('project'); + child.override(Name, (inner) => `${inner}+child`, { priority: 5 }); + root.override(Name, (inner) => `${inner}+root2`, { priority: 1 }); + expect(root.get(Name)).toBe('base+root+root2'); + expect(child.get(Name)).toBe('base+root+root2+child'); + }); + + it('children shadow their parent and see parent changes', () => { + const root = new Container(); + root.provide(Name, 'root'); + const child = root.createChild('child'); + expect(child.get(Name)).toBe('root'); + root.provide(Name, 'root2'); + expect(child.get(Name)).toBe('root2'); + const local = child.provide(Name, 'child'); + expect(child.get(Name)).toBe('child'); + expect(root.get(Name)).toBe('root2'); + local.dispose(); + expect(child.get(Name)).toBe('root2'); + }); + + it('factories resolve dependencies through their owner', () => { + const root = new Container(); + root.provide(Name, 'world'); + root.provideFactory(Greeter, (s) => ({ greet: () => `hello ${s.get(Name)}` })); + const child = root.createChild('c'); + child.provide(Name, 'child'); // does not affect the root singleton + expect(child.get(Greeter).greet()).toBe('hello world'); + }); + + it('detects cycles', () => { + const A = createToken('a'); + const B = createToken('b'); + const container = new Container(); + container.provideFactory(A, (s) => s.get(B) + 1); + container.provideFactory(B, (s) => s.get(A) + 1); + expect(() => container.get(A)).toThrow(CyclicDependencyError); + expect(() => container.get(A)).toThrow('a -> b -> a'); + }); + + it('observe follows provider changes', () => { + const container = new Container(); + const run = vi.fn(); + container.observe(Name).subscribe(run); + const provider = container.provide(Name, 'a'); + container.provide(Greeter, { greet: () => '' }); + provider.dispose(); + expect(run.mock.calls).toEqual([[undefined], ['a'], [undefined]]); + }); + + it('disposes factory instances, children, and refuses use after dispose', () => { + const root = new Container(); + const disposed = vi.fn(); + const Service = createToken<{ dispose(): void }>('svc'); + root.provideFactory(Service, () => toDisposable(disposed)); + const child = root.createChild('child'); + root.get(Service); + root.dispose(); + expect(disposed).toHaveBeenCalledOnce(); + expect(() => child.get(Name)).toThrow(/disposed/); + }); + + it('disposes the instance when its provider is removed', () => { + const container = new Container(); + const disposed = vi.fn(); + const Service = createToken<{ dispose(): void }>('svc'); + const provider = container.provideFactory(Service, () => toDisposable(disposed)); + container.get(Service); + provider.dispose(); + expect(disposed).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kernel/test/event/emitter.spec.ts b/packages/kernel/test/event/emitter.spec.ts new file mode 100644 index 00000000..52bcbf99 --- /dev/null +++ b/packages/kernel/test/event/emitter.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Emitter, setListenerErrorHandler } from '../../src/event/emitter'; +import { EventBus } from '../../src/event/event-bus'; +import { Events } from '../../src/event/events'; +import { type Disposable, DisposableStore } from '../../src/lifecycle/disposable'; + +describe('Emitter', () => { + afterEach(() => setListenerErrorHandler(console.error)); + + it('delivers to listeners until they unsubscribe', () => { + const emitter = new Emitter(); + const fn = vi.fn(); + const sub = emitter.event(fn); + emitter.fire(1); + sub.dispose(); + emitter.fire(2); + expect(fn).toHaveBeenCalledExactlyOnceWith(1); + }); + + it('isolates listener errors', () => { + const onError = vi.fn(); + setListenerErrorHandler(onError); + const emitter = new Emitter(); + const fn = vi.fn(); + emitter.event(() => { + throw new Error('boom'); + }); + emitter.event(fn); + emitter.fire(); + expect(onError).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('calls first/last listener hooks', () => { + const first = vi.fn(); + const last = vi.fn(); + const emitter = new Emitter({ onFirstListenerAdd: first, onLastListenerRemove: last }); + const a = emitter.event(() => undefined); + const b = emitter.event(() => undefined); + a.dispose(); + expect(last).not.toHaveBeenCalled(); + b.dispose(); + expect(first).toHaveBeenCalledOnce(); + expect(last).toHaveBeenCalledOnce(); + }); + + it('does not deliver to a listener removed during the same fire', () => { + const emitter = new Emitter(); + const late = vi.fn(); + let lateSub: Disposable = { dispose: () => undefined }; + emitter.event(() => lateSub.dispose()); + lateSub = emitter.event(late); + emitter.fire(); + expect(late).not.toHaveBeenCalled(); + }); + + it('registers subscriptions in a store', () => { + const emitter = new Emitter(); + const store = new DisposableStore(); + const fn = vi.fn(); + emitter.event(fn, store); + store.dispose(); + emitter.fire(1); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe('Events', () => { + it('once, map and filter', () => { + const emitter = new Emitter(); + const once = vi.fn(); + const mapped = vi.fn(); + Events.once(emitter.event)(once); + Events.map( + Events.filter(emitter.event, (n) => n > 1), + (n) => n * 10, + )(mapped); + emitter.fire(1); + emitter.fire(2); + expect(once).toHaveBeenCalledExactlyOnceWith(1); + expect(mapped).toHaveBeenCalledExactlyOnceWith(20); + }); + + it('debounce merges bursts', () => { + vi.useFakeTimers(); + const emitter = new Emitter(); + const fn = vi.fn(); + Events.debounce(emitter.event, (all = [], n) => [...all, n], 50)(fn); + emitter.fire(1); + emitter.fire(2); + vi.advanceTimersByTime(49); + emitter.fire(3); + vi.advanceTimersByTime(50); + expect(fn).toHaveBeenCalledExactlyOnceWith([1, 2, 3]); + vi.useRealTimers(); + }); + + it('toPromise resolves with the next value', async () => { + const emitter = new Emitter(); + const promise = Events.toPromise(emitter.event); + emitter.fire('ok'); + await expect(promise).resolves.toBe('ok'); + }); +}); + +describe('EventBus', () => { + it('routes typed events by key', () => { + const bus = new EventBus<{ 'test.ping': { n: number } }>(); + const fn = vi.fn(); + const sub = bus.on('test.ping', fn); + bus.emit('test.ping', { n: 1 }); + sub.dispose(); + bus.emit('test.ping', { n: 2 }); + expect(fn).toHaveBeenCalledExactlyOnceWith({ n: 1 }); + }); +}); diff --git a/packages/kernel/test/extension/extension-registry.spec.ts b/packages/kernel/test/extension/extension-registry.spec.ts new file mode 100644 index 00000000..f5b60ad3 --- /dev/null +++ b/packages/kernel/test/extension/extension-registry.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ContributionError } from '../../src/extension/contribution.exception'; +import { defineExtensionPoint } from '../../src/extension/define-extension-point'; +import { ExtensionRegistry } from '../../src/extension/extension-registry'; + +interface Mode { + id: string; +} + +describe('ExtensionRegistry', () => { + it('orders contributions by priority then registration', () => { + const point = defineExtensionPoint('viewport.modes'); + const registry = new ExtensionRegistry(); + registry.contribute(point, { id: 'game' }, { owner: 'viewport' }); + registry.contribute(point, { id: 'scene' }, { owner: 'ecs', priority: 10 }); + registry.contribute(point, { id: 'debug' }, { owner: 'inspectors' }); + expect(registry.getValues(point).map((m) => m.id)).toEqual(['scene', 'game', 'debug']); + }); + + it('keeps only the best contribution of single-valued points', () => { + const point = defineExtensionPoint('codegen.target', { multiple: false }); + const registry = new ExtensionRegistry(); + registry.contribute(point, 'entry-file', { owner: 'ecs' }); + const scene = registry.contribute(point, 'scene-files', { owner: 'scene', priority: 10 }); + expect(registry.getValues(point)).toEqual(['scene-files']); + scene.dispose(); + expect(registry.getValues(point)).toEqual(['entry-file']); + }); + + it('validates contributions', () => { + const point = defineExtensionPoint('numbers', { + validator: { + parse: (v) => { + if (typeof v !== 'number') throw new Error('not a number'); + return v; + }, + }, + }); + const registry = new ExtensionRegistry(); + expect(() => registry.contribute(point, 'x' as never, { owner: 'bad' })).toThrow( + ContributionError, + ); + expect(registry.getValues(point)).toEqual([]); + }); + + it('notifies observers and removes by owner', () => { + const point = defineExtensionPoint('menus'); + const registry = new ExtensionRegistry(); + const run = vi.fn(); + registry.observe(point).subscribe((list) => run(list.map((c) => c.value))); + registry.contribute(point, 'a', { owner: 'p1' }); + registry.contribute(point, 'b', { owner: 'p2' }); + registry.removeOwner('p1'); + expect(run.mock.calls).toEqual([[[]], [['a']], [['a', 'b']], [['b']]]); + }); +}); diff --git a/packages/kernel/test/i18n/localization-service.spec.ts b/packages/kernel/test/i18n/localization-service.spec.ts new file mode 100644 index 00000000..5d770017 --- /dev/null +++ b/packages/kernel/test/i18n/localization-service.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LocalizationService } from '../../src/i18n/localization-service'; +import { formatMessage } from '../../src/i18n/message-format'; + +describe('formatMessage', () => { + it.each([ + ['Hello {name}', { name: 'Ada' }, 'Hello Ada'], + ['Missing {x}', {}, 'Missing {x}'], + ['{n, plural, =0 {no entity} one {# entity} other {# entities}}', { n: 0 }, 'no entity'], + ['{n, plural, one {# entity} other {# entities}}', { n: 1 }, '1 entity'], + ['{n, plural, one {# entity} other {# entities}}', { n: 1200 }, '1,200 entities'], + ['{kind, select, client {Client} server {Server} other {Lib}}', { kind: 'server' }, 'Server'], + ['{kind, select, client {Client} other {Lib {name}}}', { kind: 'x', name: 'core' }, 'Lib core'], + ["Escaped '{brace}", {}, 'Escaped {brace}'], + ])('%s', (message, params, expected) => { + expect(formatMessage(message, params)).toBe(expected); + }); + + it('uses locale plural rules', () => { + expect(formatMessage('{n, plural, one {# fichier} other {# fichiers}}', { n: 0 }, 'fr')).toBe( + '0 fichier', + ); + }); +}); + +describe('LocalizationService', () => { + it('falls back from region to language to english to key', () => { + const i18n = new LocalizationService(); + i18n.registerBundle('core', 'en', { save: 'Save', quit: 'Quit' }); + i18n.registerBundle('core', 'fr', { save: 'Enregistrer' }); + const t = i18n.scope('core'); + i18n.setLocale('fr-CA'); + expect(t('save')).toBe('Enregistrer'); + expect(t('quit')).toBe('Quit'); + expect(t('unknown')).toBe('unknown'); + expect(i18n.locales).toEqual(['en', 'fr']); + }); + + it('later bundles override earlier ones until disposed', () => { + const i18n = new LocalizationService(); + const revision = vi.fn(); + i18n.revision.subscribe(revision); + i18n.registerBundle('ecs', 'en', { title: 'Entities' }); + const patch = i18n.registerBundle('ecs', 'en', { title: 'Actors' }); + expect(i18n.translate('ecs', 'title')).toBe('Actors'); + patch.dispose(); + expect(i18n.translate('ecs', 'title')).toBe('Entities'); + expect(revision).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/kernel/test/lifecycle/disposable.spec.ts b/packages/kernel/test/lifecycle/disposable.spec.ts new file mode 100644 index 00000000..fe4b477d --- /dev/null +++ b/packages/kernel/test/lifecycle/disposable.spec.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type Disposable, + DisposableStore, + combineDisposables, + disposeAll, + setDisposableTracker, + toDisposable, +} from '../../src/lifecycle/disposable'; +import { MutableDisposable } from '../../src/lifecycle/mutable-disposable'; + +describe('toDisposable', () => { + it('runs the cleanup once', () => { + const fn = vi.fn(); + const d = toDisposable(fn); + d.dispose(); + d.dispose(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('supports `using`', () => { + const fn = vi.fn(); + { + using d = toDisposable(fn) as Disposable & { [Symbol.dispose](): void }; + expect(d).toBeDefined(); + expect(fn).not.toHaveBeenCalled(); + } + expect(fn).toHaveBeenCalledOnce(); + }); +}); + +describe('DisposableStore', () => { + it('disposes in reverse order', () => { + const order: number[] = []; + const store = new DisposableStore(); + store.add(toDisposable(() => order.push(1))); + store.add(toDisposable(() => order.push(2))); + store.dispose(); + expect(order).toEqual([2, 1]); + expect(store.isDisposed).toBe(true); + }); + + it('disposes items added after disposal immediately', () => { + const store = new DisposableStore(); + store.dispose(); + const fn = vi.fn(); + store.add(toDisposable(fn)); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('clear keeps the store usable', () => { + const store = new DisposableStore(); + const a = vi.fn(); + store.add(toDisposable(a)); + store.clear(); + expect(a).toHaveBeenCalledOnce(); + const b = vi.fn(); + store.add(toDisposable(b)); + expect(b).not.toHaveBeenCalled(); + expect(store.size).toBe(1); + }); + + it('disposes everything even if one throws', () => { + const fn = vi.fn(); + const store = new DisposableStore(); + store.add(toDisposable(fn)); + store.add( + toDisposable(() => { + throw new Error('boom'); + }), + ); + expect(() => store.dispose()).toThrow('boom'); + expect(fn).toHaveBeenCalledOnce(); + }); +}); + +describe('disposeAll', () => { + it('aggregates multiple errors', () => { + const fail = () => + toDisposable(() => { + throw new Error('x'); + }); + expect(() => disposeAll([fail(), fail()])).toThrow(AggregateError); + }); +}); + +describe('MutableDisposable', () => { + it('disposes the previous value on replace and on dispose', () => { + const a = vi.fn(); + const b = vi.fn(); + const holder = new MutableDisposable(); + holder.value = toDisposable(a); + holder.value = toDisposable(b); + expect(a).toHaveBeenCalledOnce(); + holder.dispose(); + expect(b).toHaveBeenCalledOnce(); + const c = vi.fn(); + holder.value = toDisposable(c); + expect(c).toHaveBeenCalledOnce(); + }); +}); + +describe('tracker', () => { + afterEach(() => setDisposableTracker(null)); + + it('reports undisposed disposables', () => { + const live = new Set(); + setDisposableTracker({ onCreate: (d) => live.add(d), onDispose: (d) => live.delete(d) }); + const d = combineDisposables(toDisposable(() => undefined)); + expect(live.size).toBe(2); + d.dispose(); + expect(live.size).toBe(0); + }); +}); diff --git a/packages/kernel/test/log/log-value.spec.ts b/packages/kernel/test/log/log-value.spec.ts new file mode 100644 index 00000000..188cabd3 --- /dev/null +++ b/packages/kernel/test/log/log-value.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { formatLogValue, isExpandableLogValue } from '../../src/log/format-log-value'; +import { serializeLogValue } from '../../src/log/serialize-log-value'; + +class Position { + constructor( + public x = 1, + public y = 2, + ) {} +} + +describe('serializeLogValue', () => { + it('keeps primitives and tags what JSON cannot carry', () => { + expect(serializeLogValue('a')).toBe('a'); + expect(serializeLogValue(1.5)).toBe(1.5); + expect(serializeLogValue(null)).toBeNull(); + expect(serializeLogValue(undefined)).toEqual({ type: 'undefined' }); + expect(serializeLogValue(NaN)).toEqual({ type: 'number', text: 'NaN' }); + expect(serializeLogValue(-0)).toEqual({ type: 'number', text: '-0' }); + expect(serializeLogValue(10n)).toEqual({ type: 'bigint', text: '10n' }); + expect(serializeLogValue(function move() {})).toEqual({ type: 'function', text: 'ƒ move()' }); + }); + + it('snapshots objects, class instances, arrays, sets and maps', () => { + expect(serializeLogValue({ a: 1, b: [true, 'x'] })).toEqual({ + type: 'object', + entries: [ + ['a', 1], + ['b', { type: 'array', length: 2, items: [true, 'x'] }], + ], + }); + expect(serializeLogValue(new Position())).toMatchObject({ type: 'object', name: 'Position' }); + expect(serializeLogValue(new Set([1]))).toEqual({ + type: 'array', + name: 'Set', + length: 1, + items: [1], + }); + expect(serializeLogValue(new Map([['k', 1]]))).toEqual({ + type: 'map', + size: 1, + entries: [['k', 1]], + }); + expect(serializeLogValue(new Float32Array([1, 2]))).toMatchObject({ + type: 'array', + name: 'Float32Array', + items: [1, 2], + }); + }); + + it('marks cycles and cuts deep or large values', () => { + const loop: Record = { name: 'loop' }; + loop.self = loop; + expect(serializeLogValue(loop)).toEqual({ + type: 'object', + entries: [ + ['name', 'loop'], + ['self', { type: 'circular' }], + ], + }); + const shared = { v: 1 }; + expect(serializeLogValue([shared, shared])).toMatchObject({ + items: [{ type: 'object' }, { type: 'object' }], + }); + expect(serializeLogValue({ a: { b: { c: 1 } } }, { depth: 2 })).toEqual({ + type: 'object', + entries: [['a', { type: 'object', entries: [['b', { type: 'cut', text: 'Object' }]] }]], + }); + expect(serializeLogValue([1, 2, 3], { entries: 2 })).toEqual({ + type: 'array', + length: 3, + items: [1, 2], + more: 1, + }); + expect(serializeLogValue('abcdef', { text: 3 })).toBe('abc…'); + }); + + it('keeps errors with their stack, and survives throwing getters', () => { + const error = serializeLogValue(new TypeError('bad')); + expect(error).toMatchObject({ type: 'error', name: 'TypeError', message: 'bad' }); + const hostile = { + get boom(): never { + throw new Error('no'); + }, + }; + expect(serializeLogValue(hostile)).toEqual({ + type: 'object', + entries: [['boom', '[threw Error: no]']], + }); + }); +}); + +describe('formatLogValue', () => { + it('prints one line, quoting nested strings only', () => { + expect(formatLogValue('plain')).toBe('plain'); + expect(formatLogValue(serializeLogValue({ a: 'x', b: [1, undefined] }))).toBe( + '{ a: "x", b: [1, undefined] }', + ); + expect(formatLogValue(serializeLogValue(new Position()))).toBe('Position { x: 1, y: 2 }'); + expect(formatLogValue(serializeLogValue(new Map([['k', 1]])))).toBe('Map(1) {"k" => 1}'); + expect(formatLogValue(serializeLogValue([1, 2, 3], { entries: 1 }))).toBe('[1, … 2 more]'); + }); + + it('tells what can be expanded', () => { + expect(isExpandableLogValue('a')).toBe(false); + expect(isExpandableLogValue(serializeLogValue({}))).toBe(false); + expect(isExpandableLogValue(serializeLogValue({ a: 1 }))).toBe(true); + expect(isExpandableLogValue(serializeLogValue(new Error('x')))).toBe(true); + }); +}); diff --git a/packages/kernel/test/log/logger-service.spec.ts b/packages/kernel/test/log/logger-service.spec.ts new file mode 100644 index 00000000..75764be7 --- /dev/null +++ b/packages/kernel/test/log/logger-service.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LogLevel } from '../../src/log/log-level.enum'; +import { LoggerService } from '../../src/log/logger-service'; + +describe('LoggerService', () => { + it('filters by level, buffers and replays', () => { + const logs = new LoggerService(2); + const logger = logs.getLogger('kernel').child('plugins'); + logger.debug('hidden'); + logger.info('a'); + logger.warn('b', 1); + logger.error('c'); + expect(logs.history.map((e) => e.message)).toEqual(['b', 'c']); + + const sink = vi.fn(); + logs.addSink(sink, { replay: true }); + expect(sink).toHaveBeenCalledTimes(2); + expect(sink.mock.calls[0]![0]).toMatchObject({ + source: 'kernel.plugins', + level: LogLevel.Warn, + args: [1], + }); + }); + + it('isolates failing sinks', () => { + const logs = new LoggerService(); + const ok = vi.fn(); + logs.addSink(() => { + throw new Error('x'); + }); + logs.addSink(ok); + logs.getLogger('x').error('boom'); + expect(ok).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kernel/test/observable/observable.spec.ts b/packages/kernel/test/observable/observable.spec.ts new file mode 100644 index 00000000..3a67254b --- /dev/null +++ b/packages/kernel/test/observable/observable.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Emitter } from '../../src/event/emitter'; +import { + changes, + constant, + derived, + observe, + switchObservable, +} from '../../src/observable/observable'; +import { ObservableValue } from '../../src/observable/observable-value'; + +describe('ObservableValue', () => { + it('follows the svelte store contract', () => { + const value = new ObservableValue(1); + const run = vi.fn(); + const unsubscribe = value.subscribe(run); + value.set(2); + value.set(2); + unsubscribe(); + value.set(3); + expect(run.mock.calls).toEqual([[1], [2]]); + }); + + it('observe can skip the immediate call', () => { + const value = new ObservableValue('a'); + const fn = vi.fn(); + const sub = observe(value, fn, { immediate: false }); + value.update((v) => v + 'b'); + sub.dispose(); + value.set('c'); + expect(fn).toHaveBeenCalledExactlyOnceWith('ab'); + }); + + it('changes() exposes an event', () => { + const value = new ObservableValue(0); + const fn = vi.fn(); + changes(value)(fn); + value.set(1); + expect(fn).toHaveBeenCalledExactlyOnceWith(1); + }); +}); + +describe('derived', () => { + it('computes on demand without subscribers', () => { + const a = new ObservableValue(1); + const double = derived([a], (x) => x * 2); + expect(double.get()).toBe(2); + a.set(5); + expect(double.get()).toBe(10); + expect(a.subscriberCount).toBe(0); + }); + + it('subscribes upstream only while observed', () => { + const a = new ObservableValue(1); + const b = new ObservableValue(2); + const sum = derived([a, b, constant(10)], (x, y, z) => x + y + z); + const run = vi.fn(); + const unsubscribe = sum.subscribe(run); + a.set(3); + b.set(2); // unchanged: no notification + expect(run.mock.calls).toEqual([[13], [15]]); + expect(a.subscriberCount).toBe(1); + unsubscribe(); + unsubscribe(); + expect(a.subscriberCount).toBe(0); + }); + + it('does not notify when the derived value is equal', () => { + const a = new ObservableValue(1); + const parity = derived([a], (x) => x % 2); + const run = vi.fn(); + parity.subscribe(run); + a.set(3); + expect(run).toHaveBeenCalledOnce(); + }); + + it('works with emitters feeding values', () => { + const emitter = new Emitter(); + const value = new ObservableValue(0); + emitter.event((n) => value.set(n)); + const run = vi.fn(); + derived([value], (n) => `#${n}`).subscribe(run); + emitter.fire(4); + expect(run).toHaveBeenLastCalledWith('#4'); + }); +}); + +describe('switchObservable', () => { + it('follows the current inner observable', async () => { + const a = new ObservableValue(1); + const b = new ObservableValue(10); + const outer = new ObservableValue | undefined>(undefined); + const run = vi.fn(); + const unsubscribe = switchObservable(outer, 0).subscribe(run); + outer.set(a); + a.set(2); + outer.set(b); + a.set(3); // no longer followed + b.set(11); + unsubscribe(); + b.set(12); + expect(run.mock.calls.map(([v]) => v)).toEqual([0, 1, 2, 10, 11]); + expect(a.subscriberCount + b.subscriberCount).toBe(0); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-host.spec.ts b/packages/kernel/test/plugin/plugin-host.spec.ts new file mode 100644 index 00000000..2aa3f8b0 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-host.spec.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createToken } from '../../src/di/service-token'; +import { defineExtensionPoint } from '../../src/extension/define-extension-point'; +import { + type Disposable, + setDisposableTracker, + toDisposable, +} from '../../src/lifecycle/disposable'; +import { ObservableValue } from '../../src/observable/observable-value'; +import { COMMAND_METADATA } from '../../src/plugin/command-metadata'; +import { CoreServices } from '../../src/plugin/core-services.const'; +import { createCoreContainer } from '../../src/plugin/create-core-container'; +import type { AppInfo } from '../../src/plugin/engine-libs'; +import { PluginActivationError } from '../../src/plugin/plugin-activation.exception'; +import type { PluginContext, PluginModule } from '../../src/plugin/plugin-context.type'; +import { PluginHost } from '../../src/plugin/plugin-host'; +import { type PluginManifestInput, parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import { normalizePluginModule } from '../../src/plugin/plugin-module-loader'; +import type { PluginDescriptor } from '../../src/plugin/plugin-resolution.type'; + +const descriptor = (name: string, extra: Partial = {}): PluginDescriptor => ({ + source: 'bundled', + baseUrl: `https://editor.test/plugins/${name}/`, + manifest: parsePluginManifest({ + type: 'plugin', + name, + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'index.js' }, + ...extra, + }), +}); + +const Greeting = createToken('test.greeting'); +const Modes = defineExtensionPoint('test.modes'); + +const setup = (modules: Record, options: { apps?: AppInfo[] } = {}) => { + const services = createCoreContainer(); + const apps = new ObservableValue(options.apps ?? []); + if (options.apps) services.provide(CoreServices.EngineLibs, { apps }); + const loads: string[] = []; + const host = new PluginHost({ + services, + editorVersion: '1.0.0', + activationTimeoutMs: 50, + loader: { + load: async (d, generation) => { + loads.push(`${d.manifest.name}#${generation}`); + return modules[d.manifest.name] ?? {}; + }, + }, + }); + return { services, host, loads, apps }; +}; + +describe('PluginHost', () => { + let live: Set; + beforeEach(() => { + live = new Set(); + setDisposableTracker({ onCreate: (d) => live.add(d), onDispose: (d) => live.delete(d) }); + }); + afterEach(() => setDisposableTracker(null)); + + it('activates startup plugins in dependency order and shares services', async () => { + const order: string[] = []; + const { host, services } = setup({ + '@n/b': { + activate: (ctx) => { + order.push('b'); + ctx.provide(Greeting, 'hello'); + }, + }, + '@n/a': { + activate: (ctx) => { + order.push(`a:${ctx.services.get(Greeting)}`); + }, + }, + '@n/c': { activate: () => void order.push('c') }, + }); + await host.start([ + descriptor('@n/a', { dependencies: ['@n/b'] }), + descriptor('@n/b'), + descriptor('@n/c', { optionalDependencies: { '@n/a': '*' } }), + ]); + expect(order).toEqual(['b', 'a:hello', 'c']); + expect(services.get(Greeting)).toBe('hello'); + expect(host.getPlugins().map((p) => [p.name, p.state])).toEqual([ + ['@n/a', 'active'], + ['@n/b', 'active'], + ['@n/c', 'active'], + ]); + }); + + it('exposes static command contributions before lazy activation, then activates on command', async () => { + const activate = vi.fn((ctx: PluginContext) => { + ctx.registerCommand('ecs.addEntity', () => 'added'); + }); + const { host, services } = setup({ '@n/ecs': { activate } }); + await host.start([ + descriptor('@n/ecs', { + activation: ['onCommand:ecs.addEntity'], + contributes: { commands: [{ id: 'ecs.addEntity', title: 'Add entity' }] }, + }), + ]); + const metadata = services.get(CoreServices.Extensions).getValues(COMMAND_METADATA); + expect(metadata).toEqual([{ id: 'ecs.addEntity', title: 'Add entity', owner: '@n/ecs' }]); + expect(activate).not.toHaveBeenCalled(); + await expect(services.get(CoreServices.Commands).execute('ecs.addEntity')).resolves.toBe( + 'added', + ); + expect(activate).toHaveBeenCalledOnce(); + }); + + it('isolates activation failures and timeouts', async () => { + const { host } = setup({ + '@n/broken': { + activate: (ctx) => { + ctx.contribute(Modes, 'leaked'); + throw new Error('boom'); + }, + }, + '@n/slow': { activate: () => new Promise(() => undefined) }, + '@n/fine': { activate: () => undefined }, + }); + await host.start([descriptor('@n/broken'), descriptor('@n/slow'), descriptor('@n/fine')]); + expect(host.getPlugin('@n/broken')).toMatchObject({ state: 'failed' }); + expect(String(host.getPlugin('@n/slow')!.error)).toMatch(/timed out/); + expect(host.getPlugin('@n/fine')!.state).toBe('active'); + await expect(host.activate('@n/broken')).rejects.toThrow(PluginActivationError); + }); + + it('does not activate unresolved plugins', async () => { + const { host } = setup({}); + await host.start([descriptor('@n/a', { dependencies: ['@n/missing'] })]); + expect(host.getPlugin('@n/a')).toMatchObject({ + state: 'inactive', + status: { kind: 'missing-dependency' }, + }); + await expect(host.activate('@n/a')).rejects.toThrow(/missing dependency/); + }); + + it('deactivates dependents first and disposes everything the plugin registered', async () => { + const events: string[] = []; + const module = (name: string): PluginModule => ({ + activate: (ctx) => { + ctx.contribute(Modes, name); + ctx.subscriptions.add(toDisposable(() => events.push(`dispose:${name}`))); + }, + deactivate: () => void events.push(`deactivate:${name}`), + }); + const { host, services } = setup({ '@n/base': module('base'), '@n/ext': module('ext') }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + const extensions = services.get(CoreServices.Extensions); + expect(extensions.getValues(Modes)).toEqual(['base', 'ext']); + + await host.deactivate('@n/base'); + expect(events).toEqual(['deactivate:ext', 'dispose:ext', 'deactivate:base', 'dispose:base']); + expect(extensions.getValues(Modes)).toEqual([]); + + host.dispose(); + services.dispose(); + expect(live.size).toBe(0); + }); + + it('hot reloads a plugin and re-activates its dependents', async () => { + let version = 1; + const { host, services, loads } = setup({ + '@n/base': { activate: (ctx) => void ctx.provide(Greeting, `v${version}`) }, + '@n/ext': { activate: () => undefined }, + }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + version = 2; + await host.reload('@n/base'); + expect(services.get(Greeting)).toBe('v2'); + expect(host.getPlugin('@n/ext')!.state).toBe('active'); + expect(loads).toEqual(['@n/base#0', '@n/ext#0', '@n/base#1']); + }); + + it('updates the plugin set when a project with its own plugins opens and closes', async () => { + const events: string[] = []; + const module = (name: string): PluginModule => ({ + activate: (ctx) => { + events.push(`+${name}@${ctx.source}`); + ctx.contribute(Modes, name); + }, + deactivate: () => void events.push(`-${name}`), + }); + const { host, services } = setup({ + '@n/base': module('base'), + '@n/tool': module('tool'), + '@n/local': module('local'), + }); + const home = [descriptor('@n/base'), descriptor('@n/tool')]; + const project = (name: string, extra: Partial = {}): PluginDescriptor => ({ + ...descriptor(name, extra), + source: 'project', + baseUrl: `https://editor.test/plugins/project/p1/${name}/`, + }); + await host.start(home); + + await host.update([ + ...home, + project('@n/tool'), + project('@n/local', { dependencies: ['@n/base'] }), + ]); + expect(host.getPlugin('@n/tool')!.descriptor.source).toBe('project'); + expect(host.getPlugin('@n/local')!.state).toBe('active'); + const modes = () => services.get(CoreServices.Extensions).getValues(Modes).toSorted(); + expect(modes()).toEqual(['base', 'local', 'tool']); + + await host.update(home); + expect(host.getPlugin('@n/local')).toBeUndefined(); + expect(host.getPlugin('@n/tool')!.descriptor.source).toBe('bundled'); + expect(modes()).toEqual(['base', 'tool']); + expect(events).toEqual([ + '+base@bundled', + '+tool@bundled', + '-tool', + '+tool@project', + '+local@project', + '-tool', + '-local', + '+tool@bundled', + ]); + }); + + it('computes eligible apps and engine lib context keys', async () => { + let eligible: readonly AppInfo[] = []; + const client: AppInfo = { + id: 'client', + name: 'client', + type: 'client', + engineLibs: { '@nanoforge-dev/ecs': '1.4.2', '@nanoforge-dev/graphics-2d': '1.4.2' }, + }; + const server: AppInfo = { id: 'server', name: 'server', type: 'server', engineLibs: {} }; + const { host, services, apps } = setup( + { '@n/ecs': { activate: (ctx) => void ctx.eligibleApps.subscribe((a) => (eligible = a)) } }, + { apps: [client, server] }, + ); + await host.start([ + descriptor('@n/ecs', { engineLibs: { required: { '@nanoforge-dev/ecs': '^1.4.0' } } }), + ]); + expect(eligible.map((app) => app.id)).toEqual(['client']); + + const context = services.get(CoreServices.ContextKeys); + context.set('activeApp', 'client'); + expect(context.evaluate('plugin.@n/ecs.eligible && engineLib:@nanoforge-dev/graphics-2d')).toBe( + true, + ); + context.set('activeApp', 'server'); + expect(context.evaluate('plugin.@n/ecs.eligible || engineLib:@nanoforge-dev/ecs')).toBe(false); + + apps.set([client, { ...server, engineLibs: { '@nanoforge-dev/ecs': '1.5.0' } }]); + expect(eligible.map((app) => app.id)).toEqual(['client', 'server']); + expect(context.evaluate('plugin.@n/ecs.eligible')).toBe(true); + }); +}); + +describe('missingEngineLibs', () => { + it('handles installed versions, ranges and unknown specs', async () => { + const { missingEngineLibs } = await import('../../src/plugin/engine-libs'); + const manifest = parsePluginManifest({ + type: 'plugin', + name: '@n/ecs', + version: '1.0.0', + engines: { editor: '*' }, + entry: { client: 'index.js' }, + engineLibs: { + required: { '@nanoforge-dev/ecs': '^1.4.0', '@nanoforge-dev/input': '^1.0.0' }, + }, + }); + const app = (engineLibs: Record): AppInfo => ({ + id: 'a', + name: 'a', + type: 'client', + engineLibs, + }); + expect( + missingEngineLibs( + manifest, + app({ '@nanoforge-dev/ecs': 'workspace:*', '@nanoforge-dev/input': '^1.2.0' }), + ), + ).toEqual([]); + expect(missingEngineLibs(manifest, app({ '@nanoforge-dev/ecs': '1.3.0' }))).toEqual([ + { name: '@nanoforge-dev/ecs', range: '^1.4.0', installed: '1.3.0' }, + { name: '@nanoforge-dev/input', range: '^1.0.0' }, + ]); + }); +}); + +describe('normalizePluginModule', () => { + it('accepts named and default exports', async () => { + const activate = () => undefined; + expect(normalizePluginModule({ activate }).activate).toBe(activate); + expect(normalizePluginModule({ default: { activate } }).activate).toBe(activate); + expect(normalizePluginModule({ default: 'nope' })).toEqual({}); + expect(normalizePluginModule(undefined)).toEqual({}); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts new file mode 100644 index 00000000..27c83486 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts @@ -0,0 +1,12 @@ +/// +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { pluginManifestJsonSchema } from '../../src/plugin/plugin-manifest'; + +describe('plugin JSON schema', () => { + it('is up to date (run `pnpm generate:schema` in packages/kernel)', () => { + const file = new URL('../../schemas/plugin.schema.json', import.meta.url); + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(pluginManifestJsonSchema()); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-manifest.spec.ts b/packages/kernel/test/plugin/plugin-manifest.spec.ts new file mode 100644 index 00000000..09fdf060 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-manifest.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import { PluginManifestError } from '../../src/plugin/plugin-manifest.exception'; + +const base = { + type: 'plugin', + name: '@nanoforge/ecs', + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'dist/index.js' }, +}; + +describe('parsePluginManifest', () => { + it('applies defaults', () => { + const manifest = parsePluginManifest(base); + expect(manifest).toMatchObject({ + activation: ['onStartup'], + dependencies: {}, + engineLibs: { required: {}, optional: {} }, + contributes: { commands: [] }, + tags: [], + }); + }); + + it('accepts CLI-style dependency arrays and keeps unknown contributions', () => { + const manifest = parsePluginManifest({ + ...base, + dependencies: ['@nanoforge/viewport'], + optionalDependencies: { '@nanoforge/graphics-gizmos': '^2.0.0' }, + engineLibs: { required: { '@nanoforge-dev/ecs': '^1.4.0' } }, + contributes: { commands: [{ id: 'ecs.addEntity', title: 'Add entity' }], widgets: [{}] }, + }); + expect(manifest.dependencies).toEqual({ '@nanoforge/viewport': '*' }); + expect(manifest.engineLibs).toEqual({ + required: { '@nanoforge-dev/ecs': '^1.4.0' }, + optional: {}, + }); + expect(manifest.contributes.widgets).toEqual([{}]); + }); + + it('reports every issue with its path', () => { + const invalid = { + ...base, + name: 'NoScope', + version: 'one', + engines: { editor: 'latest!' }, + entry: { client: '../escape.js' }, + activation: ['whenever'], + }; + try { + parsePluginManifest(invalid, 'plugins/bad'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(PluginManifestError); + const paths = (error as PluginManifestError).issues.map((issue) => issue.path); + expect(paths).toEqual( + expect.arrayContaining([ + 'name', + 'version', + 'engines.editor', + 'entry.client', + 'activation.0', + ]), + ); + expect((error as Error).message).toContain('plugins/bad'); + } + }); + + it('requires at least one entry', () => { + expect(() => parsePluginManifest({ ...base, entry: {} })).toThrow(/at least one/); + }); +}); diff --git a/packages/kernel/test/plugin/resolve-plugins.spec.ts b/packages/kernel/test/plugin/resolve-plugins.spec.ts new file mode 100644 index 00000000..4913f4f9 --- /dev/null +++ b/packages/kernel/test/plugin/resolve-plugins.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { type PluginManifestInput, parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import type { PluginDescriptor } from '../../src/plugin/plugin-resolution.type'; +import type { PluginSourceKind } from '../../src/plugin/plugin-source.enum'; +import { resolvePlugins } from '../../src/plugin/resolve-plugins'; + +const plugin = ( + name: string, + extra: Partial = {}, + source: PluginSourceKind = 'bundled', +): PluginDescriptor => ({ + source, + baseUrl: `/plugins/${name}/`, + manifest: parsePluginManifest({ + type: 'plugin', + name, + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'index.js' }, + ...extra, + }), +}); + +const resolve = (descriptors: PluginDescriptor[], disabled: string[] = []) => + resolvePlugins(descriptors, { + editorVersion: '1.2.0', + runtimeVersions: { svelte: '5.57.1' }, + disabled: new Set(disabled), + }); + +const statuses = (resolution: ReturnType) => + Object.fromEntries([...resolution.plugins].map(([name, p]) => [name, p.status.kind])); + +const order = (resolution: ReturnType) => + resolution.order.map((d) => d.manifest.name); + +describe('resolvePlugins', () => { + it('orders dependencies first, deterministically', () => { + const resolution = resolve([ + plugin('@n/ecs', { dependencies: ['@n/viewport'] }), + plugin('@n/viewport'), + plugin('@n/console'), + plugin('@n/gizmos', { dependencies: { '@n/ecs': '^1.0.0' } }), + ]); + expect(order(resolution)).toEqual(['@n/console', '@n/viewport', '@n/ecs', '@n/gizmos']); + }); + + it('uses valid optional dependencies for ordering only', () => { + const resolution = resolve([ + plugin('@n/a', { optionalDependencies: { '@n/b': '^1.0.0', '@n/missing': '*' } }), + plugin('@n/b'), + ]); + expect(statuses(resolution)).toEqual({ '@n/a': 'ok', '@n/b': 'ok' }); + expect(order(resolution)).toEqual(['@n/b', '@n/a']); + }); + + it('reports missing, mismatched and failed dependencies', () => { + const resolution = resolve([ + plugin('@n/a', { dependencies: ['@n/missing'] }), + plugin('@n/b', { dependencies: { '@n/c': '^2.0.0' } }), + plugin('@n/c'), + plugin('@n/d', { dependencies: ['@n/a'] }), + plugin('@n/e', { dependencies: ['@n/d'] }), + ]); + expect(statuses(resolution)).toEqual({ + '@n/a': 'missing-dependency', + '@n/b': 'dependency-version', + '@n/c': 'ok', + '@n/d': 'dependency-failed', + '@n/e': 'dependency-failed', + }); + expect(order(resolution)).toEqual(['@n/c']); + }); + + it('detects cycles and fails their dependents', () => { + const resolution = resolve([ + plugin('@n/a', { dependencies: ['@n/b'] }), + plugin('@n/b', { dependencies: ['@n/a'] }), + plugin('@n/c', { dependencies: ['@n/a'] }), + ]); + expect(resolution.plugins.get('@n/a')!.status).toEqual({ + kind: 'cycle', + cycle: ['@n/a', '@n/b'], + }); + expect(statuses(resolution)['@n/c']).toBe('dependency-failed'); + }); + + it('checks editor and runtime compatibility', () => { + const resolution = resolve([ + plugin('@n/old', { engines: { editor: '^0.1.0' } }), + plugin('@n/newer-svelte', { build: { svelte: '5.60.0' } }), + plugin('@n/svelte6', { build: { svelte: '6.0.0' } }), + plugin('@n/fine', { build: { svelte: '5.40.0' } }), + ]); + expect(statuses(resolution)).toEqual({ + '@n/old': 'incompatible-editor', + '@n/newer-svelte': 'incompatible-runtime', + '@n/svelte6': 'incompatible-runtime', + '@n/fine': 'ok', + }); + }); + + it('lets dev plugins shadow installed and bundled ones', () => { + const resolution = resolve([ + plugin('@n/ecs', {}, 'dev'), + plugin('@n/ecs', {}, 'bundled'), + plugin('@n/ecs', {}, 'installed'), + ]); + expect(resolution.plugins.get('@n/ecs')!.descriptor.source).toBe('dev'); + expect(resolution.shadowed.map((p) => [p.descriptor.source, p.status])).toEqual([ + ['bundled', { kind: 'shadowed', by: 'installed' }], + ['installed', { kind: 'shadowed', by: 'dev' }], + ]); + }); + + it('disables plugins and their dependents', () => { + const resolution = resolve( + [plugin('@n/a'), plugin('@n/b', { dependencies: ['@n/a'] })], + ['@n/a'], + ); + expect(statuses(resolution)).toEqual({ '@n/a': 'disabled', '@n/b': 'dependency-failed' }); + }); +}); diff --git a/packages/kernel/test/plugin/shared-module-registry.spec.ts b/packages/kernel/test/plugin/shared-module-registry.spec.ts new file mode 100644 index 00000000..f505f8c9 --- /dev/null +++ b/packages/kernel/test/plugin/shared-module-registry.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { SHARED_GLOBAL, SharedModuleRegistry } from '../../src/plugin/shared-module-registry'; + +describe('SharedModuleRegistry', () => { + it('resolves registered modules and rejects unknown ones', () => { + const svelte = { mount: () => undefined }; + const registry = new SharedModuleRegistry().register('svelte', svelte); + + expect(registry.require('svelte')).toBe(svelte); + expect(() => registry.require('svelte/store')).toThrow(/not provided/); + expect(() => registry.register('svelte', {})).toThrow(/already registered/); + }); + + it('installs a lookup scope on the target and removes it', () => { + const target: Record = {}; + const registry = new SharedModuleRegistry().register('svelte', { a: 1 }); + const uninstall = registry.install(target); + const scope = target[SHARED_GLOBAL] as { require(id: string): unknown }; + + expect(scope.require('svelte')).toEqual({ a: 1 }); + expect(() => new SharedModuleRegistry().install(target)).toThrow(/already installed/); + uninstall(); + expect(target[SHARED_GLOBAL]).toBeUndefined(); + }); +}); diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/kernel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/layout/.prettierignore b/packages/layout/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/layout/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/layout/LICENSE b/packages/layout/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/layout/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/layout/README.md b/packages/layout/README.md new file mode 100644 index 00000000..9d07ae74 --- /dev/null +++ b/packages/layout/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-layout` is an internal package of the NanoForge Editor. + +Layout model: main screens, dock slots after IntelliJ's tool windows, floating docks. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/layout +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/layout/bench/layout.bench.ts b/packages/layout/bench/layout.bench.ts new file mode 100644 index 00000000..2d58b69b --- /dev/null +++ b/packages/layout/bench/layout.bench.ts @@ -0,0 +1,71 @@ +import { bench, describe } from 'vitest'; + +import { + type Layout, + type LayoutOp, + SLOT_IDS, + type WidgetPlacement, + composeDefaultLayout, + deserializeLayout, + reduce, + serializeLayout, +} from '../src'; + +/** + * The layout model under a load well above a real editor's (about 20 widgets): 120 docks and + * 8 screens. Every drag step, tab click and resize is one `reduce`, so these are the costs that + * could make the workbench feel slow. Run with `pnpm --filter @nanoforge-dev/editor-layout bench`. + */ +const widgets: WidgetPlacement[] = [ + ...Array.from({ length: 8 }, (_, i) => ({ + id: `screen.${i}`, + kind: 'screen' as const, + order: i, + })), + ...Array.from({ length: 120 }, (_, i) => ({ + id: `dock.${i}`, + kind: 'dock' as const, + defaultSlot: SLOT_IDS[i % SLOT_IDS.length]!, + order: i, + })), +]; +const layout: Layout = composeDefaultLayout(widgets); +const serialized = serializeLayout(layout); +const apply = (start: Layout, ops: readonly LayoutOp[]) => + ops.reduce((current, op) => reduce(current, op).layout, start); + +const resizes: LayoutOp[] = Array.from({ length: 200 }, (_, i) => ({ + type: 'resizeSlot', + slot: 'leftTop', + size: 200 + (i % 100), +})); +const activations: LayoutOp[] = Array.from({ length: 120 }, (_, i) => ({ + type: 'activate', + instanceId: `dock.${i}`, +})); +const moves: LayoutOp[] = Array.from({ length: 120 }, (_, i) => ({ + type: 'move', + instanceId: `dock.${i}`, + location: { kind: 'slot', slot: SLOT_IDS[(i + 1) % SLOT_IDS.length]!, index: 0 }, +})); + +describe('layout model, 128 widgets', () => { + bench('compose the default layout', () => { + composeDefaultLayout(widgets); + }); + bench('200 splitter resizes', () => { + apply(layout, resizes); + }); + bench('120 tab activations', () => { + apply(layout, activations); + }); + bench('120 moves between slots', () => { + apply(layout, moves); + }); + bench('serialize and read back', () => { + deserializeLayout(JSON.parse(serializeLayout(layout))); + }); + bench('read a saved layout', () => { + deserializeLayout(JSON.parse(serialized)); + }); +}); diff --git a/packages/layout/package.json b/packages/layout/package.json new file mode 100644 index 00000000..af888095 --- /dev/null +++ b/packages/layout/package.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-layout", + "version": "0.0.0", + "description": "NanoForge Editor - Layout model: main screens, dock slots, floating docks (pure data + operations)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/layout" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "bench": "vitest bench --run bench" + }, + "dependencies": { + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/layout/src/default/default-layout.ts b/packages/layout/src/default/default-layout.ts new file mode 100644 index 00000000..50bc7da9 --- /dev/null +++ b/packages/layout/src/default/default-layout.ts @@ -0,0 +1,72 @@ +import { emptyLayout } from '../model/empty-layout'; +import type { Layout } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp } from '../operations/layout-op.type'; +import { reduce } from '../operations/reduce'; + +export interface WidgetPlacement { + readonly id: string; + readonly kind: 'dock' | 'screen'; + /** Where a dock widget goes by default (ignored for screens). */ + readonly defaultSlot?: SlotId; + /** Order in its slot (docks) or in the screen switcher (screens). */ + readonly order?: number; + /** Docks: shown in the default layout (default true). */ + readonly openByDefault?: boolean; +} + +/** The layout used when nothing is saved: screens in order, docks in their default slots. */ +export const composeDefaultLayout = (widgets: readonly WidgetPlacement[]): Layout => { + const byOrder = (a: WidgetPlacement, b: WidgetPlacement) => + (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); + const screens = widgets.filter((widget) => widget.kind === 'screen').sort(byOrder); + const docks = widgets + .filter((widget) => widget.kind === 'dock' && widget.openByDefault !== false) + .sort(byOrder); + const ops: LayoutOp[] = [{ type: 'setScreens', screens: screens.map((screen) => screen.id) }]; + const counts: Partial> = {}; + for (const dock of docks) { + const slot = dock.defaultSlot ?? 'leftTop'; + const index = counts[slot] ?? 0; + counts[slot] = index + 1; + ops.push({ + type: 'add', + ref: { instanceId: dock.id, widgetId: dock.id }, + location: { kind: 'slot', slot, index }, + activate: index === 0, + }); + } + return reduce(emptyLayout(), { type: 'batch', ops }).layout; +}; + +/** Adds screens and default docks that a saved layout does not know yet (new plugins). */ +export const reconcileLayout = ( + layout: Layout, + widgets: readonly WidgetPlacement[], + known: ReadonlySet, +): Layout => { + const byOrder = (a: WidgetPlacement, b: WidgetPlacement) => + (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); + const screens = widgets + .filter((widget) => widget.kind === 'screen') + .sort(byOrder) + .map((widget) => widget.id); + const merged = [ + ...layout.screens.filter((screen) => screens.includes(screen)), + ...screens.filter((screen) => !layout.screens.includes(screen)), + ]; + const ops: LayoutOp[] = [{ type: 'setScreens', screens: merged }]; + let next = reduce(layout, { type: 'batch', ops }).layout; + for (const dock of widgets.filter( + (widget) => widget.kind === 'dock' && !known.has(widget.id) && widget.openByDefault !== false, + )) { + const slot = dock.defaultSlot ?? 'leftTop'; + next = reduce(next, { + type: 'add', + ref: { instanceId: dock.id, widgetId: dock.id }, + location: { kind: 'slot', slot, index: next.slots[slot].stack.tabs.length }, + activate: false, + }).layout; + } + return next; +}; diff --git a/packages/layout/src/default/index.ts b/packages/layout/src/default/index.ts new file mode 100644 index 00000000..473078d6 --- /dev/null +++ b/packages/layout/src/default/index.ts @@ -0,0 +1 @@ +export * from './default-layout'; diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts new file mode 100644 index 00000000..9ab9aef8 --- /dev/null +++ b/packages/layout/src/index.ts @@ -0,0 +1,4 @@ +export * from './default'; +export * from './model'; +export * from './operations'; +export * from './serialization'; diff --git a/packages/layout/src/model/empty-layout.ts b/packages/layout/src/model/empty-layout.ts new file mode 100644 index 00000000..4eb88dbc --- /dev/null +++ b/packages/layout/src/model/empty-layout.ts @@ -0,0 +1,22 @@ +import { LAYOUT_VERSION } from './layout.const'; +import type { Layout, TabStack } from './layout.type'; + +const emptyStack: TabStack = { tabs: [], active: null }; + +export const emptyLayout = (): Layout => ({ + version: LAYOUT_VERSION, + screens: [], + activeScreen: null, + slots: { + leftTop: { visible: true, size: 260, stack: emptyStack }, + leftBottom: { visible: true, size: 260, stack: emptyStack }, + rightTop: { visible: true, size: 300, stack: emptyStack }, + rightBottom: { visible: true, size: 300, stack: emptyStack }, + bottom: { visible: true, size: 220, stack: emptyStack }, + bottomRight: { visible: true, size: 220, stack: emptyStack }, + }, + split: { left: 0.55, right: 0.55, bottom: 0.5 }, + floats: [], + maximized: null, + screenOverrides: {}, +}); diff --git a/packages/layout/src/model/index.ts b/packages/layout/src/model/index.ts new file mode 100644 index 00000000..e4230941 --- /dev/null +++ b/packages/layout/src/model/index.ts @@ -0,0 +1,5 @@ +export * from './slot-id.enum'; +export * from './layout.type'; +export * from './layout.const'; +export * from './empty-layout'; +export * from './layout-queries'; diff --git a/packages/layout/src/model/layout-queries.ts b/packages/layout/src/model/layout-queries.ts new file mode 100644 index 00000000..ad2b750b --- /dev/null +++ b/packages/layout/src/model/layout-queries.ts @@ -0,0 +1,45 @@ +import type { Layout, Location, WidgetRef } from './layout.type'; +import { SLOT_IDS, type SlotId } from './slot-id.enum'; + +export const findWidget = ( + layout: Layout, + instanceId: string, +): { location: Location; ref: WidgetRef; active: boolean } | undefined => { + for (const slot of SLOT_IDS) { + const stack = layout.slots[slot].stack; + const index = stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + if (index >= 0) { + return { + location: { kind: 'slot', slot, index }, + ref: stack.tabs[index]!, + active: stack.active === instanceId, + }; + } + } + for (const float of layout.floats) { + const index = float.stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + if (index >= 0) { + const rect = { x: float.x, y: float.y, width: float.width, height: float.height }; + return { + location: { kind: 'float', floatId: float.id, index, rect }, + ref: float.stack.tabs[index]!, + active: float.stack.active === instanceId, + }; + } + } + return undefined; +}; + +/** Every widget instance in the layout. */ +export const allWidgets = (layout: Layout): WidgetRef[] => [ + ...SLOT_IDS.flatMap((slot) => layout.slots[slot].stack.tabs), + ...layout.floats.flatMap((float) => float.stack.tabs), +]; + +/** Whether a slot is shown on the active screen (overrides applied). */ +export const isSlotVisible = (layout: Layout, slot: SlotId): boolean => { + const override = layout.activeScreen + ? layout.screenOverrides[layout.activeScreen]?.[slot] + : undefined; + return override ?? layout.slots[slot].visible; +}; diff --git a/packages/layout/src/model/layout.const.ts b/packages/layout/src/model/layout.const.ts new file mode 100644 index 00000000..1201a67d --- /dev/null +++ b/packages/layout/src/model/layout.const.ts @@ -0,0 +1,4 @@ +export const LAYOUT_VERSION = 1; + +export const MIN_SLOT_SIZE = 120; +export const MIN_FLOAT = { width: 200, height: 120 } as const; diff --git a/packages/layout/src/model/layout.type.ts b/packages/layout/src/model/layout.type.ts new file mode 100644 index 00000000..89e76f18 --- /dev/null +++ b/packages/layout/src/model/layout.type.ts @@ -0,0 +1,61 @@ +import { type LAYOUT_VERSION } from './layout.const'; +import type { SlotId } from './slot-id.enum'; + +export interface WidgetRef { + /** Unique in the layout (singleton widgets use their widget id). */ + readonly instanceId: string; + readonly widgetId: string; +} + +export interface TabStack { + readonly tabs: readonly WidgetRef[]; + readonly active: string | null; +} + +export interface SlotState { + readonly visible: boolean; + /** + * Width (side slots) or height (bottom slots) in px. A column's two side slots share their + * width, the two bottom slots their height (`leftTop`, `rightTop` and `bottom` hold it). + */ + readonly size: number; + readonly stack: TabStack; +} + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface FloatWindow extends Rect { + readonly id: string; + readonly z: number; + readonly stack: TabStack; +} + +export interface Layout { + readonly version: typeof LAYOUT_VERSION; + /** Main screens in switcher order (screen widget ids). */ + readonly screens: readonly string[]; + readonly activeScreen: string | null; + readonly slots: Readonly>; + /** Share of the left/right column given to its top slot, of the bottom row to `bottom` (0-1). */ + readonly split: { readonly left: number; readonly right: number; readonly bottom: number }; + readonly floats: readonly FloatWindow[]; + /** Widget instance shown over the whole workspace, if any. */ + readonly maximized: string | null; + /** Per screen slot visibility, e.g. hide right docks on the code screen. */ + readonly screenOverrides: Readonly>>>; +} + +/** Where a widget is: a dock slot or a floating window, at a tab index. */ +export type Location = + | { readonly kind: 'slot'; readonly slot: SlotId; readonly index: number } + | { + readonly kind: 'float'; + readonly floatId: string; + readonly index: number; + readonly rect?: Rect; + }; diff --git a/packages/layout/src/model/slot-id.enum.ts b/packages/layout/src/model/slot-id.enum.ts new file mode 100644 index 00000000..584e1f22 --- /dev/null +++ b/packages/layout/src/model/slot-id.enum.ts @@ -0,0 +1,13 @@ +/** + * Dock areas around the main screen, after IntelliJ's tool windows: two on each side, and the + * bottom split in two (`bottom` is its left part). + */ +export const SLOT_IDS = [ + 'leftTop', + 'leftBottom', + 'rightTop', + 'rightBottom', + 'bottom', + 'bottomRight', +] as const; +export type SlotId = (typeof SLOT_IDS)[number]; diff --git a/packages/layout/src/operations/index.ts b/packages/layout/src/operations/index.ts new file mode 100644 index 00000000..e0605d5f --- /dev/null +++ b/packages/layout/src/operations/index.ts @@ -0,0 +1,4 @@ +export * from './layout-op.type'; +export * from './layout.exception'; +export * from './reduce'; +export * from './layout-ops'; diff --git a/packages/layout/src/operations/layout-op.type.ts b/packages/layout/src/operations/layout-op.type.ts new file mode 100644 index 00000000..06347e78 --- /dev/null +++ b/packages/layout/src/operations/layout-op.type.ts @@ -0,0 +1,44 @@ +import type { Layout, Location, Rect, WidgetRef } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; + +/** Every layout change is one of these serializable operations. */ +export type LayoutOp = + | { + readonly type: 'add'; + readonly ref: WidgetRef; + readonly location: Location; + readonly activate?: boolean; + } + | { readonly type: 'remove'; readonly instanceId: string } + | { + readonly type: 'move'; + readonly instanceId: string; + readonly location: Location; + readonly activate?: boolean; + } + | { readonly type: 'activate'; readonly instanceId: string } + | { readonly type: 'resizeSlot'; readonly slot: SlotId; readonly size: number } + | { + readonly type: 'setSplit'; + readonly side: 'left' | 'right' | 'bottom'; + readonly ratio: number; + } + | { readonly type: 'toggleSlot'; readonly slot: SlotId; readonly visible: boolean } + | { readonly type: 'setFloatRect'; readonly floatId: string; readonly rect: Rect } + | { readonly type: 'focusFloat'; readonly floatId: string; readonly z: number } + | { readonly type: 'switchScreen'; readonly screen: string | null } + | { readonly type: 'setScreens'; readonly screens: readonly string[] } + | { readonly type: 'maximize'; readonly instanceId: string | null } + | { + readonly type: 'setScreenOverride'; + readonly screen: string; + readonly slot: SlotId; + readonly visible: boolean | null; + } + | { readonly type: 'batch'; readonly ops: readonly LayoutOp[] }; + +export interface Reduction { + readonly layout: Layout; + /** Applying it to `layout` gives back the previous layout. */ + readonly inverse: LayoutOp; +} diff --git a/packages/layout/src/operations/layout-ops.ts b/packages/layout/src/operations/layout-ops.ts new file mode 100644 index 00000000..02f2e7ef --- /dev/null +++ b/packages/layout/src/operations/layout-ops.ts @@ -0,0 +1,31 @@ +import type { Layout } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp } from './layout-op.type'; +import { LayoutError } from './layout.exception'; + +/** Moves every tab of a floating window into a slot (one undoable step). */ +export const dockFloatOp = (layout: Layout, floatId: string, slot: SlotId): LayoutOp => { + const float = layout.floats.find((candidate) => candidate.id === floatId); + if (!float) throw new LayoutError(`Floating window ${floatId} does not exist`); + const start = layout.slots[slot].stack.tabs.length; + return { + type: 'batch', + ops: float.stack.tabs.map((tab, index) => ({ + type: 'move' as const, + instanceId: tab.instanceId, + location: { kind: 'slot' as const, slot, index: start + index }, + activate: tab.instanceId === float.stack.active, + })), + }; +}; + +/** + * Shows or hides a slot on the active screen: changes the screen's override when it has one + * for that slot, the slot otherwise. + */ +export const showSlotOp = (layout: Layout, slot: SlotId, visible: boolean): LayoutOp => { + const screen = layout.activeScreen; + return screen && layout.screenOverrides[screen]?.[slot] !== undefined + ? { type: 'setScreenOverride', screen, slot, visible } + : { type: 'toggleSlot', slot, visible }; +}; diff --git a/packages/layout/src/operations/layout.exception.ts b/packages/layout/src/operations/layout.exception.ts new file mode 100644 index 00000000..22435ab9 --- /dev/null +++ b/packages/layout/src/operations/layout.exception.ts @@ -0,0 +1,6 @@ +export class LayoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'LayoutError'; + } +} diff --git a/packages/layout/src/operations/reduce.ts b/packages/layout/src/operations/reduce.ts new file mode 100644 index 00000000..f6a8b75b --- /dev/null +++ b/packages/layout/src/operations/reduce.ts @@ -0,0 +1,342 @@ +import { findWidget } from '../model/layout-queries'; +import { MIN_FLOAT, MIN_SLOT_SIZE } from '../model/layout.const'; +import type { + FloatWindow, + Layout, + Location, + Rect, + TabStack, + WidgetRef, +} from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp, Reduction } from './layout-op.type'; +import { LayoutError } from './layout.exception'; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const insertTab = (stack: TabStack, ref: WidgetRef, index: number, activate: boolean): TabStack => { + const tabs = [...stack.tabs]; + tabs.splice(clamp(index, 0, tabs.length), 0, ref); + return { tabs, active: activate || !stack.active ? ref.instanceId : stack.active }; +}; + +const removeTab = (stack: TabStack, instanceId: string): TabStack => { + const index = stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + const tabs = stack.tabs.filter((tab) => tab.instanceId !== instanceId); + const active = + stack.active === instanceId + ? (tabs[Math.min(index, tabs.length - 1)]?.instanceId ?? null) + : stack.active; + return { tabs, active }; +}; + +const updateSlot = ( + layout: Layout, + slot: SlotId, + update: (stack: TabStack) => TabStack, +): Layout => ({ + ...layout, + slots: { + ...layout.slots, + [slot]: { ...layout.slots[slot], stack: update(layout.slots[slot].stack) }, + }, +}); + +/** Active tab of the stack a location points to (null for a float that does not exist yet). */ +const activeAt = (layout: Layout, location: Location): string | null => + location.kind === 'slot' + ? layout.slots[location.slot].stack.active + : (layout.floats.find((float) => float.id === location.floatId)?.stack.active ?? null); + +/** Inverse followed by re-activating the tabs that were active in the touched stacks. */ +const withActivation = ( + inverse: LayoutOp, + previousActives: readonly (string | null)[], +): LayoutOp => { + const activations = [...new Set(previousActives)] + .filter((id): id is string => id !== null) + .map((instanceId): LayoutOp => ({ type: 'activate', instanceId })); + return activations.length ? { type: 'batch', ops: [inverse, ...activations] } : inverse; +}; + +const nextZ = (layout: Layout) => Math.max(0, ...layout.floats.map((float) => float.z)) + 1; + +/** Places a widget at a location; a missing float is created from `location.rect`. */ +const place = (layout: Layout, ref: WidgetRef, location: Location, activate: boolean): Layout => { + if (location.kind === 'slot') { + return updateSlot(layout, location.slot, (stack) => + insertTab(stack, ref, location.index, activate), + ); + } + const existing = layout.floats.find((float) => float.id === location.floatId); + if (existing) { + return { + ...layout, + floats: layout.floats.map((float) => + float.id === location.floatId + ? { ...float, stack: insertTab(float.stack, ref, location.index, activate) } + : float, + ), + }; + } + if (!location.rect) throw new LayoutError(`Floating window ${location.floatId} does not exist`); + const float: FloatWindow = { + id: location.floatId, + ...normalizeRect(location.rect), + z: nextZ(layout), + stack: { tabs: [ref], active: ref.instanceId }, + }; + const floats = [...layout.floats, float].sort((a, b) => a.id.localeCompare(b.id)); + return { ...layout, floats }; +}; + +/** Removes a widget; an emptied float disappears. */ +const unplace = ( + layout: Layout, + instanceId: string, + options: { keepMaximized?: boolean } = {}, +): Layout => { + const found = findWidget(layout, instanceId); + if (!found) throw new LayoutError(`No widget ${instanceId} in the layout`); + let next: Layout; + if (found.location.kind === 'slot') { + next = updateSlot(layout, found.location.slot, (stack) => removeTab(stack, instanceId)); + } else { + const floatId = found.location.floatId; + next = { + ...layout, + floats: layout.floats + .map((float) => + float.id === floatId ? { ...float, stack: removeTab(float.stack, instanceId) } : float, + ) + .filter((float) => float.stack.tabs.length > 0), + }; + } + return next.maximized === instanceId && !options.keepMaximized + ? { ...next, maximized: null } + : next; +}; + +const normalizeRect = (rect: Rect): Rect => ({ + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(Math.max(MIN_FLOAT.width, rect.width)), + height: Math.round(Math.max(MIN_FLOAT.height, rect.height)), +}); + +/** + * Applies an operation. Pure: returns the new layout and the operation undoing it. + * Throws `LayoutError` for operations that do not apply to this layout. + */ +export const reduce = (layout: Layout, op: LayoutOp): Reduction => { + switch (op.type) { + case 'add': { + if (findWidget(layout, op.ref.instanceId)) + throw new LayoutError(`${op.ref.instanceId} is already in the layout`); + return { + layout: place(layout, op.ref, op.location, op.activate ?? true), + inverse: withActivation({ type: 'remove', instanceId: op.ref.instanceId }, [ + activeAt(layout, op.location), + ]), + }; + } + case 'remove': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const maximized = layout.maximized === op.instanceId; + const inverse: LayoutOp = { + type: 'add', + ref: found.ref, + location: found.location, + activate: found.active, + }; + return { + layout: unplace(layout, op.instanceId), + inverse: maximized + ? { type: 'batch', ops: [inverse, { type: 'maximize', instanceId: op.instanceId }] } + : inverse, + }; + } + case 'move': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const removed = unplace(layout, op.instanceId, { keepMaximized: true }); + const requested = op.location; + const target: Location = + requested.kind === 'float' && + !requested.rect && + found.location.kind === 'float' && + found.location.floatId === requested.floatId && + !removed.floats.some((float) => float.id === requested.floatId) + ? { ...requested, ...(found.location.rect && { rect: found.location.rect }) } + : requested; + return { + layout: place(removed, found.ref, target, op.activate ?? true), + inverse: withActivation( + { + type: 'move', + instanceId: op.instanceId, + location: found.location, + activate: found.active, + }, + [activeAt(layout, op.location), activeAt(layout, found.location)], + ), + }; + } + case 'activate': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const previousActive = + found.location.kind === 'slot' + ? layout.slots[found.location.slot].stack.active + : layout.floats.find( + (float) => float.id === (found.location as { floatId: string }).floatId, + )!.stack.active; + const setActive = (stack: TabStack): TabStack => ({ ...stack, active: op.instanceId }); + const next = + found.location.kind === 'slot' + ? updateSlot(layout, found.location.slot, setActive) + : { + ...layout, + floats: layout.floats.map((float) => + float.id === (found.location as { floatId: string }).floatId + ? { ...float, stack: setActive(float.stack) } + : float, + ), + }; + return { + layout: next, + inverse: previousActive + ? { type: 'activate', instanceId: previousActive } + : { type: 'batch', ops: [] }, + }; + } + case 'resizeSlot': { + const previous = layout.slots[op.slot].size; + return { + layout: { + ...layout, + slots: { + ...layout.slots, + [op.slot]: { + ...layout.slots[op.slot], + size: Math.max(MIN_SLOT_SIZE, Math.round(op.size)), + }, + }, + }, + inverse: { type: 'resizeSlot', slot: op.slot, size: previous }, + }; + } + case 'setSplit': { + const previous = layout.split[op.side]; + return { + layout: { ...layout, split: { ...layout.split, [op.side]: clamp(op.ratio, 0.15, 0.85) } }, + inverse: { type: 'setSplit', side: op.side, ratio: previous }, + }; + } + case 'toggleSlot': { + const previous = layout.slots[op.slot].visible; + return { + layout: { + ...layout, + slots: { ...layout.slots, [op.slot]: { ...layout.slots[op.slot], visible: op.visible } }, + }, + inverse: { type: 'toggleSlot', slot: op.slot, visible: previous }, + }; + } + case 'setFloatRect': { + const float = layout.floats.find((candidate) => candidate.id === op.floatId); + if (!float) throw new LayoutError(`Floating window ${op.floatId} does not exist`); + const rect = normalizeRect(op.rect); + return { + layout: { + ...layout, + floats: layout.floats.map((candidate) => + candidate === float ? { ...candidate, ...rect } : candidate, + ), + }, + inverse: { + type: 'setFloatRect', + floatId: op.floatId, + rect: { x: float.x, y: float.y, width: float.width, height: float.height }, + }, + }; + } + case 'focusFloat': { + const float = layout.floats.find((candidate) => candidate.id === op.floatId); + if (!float) throw new LayoutError(`Floating window ${op.floatId} does not exist`); + return { + layout: { + ...layout, + floats: layout.floats.map((candidate) => + candidate === float ? { ...candidate, z: op.z } : candidate, + ), + }, + inverse: { type: 'focusFloat', floatId: op.floatId, z: float.z }, + }; + } + case 'switchScreen': { + if (op.screen !== null && !layout.screens.includes(op.screen)) + throw new LayoutError(`Unknown screen ${op.screen}`); + return { + layout: { ...layout, activeScreen: op.screen }, + inverse: { type: 'switchScreen', screen: layout.activeScreen }, + }; + } + case 'setScreens': { + const activeScreen = + layout.activeScreen && op.screens.includes(layout.activeScreen) + ? layout.activeScreen + : (op.screens[0] ?? null); + return { + layout: { ...layout, screens: [...op.screens], activeScreen }, + inverse: { + type: 'batch', + ops: [ + { type: 'setScreens', screens: layout.screens }, + { type: 'switchScreen', screen: layout.activeScreen }, + ], + }, + }; + } + case 'maximize': { + if (op.instanceId !== null && !findWidget(layout, op.instanceId)) + throw new LayoutError(`No widget ${op.instanceId} in the layout`); + return { + layout: { ...layout, maximized: op.instanceId }, + inverse: { type: 'maximize', instanceId: layout.maximized }, + }; + } + case 'setScreenOverride': { + const current = layout.screenOverrides[op.screen] ?? {}; + const previous = current[op.slot]; + const overrides = Object.fromEntries( + Object.entries({ ...current, [op.slot]: op.visible }).filter( + ([, visible]) => visible !== null, + ), + ) as Partial>; + return { + layout: { + ...layout, + screenOverrides: { ...layout.screenOverrides, [op.screen]: overrides }, + }, + inverse: { + type: 'setScreenOverride', + screen: op.screen, + slot: op.slot, + visible: previous ?? null, + }, + }; + } + case 'batch': { + let current = layout; + const inverses: LayoutOp[] = []; + for (const inner of op.ops) { + const result = reduce(current, inner); + current = result.layout; + inverses.unshift(result.inverse); + } + return { layout: current, inverse: { type: 'batch', ops: inverses } }; + } + } +}; diff --git a/packages/layout/src/serialization/index.ts b/packages/layout/src/serialization/index.ts new file mode 100644 index 00000000..f26656c3 --- /dev/null +++ b/packages/layout/src/serialization/index.ts @@ -0,0 +1,2 @@ +export * from './serialize-layout'; +export * from './repair-layout'; diff --git a/packages/layout/src/serialization/layout.schema.ts b/packages/layout/src/serialization/layout.schema.ts new file mode 100644 index 00000000..f01f2dc3 --- /dev/null +++ b/packages/layout/src/serialization/layout.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const Ref = z.object({ instanceId: z.string().min(1), widgetId: z.string().min(1) }); +export const Stack = z.object({ + tabs: z.array(Ref).catch([]), + active: z.string().nullable().catch(null), +}); +export const Slot = z.object({ + visible: z.boolean().catch(true), + size: z.number().catch(260), + stack: Stack.catch({ tabs: [], active: null }), +}); +export const Float = z.object({ + id: z.string().min(1), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + z: z.number().catch(1), + stack: Stack, +}); diff --git a/packages/layout/src/serialization/repair-layout.ts b/packages/layout/src/serialization/repair-layout.ts new file mode 100644 index 00000000..87e42cdd --- /dev/null +++ b/packages/layout/src/serialization/repair-layout.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; + +import { emptyLayout } from '../model/empty-layout'; +import { LAYOUT_VERSION, MIN_FLOAT, MIN_SLOT_SIZE } from '../model/layout.const'; +import type { Layout, TabStack } from '../model/layout.type'; +import { SLOT_IDS, type SlotId } from '../model/slot-id.enum'; +import { Float, Slot } from './layout.schema'; + +export const repairLayout = (raw: Record): Layout => { + const base = emptyLayout(); + const seen = new Set(); + const cleanStack = (stack: TabStack): TabStack => { + const tabs = stack.tabs.filter((tab) => { + if (seen.has(tab.instanceId)) return false; + seen.add(tab.instanceId); + return true; + }); + const active = tabs.some((tab) => tab.instanceId === stack.active) + ? stack.active + : (tabs[0]?.instanceId ?? null); + return { tabs, active }; + }; + + const rawSlots = (raw.slots ?? {}) as Record; + const slots = Object.fromEntries( + SLOT_IDS.map((id) => { + const parsed = Slot.safeParse(rawSlots[id]); + const slot = parsed.success ? parsed.data : base.slots[id]; + return [ + id, + { + visible: slot.visible, + size: Math.max(MIN_SLOT_SIZE, Math.round(slot.size)), + stack: cleanStack(slot.stack), + }, + ]; + }), + ) as Record; + + const floats = z + .array(z.unknown()) + .catch([]) + .parse(raw.floats) + .flatMap((candidate) => { + const parsed = Float.safeParse(candidate); + if (!parsed.success) return []; + const stack = cleanStack(parsed.data.stack); + if (!stack.tabs.length) return []; + return [ + { + ...parsed.data, + width: Math.max(MIN_FLOAT.width, parsed.data.width), + height: Math.max(MIN_FLOAT.height, parsed.data.height), + stack, + }, + ]; + }); + + const screens = z.array(z.string()).catch([]).parse(raw.screens); + const activeScreen = z.string().nullable().catch(null).parse(raw.activeScreen); + const maximized = z.string().nullable().catch(null).parse(raw.maximized); + const split = z + .object({ + left: z.number().catch(base.split.left), + right: z.number().catch(base.split.right), + bottom: z.number().catch(base.split.bottom), + }) + .catch(base.split) + .parse(raw.split); + const overrides = z + .record(z.string(), z.record(z.string(), z.boolean())) + .catch({}) + .parse(raw.screenOverrides); + + return { + version: LAYOUT_VERSION, + screens: [...new Set(screens)], + activeScreen: + activeScreen && screens.includes(activeScreen) ? activeScreen : (screens[0] ?? null), + slots, + split: { + left: Math.min(0.85, Math.max(0.15, split.left)), + right: Math.min(0.85, Math.max(0.15, split.right)), + bottom: Math.min(0.85, Math.max(0.15, split.bottom)), + }, + floats, + maximized: maximized && seen.has(maximized) ? maximized : null, + screenOverrides: Object.fromEntries( + Object.entries(overrides).map(([screen, values]) => [ + screen, + Object.fromEntries( + Object.entries(values).filter(([slot]) => (SLOT_IDS as readonly string[]).includes(slot)), + ), + ]), + ), + }; +}; diff --git a/packages/layout/src/serialization/serialize-layout.ts b/packages/layout/src/serialization/serialize-layout.ts new file mode 100644 index 00000000..31ddd8a4 --- /dev/null +++ b/packages/layout/src/serialization/serialize-layout.ts @@ -0,0 +1,37 @@ +import { LAYOUT_VERSION } from '../model/layout.const'; +import type { Layout } from '../model/layout.type'; +import { repairLayout } from './repair-layout'; + +type Migration = (raw: Record) => Record; + +/** Migrations from version N to N + 1 (none yet: version 1 is the first). */ +const MIGRATIONS: Record = {}; + +export const serializeLayout = (layout: Layout): string => JSON.stringify(layout); + +/** + * Parses a stored layout: migrates old versions and repairs inconsistencies (duplicate + * instances, dangling active tabs, invalid sizes). Widgets are kept even when their plugin is + * not installed; the renderer shows them as placeholders. Returns undefined when unusable. + */ +export const deserializeLayout = (input: unknown): Layout | undefined => { + let raw: unknown = input; + if (typeof raw === 'string') { + try { + raw = JSON.parse(raw); + } catch { + return undefined; + } + } + if (typeof raw !== 'object' || raw === null) return undefined; + let document = raw as Record; + let version = typeof document.version === 'number' ? document.version : 0; + if (version > LAYOUT_VERSION || version < 1) return undefined; + while (version < LAYOUT_VERSION) { + const migrate = MIGRATIONS[version]; + if (!migrate) return undefined; + document = migrate(document); + version++; + } + return repairLayout(document); +}; diff --git a/packages/layout/test/default/default-layout.spec.ts b/packages/layout/test/default/default-layout.spec.ts new file mode 100644 index 00000000..65c45fc6 --- /dev/null +++ b/packages/layout/test/default/default-layout.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { allWidgets, reconcileLayout } from '../../src'; +import { apply, base, widgets } from '../fixtures/layout'; + +describe('default layout', () => { + it('places screens and docks by order', () => { + const layout = base(); + expect(layout.screens).toEqual(['scene', 'script', 'game']); + expect(layout.activeScreen).toBe('scene'); + expect(layout.slots.leftTop.stack).toEqual({ + tabs: [ + { instanceId: 'hierarchy', widgetId: 'hierarchy' }, + { instanceId: 'files', widgetId: 'files' }, + ], + active: 'hierarchy', + }); + expect(allWidgets(layout).map((ref) => ref.instanceId)).not.toContain('profiler'); + }); + + it('reconciles saved layouts with new plugins', () => { + const saved = apply(base(), { type: 'remove', instanceId: 'console' }); + const reconciled = reconcileLayout( + saved, + [ + ...widgets, + { id: 'network', kind: 'dock', defaultSlot: 'bottom' }, + { id: 'music', kind: 'screen', order: 9 }, + ], + new Set([ + 'scene', + 'script', + 'game', + 'files', + 'hierarchy', + 'inspector', + 'console', + 'profiler', + ]), + ); + expect(reconciled.screens).toEqual(['scene', 'script', 'game', 'music']); + expect(reconciled.slots.bottom.stack.tabs.map((tab) => tab.instanceId)).toEqual(['network']); + }); +}); diff --git a/packages/layout/test/fixtures/layout.ts b/packages/layout/test/fixtures/layout.ts new file mode 100644 index 00000000..e652ace4 --- /dev/null +++ b/packages/layout/test/fixtures/layout.ts @@ -0,0 +1,16 @@ +import { type Layout, type LayoutOp, composeDefaultLayout, reduce } from '../../src'; + +export const widgets = [ + { id: 'scene', kind: 'screen' as const, order: 0 }, + { id: 'script', kind: 'screen' as const, order: 1 }, + { id: 'game', kind: 'screen' as const, order: 2 }, + { id: 'files', kind: 'dock' as const, defaultSlot: 'leftTop' as const }, + { id: 'hierarchy', kind: 'dock' as const, defaultSlot: 'leftTop' as const, order: -1 }, + { id: 'inspector', kind: 'dock' as const, defaultSlot: 'rightTop' as const }, + { id: 'console', kind: 'dock' as const, defaultSlot: 'bottom' as const }, + { id: 'profiler', kind: 'dock' as const, defaultSlot: 'bottom' as const, openByDefault: false }, +]; + +export const base = () => composeDefaultLayout(widgets); +export const apply = (layout: Layout, ...ops: LayoutOp[]) => + ops.reduce((current, op) => reduce(current, op).layout, layout); diff --git a/packages/layout/test/operations/inverse-operations.spec.ts b/packages/layout/test/operations/inverse-operations.spec.ts new file mode 100644 index 00000000..ab5c90b9 --- /dev/null +++ b/packages/layout/test/operations/inverse-operations.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { type Layout, type LayoutOp, SLOT_IDS, allWidgets, reduce } from '../../src'; +import { base } from '../fixtures/layout'; + +/** Deterministic pseudo random generator (mulberry32). */ +const random = (seed: number) => () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const randomOp = (layout: Layout, next: () => number, counter: { n: number }): LayoutOp => { + const pick = (items: readonly T[]) => items[Math.floor(next() * items.length)]!; + const instances = allWidgets(layout).map((ref) => ref.instanceId); + const location = () => + next() < 0.3 + ? layout.floats.length && next() < 0.5 + ? { kind: 'float' as const, floatId: pick(layout.floats).id, index: Math.floor(next() * 3) } + : { + kind: 'float' as const, + floatId: `f${counter.n++}`, + index: 0, + rect: { + x: next() * 800, + y: next() * 600, + width: 150 + next() * 400, + height: 100 + next() * 300, + }, + } + : { kind: 'slot' as const, slot: pick(SLOT_IDS), index: Math.floor(next() * 4) }; + const kinds = [ + 'add', + 'remove', + 'move', + 'activate', + 'resizeSlot', + 'toggleSlot', + 'switchScreen', + 'maximize', + 'setSplit', + 'batch', + ]; + switch (pick(kinds)) { + case 'add': + return { + type: 'add', + ref: { instanceId: `w${counter.n++}`, widgetId: 'dummy' }, + location: location(), + activate: next() < 0.5, + }; + case 'remove': + return instances.length + ? { type: 'remove', instanceId: pick(instances) } + : { type: 'batch', ops: [] }; + case 'move': + return instances.length + ? { + type: 'move', + instanceId: pick(instances), + location: location(), + activate: next() < 0.5, + } + : { type: 'batch', ops: [] }; + case 'activate': + return instances.length + ? { type: 'activate', instanceId: pick(instances) } + : { type: 'batch', ops: [] }; + case 'resizeSlot': + return { type: 'resizeSlot', slot: pick(SLOT_IDS), size: 50 + next() * 500 }; + case 'toggleSlot': + return { type: 'toggleSlot', slot: pick(SLOT_IDS), visible: next() < 0.5 }; + case 'switchScreen': + return { type: 'switchScreen', screen: pick(layout.screens) }; + case 'maximize': + return { + type: 'maximize', + instanceId: instances.length && next() < 0.7 ? pick(instances) : null, + }; + case 'setSplit': + return { type: 'setSplit', side: next() < 0.5 ? 'left' : 'right', ratio: next() }; + default: { + const first = randomOp(layout, next, counter); + return { type: 'batch', ops: [first] }; + } + } +}; + +describe('inverse operations', () => { + it('restore the exact previous layout (1000 random sequences)', () => { + const next = random(42); + const counter = { n: 0 }; + for (let run = 0; run < 1000; run++) { + let layout = base(); + const history: { before: Layout; inverse: LayoutOp }[] = []; + for (let step = 0; step < 8; step++) { + const op = randomOp(layout, next, counter); + const { layout: after, inverse } = reduce(layout, op); + history.push({ before: layout, inverse }); + layout = after; + } + for (const { before, inverse } of history.reverse()) { + layout = reduce(layout, inverse).layout; + const strip = (value: Layout) => ({ + ...value, + floats: value.floats.map((float) => ({ ...float, z: 0 })), + }); + expect(strip(layout)).toEqual(strip(before)); + } + } + }); +}); diff --git a/packages/layout/test/operations/layout-ops.spec.ts b/packages/layout/test/operations/layout-ops.spec.ts new file mode 100644 index 00000000..c37d4386 --- /dev/null +++ b/packages/layout/test/operations/layout-ops.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { isSlotVisible, showSlotOp } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('showing slots', () => { + it('changes the slot, or the screen override when there is one', () => { + const hidden = apply(base(), showSlotOp(base(), 'leftTop', false)); + expect(isSlotVisible(hidden, 'leftTop')).toBe(false); + expect(hidden.slots.leftTop.visible).toBe(false); + + const overridden = apply(base(), { + type: 'setScreenOverride', + screen: 'scene', + slot: 'bottom', + visible: false, + }); + const shown = apply(overridden, showSlotOp(overridden, 'bottom', true)); + expect(isSlotVisible(shown, 'bottom')).toBe(true); + expect(shown.screenOverrides.scene).toEqual({ bottom: true }); + }); +}); diff --git a/packages/layout/test/operations/reduce.spec.ts b/packages/layout/test/operations/reduce.spec.ts new file mode 100644 index 00000000..07a5dde8 --- /dev/null +++ b/packages/layout/test/operations/reduce.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { LayoutError, dockFloatOp, isSlotVisible, reduce } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('operations', () => { + it('floats a widget into a new window and docks it back', () => { + const floated = apply(base(), { + type: 'move', + instanceId: 'inspector', + location: { + kind: 'float', + floatId: 'f1', + index: 0, + rect: { x: 40, y: 50, width: 90, height: 300 }, + }, + }); + expect(floated.floats).toHaveLength(1); + expect(floated.floats[0]).toMatchObject({ + id: 'f1', + width: 200, + height: 300, + stack: { active: 'inspector' }, + }); + expect(floated.slots.rightTop.stack.tabs).toEqual([]); + const docked = apply(floated, dockFloatOp(floated, 'f1', 'bottom')); + expect(docked.floats).toEqual([]); + expect(docked.slots.bottom.stack.tabs.map((tab) => tab.instanceId)).toEqual([ + 'console', + 'inspector', + ]); + }); + + it('reorders tabs inside a stack', () => { + const layout = apply(base(), { + type: 'move', + instanceId: 'hierarchy', + location: { kind: 'slot', slot: 'leftTop', index: 1 }, + }); + expect(layout.slots.leftTop.stack.tabs.map((tab) => tab.instanceId)).toEqual([ + 'files', + 'hierarchy', + ]); + }); + + it('keeps the neighbour active when the active tab closes', () => { + const layout = apply(base(), { type: 'remove', instanceId: 'hierarchy' }); + expect(layout.slots.leftTop.stack.active).toBe('files'); + }); + + it('applies screen overrides to slot visibility', () => { + const layout = apply( + base(), + { type: 'setScreenOverride', screen: 'script', slot: 'rightTop', visible: false }, + { type: 'switchScreen', screen: 'script' }, + ); + expect(isSlotVisible(layout, 'rightTop')).toBe(false); + expect( + isSlotVisible(apply(layout, { type: 'switchScreen', screen: 'scene' }), 'rightTop'), + ).toBe(true); + }); + + it('rejects operations that do not apply', () => { + expect(() => reduce(base(), { type: 'remove', instanceId: 'nope' })).toThrow(LayoutError); + expect(() => reduce(base(), { type: 'switchScreen', screen: 'nope' })).toThrow(LayoutError); + expect(() => + reduce(base(), { + type: 'add', + ref: { instanceId: 'files', widgetId: 'files' }, + location: { kind: 'slot', slot: 'bottom', index: 0 }, + }), + ).toThrow(/already/); + }); +}); diff --git a/packages/layout/test/serialization/serialize-layout.spec.ts b/packages/layout/test/serialization/serialize-layout.spec.ts new file mode 100644 index 00000000..53c8edf7 --- /dev/null +++ b/packages/layout/test/serialization/serialize-layout.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { deserializeLayout, emptyLayout, findWidget, serializeLayout } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('serialization', () => { + it('round-trips and repairs broken documents', () => { + const layout = apply(base(), { + type: 'move', + instanceId: 'console', + location: { + kind: 'float', + floatId: 'f', + index: 0, + rect: { x: 1, y: 2, width: 300, height: 200 }, + }, + }); + expect(deserializeLayout(serializeLayout(layout))).toEqual(layout); + + const broken = JSON.parse(serializeLayout(layout)); + broken.slots.bottom.stack = { + tabs: [{ instanceId: 'files', widgetId: 'files' }], + active: 'ghost', + }; + broken.slots.leftTop.size = -5; + broken.maximized = 'ghost'; + broken.floats.push({ + id: 'empty', + x: 0, + y: 0, + width: 10, + height: 10, + stack: { tabs: [], active: null }, + }); + const repaired = deserializeLayout(broken)!; + expect(findWidget(repaired, 'files')!.location).toMatchObject({ + kind: 'slot', + slot: 'leftTop', + }); + expect(repaired.slots.bottom.stack).toEqual({ tabs: [], active: null }); + expect(repaired.slots.leftTop.size).toBe(120); + expect(repaired.maximized).toBeNull(); + expect(repaired.floats.map((float) => float.id)).toEqual(['f']); + }); + + it('reads layouts saved before the bottom right slot', () => { + const old = JSON.parse(serializeLayout(base())); + delete old.slots.bottomRight; + old.split = { left: 0.4, right: 0.6 }; + const layout = deserializeLayout(old)!; + expect(layout.slots.bottomRight).toEqual(emptyLayout().slots.bottomRight); + expect(layout.split).toEqual({ left: 0.4, right: 0.6, bottom: 0.5 }); + }); + + it('rejects unusable documents', () => { + expect(deserializeLayout('not json')).toBeUndefined(); + expect(deserializeLayout({ version: 99 })).toBeUndefined(); + expect(deserializeLayout(null)).toBeUndefined(); + expect(deserializeLayout({ version: 1 })).toEqual({ ...emptyLayout() }); + }); +}); diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/layout/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/meta/.prettierignore b/packages/meta/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/meta/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/meta/LICENSE b/packages/meta/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/meta/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/meta/README.md b/packages/meta/README.md new file mode 100644 index 00000000..f8756763 --- /dev/null +++ b/packages/meta/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-meta` is an internal package of the NanoForge Editor. + +Item metadata: TSDoc and TypeScript extraction, owner extensions, meta schemas (ADR 0003). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/meta +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/meta/package.json b/packages/meta/package.json new file mode 100644 index 00000000..14ffe1f0 --- /dev/null +++ b/packages/meta/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-meta", + "version": "0.0.0", + "description": "NanoForge Editor - Item metadata: TSDoc and TypeScript extraction, owner extensions, meta schemas (ADR 0003)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./pure": "./src/pure.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/meta" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "ts-morph": "catalog:code", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/meta/src/doc/doc-comment.ts b/packages/meta/src/doc/doc-comment.ts new file mode 100644 index 00000000..35427757 --- /dev/null +++ b/packages/meta/src/doc/doc-comment.ts @@ -0,0 +1,102 @@ +import { type Node, ts } from 'ts-morph'; + +import type { Doc } from './doc.type'; + +const EMPTY: Doc = { description: '', tags: [] }; +const TAG = /(^|\s)@([A-Za-z][\w-]*)/g; +const FENCE = /^\s*```/; + +/** + * Parses a `/** … *\/` comment. Tags are read anywhere after whitespace, also on one line + * (`/** Distance. @group Coords @preset vector.x *\/`), except inside ``` fences and `{@link}`. + */ +export const parseDocComment = (raw: string): Doc => { + const body = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n'); + + const marks: { start: number; end: number; name: string }[] = []; + let offset = 0; + let fenced = false; + for (const line of body.split('\n')) { + if (FENCE.test(line)) fenced = !fenced; + else if (!fenced) { + for (const match of line.matchAll(TAG)) { + const start = offset + match.index + match[1]!.length; + marks.push({ start, end: start + 1 + match[2]!.length, name: match[2]! }); + } + } + offset += line.length + 1; + } + + const description = body.slice(0, marks[0]?.start ?? body.length).trim(); + const tags = marks.map((mark, index) => ({ + name: mark.name, + text: body.slice(mark.end, marks[index + 1]?.start ?? body.length).trim(), + })); + return { description, tags }; +}; + +/** + * `/** … *\/` comments right before a node, as `{ start, end, text }`. Read from the node's full + * start with the compiler API: ts-morph gives the first parameter's comment (`(/** … *\/ x`) to + * the parameter list instead. + */ +export const leadingDocComments = ( + node: Node, +): { readonly start: number; readonly end: number; readonly text: string }[] => { + const text = node.getSourceFile().getFullText(); + const ranges = [ + ...(ts.getTrailingCommentRanges(text, node.getPos()) ?? []), + ...(ts.getLeadingCommentRanges(text, node.getPos()) ?? []), + ].filter((range, index, all) => all.findIndex((other) => other.pos === range.pos) === index); + return ranges + .sort((a, b) => a.pos - b.pos) + .map((range) => ({ start: range.pos, end: range.end, text: text.slice(range.pos, range.end) })) + .filter((range) => range.text.startsWith('/**')); +}; + +/** The last `/** … *\/` comment right before a node, parsed. */ +export const docOf = (node: Node | undefined): Doc => { + if (!node) return EMPTY; + const last = leadingDocComments(node).at(-1); + return last ? parseDocComment(last.text) : EMPTY; +}; + +export const tagTexts = (doc: Doc, name: string): string[] => + doc.tags.filter((tag) => tag.name === name).map((tag) => tag.text); + +export const firstTag = (doc: Doc, name: string): string | undefined => tagTexts(doc, name)[0]; + +export const hasTag = (doc: Doc, name: string): boolean => + doc.tags.some((tag) => tag.name === name); + +/** `@param name - text` (or `@param name text`) of a doc. */ +export const paramTexts = (doc: Doc): Map => { + const result = new Map(); + for (const text of tagTexts(doc, 'param')) { + const match = /^\{[^}]*\}\s*/.exec(text); + const rest = match ? text.slice(match[0].length) : text; + const [, name, description] = /^\[?([\w$.]+)\]?\s*(?:-\s*)?([\s\S]*)$/.exec(rest) ?? []; + if (name) result.set(name, (description ?? '').replace(/\s+/g, ' ').trim()); + } + return result; +}; + +/** The text of an `@example`, without its ``` fence. */ +export const exampleText = (text: string): string => + text + .replace(/^\s*```\w*\s*\n?/, '') + .replace(/\n?\s*```\s*$/, '') + .trim(); + +/** Collapses the line breaks of a description written over several lines. */ +export const oneParagraph = (text: string): string => + text + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n\n'); diff --git a/packages/meta/src/doc/doc.type.ts b/packages/meta/src/doc/doc.type.ts new file mode 100644 index 00000000..b96cb07d --- /dev/null +++ b/packages/meta/src/doc/doc.type.ts @@ -0,0 +1,11 @@ +export interface DocTag { + /** Without `@`. */ + readonly name: string; + /** The text after the tag name, trimmed (`''` for a flag). */ + readonly text: string; +} + +export interface Doc { + readonly description: string; + readonly tags: readonly DocTag[]; +} diff --git a/packages/meta/src/doc/index.ts b/packages/meta/src/doc/index.ts new file mode 100644 index 00000000..51b3bc90 --- /dev/null +++ b/packages/meta/src/doc/index.ts @@ -0,0 +1,2 @@ +export * from './doc.type'; +export * from './doc-comment'; diff --git a/packages/meta/src/extract/exported-declarations.ts b/packages/meta/src/extract/exported-declarations.ts new file mode 100644 index 00000000..c53bf633 --- /dev/null +++ b/packages/meta/src/extract/exported-declarations.ts @@ -0,0 +1,64 @@ +import { + type ClassDeclaration, + type Node, + Node as NodeGuards, + type ParameterDeclaration, + type SourceFile, +} from 'ts-morph'; + +import type { Doc } from '../doc/doc.type'; +import type { ItemDeclaration, ItemKind } from '../owner/item-owner.type'; + +type Tags = Record; + +export const tagsByName = (doc: Doc): Tags => { + const result: Tags = {}; + for (const tag of doc.tags) (result[tag.name] ??= []).push(tag.text); + return result; +}; + +/** The export declarations of the file itself (not re-exports), with their export name. */ +interface ExportedItem { + readonly exportName: string; + readonly declaration: ItemDeclaration; + readonly kind: ItemKind; +} + +export const exportedDeclarations = (file: SourceFile): ExportedItem[] => + [...file.getExportedDeclarations()].flatMap(([exportName, declarations]) => + declarations.flatMap((declaration): ExportedItem[] => { + if (declaration.getSourceFile() !== file) return []; + if (NodeGuards.isClassDeclaration(declaration)) + return [{ exportName, declaration, kind: 'class' as ItemKind }]; + if (NodeGuards.isFunctionDeclaration(declaration)) + return [{ exportName, declaration, kind: 'function' as ItemKind }]; + if (NodeGuards.isVariableDeclaration(declaration)) + return [{ exportName, declaration, kind: 'const' as ItemKind }]; + return []; + }), + ); + +export const docNodeOf = (declaration: ItemDeclaration): Node => + NodeGuards.isVariableDeclaration(declaration) + ? (declaration.getVariableStatement() ?? declaration) + : declaration; + +/** The constructor that has a body (the implementation), or the first one. */ +export const constructorOf = (declaration: ClassDeclaration) => { + const constructors = declaration.getConstructors(); + return constructors.find((constructor) => constructor.hasBody()) ?? constructors[0]; +}; + +/** Parameters of the item: its constructor's (class), its own (function, arrow const). */ +export const parametersOf = (declaration: ItemDeclaration): ParameterDeclaration[] => { + if (NodeGuards.isClassDeclaration(declaration)) + return constructorOf(declaration)?.getParameters() ?? []; + if (NodeGuards.isFunctionDeclaration(declaration)) return declaration.getParameters(); + const initializer = declaration.getInitializer(); + if ( + initializer && + (NodeGuards.isArrowFunction(initializer) || NodeGuards.isFunctionExpression(initializer)) + ) + return initializer.getParameters(); + return []; +}; diff --git a/packages/meta/src/extract/extract-items.ts b/packages/meta/src/extract/extract-items.ts new file mode 100644 index 00000000..04d9ab05 --- /dev/null +++ b/packages/meta/src/extract/extract-items.ts @@ -0,0 +1,202 @@ +import { type Node, Node as NodeGuards, type SourceFile } from 'ts-morph'; + +import { + docOf, + exampleText, + firstTag, + hasTag, + oneParagraph, + paramTexts, + tagTexts, +} from '../doc/doc-comment'; +import type { ItemOwner, OwnerContext, OwnerInput } from '../owner/item-owner.type'; +import type { Element, ParamGroup } from '../schema/element.schema'; +import type { Side } from '../schema/item-ref.schema'; +import type { ItemMeta } from '../schema/meta-file.schema'; +import { elementOfType } from '../type/element-of-type'; +import type { TypeContext } from '../type/type-context.type'; +import { + constructorOf, + docNodeOf, + exportedDeclarations, + parametersOf, + tagsByName, +} from './exported-declarations'; +import type { ExtractOptions, ExtractResult, MetaDiagnostic } from './extract.type'; +import { CORE_TAGS, SIDES, STANDARD_TAGS } from './meta-tags.const'; +import { + asAsset, + assetTags, + fieldsSetFromConstructor, + isPublicInstanceField, + withDefault, +} from './param-fields'; +import { layoutOf, parseGroupDeclaration } from './param-layout'; +import { requiresOf } from './requires'; + +/** + * Extracts the items of one file: core fields from TSDoc and TypeScript, and each owner's object. + * An export is an item when the file is `listed`, when an owner claims it (tags or inference), or + * when it carries tags of an owner that isn't installed (kept in `unclaimedTags`). + */ +export const extractItems = (file: SourceFile, options: ExtractOptions): ExtractResult => { + const diagnostics: MetaDiagnostic[] = []; + const typeContext: TypeContext = { modulePath: options.modulePath }; + const context: OwnerContext = { + file, + path: options.path, + source: options.source, + ...(options.folder && { folder: options.folder }), + refOf: options.refOf, + }; + const ownedTags = new Map(); + for (const owner of options.owners) for (const tag of owner.tags) ownedTags.set(tag, owner); + const warn = (node: Node, message: string, source = 'meta') => + diagnostics.push({ + path: options.path, + start: node.getStart(), + length: node.getWidth(), + message, + severity: 'warning', + source, + }); + const requires = requiresOf(file); + const items: ItemMeta[] = []; + + for (const { exportName, declaration, kind } of exportedDeclarations(file)) { + const doc = docOf(docNodeOf(declaration)); + if (hasTag(doc, 'internal')) continue; + const tags = tagsByName(doc); + const nameNode = declaration.getNameNode() ?? declaration; + + const ownerObjects: Record> = {}; + const claimedParams = new Set(); + const claimedFields = new Set(); + for (const owner of options.owners) { + const own: Record = {}; + for (const tag of owner.tags) if (tags[tag]) own[tag] = tags[tag]; + const input: OwnerInput = { declaration, kind, tags: own, context }; + if (!Object.keys(own).length && !owner.infer?.(input)) continue; + let value: Record | undefined; + try { + value = owner.extract(input); + } catch (error) { + warn(nameNode, `${owner.name} could not read ${exportName}: ${String(error)}`, owner.name); + continue; + } + if (!value) continue; + const check = owner.validate?.safeParse(value); + if (check && !check.success) { + warn(nameNode, `${owner.name} wrote an invalid description of ${exportName}`, owner.name); + continue; + } + ownerObjects[owner.name] = value; + const claims = owner.claims?.(input); + for (const name of claims?.params ?? []) claimedParams.add(name); + for (const name of claims?.fields ?? []) claimedFields.add(name); + } + + const unclaimed: Record = {}; + for (const [name, texts] of Object.entries(tags)) { + if (CORE_TAGS.has(name) || STANDARD_TAGS.has(name) || ownedTags.has(name)) continue; + unclaimed[name] = texts; + } + + const isItem = + options.listed || Object.keys(ownerObjects).length > 0 || Object.keys(unclaimed).length > 0; + if (!isItem) continue; + + let side: Side = 'shared'; + const sideText = firstTag(doc, 'side'); + if (sideText !== undefined) { + const value = sideText.split(/\s+/)[0] ?? ''; + if (SIDES.has(value)) side = value as Side; + else warn(nameNode, `@side must be client, server or shared, not "${value}"`); + } + + const ownerDoc = NodeGuards.isClassDeclaration(declaration) + ? docOf(constructorOf(declaration)) + : doc; + const paramDocs = paramTexts(ownerDoc); + const constructorAssets = assetTags(ownerDoc, false); + const params: Element[] = []; + for (const param of parametersOf(declaration)) { + const name = param.getName(); + if (claimedParams.has(name)) continue; + const own = docOf(param); + let element = elementOfType(name, param.getType(), param, typeContext); + element = withDefault(element, param.getInitializer()); + const assets = constructorAssets.get(name) ?? assetTags(own, true).get(''); + if (assets) element = asAsset(element, assets); + const description = oneParagraph(own.description) || paramDocs.get(name); + const layout = layoutOf(own); + const deprecated = firstTag(own, 'deprecated'); + params.push({ + ...element, + ...((param.isOptional() || param.hasInitializer()) && { optional: true }), + ...(description && { description }), + ...(deprecated !== undefined && { deprecated }), + ...(layout && { layout }), + } as Element); + } + + const fields: Element[] = []; + if (NodeGuards.isClassDeclaration(declaration)) { + const fromConstructor = fieldsSetFromConstructor(declaration); + for (const property of declaration.getProperties()) { + const name = property.getName(); + if (!isPublicInstanceField(property) || claimedFields.has(name)) continue; + if (fromConstructor.has(name)) continue; + const own = docOf(property); + if (hasTag(own, 'internal')) continue; + let element = elementOfType(name, property.getType(), property, typeContext); + element = withDefault(element, property.getInitializer()); + const assets = assetTags(own, true).get(''); + if (assets) element = asAsset(element, assets); + const description = oneParagraph(own.description); + const layout = layoutOf(own); + fields.push({ + ...element, + ...(property.hasQuestionToken() && { optional: true }), + ...(description && { description }), + ...(layout && { layout }), + } as Element); + } + } + + const groups: ParamGroup[] = []; + for (const text of tags.group ?? []) { + const group = parseGroupDeclaration(text); + if (!group) warn(nameNode, `@group needs a name: "@group Coords - description"`); + else if (groups.some((existing) => existing.name === group.name)) + warn(nameNode, `The group "${group.name}" is declared twice`); + else groups.push(group); + } + for (const element of [...params, ...fields]) { + const name = element.layout?.group; + if (name && !groups.some((group) => group.name === name)) groups.push({ name }); + } + + const description = oneParagraph( + [doc.description, ...tagTexts(doc, 'remarks')].filter(Boolean).join('\n\n'), + ); + const example = firstTag(doc, 'example'); + const deprecated = firstTag(doc, 'deprecated'); + items.push({ + export: exportName, + source: options.path, + kind, + side, + ...(description && { description }), + ...(example && { example: exampleText(example) }), + ...(deprecated !== undefined && { deprecated }), + params, + fields, + groups, + requires, + ...(Object.keys(unclaimed).length && { unclaimedTags: unclaimed }), + ...ownerObjects, + } as ItemMeta); + } + return { items, diagnostics }; +}; diff --git a/packages/meta/src/extract/extract.type.ts b/packages/meta/src/extract/extract.type.ts new file mode 100644 index 00000000..eb5feeb4 --- /dev/null +++ b/packages/meta/src/extract/extract.type.ts @@ -0,0 +1,35 @@ +import type { Node, SourceFile } from 'ts-morph'; + +import type { ItemOwner } from '../owner/item-owner.type'; +import type { ItemRef } from '../schema/item-ref.schema'; +import type { ItemMeta, MetaSource } from '../schema/meta-file.schema'; + +export interface MetaDiagnostic { + readonly path: string; + readonly start: number; + readonly length: number; + readonly message: string; + readonly severity: 'warning' | 'info'; + /** `meta` or the owner that reported it. */ + readonly source: string; +} + +export interface ExtractOptions { + readonly source: MetaSource; + /** Project path of the file. */ + readonly path: string; + readonly owners: readonly ItemOwner[]; + /** The file is listed in a manifest's `items`: each of its exports is an item. */ + readonly listed?: boolean; + /** The item folder of the app or library the file is in. */ + readonly folder?: 'components' | 'systems'; + /** Module of a declaration file: a package name, or the project path of a project file. */ + modulePath(file: SourceFile): string; + /** The item a node refers to (for owners, e.g. the components a system queries). */ + refOf(node: Node): ItemRef | undefined; +} + +export interface ExtractResult { + readonly items: ItemMeta[]; + readonly diagnostics: MetaDiagnostic[]; +} diff --git a/packages/meta/src/extract/index.ts b/packages/meta/src/extract/index.ts new file mode 100644 index 00000000..bc3dac97 --- /dev/null +++ b/packages/meta/src/extract/index.ts @@ -0,0 +1,5 @@ +export { CORE_TAGS } from './meta-tags.const'; +export * from './extract.type'; +export { parseGroupDeclaration } from './param-layout'; +export * from './requires'; +export * from './extract-items'; diff --git a/packages/meta/src/extract/meta-tags.const.ts b/packages/meta/src/extract/meta-tags.const.ts new file mode 100644 index 00000000..3f2532a5 --- /dev/null +++ b/packages/meta/src/extract/meta-tags.const.ts @@ -0,0 +1,46 @@ +/** Tags the core reads itself (ADR 0003). */ +export const CORE_TAGS: ReadonlySet = new Set([ + 'remarks', + 'param', + 'example', + 'side', + 'asset', + 'deprecated', + 'internal', + 'group', + 'preset', + 'label', + 'color', + 'hidden', +]); + +/** Common JSDoc/TSDoc tags that are neither core nor owner tags: never reported. */ +export const STANDARD_TAGS: ReadonlySet = new Set([ + 'returns', + 'return', + 'see', + 'throws', + 'since', + 'template', + 'typeParam', + 'link', + 'inheritDoc', + 'default', + 'defaultValue', + 'privateRemarks', + 'beta', + 'alpha', + 'experimental', + 'public', + 'override', + 'virtual', + 'sealed', + 'readonly', + 'author', + 'todo', + 'type', + 'module', + 'packageDocumentation', +]); + +export const SIDES: ReadonlySet = new Set(['client', 'server', 'shared']); diff --git a/packages/meta/src/extract/param-fields.ts b/packages/meta/src/extract/param-fields.ts new file mode 100644 index 00000000..1291fb2f --- /dev/null +++ b/packages/meta/src/extract/param-fields.ts @@ -0,0 +1,92 @@ +import { + type ClassDeclaration, + type Node, + Node as NodeGuards, + type PropertyDeclaration, + ts, +} from 'ts-morph'; + +import { tagTexts } from '../doc/doc-comment'; +import type { Doc } from '../doc/doc.type'; +import type { Element } from '../schema/element.schema'; +import { defaultOf } from '../type/default-of'; +import { constructorOf } from './exported-declarations'; + +/** `@asset .png .jpg` of a constructor doc, or `@asset .png` of a param's own doc. */ +export const assetTags = (doc: Doc, own: boolean): Map => { + const result = new Map(); + for (const text of tagTexts(doc, 'asset')) { + const words = text.split(/\s+/).filter(Boolean); + const target = own ? '' : words.shift(); + if (target === undefined) continue; + result.set( + target, + words.filter((word) => word.startsWith('.')), + ); + } + return result; +}; + +export const withDefault = (element: Element, initializer: Node | undefined): Element => { + if (!initializer) return element; + const value = defaultOf(initializer); + if ('code' in value) return { ...element, defaultCode: value.code }; + const matches = + (typeof value.value === 'string' && (element.type === 'string' || element.type === 'asset')) || + (typeof value.value === 'number' && element.type === 'number') || + (typeof value.value === 'boolean' && element.type === 'boolean') || + (Array.isArray(value.value) && element.type === 'array') || + (typeof value.value === 'object' && + value.value !== null && + !Array.isArray(value.value) && + element.type === 'object'); + return matches + ? ({ ...element, default: value.value } as Element) + : { ...element, defaultCode: initializer.getText() }; +}; + +export const asAsset = (element: Element, accept: readonly string[]): Element => { + if (element.type !== 'string') return element; + const rest: Record = { ...element }; + delete rest.enum; + delete rest.enumRef; + delete rest.enumMembers; + return { ...rest, type: 'asset', ...(accept.length && { accept: [...accept] }) } as Element; +}; + +/** Fields assigned from constructor params (`this.x = x`): they are params, not fields. */ +export const fieldsSetFromConstructor = (declaration: ClassDeclaration): Set => { + const constructor = constructorOf(declaration); + const names = new Set(); + if (!constructor) return names; + const params = new Set(constructor.getParameters().map((param) => param.getName())); + for (const statement of constructor.getStatements()) { + if (!NodeGuards.isExpressionStatement(statement)) continue; + const expression = statement.getExpression(); + if (!NodeGuards.isBinaryExpression(expression)) continue; + if (expression.getOperatorToken().getKind() !== ts.SyntaxKind.EqualsToken) continue; + const left = expression.getLeft(); + const right = expression.getRight(); + if ( + NodeGuards.isPropertyAccessExpression(left) && + NodeGuards.isThisExpression(left.getExpression()) && + NodeGuards.isIdentifier(right) && + params.has(right.getText()) + ) + names.add(left.getName()); + } + return names; +}; + +export const isPublicInstanceField = (property: PropertyDeclaration) => + !property.isStatic() && + !property.hasModifier(ts.SyntaxKind.PrivateKeyword) && + !property.hasModifier(ts.SyntaxKind.ProtectedKeyword) && + !property.getName().startsWith('#'); + +export const packageOfSpecifier = (specifier: string): string | undefined => { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) + return undefined; + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +}; diff --git a/packages/meta/src/extract/param-layout.ts b/packages/meta/src/extract/param-layout.ts new file mode 100644 index 00000000..52b35791 --- /dev/null +++ b/packages/meta/src/extract/param-layout.ts @@ -0,0 +1,47 @@ +import { firstTag, hasTag, oneParagraph } from '../doc/doc-comment'; +import type { Doc } from '../doc/doc.type'; +import type { ElementLayout, ParamGroup } from '../schema/element.schema'; + +/** `@group Name [color=] [hidden] - description` of a header. */ +export const parseGroupDeclaration = (text: string): ParamGroup | undefined => { + const separator = text.search(/\s-\s|\s-$/); + const head = (separator < 0 ? text : text.slice(0, separator)).trim(); + const description = + separator < 0 ? '' : oneParagraph(text.slice(separator).replace(/^\s-\s?/, '')); + const words = head.split(/\s+/).filter(Boolean); + let color: string | undefined; + let hidden = false; + while (words.length > 1) { + const last = words.at(-1)!; + if (last === 'hidden') hidden = true; + else if (last.startsWith('color=')) color = last.slice('color='.length); + else break; + words.pop(); + } + const name = words.join(' '); + if (!name) return undefined; + return { + name, + ...(description && { description }), + ...(color && { color }), + ...(hidden && { hidden }), + }; +}; + +/** Layout of a param or field from its own doc. */ +export const layoutOf = (doc: Doc): ElementLayout | undefined => { + const layout: ElementLayout = {}; + const label = firstTag(doc, 'label'); + if (label) layout.label = label; + const group = firstTag(doc, 'group'); + if (group) layout.group = group; + const preset = firstTag(doc, 'preset'); + if (preset) { + const [id, slot] = preset.split(/\s+/)[0]!.split('.'); + if (id) layout.preset = { id, ...(slot && { slot }) }; + } + const color = firstTag(doc, 'color'); + if (color) layout.color = color.split(/\s+/)[0]!; + if (hasTag(doc, 'hidden')) layout.hidden = true; + return Object.keys(layout).length ? layout : undefined; +}; diff --git a/packages/meta/src/extract/requires.ts b/packages/meta/src/extract/requires.ts new file mode 100644 index 00000000..ae452d6b --- /dev/null +++ b/packages/meta/src/extract/requires.ts @@ -0,0 +1,22 @@ +import type { SourceFile } from 'ts-morph'; + +import { packageOfPath } from '../type/package-of-path'; +import { packageOfSpecifier } from './param-fields'; + +/** npm packages a file imports (type-only too), sorted. */ +export const requiresOf = (file: SourceFile): string[] => + [ + ...new Set( + [...file.getImportDeclarations(), ...file.getExportDeclarations()].flatMap((declaration) => { + const specifier = declaration.getModuleSpecifierValue(); + const name = specifier ? packageOfSpecifier(specifier) : undefined; + return name ? [name] : []; + }), + ), + ].sort(); + +/** Default module path: a package inside `node_modules`, else the file path without `/project/`. */ +export const defaultModulePath = (file: SourceFile): string => { + const path = file.getFilePath(); + return packageOfPath(path) ?? path.replace(/^\/project\//, ''); +}; diff --git a/packages/meta/src/hash/index.ts b/packages/meta/src/hash/index.ts new file mode 100644 index 00000000..39ccd906 --- /dev/null +++ b/packages/meta/src/hash/index.ts @@ -0,0 +1 @@ +export * from './source-hash'; diff --git a/packages/meta/src/hash/source-hash.ts b/packages/meta/src/hash/source-hash.ts new file mode 100644 index 00000000..e71088ce --- /dev/null +++ b/packages/meta/src/hash/source-hash.ts @@ -0,0 +1,19 @@ +/** The text of a source file, for hashing. */ +export interface HashedSource { + readonly path: string; + readonly text: string; +} + +/** + * `sha256-` over each source (path, NUL, text, NUL), sorted by path. Uses WebCrypto, which + * the browser, the code worker and Bun all have. + */ +export const sourceHash = async (sources: readonly HashedSource[]): Promise => { + const sorted = [...sources].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const text = sorted.map((source) => `${source.path}\0${source.text}\0`).join(''); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `sha256-${hex}`; +}; diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts new file mode 100644 index 00000000..89947e15 --- /dev/null +++ b/packages/meta/src/index.ts @@ -0,0 +1,6 @@ +export * from './doc'; +export * from './extract'; +export * from './hash'; +export * from './owner'; +export * from './schema'; +export * from './type'; diff --git a/packages/meta/src/owner/index.ts b/packages/meta/src/owner/index.ts new file mode 100644 index 00000000..6a02b4ec --- /dev/null +++ b/packages/meta/src/owner/index.ts @@ -0,0 +1 @@ +export * from './item-owner.type'; diff --git a/packages/meta/src/owner/item-owner.type.ts b/packages/meta/src/owner/item-owner.type.ts new file mode 100644 index 00000000..eb9f444a --- /dev/null +++ b/packages/meta/src/owner/item-owner.type.ts @@ -0,0 +1,59 @@ +import type { + ClassDeclaration, + FunctionDeclaration, + Node, + SourceFile, + VariableDeclaration, +} from 'ts-morph'; + +import type { ItemRef } from '../schema/item-ref.schema'; +import type { MetaSource } from '../schema/meta-file.schema'; + +export type ItemDeclaration = ClassDeclaration | FunctionDeclaration | VariableDeclaration; +export type ItemKind = 'class' | 'function' | 'const'; + +/** Where the extractor is, for owners. */ +export interface OwnerContext { + readonly file: SourceFile; + /** Project path of the file. */ + readonly path: string; + readonly source: MetaSource; + /** The item folder of the app or library the file is in (`dir.components`, `dir.systems`). */ + readonly folder?: 'components' | 'systems'; + /** The item a node (usually an identifier) refers to, when its declaration is exported. */ + refOf(node: Node): ItemRef | undefined; +} + +export interface OwnerInput { + readonly declaration: ItemDeclaration; + readonly kind: ItemKind; + /** This owner's tags on the item, by name (without `@`). */ + readonly tags: Readonly>; + readonly context: OwnerContext; +} + +/** + * An owner of item data (ADR 0003), registered by a plugin's worker entry. It reads its own + * tags, may infer untagged items, and returns its object for the item's owner key. + */ +export interface ItemOwner { + /** The plugin's registry name, also the owner key: `@nanoforge/ecs`. */ + readonly name: string; + /** The plugin's version (written in the meta's `owners`). */ + readonly version: string; + /** Version of the owner object's shape. A change regenerates the metas. */ + readonly schema: number; + /** The tags it owns, without `@`. */ + readonly tags: readonly string[]; + /** Checks the owner object (a zod schema fits). */ + readonly validate?: { safeParse(value: unknown): { success: boolean; error?: unknown } }; + /** Whether an export without this owner's tags is an item for it (inference). */ + infer?(input: OwnerInput): boolean; + /** Members the owner describes itself: left out of `params` / `fields`. */ + claims?(input: OwnerInput): { + readonly params?: readonly string[]; + readonly fields?: readonly string[]; + }; + /** The owner object, or undefined when the export isn't an item for this owner. */ + extract(input: OwnerInput): Record | undefined; +} diff --git a/packages/meta/src/pure.ts b/packages/meta/src/pure.ts new file mode 100644 index 00000000..9666cf0c --- /dev/null +++ b/packages/meta/src/pure.ts @@ -0,0 +1,8 @@ +/** + * The part of the package that does not need the TypeScript compiler: the meta format, hashes + * and owner types. Code that runs on the editor's main thread imports this entry, so the + * compiler (7 MB) stays in the code worker. + */ +export * from './hash'; +export * from './owner'; +export * from './schema'; diff --git a/packages/meta/src/schema/element.schema.ts b/packages/meta/src/schema/element.schema.ts new file mode 100644 index 00000000..b6c8fc40 --- /dev/null +++ b/packages/meta/src/schema/element.schema.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +import { ExportRef } from './item-ref.schema'; +import { withOwners } from './owner-object'; + +/** A CSS color, as written in the tag (`orange`, `#4aa3ff`, `rgb(…)`). */ +const CssColor = z.string().min(1); + +/** How a param or field looks in the inspector. Every field is optional. */ +export const ElementLayout = z.object({ + /** Name shown instead of the param name. */ + label: z.string().optional(), + /** The group it belongs to (`ItemMeta.groups[].name`). */ + group: z.string().optional(), + /** A visual preset, and the slot it fills: `{ id: "vector", slot: "x" }`, `{ id: "color" }`. */ + preset: z.object({ id: z.string(), slot: z.string().optional() }).optional(), + /** Color of its name. */ + color: CssColor.optional(), + /** Hidden until the user chooses to show it. */ + hidden: z.boolean().optional(), +}); +export type ElementLayout = z.output; + +/** A group of params, declared in the item's header (`@group Coords color=… hidden - …`). */ +export const ParamGroup = z.object({ + name: z.string(), + description: z.string().optional(), + color: CssColor.optional(), + hidden: z.boolean().optional(), +}); +export type ParamGroup = z.output; + +export interface EnumMember { + readonly name: string; + readonly value: string | number; +} + +interface ElementCommon { + name: string; + description?: string; + /** Default false; true when the param has a default or `?`. */ + optional?: boolean; + deprecated?: string; + layout?: ElementLayout; + /** The initializer's source text when it isn't a JSON value (`new Rect({ width: 10 })`). */ + defaultCode?: string; + [owner: `@${string}/${string}`]: Record; +} + +/** + * A value's type. Core types describe TypeScript, not a lib. + * `default` is a JSON value when the initializer is a literal (an enum member gives its value). + */ +export type Element = + | ({ + type: 'string'; + default?: string; + /** Allowed values, from a string literal union (`"left" | "right"`). */ + enum?: string[]; + /** Values come from an exported enum: `@nanoforge-dev/input#InputEnum`. */ + enumRef?: string; + enumMembers?: EnumMember[]; + } & ElementCommon) + | ({ + type: 'number'; + default?: number; + enumRef?: string; + enumMembers?: EnumMember[]; + } & ElementCommon) + | ({ type: 'boolean'; default?: boolean } & ElementCommon) + | ({ type: 'asset'; default?: string; accept?: string[] } & ElementCommon) + | ({ type: 'array'; items: Element; default?: unknown[] } & ElementCommon) + | ({ type: 'object'; properties: Element[]; default?: Record } & ElementCommon) + | ({ type: 'ref'; ref: string } & ElementCommon) + | ({ type: 'unknown'; tsType: string } & ElementCommon); + +export type ElementType = Element['type']; + +const EnumMemberSchema = z.object({ name: z.string(), value: z.union([z.string(), z.number()]) }); + +const elementBase = { + name: z.string(), + description: z.string().optional(), + optional: z.boolean().optional(), + deprecated: z.string().optional(), + layout: ElementLayout.optional(), + defaultCode: z.string().optional(), +}; + +export const Element: z.ZodType = z.lazy(() => + z.union([ + withOwners({ + ...elementBase, + type: z.literal('string'), + default: z.string().optional(), + enum: z.array(z.string()).optional(), + enumRef: ExportRef.optional(), + enumMembers: z.array(EnumMemberSchema).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('number'), + default: z.number().optional(), + enumRef: ExportRef.optional(), + enumMembers: z.array(EnumMemberSchema).optional(), + }), + withOwners({ ...elementBase, type: z.literal('boolean'), default: z.boolean().optional() }), + withOwners({ + ...elementBase, + type: z.literal('asset'), + default: z.string().optional(), + accept: z.array(z.string().regex(/^\.[\w.]+$/)).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('array'), + items: Element, + default: z.array(z.unknown()).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('object'), + properties: z.array(Element), + default: z.record(z.string(), z.unknown()).optional(), + }), + withOwners({ ...elementBase, type: z.literal('ref'), ref: ExportRef }), + withOwners({ ...elementBase, type: z.literal('unknown'), tsType: z.string() }), + ]), +) as z.ZodType; + +export const ItemKindSchema = z.enum(['class', 'function', 'const']); diff --git a/packages/meta/src/schema/index.ts b/packages/meta/src/schema/index.ts new file mode 100644 index 00000000..ec2879bb --- /dev/null +++ b/packages/meta/src/schema/index.ts @@ -0,0 +1,5 @@ +export * from './item-ref.schema'; +export { ownerObjects } from './owner-object'; +export * from './element.schema'; +export * from './meta-file.schema'; +export * from './meta-generator.const'; diff --git a/packages/meta/src/schema/item-ref.schema.ts b/packages/meta/src/schema/item-ref.schema.ts new file mode 100644 index 00000000..08478951 --- /dev/null +++ b/packages/meta/src/schema/item-ref.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Item metadata (ADR 0003): what the editor knows about each component, system or other item, + * extracted from the TSDoc and the TypeScript of its source. Core fields never name a lib: lib + * data lives in owner objects keyed by the owner plugin's registry name (`"@nanoforge/ecs": {…}`). + */ + +/** A package or plugin of our registry: `@scope/name`. Also the key of an owner object. */ +export const REGISTRY_NAME = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; +export const RegistryName = z.string().regex(REGISTRY_NAME); + +/** An npm package name: `@scope/name` or `name`. */ +export const NpmName = z.string().regex(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/); + +/** An export of a module: `@nanoforge-dev/graphics-2d#Rect`, or `apps/client/src/x.ts#Foo`. */ +export const ExportRef = z.string().regex(/^[^#\s]+#[A-Za-z_$][\w$]*$/); + +/** + * An item: `#`. The source is an installed package or a shared library + * (`@nanoforge/motion#Position`, `@pong-network/shared#Player`), or an app (`app:client#Paddle`). + */ +export const ItemRef = z + .string() + .regex(/^(app:[\w.-]+|(@[a-z0-9-~][\w.-]*\/)?[a-z0-9-~][\w.-]*)#[A-Za-z_$][\w$]*$/); +export type ItemRef = z.output; + +export const itemRef = (source: string, exportName: string): ItemRef => `${source}#${exportName}`; + +/** Where an item runs. A NanoForge app concept (client and server apps), so it's core. */ +export const Side = z.enum(['client', 'server', 'shared']); +export type Side = z.output; diff --git a/packages/meta/src/schema/meta-file.schema.ts b/packages/meta/src/schema/meta-file.schema.ts new file mode 100644 index 00000000..e85a2467 --- /dev/null +++ b/packages/meta/src/schema/meta-file.schema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +import { Element, ItemKindSchema, ParamGroup } from './element.schema'; +import { NpmName, RegistryName, Side } from './item-ref.schema'; +import { withOwners } from './owner-object'; + +export const ItemMeta = withOwners({ + export: z.string(), + /** Project path of the source file. */ + source: z.string(), + kind: ItemKindSchema, + side: Side, + description: z.string().optional(), + example: z.string().optional(), + deprecated: z.string().optional(), + /** Constructor parameters (class) or parameters (function), in order, minus claimed ones. */ + params: z.array(Element), + /** Public fields not set from the constructor, minus claimed ones. */ + fields: z.array(Element).default([]), + /** Param groups declared in the header, in declaration order, then undeclared ones used. */ + groups: z.array(ParamGroup).default([]), + /** npm packages the item's source imports (type-only imports included). */ + requires: z.array(NpmName).default([]), + /** Tags no installed owner claimed, kept raw: `{ "component": [""], "query": ["A B"] }`. */ + unclaimedTags: z.record(z.string(), z.array(z.string())).optional(), +}); +export type ItemMeta = z.output; + +/** + * Where items come from: + * - `module`: an installed package (`nf_modules/@scope/name`), read-only; + * - `lib`: a shared library of the project (`libs/`, ADR 0004); + * - `app`: an app of the project (`apps/client`). + */ +export const MetaSource = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('module'), + name: RegistryName, + version: z.string(), + path: z.string(), + }), + z.object({ kind: z.literal('lib'), name: NpmName, path: z.string() }), + z.object({ kind: z.literal('app'), name: z.string(), path: z.string() }), +]); +export type MetaSource = z.output; + +/** The `` part of the item references of a source. */ +export const sourceRefBase = (source: MetaSource): string => + source.kind === 'app' ? `app:${source.name}` : source.name; + +export const META_VERSION = 1; +export const META_NOTICE = 'Generated by the NanoForge editor. Do not edit.'; + +/** One cache file per source, in `.nanoforge/editor/cache/meta/`. */ +export const MetaFile = z.object({ + generated: z.literal(META_NOTICE), + metaVersion: z.literal(META_VERSION), + /** The package that holds the extraction logic. */ + generator: z.object({ name: z.string(), version: z.string() }), + source: MetaSource, + /** sha256 over each source file (path, NUL, text, NUL), sorted by path. */ + sourceHash: z.string().regex(/^sha256-[0-9a-f]{64}$/), + /** The owner plugins that ran, and the schema version of what they wrote. */ + owners: z.record(RegistryName, z.object({ version: z.string(), schema: z.number().int() })), + items: z.array(ItemMeta), +}); +export type MetaFile = z.output; + +/** Cache path of a source's meta, relative to the project root. */ +export const metaCachePath = (source: MetaSource): string => { + const base = '.nanoforge/editor/cache/meta'; + switch (source.kind) { + case 'module': + return `${base}/modules/${source.name}.json`; + case 'lib': + return `${base}/libs/${source.name}.json`; + case 'app': + return `${base}/apps/${source.name}.json`; + } +}; diff --git a/packages/meta/src/schema/meta-generator.const.ts b/packages/meta/src/schema/meta-generator.const.ts new file mode 100644 index 00000000..d4bef213 --- /dev/null +++ b/packages/meta/src/schema/meta-generator.const.ts @@ -0,0 +1,2 @@ +/** Name and version written in each meta file's `generator`. Bump the version when output changes. */ +export const META_GENERATOR = { name: '@nanoforge-dev/editor-meta', version: '0.1.0' } as const; diff --git a/packages/meta/src/schema/owner-object.ts b/packages/meta/src/schema/owner-object.ts new file mode 100644 index 00000000..66ab14af --- /dev/null +++ b/packages/meta/src/schema/owner-object.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { REGISTRY_NAME } from './item-ref.schema'; + +const isOwnerKey = (key: string) => REGISTRY_NAME.test(key); +const OwnerObject = z.record(z.string(), z.unknown()); + +/** + * Adds owner objects to a core object: any extra key must be a registry name, and its value is an + * object only that owner parses. The core keeps it untouched. + */ +export const withOwners = (shape: Shape) => + z + .object(shape) + .catchall(OwnerObject) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (key in shape || isOwnerKey(key)) continue; + ctx.addIssue({ + code: 'custom', + path: [key], + message: 'unknown field: owner keys are registry names ("@scope/name")', + }); + } + }); + +/** Owner objects of a core object (its keys that are registry names). */ +export const ownerObjects = (value: object): Record> => + Object.fromEntries( + Object.entries(value).filter(([key]) => isOwnerKey(key)) as [string, Record][], + ); diff --git a/packages/meta/src/type/default-of.ts b/packages/meta/src/type/default-of.ts new file mode 100644 index 00000000..95849069 --- /dev/null +++ b/packages/meta/src/type/default-of.ts @@ -0,0 +1,66 @@ +import { Node, ts } from 'ts-morph'; + +export type DefaultValue = + | { readonly value: string | number | boolean | null | unknown[] | Record } + | { readonly code: string }; + +/** The default of an initializer: a JSON value when it is a literal, else its code. */ +export const defaultOf = (initializer: Node): DefaultValue => { + const literal = literalValue(initializer); + return literal === NOT_LITERAL ? { code: initializer.getText() } : { value: literal }; +}; + +const NOT_LITERAL = Symbol('not a literal'); +type Literal = string | number | boolean | null | Literal[] | { [key: string]: Literal }; + +const literalValue = (node: Node): Literal | typeof NOT_LITERAL => { + if ( + Node.isParenthesizedExpression(node) || + Node.isAsExpression(node) || + Node.isSatisfiesExpression(node) + ) + return literalValue(node.getExpression()); + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) + return node.getLiteralValue(); + if (Node.isNumericLiteral(node)) return node.getLiteralValue(); + if (Node.isTrueLiteral(node)) return true; + if (Node.isFalseLiteral(node)) return false; + if (Node.isNullLiteral(node)) return null; + if (Node.isPrefixUnaryExpression(node)) { + const operand = node.getOperand(); + if (Node.isNumericLiteral(operand)) { + if (node.getOperatorToken() === ts.SyntaxKind.MinusToken) return -operand.getLiteralValue(); + if (node.getOperatorToken() === ts.SyntaxKind.PlusToken) return operand.getLiteralValue(); + } + return NOT_LITERAL; + } + if (Node.isPropertyAccessExpression(node)) { + const value = node + .getProject() + .getTypeChecker() + .compilerObject.getConstantValue(node.compilerNode); + if (value !== undefined) return value; + const member = node.getSymbol()?.getDeclarations()[0]; + return member && Node.isEnumMember(member) ? (member.getValue() ?? NOT_LITERAL) : NOT_LITERAL; + } + if (Node.isArrayLiteralExpression(node)) { + const values: Literal[] = []; + for (const element of node.getElements()) { + const value = literalValue(element); + if (value === NOT_LITERAL) return NOT_LITERAL; + values.push(value); + } + return values; + } + if (Node.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.getProperties()) { + if (!Node.isPropertyAssignment(property)) return NOT_LITERAL; + const value = literalValue(property.getInitializerOrThrow()); + if (value === NOT_LITERAL) return NOT_LITERAL; + result[property.getName().replace(/^['"]|['"]$/g, '')] = value; + } + return result; + } + return NOT_LITERAL; +}; diff --git a/packages/meta/src/type/element-of-type.ts b/packages/meta/src/type/element-of-type.ts new file mode 100644 index 00000000..945505d5 --- /dev/null +++ b/packages/meta/src/type/element-of-type.ts @@ -0,0 +1,114 @@ +import { Node, type Type, ts } from 'ts-morph'; + +import type { Element, EnumMember } from '../schema/element.schema'; +import type { TypeContext } from './type-context.type'; + +const exportRef = (declaration: Node, name: string, context: TypeContext) => + `${context.modulePath(declaration.getSourceFile())}#${name}`; + +const enumOf = (type: Type): { symbolName: string; declaration: Node } | undefined => { + const symbol = type.isUnion() + ? type.getUnionTypes()[0]?.getSymbol()?.getDeclarations()[0]?.getParent()?.getSymbol() + : (type.getSymbol() ?? type.getAliasSymbol()); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration || !Node.isEnumDeclaration(declaration)) return undefined; + return { symbolName: symbol.getName(), declaration }; +}; + +const enumMembers = (declaration: Node): EnumMember[] => + Node.isEnumDeclaration(declaration) + ? declaration.getMembers().flatMap((member) => { + const value = member.getValue(); + return value === undefined ? [] : [{ name: member.getName(), value }]; + }) + : []; + +const isStringLiteralUnion = (type: Type) => + type.isUnion() && type.getUnionTypes().every((part) => part.isStringLiteral()); + +/** A class (or interface) declared in a package or the project, as `{ type: 'ref' }`. */ +const refOf = (type: Type, context: TypeContext): string | undefined => { + const symbol = type.getSymbol() ?? type.getAliasSymbol(); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration) return undefined; + if (!Node.isClassDeclaration(declaration) && !Node.isInterfaceDeclaration(declaration)) + return undefined; + return exportRef(declaration, symbol.getName(), context); +}; + +/** + * The `Element` of a TypeScript type (a param, a field, an array item). `node` is where the type + * is used, for its text when nothing fits. + */ +export const elementOfType = ( + name: string, + rawType: Type, + node: Node, + context: TypeContext, + depth = 0, +): Element => { + const type = rawType.isUnion() + ? (() => { + const parts = rawType.getUnionTypes().filter((part) => !part.isUndefined()); + return parts.length === 1 ? parts[0]! : rawType; + })() + : rawType; + + const enumInfo = enumOf(type); + if (enumInfo) { + const members = enumMembers(enumInfo.declaration); + const numeric = + members.length > 0 && members.every((member) => typeof member.value === 'number'); + return { + type: numeric ? 'number' : 'string', + name, + enumRef: exportRef(enumInfo.declaration, enumInfo.symbolName, context), + enumMembers: members, + }; + } + if (type.isBoolean() || type.isBooleanLiteral()) return { type: 'boolean', name }; + if (type.isNumber() || type.isNumberLiteral()) return { type: 'number', name }; + if (type.isString() || type.isStringLiteral() || type.isTemplateLiteral()) { + return type.isStringLiteral() + ? { type: 'string', name, enum: [type.getLiteralValue() as string] } + : { type: 'string', name }; + } + if (isStringLiteralUnion(type)) { + return { + type: 'string', + name, + enum: type.getUnionTypes().map((part) => part.getLiteralValue() as string), + }; + } + if (type.isArray() && depth < 4) { + return { + type: 'array', + name, + items: elementOfType('item', type.getArrayElementTypeOrThrow(), node, context, depth + 1), + }; + } + const ref = refOf(type, context); + if (ref) return { type: 'ref', name, ref }; + if (type.isObject() && type.isAnonymous() && depth < 4) { + return { + type: 'object', + name, + properties: type + .getProperties() + .map((property) => + elementOfType( + property.getName(), + property.getTypeAtLocation(node), + node, + context, + depth + 1, + ), + ), + }; + } + return { + type: 'unknown', + name, + tsType: type.getText(node, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope), + }; +}; diff --git a/packages/meta/src/type/index.ts b/packages/meta/src/type/index.ts new file mode 100644 index 00000000..846411e4 --- /dev/null +++ b/packages/meta/src/type/index.ts @@ -0,0 +1,4 @@ +export { type DefaultValue, defaultOf } from './default-of'; +export type { TypeContext } from './type-context.type'; +export { elementOfType } from './element-of-type'; +export { packageOfPath } from './package-of-path'; diff --git a/packages/meta/src/type/package-of-path.ts b/packages/meta/src/type/package-of-path.ts new file mode 100644 index 00000000..88acdaea --- /dev/null +++ b/packages/meta/src/type/package-of-path.ts @@ -0,0 +1,11 @@ +const NODE_MODULES = '/node_modules/'; + +/** A package name from a path inside `node_modules`, else undefined. */ +export const packageOfPath = (path: string): string | undefined => { + const index = path.lastIndexOf(NODE_MODULES); + if (index < 0) return undefined; + const [first, second] = path.slice(index + NODE_MODULES.length).split('/'); + if (!first) return undefined; + const name = first.startsWith('@') ? `${first}/${second ?? ''}` : first; + return name.startsWith('@types/') ? name.slice('@types/'.length).replace('__', '/') : name; +}; diff --git a/packages/meta/src/type/type-context.type.ts b/packages/meta/src/type/type-context.type.ts new file mode 100644 index 00000000..2ef738d7 --- /dev/null +++ b/packages/meta/src/type/type-context.type.ts @@ -0,0 +1,6 @@ +import type { SourceFile } from 'ts-morph'; + +export interface TypeContext { + /** Module of a declaration file: a package name, or the project path of a project file. */ + modulePath(file: SourceFile): string; +} diff --git a/packages/meta/test/extract/extract-items.spec.ts b/packages/meta/test/extract/extract-items.spec.ts new file mode 100644 index 00000000..8f7da3f4 --- /dev/null +++ b/packages/meta/test/extract/extract-items.spec.ts @@ -0,0 +1,115 @@ +import { Project } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; + +import { type ItemOwner, defaultModulePath, extractItems, parseDocComment } from '../../src'; + +const project = () => { + const p = new Project({ useInMemoryFileSystem: true, compilerOptions: { strict: true } }); + p.createSourceFile( + '/project/node_modules/@nanoforge-dev/input/index.d.ts', + 'export declare enum InputEnum { ArrowUp = "ArrowUp", ArrowDown = "ArrowDown" }', + ); + return p; +}; + +const ecs: ItemOwner = { + name: '@nanoforge/ecs', + version: '0.0.0', + schema: 1, + tags: ['component'], + infer: ({ declaration, context }) => + context.folder === 'components' && declaration.getKindName() === 'ClassDeclaration', + claims: () => ({ fields: ['name'] }), + extract: ({ declaration }) => ({ type: 'component', name: declaration.getName() }), +}; + +const extract = (text: string, folder?: 'components') => { + const file = project().createSourceFile('/project/apps/client/src/c.ts', text); + return extractItems(file, { + source: { kind: 'app', name: 'client', path: 'apps/client' }, + path: 'apps/client/src/c.ts', + owners: [ecs], + ...(folder && { folder }), + modulePath: defaultModulePath, + refOf: () => undefined, + }); +}; + +describe('parseDocComment', () => { + it('reads tags on one line, not inside fences', () => { + const doc = parseDocComment('/** Distance. @group Coords @preset vector.x */'); + expect(doc.description).toBe('Distance.'); + expect(doc.tags).toEqual([ + { name: 'group', text: 'Coords' }, + { name: 'preset', text: 'vector.x' }, + ]); + expect(parseDocComment('/**\n * @example\n * ```ts\n * @dec x\n * ```\n */').tags).toHaveLength( + 1, + ); + }); +}); + +describe('extractItems', () => { + it('extracts a tagged component with enums, defaults, layout and groups', () => { + const { items, diagnostics } = extract(` +import { InputEnum } from "@nanoforge-dev/input"; +/** + * The keys of a paddle. + * @component + * @side client + * @group Keys color=#4aa3ff - Which keys move it. + * @group Debug hidden + */ +export class Controller { + name = "Controller"; + /** Whether up was held. */ + lastUp = false; + constructor( + /** Key that moves up. @group Keys @label Up key @color orange */ + public up: InputEnum = InputEnum.ArrowUp, + /** @group Debug @hidden */ + public trail = false, + ) {} +}`); + expect(diagnostics).toEqual([]); + const [item] = items; + expect(item).toMatchObject({ + export: 'Controller', + side: 'client', + description: 'The keys of a paddle.', + requires: ['@nanoforge-dev/input'], + groups: [ + { name: 'Keys', color: '#4aa3ff', description: 'Which keys move it.' }, + { name: 'Debug', hidden: true }, + ], + '@nanoforge/ecs': { type: 'component', name: 'Controller' }, + }); + expect(item!.params[0]).toMatchObject({ + name: 'up', + type: 'string', + enumRef: '@nanoforge-dev/input#InputEnum', + default: 'ArrowUp', + optional: true, + description: 'Key that moves up.', + layout: { group: 'Keys', label: 'Up key', color: 'orange' }, + }); + expect(item!.params[1]).toMatchObject({ layout: { group: 'Debug', hidden: true } }); + expect(item!.fields.map((field) => field.name)).toEqual(['lastUp']); + }); + + it('infers untagged components and skips fields set from the constructor', () => { + const { items } = extract( + `export class Position { name = "Position"; x: number; constructor(x: number) { this.x = x; } } + export const helper = 1;`, + 'components', + ); + expect(items.map((item) => item.export)).toEqual(['Position']); + expect(items[0]!.fields).toEqual([]); + expect(items[0]!.params).toEqual([{ name: 'x', type: 'number' }]); + }); + + it('keeps tags of a missing owner as unclaimed', () => { + const { items } = extract('/** @tilemap layer */ export class Tiles {}'); + expect(items[0]!.unclaimedTags).toEqual({ tilemap: ['layer'] }); + }); +}); diff --git a/packages/meta/test/extract/inline.spec.ts b/packages/meta/test/extract/inline.spec.ts new file mode 100644 index 00000000..2fbd3f01 --- /dev/null +++ b/packages/meta/test/extract/inline.spec.ts @@ -0,0 +1,23 @@ +import { Project } from 'ts-morph'; +import { expect, it } from 'vitest'; + +import { defaultModulePath, extractItems } from '../../src'; + +it('reads the doc of a first parameter written right after the parenthesis', () => { + const file = new Project({ useInMemoryFileSystem: true }).createSourceFile( + '/project/a.ts', + 'export class Position {\n name = "Position";\n constructor(/** @group Coords */ x: number, /** Top. */ y: number) {}\n}\n', + ); + const { items } = extractItems(file, { + source: { kind: 'app', name: 'c', path: '' }, + path: 'a.ts', + owners: [], + listed: true, + modulePath: defaultModulePath, + refOf: () => undefined, + }); + expect(items[0]!.params).toMatchObject([ + { name: 'x', layout: { group: 'Coords' } }, + { name: 'y', description: 'Top.' }, + ]); +}); diff --git a/packages/meta/tsconfig.json b/packages/meta/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/meta/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/project/.prettierignore b/packages/project/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/project/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/project/LICENSE b/packages/project/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/project/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/project/README.md b/packages/project/README.md new file mode 100644 index 00000000..d666c5d7 --- /dev/null +++ b/packages/project/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-project` is an internal package of the NanoForge Editor. + +Workspace discovery and project file system. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/project +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/project/package.json b/packages/project/package.json new file mode 100644 index 00000000..0e9d9d2b --- /dev/null +++ b/packages/project/package.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-project", + "version": "0.0.0", + "description": "NanoForge Editor - Client project service: project model, file system and caches", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/project" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "typescript": "catalog:build", + "vitest": "catalog:test", + "zod": "catalog:libs" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/project/src/cache/cache.type.ts b/packages/project/src/cache/cache.type.ts new file mode 100644 index 00000000..3de7b030 --- /dev/null +++ b/packages/project/src/cache/cache.type.ts @@ -0,0 +1,21 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +export interface CachedContent { + readonly content: Uint8Array; + readonly mtime: number; + readonly hash: string; +} + +/** File contents cache (OPFS in browsers). Keys are project-relative paths. */ +export interface ContentCache { + get(path: string): Promise; + set(path: string, value: CachedContent): Promise; + delete(path: string): Promise; + clear(): Promise; +} + +/** Last known file tree (IndexedDB in browsers), for fast startup before revalidation. */ +export interface TreeCache { + load(): Promise; + save(entries: readonly FileEntry[]): Promise; +} diff --git a/packages/project/src/cache/index.ts b/packages/project/src/cache/index.ts new file mode 100644 index 00000000..e90bf1b2 --- /dev/null +++ b/packages/project/src/cache/index.ts @@ -0,0 +1,5 @@ +export { OpfsContentCache } from './opfs.content-cache'; +export { IndexedDbTreeCache } from './indexed-db.tree-cache'; +export * from './cache.type'; +export * from './memory.content-cache'; +export * from './memory.tree-cache'; diff --git a/packages/project/src/cache/indexed-db.tree-cache.ts b/packages/project/src/cache/indexed-db.tree-cache.ts new file mode 100644 index 00000000..590c68f5 --- /dev/null +++ b/packages/project/src/cache/indexed-db.tree-cache.ts @@ -0,0 +1,52 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +import type { TreeCache } from './cache.type'; + +export const DB_NAME = 'nanoforge-editor'; +export const STORE = 'trees'; + +/** IndexedDB tree cache (one record per project). */ +export class IndexedDbTreeCache implements TreeCache { + static isSupported(): boolean { + return typeof indexedDB !== 'undefined'; + } + + constructor(private readonly _projectId: string) {} + + async load(): Promise { + try { + return await this._run('readonly', (store) => + store.get(this._projectId), + ); + } catch { + return undefined; + } + } + + async save(entries: readonly FileEntry[]): Promise { + try { + await this._run('readwrite', (store) => store.put(entries, this._projectId)); + } catch {} + } + + private async _run( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB unavailable')); + }); + try { + return await new Promise((resolve, reject) => { + const request = action(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } finally { + db.close(); + } + } +} diff --git a/packages/project/src/cache/memory.content-cache.ts b/packages/project/src/cache/memory.content-cache.ts new file mode 100644 index 00000000..94d42f3e --- /dev/null +++ b/packages/project/src/cache/memory.content-cache.ts @@ -0,0 +1,26 @@ +import type { CachedContent, ContentCache } from './cache.type'; + +export class MemoryContentCache implements ContentCache { + private readonly _entries = new Map(); + + get(path: string) { + return Promise.resolve(this._entries.get(path)); + } + + set(path: string, value: CachedContent) { + this._entries.set(path, value); + return Promise.resolve(); + } + + delete(path: string) { + for (const key of [...this._entries.keys()]) { + if (key === path || key.startsWith(`${path}/`)) this._entries.delete(key); + } + return Promise.resolve(); + } + + clear() { + this._entries.clear(); + return Promise.resolve(); + } +} diff --git a/packages/project/src/cache/memory.tree-cache.ts b/packages/project/src/cache/memory.tree-cache.ts new file mode 100644 index 00000000..f0fb853c --- /dev/null +++ b/packages/project/src/cache/memory.tree-cache.ts @@ -0,0 +1,16 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +import type { TreeCache } from './cache.type'; + +export class MemoryTreeCache implements TreeCache { + private _entries: FileEntry[] | undefined; + + load() { + return Promise.resolve(this._entries); + } + + save(entries: readonly FileEntry[]) { + this._entries = [...entries]; + return Promise.resolve(); + } +} diff --git a/packages/project/src/cache/opfs.content-cache.ts b/packages/project/src/cache/opfs.content-cache.ts new file mode 100644 index 00000000..8971fe5d --- /dev/null +++ b/packages/project/src/cache/opfs.content-cache.ts @@ -0,0 +1,83 @@ +import type { CachedContent, ContentCache } from './cache.type'; + +/** + * OPFS content cache: `nanoforge-editor//` holds the bytes and a + * small sidecar JSON holds mtime and hash. Failures degrade to cache misses. + */ +export class OpfsContentCache implements ContentCache { + private _dir: Promise | undefined; + + static isSupported(): boolean { + return ( + typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function' + ); + } + + constructor(private readonly _projectId: string) {} + + async get(path: string): Promise { + try { + const dir = await this._directory(); + const meta = await (await (await dir.getFileHandle(`${key(path)}.meta`)).getFile()).text(); + const bytes = await (await (await dir.getFileHandle(key(path))).getFile()).arrayBuffer(); + const { mtime, hash } = JSON.parse(meta) as { mtime: number; hash: string }; + return { content: new Uint8Array(bytes), mtime, hash }; + } catch { + return undefined; + } + } + + async set(path: string, value: CachedContent): Promise { + try { + const dir = await this._directory(); + await write(dir, key(path), value.content); + await write( + dir, + `${key(path)}.meta`, + JSON.stringify({ mtime: value.mtime, hash: value.hash }), + ); + } catch {} + } + + async delete(path: string): Promise { + try { + const dir = await this._directory(); + const prefix = key(path); + for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) { + if (name === prefix || name === `${prefix}.meta` || name.startsWith(`${prefix}%2F`)) { + await dir.removeEntry(name); + } + } + } catch {} + } + + async clear(): Promise { + try { + const root = await ( + await navigator.storage.getDirectory() + ).getDirectoryHandle('nanoforge-editor', { create: true }); + await root.removeEntry(this._projectId, { recursive: true }); + this._dir = undefined; + } catch {} + } + + private _directory(): Promise { + this._dir ??= navigator.storage + .getDirectory() + .then((root) => root.getDirectoryHandle('nanoforge-editor', { create: true })) + .then((root) => root.getDirectoryHandle(this._projectId, { create: true })); + return this._dir; + } +} + +export const key = (path: string) => encodeURIComponent(path); + +export const write = async ( + dir: FileSystemDirectoryHandle, + name: string, + data: Uint8Array | string, +) => { + const writable = await (await dir.getFileHandle(name, { create: true })).createWritable(); + await writable.write(typeof data === 'string' ? data : new Blob([data as BlobPart])); + await writable.close(); +}; diff --git a/packages/project/src/fs/index.ts b/packages/project/src/fs/index.ts new file mode 100644 index 00000000..afef279e --- /dev/null +++ b/packages/project/src/fs/index.ts @@ -0,0 +1 @@ +export * from './project-fs'; diff --git a/packages/project/src/fs/project-fs.ts b/packages/project/src/fs/project-fs.ts new file mode 100644 index 00000000..21f6e9a9 --- /dev/null +++ b/packages/project/src/fs/project-fs.ts @@ -0,0 +1,233 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; +import { + type FileChange, + type FileContent, + type FileEntry, + FsContract, +} from '@nanoforge-dev/editor-protocol'; +import type { RpcApi, RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { ContentCache, TreeCache } from '../cache/cache.type'; + +const decoder = new TextDecoder(); + +/** Hides files from the whole editor (see the server's file system). */ +export const NFIGNORE_FILE = '.nfignore'; + +export interface WriteOptions { + /** Fails with CONFLICT if the file changed since this hash (null = must not exist). */ + expectedHash?: string | null; +} + +/** + * Files of the open project. The tree is loaded once (cached for fast startup), then kept in + * sync by the server change stream, so contents can be served from the local cache whenever + * the cached copy is as recent as the tree says. + */ +export class ProjectFs implements Disposable { + private readonly _fs: RpcApi; + private readonly _tree = new Map(); + private readonly _ready = new ObservableValue(false); + private readonly _onDidChange = new Emitter(); + private readonly _store = new DisposableStore(); + + /** Batched changes (from the server watcher and this client's own writes). */ + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + readonly projectId: string, + private readonly _rpc: RpcClient, + private readonly _contents: ContentCache, + private readonly _treeCache: TreeCache, + ) { + this._fs = _rpc.api(FsContract); + } + + /** True once the tree was fetched from the server (cached trees may be stale before). */ + get ready(): Observable { + return this._ready.readonly(); + } + + /** Loads the cached tree, subscribes to changes, then revalidates against the server. */ + async initialize(): Promise { + const cached = await this._treeCache.load(); + for (const entry of cached ?? []) this._tree.set(entry.path, entry); + this._store.add( + this._rpc.subscribe( + FsContract, + 'changes', + { project: this.projectId }, + ({ changes }) => void this._applyRemote(changes), + ), + ); + this._store.add(this._rpc.onDidReconnect(() => void this.refresh().catch(() => undefined))); + await this.refresh(); + } + + /** Re-fetches the whole tree and reports the differences as changes. */ + async refresh(): Promise { + const entries = await this._fs.list({ project: this.projectId, path: '', recursive: true }); + const next = new Map(entries.map((entry) => [entry.path, entry])); + const changes: FileChange[] = []; + for (const [path, entry] of next) { + const previous = this._tree.get(path); + if (!previous) changes.push({ type: 'created', path, kind: entry.kind }); + else if (previous.mtime !== entry.mtime || previous.size !== entry.size) { + changes.push({ type: 'changed', path, kind: entry.kind }); + } + } + for (const [path, entry] of this._tree) { + if (!next.has(path)) changes.push({ type: 'deleted', path, kind: entry.kind }); + } + this._tree.clear(); + for (const [path, entry] of next) this._tree.set(path, entry); + this._ready.set(true); + await this._treeCache.save(entries); + for (const change of changes) + if (change.type === 'deleted') await this._contents.delete(change.path); + if (changes.length) this._onDidChange.fire(changes); + } + + /** Last known entry (synchronous, from the tree). */ + entry(path: string): FileEntry | undefined { + return this._tree.get(path); + } + + /** Direct children of a directory, from the tree. */ + children(path: string): FileEntry[] { + const prefix = path ? `${path}/` : ''; + return [...this._tree.values()] + .filter( + (entry) => + entry.path.startsWith(prefix) && + !entry.path.slice(prefix.length).includes('/') && + entry.path !== path, + ) + .sort((a, b) => + a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'directory' ? -1 : 1, + ); + } + + get entries(): readonly FileEntry[] { + return [...this._tree.values()]; + } + + /** + * `fresh` asks the server even when the cached content looks current: the tree only learns + * of a change on disk when the watcher reports it. + */ + async read(path: string, options: { fresh?: boolean } = {}): Promise { + const known = this._tree.get(path); + const cached = !options.fresh && known && (await this._contents.get(path)); + if (known && cached && cached.mtime === known.mtime && cached.content.length === known.size) { + return { ...known, content: cached.content, hash: cached.hash }; + } + const file = await this._fs.read({ project: this.projectId, path }); + this._tree.set(path, { path, kind: 'file', size: file.size, mtime: file.mtime }); + await this._contents.set(path, { content: file.content, mtime: file.mtime, hash: file.hash }); + return file; + } + + async readText( + path: string, + options: { fresh?: boolean } = {}, + ): Promise<{ text: string; hash: string; mtime: number }> { + const file = await this.read(path, options); + return { text: decoder.decode(file.content), hash: file.hash, mtime: file.mtime }; + } + + async write( + path: string, + content: Uint8Array | string, + options: WriteOptions = {}, + ): Promise { + const result = await this._fs.write({ + project: this.projectId, + path, + content, + ...(options.expectedHash !== undefined && { expectedHash: options.expectedHash }), + }); + const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; + const existed = this._tree.has(path); + this._tree.set(path, { path, kind: 'file', size: result.size, mtime: result.mtime }); + await this._contents.set(path, { content: bytes, mtime: result.mtime, hash: result.hash }); + this._onDidChange.fire([{ type: existed ? 'changed' : 'created', path, kind: 'file' }]); + return result; + } + + async mkdir(path: string): Promise { + const entry = await this._fs.mkdir({ project: this.projectId, path }); + this._tree.set(path, entry); + } + + async rename(from: string, to: string, overwrite = false): Promise { + await this._fs.rename({ project: this.projectId, from, to, overwrite }); + await this.refresh(); + } + + async copy(from: string, to: string, overwrite = false): Promise { + await this._fs.copy({ project: this.projectId, from, to, overwrite }); + await this.refresh(); + } + + /** Moves the entry to the project trash; resolves with its place there (for `restore`). */ + async delete(path: string): Promise<{ trashPath: string }> { + const kind = this.entry(path)?.kind ?? 'file'; + const result = await this._fs.delete({ project: this.projectId, path }); + this._removeFromTree(path); + await this._contents.delete(path); + this._onDidChange.fire([{ type: 'deleted', path, kind }]); + return result; + } + + /** Undo of `delete`: brings the entry back from the trash. */ + async restore(trashPath: string, path: string): Promise { + await this._fs.restore({ project: this.projectId, trashPath, path }); + await this.refresh(); + } + + /** Local editors: shows the entry in the OS file manager. */ + async reveal(path: string): Promise { + await this._fs.reveal({ project: this.projectId, path }); + } + + dispose(): void { + this._store.dispose(); + this._onDidChange.dispose(); + } + + private async _applyRemote(changes: readonly FileChange[]): Promise { + const relevant: FileChange[] = []; + for (const change of changes) { + if (change.type === 'deleted') { + this._removeFromTree(change.path); + await this._contents.delete(change.path); + relevant.push(change); + continue; + } + const entry = await this._fs + .stat({ project: this.projectId, path: change.path }) + .catch(() => null); + if (!entry) continue; + const previous = this._tree.get(change.path); + this._tree.set(change.path, entry); + if (previous && previous.mtime === entry.mtime && previous.size === entry.size) continue; + relevant.push(change); + } + if (relevant.length) this._onDidChange.fire(relevant); + if (changes.some((change) => change.path === NFIGNORE_FILE)) await this.refresh(); + } + + private _removeFromTree(path: string): void { + for (const key of [...this._tree.keys()]) { + if (key === path || key.startsWith(`${path}/`)) this._tree.delete(key); + } + } +} diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts new file mode 100644 index 00000000..ddf95ca9 --- /dev/null +++ b/packages/project/src/index.ts @@ -0,0 +1,4 @@ +export * from './cache'; +export * from './fs'; +export * from './service'; +export * from './workspace'; diff --git a/packages/project/src/service/client-project.ts b/packages/project/src/service/client-project.ts new file mode 100644 index 00000000..0b241d5e --- /dev/null +++ b/packages/project/src/service/client-project.ts @@ -0,0 +1,73 @@ +import { + type AppInfo, + type Disposable, + DisposableStore, + type EngineLibProvider, + type Observable, + ObservableValue, + derived, +} from '@nanoforge-dev/editor-kernel'; +import { type ProjectModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { ContentCache, TreeCache } from '../cache/cache.type'; +import { IndexedDbTreeCache } from '../cache/indexed-db.tree-cache'; +import { MemoryContentCache } from '../cache/memory.content-cache'; +import { MemoryTreeCache } from '../cache/memory.tree-cache'; +import { OpfsContentCache } from '../cache/opfs.content-cache'; +import { ProjectFs } from '../fs/project-fs'; + +export interface CacheFactory { + contents(projectId: string): ContentCache; + tree(projectId: string): TreeCache; +} + +/** Browser caches when available, memory otherwise. */ +export const defaultCacheFactory: CacheFactory = { + contents: (id) => + OpfsContentCache.isSupported() ? new OpfsContentCache(id) : new MemoryContentCache(), + tree: (id) => + IndexedDbTreeCache.isSupported() ? new IndexedDbTreeCache(id) : new MemoryTreeCache(), +}; + +/** A project opened in this editor window. */ +export class ClientProject implements Disposable, EngineLibProvider { + private readonly _store = new DisposableStore(); + private readonly _model: ObservableValue; + + readonly fs: ProjectFs; + /** Apps as the kernel sees them (engine lib gating). */ + readonly apps: Observable; + + constructor(initial: ProjectModel, rpc: RpcClient, caches: CacheFactory) { + this._model = new ObservableValue(initial); + this.fs = this._store.add( + new ProjectFs(initial.id, rpc, caches.contents(initial.id), caches.tree(initial.id)), + ); + this._store.add( + rpc.subscribe(ProjectsContract, 'model', { id: initial.id }, (model) => + this._model.set(model), + ), + ); + this.apps = derived([this._model], (model) => + model.apps.map((app) => ({ + id: app.id, + name: app.name, + type: app.type, + engineLibs: app.engineLibs, + })), + ); + } + + get id(): string { + return this._model.get().id; + } + + get model(): Observable { + return this._model.readonly(); + } + + dispose(): void { + this._store.dispose(); + } +} diff --git a/packages/project/src/service/index.ts b/packages/project/src/service/index.ts new file mode 100644 index 00000000..dc688bcd --- /dev/null +++ b/packages/project/src/service/index.ts @@ -0,0 +1,2 @@ +export * from './client-project'; +export * from './project-service'; diff --git a/packages/project/src/service/project-service.ts b/packages/project/src/service/project-service.ts new file mode 100644 index 00000000..f7ea7dd5 --- /dev/null +++ b/packages/project/src/service/project-service.ts @@ -0,0 +1,63 @@ +import { + type Disposable, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type ProjectRef, + ProjectsContract, + type RecentProject, +} from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import { type CacheFactory, ClientProject, defaultCacheFactory } from './client-project'; + +/** Opens projects through the editor server; one project is active per window. */ +export class ProjectService implements Disposable { + private readonly _current = new ObservableValue(undefined); + + constructor( + private readonly _rpc: RpcClient, + private readonly _caches: CacheFactory = defaultCacheFactory, + ) {} + + get current(): Observable { + return this._current.readonly(); + } + + async open(ref: ProjectRef): Promise { + const projects = this._rpc.api(ProjectsContract); + const { id } = await projects.open(ref); + return this.load(id); + } + + /** Attaches to a project already opened on the server (e.g. `/project/` reload). */ + async load(id: string, onStage?: (stage: 'model' | 'files') => void): Promise { + if (this._current.get()?.id === id) return this._current.get()!; + onStage?.('model'); + const model = await this._rpc.api(ProjectsContract).model({ id }); + const project = new ClientProject(model, this._rpc, this._caches); + onStage?.('files'); + await project.fs.initialize(); + this.close(); + this._current.set(project); + return project; + } + + recent(): Promise { + return this._rpc.api(ProjectsContract).recent(null); + } + + close(): void { + const current = this._current.get(); + this._current.set(undefined); + current?.dispose(); + } + + dispose(): void { + this.close(); + } +} + +export const ProjectServiceToken = createToken('project.service'); diff --git a/packages/project/src/workspace/app-operations.ts b/packages/project/src/workspace/app-operations.ts new file mode 100644 index 00000000..834f5f2f --- /dev/null +++ b/packages/project/src/workspace/app-operations.ts @@ -0,0 +1,104 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; + +import { Changes } from './changes'; +import { withEditorLibrary } from './editor-library'; +import { type Json, editJson, packageJson, withWorkspaceGlob } from './workspace-files'; +import { runnableApps, validateFolder, validatePackageName } from './workspace-queries'; +import { EDITOR_LIBRARY, ENGINE_BASE, MAIN, WORKSPACE_CONFIG } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { NewApp, Undo, WorkspaceIo } from './workspace.type'; + +/** + * A new client or server app in `apps/`: a copy of an app of the project, or an empty + * one whose engine libraries (and their versions) are those of an app of the project. Its + * dependencies are installed apart (a copy brings its `node_modules` with it). + */ +export const createApp = (io: WorkspaceIo, model: ProjectModel, app: NewApp): Promise => { + const changes = new Changes(io); + const root = `apps/${app.folder}`; + return changes.run(async () => { + const invalid = validateFolder(io, 'apps', app.folder) ?? validatePackageName(app.name); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((existing) => existing.name === app.name)) + throw new WorkspaceError(`${app.name} is already the name of an app or library.`); + if (app.from) { + await io.copy(app.from.root, root); + changes.created(root); + for (const leftover of ['dist', '.nanoforge']) + if (io.exists(`${root}/${leftover}`)) await io.remove(`${root}/${leftover}`); + await editJson(io, changes, `${root}/package.json`, (json) => { + json.name = app.name; + }); + } else { + const sibling = + runnableApps(model).find((existing) => existing.type === app.type) ?? + runnableApps(model)[0]; + const siblingJson = sibling + ? (JSON.parse(await io.read(packageJson(sibling))) as Json) + : undefined; + const known = { + ...(siblingJson?.dependencies as Record | undefined), + ...(siblingJson?.devDependencies as Record | undefined), + }; + const devDependencies = Object.fromEntries( + [...ENGINE_BASE, EDITOR_LIBRARY, 'typescript'] + .filter((name) => !sibling || name in known) + .map((name) => [name, known[name] ?? '*']), + ); + await changes.write( + `${root}/package.json`, + `${JSON.stringify({ name: app.name, version: '0.0.0', private: true, type: 'module', devDependencies }, null, 2)}\n`, + ); + changes.created(root); + await changes.write( + `${root}/nanoforge.config.ts`, + `import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "${app.type}",\n});\n`, + ); + const tsconfig = sibling && `${sibling.root}/tsconfig.json`; + if (tsconfig && io.exists(tsconfig)) + await changes.write(`${root}/tsconfig.json`, await io.read(tsconfig)); + const main = MAIN[app.type]; + await changes.write( + `${root}/src/main.ts`, + EDITOR_LIBRARY in devDependencies ? (withEditorLibrary(main) ?? main) : main, + ); + await changes.write(`${root}/src/components/.gitkeep`, ''); + await changes.write(`${root}/src/systems/.gitkeep`, ''); + } + if (io.exists(WORKSPACE_CONFIG)) + await changes.write( + WORKSPACE_CONFIG, + withWorkspaceGlob(await io.read(WORKSPACE_CONFIG), 'apps/*'), + ); + }); +}; + +/** Renames an app: the name in its `package.json`. */ +export const renameApp = ( + io: WorkspaceIo, + model: ProjectModel, + app: AppModel, + name: string, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const invalid = validatePackageName(name); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((existing) => existing !== app && existing.name === name)) + throw new WorkspaceError(`${name} is already the name of an app or library.`); + await editJson(io, changes, packageJson(app), (json) => { + json.name = name; + }); + }); +}; + +/** Removes an app (its folder goes to the trash). The last app of a project stays. */ +export const removeApp = (io: WorkspaceIo, model: ProjectModel, app: AppModel): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + if (!app.root) throw new WorkspaceError('The project is this app: it cannot be removed.'); + if (runnableApps(model).length <= 1) + throw new WorkspaceError('A project needs at least one app.'); + await changes.remove(app.root); + }); +}; diff --git a/packages/project/src/workspace/changes.ts b/packages/project/src/workspace/changes.ts new file mode 100644 index 00000000..6ba7e11b --- /dev/null +++ b/packages/project/src/workspace/changes.ts @@ -0,0 +1,47 @@ +import type { Undo, WorkspaceIo } from './workspace.type'; + +/** Records what an operation changed, to put it back in reverse order. */ +export class Changes { + private readonly _undo: Undo[] = []; + + constructor(private readonly _io: WorkspaceIo) {} + + /** Writes a file, remembering what was there. */ + async write(path: string, text: string): Promise { + const before = this._io.exists(path) ? await this._io.read(path) : undefined; + if (before === text) return; + await this._io.write(path, text); + this._undo.push(async () => { + if (before !== undefined) await this._io.write(path, before); + else if (this._io.exists(path)) await this._io.remove(path); + }); + } + + /** Removes a file or folder (to the trash). */ + async remove(path: string): Promise { + if (!this._io.exists(path)) return; + this._undo.push(await this._io.remove(path)); + } + + /** Something new was made at `path` (a copy): undo removes it. */ + created(path: string): void { + this._undo.push(async () => { + if (this._io.exists(path)) await this._io.remove(path); + }); + } + + /** Runs an operation; what it changed before failing is put back. */ + async run(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + await this.undo().catch(() => undefined); + throw error; + } + return () => this.undo(); + } + + async undo(): Promise { + for (const undo of this._undo.splice(0).reverse()) await undo(); + } +} diff --git a/packages/project/src/workspace/editor-library.ts b/packages/project/src/workspace/editor-library.ts new file mode 100644 index 00000000..cec2f472 --- /dev/null +++ b/packages/project/src/workspace/editor-library.ts @@ -0,0 +1,65 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { Changes } from './changes'; +import { editJson, packageJson } from './workspace-files'; +import { EDITOR_LIBRARY } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { Undo, WorkspaceIo } from './workspace.type'; + +/** + * The text of an entry file with `EditorLibrary` registered after its last `app.use(...)`. + * Undefined when it already is, or when the file registers no library to put it after. + */ +export const withEditorLibrary = (text: string): string | undefined => { + if (/\bEditorLibrary\b/.test(text)) return undefined; + const last = [...text.matchAll(/^([ \t]*)(\w+)\.use\(.*\);[ \t]*$/gm)].at(-1); + if (!last) return undefined; + const end = last.index + last[0].length; + const imports = [ + ...text.matchAll(/^import [^;]*?from\s*["']@nanoforge-dev\/[^"']+["'];?[ \t]*$/gm), + ]; + const after = imports.at(-1); + const line = `import { EditorLibrary } from "${EDITOR_LIBRARY}";`; + const used = `${text.slice(0, end)}\n${last[1]}${last[2]}.use(new EditorLibrary());${text.slice(end)}`; + if (!after) return `${line}\n${used}`; + const at = after.index + after[0].length; + return `${used.slice(0, at)}\n${line}${used.slice(at)}`; +}; + +/** + * Makes an app drivable by the editor: `@nanoforge-dev/editor-lib` next to its other engine + * libraries (at the version of `@nanoforge-dev/core`), and `EditorLibrary` registered in its + * entry file. Refused when the entry file already registers it. + */ +export const addEditorLibrary = (io: WorkspaceIo, app: AppModel): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + if (!app.entryFile || !io.exists(app.entryFile)) + throw new WorkspaceError(`${app.name} has no entry file.`); + const main = await io.read(app.entryFile); + if (/\bEditorLibrary\b/.test(main)) + throw new WorkspaceError( + `${app.entryFile} already registers the editor library: update the NanoForge engine packages of the project.`, + ); + const edited = withEditorLibrary(main); + if (!edited) + throw new WorkspaceError( + `${app.entryFile} registers no library with app.use(…): add app.use(new EditorLibrary()) by hand, from ${EDITOR_LIBRARY}.`, + ); + await editJson(io, changes, packageJson(app), (json) => { + const sections = ['dependencies', 'devDependencies'] as const; + if (sections.some((key) => EDITOR_LIBRARY in ((json[key] ?? {}) as object))) return; + const key = + sections.find((section) => '@nanoforge-dev/core' in ((json[section] ?? {}) as object)) ?? + 'dependencies'; + const entries = (json[key] ?? {}) as Record; + json[key] = Object.fromEntries( + Object.entries({ + ...entries, + [EDITOR_LIBRARY]: entries['@nanoforge-dev/core'] ?? '*', + }).sort(([a], [b]) => a.localeCompare(b)), + ); + }); + await changes.write(app.entryFile, edited); + }); +}; diff --git a/packages/project/src/workspace/index.ts b/packages/project/src/workspace/index.ts new file mode 100644 index 00000000..95cc8793 --- /dev/null +++ b/packages/project/src/workspace/index.ts @@ -0,0 +1,16 @@ +export * from './workspace.type'; +export * from './workspace.exception'; +export { EDITOR_LIBRARY } from './workspace.const'; +export * from './workspace-queries'; +export { withConfigLibrary } from './library-config'; +export { + createLibrary, + setLibraryUse, + libraryImporters, + withRenamedImports, + renameLibrary, + removeLibrary, +} from './library-operations'; +export * from './editor-library'; +export * from './app-operations'; +export * from './workspace-io'; diff --git a/packages/project/src/workspace/library-config.ts b/packages/project/src/workspace/library-config.ts new file mode 100644 index 00000000..1db45a94 --- /dev/null +++ b/packages/project/src/workspace/library-config.ts @@ -0,0 +1,83 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { type Changes } from './changes'; +import { + configFile, + editJson, + packageJson, + relativeTo, + resolveFrom, + setDependency, +} from './workspace-files'; +import { WorkspaceError } from './workspace.exception'; +import type { WorkspaceIo } from './workspace.type'; + +/** + * The text of an app's `nanoforge.config` with a shared library added to its `libs` (paths + * relative to the app, as the CLI writes them) or removed from it. Entries are compared by the + * folder they point at. Undefined when the text has nowhere to put it (no `type` property). + */ +export const withConfigLibrary = ( + text: string, + appRoot: string, + libraryRoot: string, + used: boolean, +): string | undefined => { + const quote = /\btype\s*:\s*(["'])/.exec(text)?.[1] ?? '"'; + const literal = (value: string) => `${quote}${value}${quote}`; + const property = /(\blibs\s*:\s*)\[([^\]]*)\]/.exec(text); + if (property) { + const entries = [...property[2]!.matchAll(/(["'`])((?:\\.|(?!\1).)*)\1/g)].map( + (match) => match[2]!, + ); + const others = entries.filter((entry) => resolveFrom(appRoot, entry) !== libraryRoot); + if (used && others.length < entries.length) return text; + const next = used ? [...others, relativeTo(appRoot, libraryRoot)] : others; + if (next.length === entries.length && next.every((entry, index) => entry === entries[index])) + return text; + const start = property.index + property[1]!.length; + const end = property.index + property[0].length; + return `${text.slice(0, start)}[${next.map(literal).join(', ')}]${text.slice(end)}`; + } + if (!used) return text; + const type = /(^[ \t]*)?\btype\s*:\s*(["'])[^"']*\2([ \t]*,)?/m.exec(text); + if (!type) return undefined; + const end = type.index + type[0].length; + const entry = `libs: [${literal(relativeTo(appRoot, libraryRoot))}]`; + const added = + type[1] !== undefined + ? `${type[3] ? '' : ','}\n${type[1]}${entry},` + : type[3] + ? ` ${entry},` + : `, ${entry}`; + return `${text.slice(0, end)}${added}${text.slice(end)}`; +}; + +/** + * Makes an app use a library or stop: its config's `libs` (what the editor and the CLI read) + * and its package.json dependency (what package managers read), kept in step. A library has no + * `libs`: it uses others through its package.json only. + */ +export const setUse = async ( + io: WorkspaceIo, + changes: Changes, + app: Pick, + library: Pick, + used: boolean, +): Promise => { + await editJson(io, changes, packageJson(app), (json) => setDependency(json, library.name, used)); + if (app.type === 'lib') return; + const file = configFile(io, app); + if (!file) { + if (used) throw new WorkspaceError(`${app.root || 'The app'} has no nanoforge.config.ts.`); + return; + } + const text = await io.read(file); + const edited = withConfigLibrary(text, app.root, library.root, used); + if (edited === undefined) + throw new WorkspaceError( + `${file} could not be edited: add "${relativeTo(app.root, library.root)}" to its libs by hand.`, + [file], + ); + if (edited !== text) await changes.write(file, edited); +}; diff --git a/packages/project/src/workspace/library-operations.ts b/packages/project/src/workspace/library-operations.ts new file mode 100644 index 00000000..9985b2cc --- /dev/null +++ b/packages/project/src/workspace/library-operations.ts @@ -0,0 +1,168 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import { withPathsEntry, withoutPathsEntry } from '@nanoforge-dev/registry/tsconfig-paths'; + +import { Changes } from './changes'; +import { setUse } from './library-config'; +import { editJson, packageJson, withWorkspaceGlob } from './workspace-files'; +import { validateFolder, validatePackageName } from './workspace-queries'; +import { CODE, LIB_CONFIG, TSCONFIG, WORKSPACE_CONFIG } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { NewLibrary, Undo, WorkspaceIo } from './workspace.type'; + +/** + * A shared library: `libs/` with its config and package.json, `libs/*` in the + * workspace's packages, a `paths` entry so it is imported by package name, and the dependency in + * each app that uses it. + */ +export const createLibrary = (io: WorkspaceIo, library: NewLibrary): Promise => { + const changes = new Changes(io); + const root = `libs/${library.folder}`; + return changes.run(async () => { + const invalid = + validateFolder(io, 'libs', library.folder) ?? validatePackageName(library.packageName); + if (invalid) throw new WorkspaceError(invalid); + await changes.write( + `${root}/package.json`, + `${JSON.stringify({ name: library.packageName, version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`, + ); + await changes.write(`${root}/nanoforge.config.ts`, LIB_CONFIG); + await changes.write(`${root}/src/components/.gitkeep`, ''); + await changes.write(`${root}/src/systems/.gitkeep`, ''); + changes.created(root); + + const tsconfig = io.exists(TSCONFIG) ? await io.read(TSCONFIG) : '{}\n'; + await changes.write( + TSCONFIG, + withPathsEntry(tsconfig, `${library.packageName}/*`, `./${root}/src/*`), + ); + for (const app of library.usedBy) + await setUse(io, changes, app, { root, name: library.packageName }, true); + if (io.exists(WORKSPACE_CONFIG)) + await changes.write( + WORKSPACE_CONFIG, + withWorkspaceGlob(await io.read(WORKSPACE_CONFIG), 'libs/*'), + ); + }); +}; + +/** + * Makes an app (or a library) use a shared library, or stop using it: its config's `libs` and + * its package.json dependency. + */ +export const setLibraryUse = ( + io: WorkspaceIo, + app: Pick, + library: Pick, + used: boolean, +): Promise => { + const changes = new Changes(io); + return changes.run(() => setUse(io, changes, app, library, used)); +}; + +export const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** A module specifier of the library in an import, an export or a dynamic import. */ +export const specifier = (library: string) => + new RegExp( + `(\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\brequire\\s*\\(\\s*)(["'])${escape(library)}(/[^"']*)?\\2`, + 'g', + ); + +/** The code files that import a library. */ +export const libraryImporters = async (io: WorkspaceIo, library: AppModel): Promise => { + const importers: string[] = []; + for (const path of io.files()) { + if (!CODE.test(path) || path === library.root || path.startsWith(`${library.root}/`)) continue; + if (specifier(library.name).test(await io.read(path))) importers.push(path); + } + return importers.sort(); +}; + +/** A text with the imports of a library pointed at its new name; undefined when it has none. */ +export const withRenamedImports = (text: string, from: string, to: string): string | undefined => { + const renamed = text.replace( + specifier(from), + (_match, keyword: string, quote: string, rest: string | undefined) => + `${keyword}${quote}${to}${rest ?? ''}${quote}`, + ); + return renamed === text ? undefined : renamed; +}; + +/** + * Renames a shared library's package: its `package.json`, the `paths` entry, the dependencies of + * the apps that use it, and every import of it in the project. + */ +export const renameLibrary = ( + io: WorkspaceIo, + model: ProjectModel, + library: AppModel, + packageName: string, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const invalid = validatePackageName(packageName); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((app) => app.name === packageName)) + throw new WorkspaceError(`${packageName} is already the name of an app or library.`); + if (io.exists(TSCONFIG)) { + const without = withoutPathsEntry(await io.read(TSCONFIG), `${library.name}/*`); + await changes.write( + TSCONFIG, + withPathsEntry(without, `${packageName}/*`, `./${library.root}/src/*`), + ); + } + for (const app of model.apps) { + if (app === library || !io.exists(packageJson(app))) continue; + await editJson(io, changes, packageJson(app), (json) => { + for (const key of ['dependencies', 'devDependencies'] as const) { + const entries = json[key] as Record | undefined; + if (!entries || !(library.name in entries)) continue; + json[key] = Object.fromEntries( + Object.entries(entries) + .map(([name, range]) => [name === library.name ? packageName : name, range] as const) + .sort(([a], [b]) => a.localeCompare(b)), + ); + } + }); + } + for (const path of io.files()) { + if (!CODE.test(path)) continue; + const renamed = withRenamedImports(await io.read(path), library.name, packageName); + if (renamed !== undefined) await changes.write(path, renamed); + } + await editJson(io, changes, packageJson(library), (json) => { + json.name = packageName; + }); + }); +}; + +/** + * Removes a shared library: its folder (to the trash), its `paths` entry and the dependencies on + * it. Refused while code imports it: the files are named. + */ +export const removeLibrary = ( + io: WorkspaceIo, + model: ProjectModel, + library: AppModel, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const importers = await libraryImporters(io, library); + if (importers.length) { + throw new WorkspaceError( + `${library.name} is imported by ${importers.length === 1 ? 'a file' : `${importers.length} files`}: remove those imports first.`, + importers, + ); + } + for (const app of model.apps) { + if (app === library || !io.exists(packageJson(app))) continue; + await setUse(io, changes, app, library, false); + } + if (io.exists(TSCONFIG)) + await changes.write( + TSCONFIG, + withoutPathsEntry(await io.read(TSCONFIG), `${library.name}/*`), + ); + await changes.remove(library.root); + }); +}; diff --git a/packages/project/src/workspace/workspace-files.ts b/packages/project/src/workspace/workspace-files.ts new file mode 100644 index 00000000..fb3ab2a7 --- /dev/null +++ b/packages/project/src/workspace/workspace-files.ts @@ -0,0 +1,82 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { type Changes } from './changes'; +import { CONFIG_NAMES } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { WorkspaceIo } from './workspace.type'; + +export type Json = Record; + +/** A JSON file rewritten with its own indentation. */ +export const editJson = async ( + io: WorkspaceIo, + changes: Changes, + path: string, + edit: (json: Json) => void, +): Promise => { + const text = io.exists(path) ? await io.read(path) : '{}\n'; + let json: Json; + try { + json = JSON.parse(text) as Json; + } catch { + throw new WorkspaceError(`${path} is not valid JSON`); + } + edit(json); + const indent = /^(\s+)"/m.exec(text)?.[1]?.replace(/\n/g, '') ?? ' '; + await changes.write(path, `${JSON.stringify(json, null, indent)}\n`); +}; + +/** The project path of `path` written relative to the folder `from` (both project paths). */ +export const resolveFrom = (from: string, path: string): string => { + const parts = from ? from.split('/') : []; + for (const part of path.split('/')) { + if (part === '..') parts.pop(); + else if (part && part !== '.') parts.push(part); + } + return parts.join('/'); +}; + +/** `to` written relative to the folder `from`, as a config's `libs` holds it. */ +export const relativeTo = (from: string, to: string): string => { + const source = from ? from.split('/') : []; + const target = to.split('/'); + let common = 0; + while (common < source.length && source[common] === target[common]) common++; + return [...source.slice(common).map(() => '..'), ...target.slice(common)].join('/') || '.'; +}; + +/** The `nanoforge.config.*` file of an app. */ +export const configFile = (io: Pick, app: Pick) => + CONFIG_NAMES.map((name) => (app.root ? `${app.root}/${name}` : name)).find((path) => + io.exists(path), + ); + +export const packageJson = (app: Pick): string => + app.root ? `${app.root}/package.json` : 'package.json'; + +/** Adds or removes a dependency on a library, keeping the keys sorted. */ +export const setDependency = (json: Json, name: string, used: boolean): void => { + const without = (entries: unknown) => + Object.entries((entries ?? {}) as Record).filter(([key]) => key !== name); + const dependencies = without(json.dependencies); + if (used) + dependencies.push([ + name, + (json.dependencies as Record | undefined)?.[name] ?? 'workspace:*', + ]); + else if (json.devDependencies) + json.devDependencies = Object.fromEntries(without(json.devDependencies)); + const sorted = Object.fromEntries(dependencies.sort(([a], [b]) => a.localeCompare(b))); + if (Object.keys(sorted).length) json.dependencies = sorted; + else delete json.dependencies; +}; + +/** `libs/*` (or `apps/*`) in the workspace's `packages`, when missing. */ +export const withWorkspaceGlob = (text: string, glob: string): string => { + if (text.includes(glob)) return text; + return text.replace(/packages\s*:\s*\[([^\]]*)\]/, (_match, inner: string) => { + const quote = inner.includes("'") ? "'" : '"'; + const items = inner.trim().replace(/,\s*$/, ''); + return `packages: [${items ? `${items}, ` : ''}${quote}${glob}${quote}]`; + }); +}; diff --git a/packages/project/src/workspace/workspace-io.ts b/packages/project/src/workspace/workspace-io.ts new file mode 100644 index 00000000..da648fa3 --- /dev/null +++ b/packages/project/src/workspace/workspace-io.ts @@ -0,0 +1,16 @@ +import type { ClientProject } from '../service/client-project'; +import type { WorkspaceIo } from './workspace.type'; + +/** The files of an open project, as the workspace operations need them. */ +export const workspaceIo = (project: ClientProject): WorkspaceIo => ({ + exists: (path) => project.fs.entry(path) !== undefined, + files: () => + project.fs.entries.filter((entry) => entry.kind === 'file').map((entry) => entry.path), + read: async (path) => (await project.fs.readText(path, { fresh: true })).text, + write: (path, text) => project.fs.write(path, text).then(() => undefined), + remove: async (path) => { + const { trashPath } = await project.fs.delete(path); + return () => project.fs.restore(trashPath, path); + }, + copy: (from, to) => project.fs.copy(from, to), +}); diff --git a/packages/project/src/workspace/workspace-queries.ts b/packages/project/src/workspace/workspace-queries.ts new file mode 100644 index 00000000..23697e0f --- /dev/null +++ b/packages/project/src/workspace/workspace-queries.ts @@ -0,0 +1,26 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; + +import { FOLDER, PACKAGE_NAME } from './workspace.const'; +import type { WorkspaceIo } from './workspace.type'; + +export const validatePackageName = (name: string): string | undefined => + PACKAGE_NAME.test(name) ? undefined : 'A package name: lowercase, like @my-game/shared.'; + +/** A new folder of `libs/` or `apps/`: its name, and that it is free. */ +export const validateFolder = ( + io: Pick, + parent: 'libs' | 'apps', + folder: string, +): string | undefined => { + if (!FOLDER.test(folder)) return 'Use lowercase letters, digits and dashes.'; + if (io.exists(`${parent}/${folder}`)) return `${parent}/${folder} already exists.`; + return undefined; +}; + +/** The shared libraries of a project. */ +export const librariesOf = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type === 'lib'); + +/** The apps of a project that run: its clients and servers. */ +export const runnableApps = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type !== 'lib'); diff --git a/packages/project/src/workspace/workspace.const.ts b/packages/project/src/workspace/workspace.const.ts new file mode 100644 index 00000000..1b006f78 --- /dev/null +++ b/packages/project/src/workspace/workspace.const.ts @@ -0,0 +1,74 @@ +import type { NewApp } from './workspace.type'; + +export const PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +export const FOLDER = /^[a-z0-9][a-z0-9-]*$/; +export const CODE = /\.(?:[cm]?[jt]sx?)$/; +export const WORKSPACE_CONFIG = 'nanoforge.config.ts'; +export const TSCONFIG = 'tsconfig.json'; + +export const CONFIG_NAMES = [ + 'nanoforge.config.ts', + 'nanoforge.config.mts', + 'nanoforge.config.js', + 'nanoforge.config.mjs', +]; + +export const LIB_CONFIG = [ + 'import { defineConfig } from "@nanoforge-dev/config";', + '', + 'export default defineConfig({', + ' type: "lib",', + ' dir: { components: "src/components", systems: "src/systems", scenes: "src/scenes" },', + '});', + '', +].join('\n'); + +export const ENGINE_BASE = ['asset', 'common', 'core', 'env', 'ecs'].map( + (name) => `@nanoforge-dev/${name}`, +); + +/** The engine library a game registers to be driven by the editor (engine 2.0 and later). */ +export const EDITOR_LIBRARY = '@nanoforge-dev/editor-lib'; + +export const MAIN: Record = { + client: [ + 'import type { ClientRunOptions } from "@nanoforge-dev/common";', + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + '', + 'export const main = async (options: ClientRunOptions): Promise => {', + ' const app = NanoforgeFactory.createClient({ tickRate: 60 });', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + '', + ' const registry = ecs.registry;', + ' void registry;', + '', + ' await app.run();', + '};', + '', + ].join('\n'), + server: [ + 'import type { RunOptions } from "@nanoforge-dev/common";', + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/server";', + '', + 'export const main = async (options: RunOptions): Promise => {', + ' const app = NanoforgeFactory.createServer({ tickRate: 60 });', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + '', + ' const registry = ecs.registry;', + ' void registry;', + '', + ' await app.run();', + '};', + '', + ].join('\n'), +}; diff --git a/packages/project/src/workspace/workspace.exception.ts b/packages/project/src/workspace/workspace.exception.ts new file mode 100644 index 00000000..f67e2d19 --- /dev/null +++ b/packages/project/src/workspace/workspace.exception.ts @@ -0,0 +1,11 @@ +/** A change refused for a reason the user can act on. */ +export class WorkspaceError extends Error { + constructor( + message: string, + /** Files that stand in the way (the importers of a library being removed). */ + readonly paths: readonly string[] = [], + ) { + super(message); + this.name = 'WorkspaceError'; + } +} diff --git a/packages/project/src/workspace/workspace.type.ts b/packages/project/src/workspace/workspace.type.ts new file mode 100644 index 00000000..e527d80b --- /dev/null +++ b/packages/project/src/workspace/workspace.type.ts @@ -0,0 +1,45 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** + * Changes of a project's workspace: its apps (clients, servers) and its shared libraries + * (ADR 0004). Each operation edits the project's files through a small file interface and + * resolves with what undoes it. + * + * An app **uses** a shared library when its `package.json` lists the library's package name in + * `dependencies`: the meaning it has in any workspace. + */ + +/** The files of the project, as the operations need them. */ +export interface WorkspaceIo { + exists(path: string): boolean; + /** Paths of the project's files (what the editor lists: no `node_modules`). */ + files(): readonly string[]; + read(path: string): Promise; + /** Creates or replaces a file. */ + write(path: string, text: string): Promise; + /** Removes a file or folder; resolves with what brings it back. */ + remove(path: string): Promise<() => Promise>; + /** Copies a file or a folder with everything in it. */ + copy(from: string, to: string): Promise; +} + +export type Undo = () => Promise; + +export interface NewLibrary { + /** Folder in `libs/`. */ + readonly folder: string; + /** How apps import it: `@my-game/shared`. */ + readonly packageName: string; + /** The apps (and libraries) that use it from the start. */ + readonly usedBy: readonly Pick[]; +} + +export interface NewApp { + readonly type: 'client' | 'server'; + /** Folder in `apps/`. */ + readonly folder: string; + /** Package name of the app. */ + readonly name: string; + /** An app of the same type to copy; an empty app without it. */ + readonly from?: AppModel; +} diff --git a/packages/project/test/fixtures/workspace.ts b/packages/project/test/fixtures/workspace.ts new file mode 100644 index 00000000..1ab499ca --- /dev/null +++ b/packages/project/test/fixtures/workspace.ts @@ -0,0 +1,88 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import type { WorkspaceIo } from '../../src/workspace/workspace.type'; + +/** A project in memory: files by path. */ +export const memory = (initial: Record) => { + const files = new Map(Object.entries(initial)); + const under = (path: string) => [...files.keys()].filter((key) => key.startsWith(`${path}/`)); + const io: WorkspaceIo = { + exists: (path) => files.has(path) || under(path).length > 0, + files: () => [...files.keys()], + read: async (path) => { + const text = files.get(path); + if (text === undefined) throw new Error(`no file ${path}`); + return text; + }, + write: async (path, text) => void files.set(path, text), + remove: async (path) => { + const removed = [path, ...under(path)].flatMap((key) => + files.has(key) ? [[key, files.get(key)!] as const] : [], + ); + for (const [key] of removed) files.delete(key); + return async () => { + for (const [key, text] of removed) files.set(key, text); + }; + }, + copy: async (from, to) => { + for (const key of under(from)) files.set(`${to}${key.slice(from.length)}`, files.get(key)!); + }, + }; + return { io, files, snapshot: () => Object.fromEntries([...files].sort()) }; +}; + +export const editPackage = async ( + files: Map, + path: string, + name: string, + range: string, +) => { + const pkg = JSON.parse(files.get(path)!) as { devDependencies: Record }; + pkg.devDependencies[name] = range; + files.set(path, `${JSON.stringify(pkg, null, 2)}\n`); +}; + +export const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; +export const app = (root: string, name: string, type: AppModel['type']): AppModel => ({ + id: root, + name, + type, + root, + language: 'ts', + dirs: { + assets: '', + components: `${root}/src/components`, + systems: `${root}/src/systems`, + scenes: '', + }, + engineLibs: {}, + libraries: [], +}); +export const client = app('apps/client', 'pong-client', 'client'); +export const shared = app('libs/shared', '@pong/shared', 'lib'); +export const server = app('apps/server', 'pong-server', 'server'); +export const model = (...apps: AppModel[]): ProjectModel => + ({ id: 'p', name: 'pong', location: '/pong', apps, diagnostics: [] }) as ProjectModel; + +export const project = () => + memory({ + 'nanoforge.config.ts': + 'import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "workspace",\n packages: ["apps/*"],\n});\n', + 'tsconfig.json': '{\n // shared options\n "compilerOptions": {\n "strict": true\n }\n}\n', + 'apps/client/package.json': json({ + name: 'pong-client', + devDependencies: { + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/ecs': '^2.0.0', + typescript: '^6.0.0', + eslint: '^10.0.0', + }, + }), + 'apps/client/nanoforge.config.ts': + 'import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "client",\n});\n', + 'apps/client/tsconfig.json': '{ "extends": "../../tsconfig.json" }\n', + 'apps/client/src/main.ts': 'export const main = 1;\n', + 'apps/client/dist/main.js': 'built', + 'apps/server/package.json': json({ name: 'pong-server' }), + 'apps/server/nanoforge.config.ts': "export default { type: 'server', libs: [] };\n", + 'apps/server/src/main.ts': 'export const main = 2;\n', + }); diff --git a/packages/project/test/fs/project-fs.spec.ts b/packages/project/test/fs/project-fs.spec.ts new file mode 100644 index 00000000..97074539 --- /dev/null +++ b/packages/project/test/fs/project-fs.spec.ts @@ -0,0 +1,115 @@ +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { FileChange } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import type { ContentCache } from '../../src/cache/cache.type'; +import { MemoryContentCache } from '../../src/cache/memory.content-cache'; +import { MemoryTreeCache } from '../../src/cache/memory.tree-cache'; +import { ProjectService } from '../../src/service/project-service'; + +const FIXTURE = join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'); + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let service: ProjectService; +const contents = new MemoryContentCache(); +const reads: string[] = []; + +/** Content cache spy: counts server reads by observing cache fills. */ +const spyCache = (inner: ContentCache): ContentCache => ({ + get: (path) => inner.get(path), + set: (path, value) => { + reads.push(path); + return inner.set(path, value); + }, + delete: (path) => inner.delete(path), + clear: () => inner.clear(), +}); + +const waitFor = async (predicate: () => boolean, timeout = 2000) => { + const start = Date.now(); + while (!predicate() && Date.now() - start < timeout) await new Promise((r) => setTimeout(r, 5)); + return Date.now() - start; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-project-')); + cpSync(FIXTURE, join(workspace, 'pong'), { recursive: true }); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + service = new ProjectService(rpc, { + contents: () => spyCache(contents), + tree: () => new MemoryTreeCache(), + }); +}); + +afterAll(() => { + service.dispose(); + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('ProjectFs', () => { + it('opens the project with its tree and engine libs', async () => { + const project = await service.open({ path: 'pong' }); + expect(project.fs.ready.get()).toBe(true); + expect(project.fs.children('apps').map((entry) => entry.path)).toEqual([ + 'apps/client', + 'apps/server', + ]); + expect(project.apps.get().map((app) => app.id)).toEqual(['apps/client', 'apps/server']); + expect(project.apps.get()[0]!.engineLibs).toHaveProperty('@nanoforge-dev/ecs'); + }); + + it('serves unchanged files from the content cache', async () => { + const project = service.current.get()!; + reads.length = 0; + const first = await project.fs.readText('apps/client/src/main.ts'); + const second = await project.fs.readText('apps/client/src/main.ts'); + expect(second).toEqual(first); + expect(reads).toEqual(['apps/client/src/main.ts']); + }); + + it('sees files written on disk within 200 ms', async () => { + const project = service.current.get()!; + const changes: FileChange[] = []; + const subscription = project.fs.onDidChange((batch) => changes.push(...batch)); + await new Promise((resolve) => setTimeout(resolve, 300)); // server watcher initial scan + writeFileSync(join(workspace, 'pong/apps/client/src/main.ts'), '// edited outside\n'); + const elapsed = await waitFor(() => changes.length > 0); + subscription.dispose(); + expect(changes).toEqual([{ type: 'changed', path: 'apps/client/src/main.ts', kind: 'file' }]); + expect(elapsed).toBeLessThan(200); + expect((await project.fs.readText('apps/client/src/main.ts')).text).toBe('// edited outside\n'); + }); + + it('writes with conflict detection and without echoing its own writes', async () => { + const project = service.current.get()!; + const { hash } = await project.fs.readText('apps/client/src/main.ts'); + const changes: FileChange[] = []; + const subscription = project.fs.onDidChange((batch) => changes.push(...batch)); + await project.fs.write('apps/client/src/main.ts', '// v2\n', { expectedHash: hash }); + await new Promise((resolve) => setTimeout(resolve, 250)); + subscription.dispose(); + expect(changes).toEqual([{ type: 'changed', path: 'apps/client/src/main.ts', kind: 'file' }]); + await expect( + project.fs.write('apps/client/src/main.ts', 'x', { expectedHash: hash }), + ).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); +}); diff --git a/packages/project/test/workspace/app-operations.spec.ts b/packages/project/test/workspace/app-operations.spec.ts new file mode 100644 index 00000000..33d161b7 --- /dev/null +++ b/packages/project/test/workspace/app-operations.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { createApp, removeApp, renameApp } from '../../src/workspace/app-operations'; +import { addEditorLibrary, withEditorLibrary } from '../../src/workspace/editor-library'; +import { client, editPackage, model, project, server } from '../fixtures/workspace'; + +describe('apps', () => { + it('adds an empty app with the engine versions the project has', async () => { + const { io, files, snapshot } = project(); + const before = snapshot(); + const undo = await createApp(io, model(client, server), { + type: 'client', + folder: 'spectator', + name: 'pong-spectator', + }); + expect(JSON.parse(files.get('apps/spectator/package.json')!)).toEqual({ + name: 'pong-spectator', + version: '0.0.0', + private: true, + type: 'module', + devDependencies: { + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/ecs': '^2.0.0', + typescript: '^6.0.0', + }, + }); + expect(files.get('apps/spectator/nanoforge.config.ts')).toContain('type: "client"'); + expect(files.get('apps/spectator/tsconfig.json')).toBe(files.get('apps/client/tsconfig.json')); + expect(files.get('apps/spectator/src/main.ts')).toContain('createClient'); + await undo(); + expect(snapshot()).toEqual(before); + }); + + it('adds an app as a copy of another, without its build output', async () => { + const { io, files } = project(); + await createApp(io, model(client, server), { + type: 'client', + folder: 'client-two', + name: 'pong-client-two', + from: client, + }); + expect(files.get('apps/client-two/src/main.ts')).toBe('export const main = 1;\n'); + expect(JSON.parse(files.get('apps/client-two/package.json')!).name).toBe('pong-client-two'); + expect(files.has('apps/client-two/dist/main.js')).toBe(false); + expect(files.has('apps/client/dist/main.js')).toBe(true); + + await expect( + createApp(io, model(client, server), { type: 'client', folder: 'client', name: 'x' }), + ).rejects.toThrow(/already exists/); + await expect( + createApp(io, model(client, server), { type: 'client', folder: 'new', name: 'pong-server' }), + ).rejects.toThrow(/already the name/); + }); + + it('registers the editor library in an app, next to its engine libraries', async () => { + const { io, files, snapshot } = project(); + files.set( + 'apps/client/src/main.ts', + [ + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + '', + 'import { move } from "./systems/systems";', + '', + 'export const main = async (options) => {', + ' const app = NanoforgeFactory.createClient();', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + ' await app.run();', + '};', + '', + ].join('\n'), + ); + const before = snapshot(); + const entry = { ...client, entryFile: 'apps/client/src/main.ts' }; + + const undo = await addEditorLibrary(io, entry); + expect(files.get('apps/client/src/main.ts')).toBe( + [ + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + 'import { EditorLibrary } from "@nanoforge-dev/editor-lib";', + '', + 'import { move } from "./systems/systems";', + '', + 'export const main = async (options) => {', + ' const app = NanoforgeFactory.createClient();', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + ' app.use(new EditorLibrary());', + '', + ' await app.init(options);', + ' await app.run();', + '};', + '', + ].join('\n'), + ); + expect(JSON.parse(files.get('apps/client/package.json')!).devDependencies).toMatchObject({ + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/editor-lib': '^2.0.0', + }); + + await expect(addEditorLibrary(io, entry)).rejects.toThrow(/already registers/); + await undo(); + expect(snapshot()).toEqual(before); + + files.set('apps/client/src/main.ts', 'export const main = async () => {};\n'); + await expect(addEditorLibrary(io, entry)).rejects.toThrow(/by hand/); + expect(withEditorLibrary('const a = 1;')).toBeUndefined(); + }); + + it('gives a new app the editor library when the project has it', async () => { + const { io, files } = project(); + await editPackage(files, 'apps/client/package.json', '@nanoforge-dev/editor-lib', '^2.0.0'); + await createApp(io, model(client, server), { + type: 'client', + folder: 'spectator', + name: 'pong-spectator', + }); + expect(JSON.parse(files.get('apps/spectator/package.json')!).devDependencies).toHaveProperty( + '@nanoforge-dev/editor-lib', + '^2.0.0', + ); + const main = files.get('apps/spectator/src/main.ts')!; + expect(main).toContain('import { EditorLibrary } from "@nanoforge-dev/editor-lib";'); + expect(main).toContain(' app.use(ecs);\n app.use(new EditorLibrary());'); + }); + + it('renames and removes an app, but never the last one', async () => { + const { io, files, snapshot } = project(); + await renameApp(io, model(client, server), client, 'pong-game'); + expect(JSON.parse(files.get('apps/client/package.json')!).name).toBe('pong-game'); + await expect(renameApp(io, model(client, server), client, 'pong-server')).rejects.toThrow(); + + const before = snapshot(); + const undo = await removeApp(io, model(client, server), server); + expect(files.has('apps/server/src/main.ts')).toBe(false); + await undo(); + expect(snapshot()).toEqual(before); + await expect(removeApp(io, model(client), client)).rejects.toThrow(/at least one app/); + }); +}); diff --git a/packages/project/test/workspace/library-operations.spec.ts b/packages/project/test/workspace/library-operations.spec.ts new file mode 100644 index 00000000..718202d0 --- /dev/null +++ b/packages/project/test/workspace/library-operations.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { withConfigLibrary } from '../../src/workspace/library-config'; +import { + createLibrary, + libraryImporters, + removeLibrary, + renameLibrary, + setLibraryUse, + withRenamedImports, +} from '../../src/workspace/library-operations'; +import { WorkspaceError } from '../../src/workspace/workspace.exception'; +import { app, client, model, project, server, shared } from '../fixtures/workspace'; + +describe('shared libraries', () => { + it('creates a library used by the chosen apps, and undoes it', async () => { + const { io, files, snapshot } = project(); + const before = snapshot(); + const undo = await createLibrary(io, { + folder: 'shared', + packageName: '@pong/shared', + usedBy: [client, server], + }); + expect(JSON.parse(files.get('libs/shared/package.json')!)).toMatchObject({ + name: '@pong/shared', + private: true, + }); + expect(files.get('libs/shared/nanoforge.config.ts')).toContain('type: "lib"'); + expect(files.get('nanoforge.config.ts')).toContain('packages: ["apps/*", "libs/*"]'); + expect(files.get('tsconfig.json')).toContain('// shared options'); + expect(files.get('tsconfig.json')).toContain('"@pong/shared/*": ["./libs/shared/src/*"]'); + for (const path of ['apps/client/package.json', 'apps/server/package.json']) + expect(JSON.parse(files.get(path)!).dependencies).toEqual({ '@pong/shared': 'workspace:*' }); + expect(files.get('apps/client/nanoforge.config.ts')).toContain( + ' type: "client",\n libs: ["../../libs/shared"],\n', + ); + expect(files.get('apps/server/nanoforge.config.ts')).toBe( + "export default { type: 'server', libs: ['../../libs/shared'] };\n", + ); + + await undo(); + expect(snapshot()).toEqual(before); + + await expect( + createLibrary(io, { folder: 'Bad Name', packageName: '@pong/shared', usedBy: [] }), + ).rejects.toBeInstanceOf(WorkspaceError); + await expect( + createLibrary(io, { folder: 'ok', packageName: 'Not A Name', usedBy: [] }), + ).rejects.toThrow(/package name/); + expect(snapshot()).toEqual(before); + }); + + it('adds and removes the use of a library by an app', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toBeUndefined(); + + const undo = await setLibraryUse(io, server, shared, true); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toEqual({ + '@pong/shared': 'workspace:*', + }); + expect(files.get('apps/server/nanoforge.config.ts')).toContain("libs: ['../../libs/shared']"); + await undo(); + expect(files.get('apps/server/nanoforge.config.ts')).toContain('libs: []'); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toBeUndefined(); + + await setLibraryUse(io, client, shared, false); + const pkg = JSON.parse(files.get('apps/client/package.json')!); + expect(pkg.dependencies).toBeUndefined(); + expect(pkg.devDependencies).toHaveProperty('typescript'); + expect(files.get('apps/client/nanoforge.config.ts')).toContain(' libs: [],\n'); + }); + + it("edits a config's libs in place, by the folder each entry points at", () => { + const config = + 'export default defineConfig({\n type: "client",\n entryFile: "src/main.ts",\n});\n'; + const added = withConfigLibrary(config, 'apps/client', 'libs/shared', true)!; + expect(added).toBe( + 'export default defineConfig({\n type: "client",\n libs: ["../../libs/shared"],\n entryFile: "src/main.ts",\n});\n', + ); + const other = added.replace('"../../libs/shared"', '"./../../libs/shared/"'); + expect(withConfigLibrary(other, 'apps/client', 'libs/shared', true)).toBe(other); + expect(withConfigLibrary(added, 'apps/client', 'libs/ui', true)).toContain( + 'libs: ["../../libs/shared", "../../libs/ui"],', + ); + expect(withConfigLibrary(other, 'apps/client', 'libs/shared', false)).toContain('libs: [],'); + expect( + withConfigLibrary("export default { type: 'server' };\n", 'apps/server', 'libs/a', true), + ).toBe("export default { type: 'server', libs: ['../../libs/a'] };\n"); + expect(withConfigLibrary('export default config;\n', 'apps/x', 'libs/a', true)).toBeUndefined(); + expect(withConfigLibrary(config, 'apps/client', 'libs/shared', false)).toBe(config); + }); + + it('renames a library: package, paths, dependencies and imports', async () => { + const { io, files, snapshot } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set( + 'apps/client/src/main.ts', + 'import { Position } from "@pong/shared/components/position";\nexport * from \'@pong/shared/systems/move\';\nconst lazy = import("@pong/shared/later");\nimport other from "@pong/shared-tools";\n', + ); + const before = snapshot(); + const library = app('libs/shared', '@pong/shared', 'lib'); + const all = model(client, server, library); + + const undo = await renameLibrary(io, all, library, '@pong/common'); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/common'); + const tsconfig = files.get('tsconfig.json')!; + expect(tsconfig).toContain('"@pong/common/*": ["./libs/shared/src/*"]'); + expect(tsconfig).not.toContain('@pong/shared/'); + expect(JSON.parse(files.get('apps/client/package.json')!).dependencies).toEqual({ + '@pong/common': 'workspace:*', + }); + expect(files.get('apps/client/src/main.ts')).toBe( + 'import { Position } from "@pong/common/components/position";\nexport * from \'@pong/common/systems/move\';\nconst lazy = import("@pong/common/later");\nimport other from "@pong/shared-tools";\n', + ); + await undo(); + expect(snapshot()).toEqual(before); + + await expect(renameLibrary(io, all, library, 'pong-client')).rejects.toThrow(/already/); + expect(withRenamedImports('const a = 1;', '@pong/shared', '@x/y')).toBeUndefined(); + }); + + it('refuses to remove a library that is imported, then removes it', async () => { + const { io, files, snapshot } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set('libs/shared/src/components/position.ts', 'export class Position {}\n'); + files.set( + 'apps/client/src/main.ts', + 'import { Position } from "@pong/shared/components/position";\n', + ); + const library = app('libs/shared', '@pong/shared', 'lib'); + const all = model(client, server, library); + + expect(await libraryImporters(io, library)).toEqual(['apps/client/src/main.ts']); + const refused = await removeLibrary(io, all, library).catch((error: unknown) => error); + expect(refused).toBeInstanceOf(WorkspaceError); + expect((refused as WorkspaceError).paths).toEqual(['apps/client/src/main.ts']); + expect(files.has('libs/shared/package.json')).toBe(true); + + files.set('apps/client/src/main.ts', 'export const main = 1;\n'); + const before = snapshot(); + const undo = await removeLibrary(io, all, library); + expect([...files.keys()].some((path) => path.startsWith('libs/shared/'))).toBe(false); + expect(files.get('tsconfig.json')).not.toContain('@pong/shared'); + expect(files.get('apps/client/nanoforge.config.ts')).toContain('libs: [],'); + expect(JSON.parse(files.get('apps/client/package.json')!).dependencies).toBeUndefined(); + await undo(); + expect(snapshot()).toEqual(before); + }); +}); diff --git a/packages/project/tsconfig.json b/packages/project/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/project/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/protocol/.prettierignore b/packages/protocol/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/protocol/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/protocol/LICENSE b/packages/protocol/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/protocol/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 00000000..24b1fa4e --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-protocol` is an internal package of the NanoForge Editor. + +RPC contracts (zod schemas) shared by the editor client packages and `server-core`: session, projects, file system and plugins. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/protocol +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 00000000..b4d5c0a6 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-protocol", + "version": "0.0.0", + "description": "NanoForge Editor - RPC contracts shared by the editor client and server", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/protocol" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/protocol/src/code/code.contract.ts b/packages/protocol/src/code/code.contract.ts new file mode 100644 index 00000000..552f53c0 --- /dev/null +++ b/packages/protocol/src/code/code.contract.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; + +export const CodeContract = defineContract('code', { + methods: { + /** + * Type declarations of a package as resolved from a file (node_modules lookup): + * its package.json and `.d.ts` files, with project-relative paths. + */ + typeFiles: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + module: z.string().regex(/^(@[\w.-]+\/)?[\w.-]+$/, 'package name'), + }), + output: z.array(z.object({ path: ProjectPath, text: z.string() })), + }, + /** + * Formats a file's text with Prettier: the project's own Prettier, config and plugins on + * local editors; the bundled Prettier and the project's JSON/YAML config on hosted ones. + * `formatter` is `none` when the file is ignored or unsupported (text unchanged). + */ + format: { + input: z.object({ project: ProjectId, path: ProjectPath, text: z.string() }), + output: z.object({ text: z.string(), formatter: z.enum(['project', 'bundled', 'none']) }), + }, + }, +}); diff --git a/packages/protocol/src/code/index.ts b/packages/protocol/src/code/index.ts new file mode 100644 index 00000000..01cdb576 --- /dev/null +++ b/packages/protocol/src/code/index.ts @@ -0,0 +1 @@ +export * from './code.contract'; diff --git a/packages/protocol/src/fs/fs.contract.ts b/packages/protocol/src/fs/fs.contract.ts new file mode 100644 index 00000000..ffa94ebf --- /dev/null +++ b/packages/protocol/src/fs/fs.contract.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; +import { Bytes, FileChange, FileContent, FileEntry } from './fs.schema'; + +const Target = z.object({ project: ProjectId, path: ProjectPath }); + +export const FsContract = defineContract('fs', { + methods: { + stat: { input: Target, output: FileEntry.nullable() }, + list: { + input: Target.extend({ recursive: z.boolean().default(false) }), + output: z.array(FileEntry), + }, + read: { input: Target, output: FileContent }, + write: { + input: Target.extend({ + content: z.union([Bytes, z.string()]), + /** Fails with CONFLICT when the file changed since this hash was read. */ + expectedHash: z.string().nullable().optional(), + createParents: z.boolean().default(true), + }), + output: FileEntry.extend({ hash: z.string() }), + }, + mkdir: { input: Target, output: FileEntry }, + rename: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + to: ProjectPath, + overwrite: z.boolean().default(false), + }), + output: FileEntry, + }, + copy: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + to: ProjectPath, + overwrite: z.boolean().default(false), + }), + output: FileEntry, + }, + /** Moves the entry to `.nanoforge/editor/trash`; `trashPath` brings it back (`restore`). */ + delete: { input: Target, output: z.object({ trashPath: ProjectPath }) }, + /** Undo of `delete`: moves the entry back from the trash to `path`. */ + restore: { + input: z.object({ project: ProjectId, trashPath: ProjectPath, path: ProjectPath }), + output: FileEntry, + }, + /** Local editors: shows the entry in the OS file manager. */ + reveal: { input: Target, output: z.null() }, + }, + streams: { + /** Batched changes in the project (node_modules, .git and build outputs are ignored). */ + changes: { + params: z.object({ project: ProjectId }), + event: z.object({ changes: z.array(FileChange) }), + }, + }, +}); diff --git a/packages/protocol/src/fs/fs.schema.ts b/packages/protocol/src/fs/fs.schema.ts new file mode 100644 index 00000000..3d2df3d7 --- /dev/null +++ b/packages/protocol/src/fs/fs.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ProjectPath } from '../path/project-path'; + +/** Raw bytes (any Uint8Array, whatever its backing buffer). */ +export const Bytes = z.custom((value) => value instanceof Uint8Array, 'expected bytes'); + +export const FileKind = z.enum(['file', 'directory']); + +export const FileEntry = z.object({ + path: ProjectPath, + kind: FileKind, + size: z.number().int().nonnegative(), + /** Modification time in ms since epoch. */ + mtime: z.number(), +}); +export type FileEntry = z.output; + +export const FileContent = FileEntry.extend({ + content: Bytes, + /** sha1 of the content, used for optimistic concurrency on writes. */ + hash: z.string(), +}); +export type FileContent = z.output; + +export const FileChange = z.object({ + type: z.enum(['created', 'changed', 'deleted']), + path: ProjectPath, + kind: FileKind, +}); +export type FileChange = z.output; diff --git a/packages/protocol/src/fs/index.ts b/packages/protocol/src/fs/index.ts new file mode 100644 index 00000000..d33bb6b9 --- /dev/null +++ b/packages/protocol/src/fs/index.ts @@ -0,0 +1,2 @@ +export * from './fs.schema'; +export * from './fs.contract'; diff --git a/packages/protocol/src/git/git.contract.ts b/packages/protocol/src/git/git.contract.ts new file mode 100644 index 00000000..7ed25937 --- /dev/null +++ b/packages/protocol/src/git/git.contract.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; +import { GitBranch, GitChange, GitCommit, GitStash, GitStatus } from './git.schema'; + +const Project = z.object({ project: ProjectId }); +const Paths = Project.extend({ paths: z.array(ProjectPath).min(1) }); +const BranchName = z + .string() + .min(1) + .max(200) + .regex(/^(?!-)(?!.*\.\.)[^\s~^:?*[\\]+$/, 'not a valid branch name'); +const Hash = z.string().regex(/^[0-9a-f]{7,40}$/); + +/** + * Git operations on the repository of a project's folder (local editors only). A repository + * above the folder is never used. + */ +export const GitContract = defineContract('git', { + methods: { + status: { input: Project, output: GitStatus }, + init: { input: Project, output: z.null() }, + stage: { input: Paths, output: z.null() }, + unstage: { input: Paths, output: z.null() }, + /** + * Rolls files back to the last commit. A file added since then stays on disk, out of git; + * a file git never knew is deleted. + */ + discard: { input: Paths, output: z.null() }, + /** + * Commits `paths` only (whatever else is staged stays out), the staged changes without + * `paths`, or everything with `all`. `amend` replaces the last commit. + */ + commit: { + input: Project.extend({ + message: z.string().min(1), + all: z.boolean().default(false), + paths: z.array(ProjectPath).optional(), + amend: z.boolean().default(false), + }), + output: z.null(), + }, + fetch: { input: Project, output: z.null() }, + /** Merge pull. `conflicts`: the merge stopped on conflicts (see the status). */ + pull: { input: Project, output: z.object({ conflicts: z.boolean() }) }, + /** Pushes the branch, setting its upstream (`origin`) on the first push. */ + push: { input: Project, output: z.null() }, + branches: { input: Project, output: z.array(GitBranch) }, + /** A branch of a remote (`origin/x`) is checked out as a local branch tracking it. */ + switchBranch: { input: Project.extend({ name: BranchName }), output: z.null() }, + /** Creates a branch at the current commit, or at `from` (a branch), and switches to it. */ + createBranch: { + input: Project.extend({ name: BranchName, from: BranchName.optional() }), + output: z.null(), + }, + /** `force`: also when it is not merged. */ + deleteBranch: { + input: Project.extend({ name: BranchName, force: z.boolean().default(false) }), + output: z.null(), + }, + log: { + input: Project.extend({ + limit: z.number().int().min(1).max(200).default(50), + skip: z.number().int().min(0).default(0), + /** A branch to list instead of the current one. */ + branch: BranchName.optional(), + }), + output: z.array(GitCommit), + }, + /** Commits a push would send: those the upstream does not have (all, without one). */ + outgoing: { input: Project, output: z.array(GitCommit) }, + /** Files a commit changed. */ + commitFiles: { + input: Project.extend({ hash: Hash }), + output: z.array(z.object({ path: z.string(), change: GitChange })), + }, + /** + * Text of a file at a revision: `HEAD`, `INDEX` (staged), or `^` (before a commit). + * Null when the file does not exist there. + */ + show: { + input: Project.extend({ + path: ProjectPath, + revision: z.string().regex(/^(HEAD|INDEX|[0-9a-f]{7,40}\^?)$/), + }), + output: z.string().nullable(), + }, + stashes: { input: Project, output: z.array(GitStash) }, + /** Stashes the changes, untracked files included. */ + stash: { input: Project.extend({ message: z.string().optional() }), output: z.null() }, + /** `pop`: drops the stash once applied. */ + applyStash: { + input: Project.extend({ index: z.number().int().min(0), pop: z.boolean().default(false) }), + output: z.null(), + }, + dropStash: { input: Project.extend({ index: z.number().int().min(0) }), output: z.null() }, + }, +}); diff --git a/packages/protocol/src/git/git.schema.ts b/packages/protocol/src/git/git.schema.ts new file mode 100644 index 00000000..03b202ca --- /dev/null +++ b/packages/protocol/src/git/git.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +/** What happened to a file: modified, added, deleted, renamed, untracked. */ +export const GitChange = z.enum(['M', 'A', 'D', 'R', 'U']); +export type GitChange = z.output; + +export const GitFile = z.object({ + path: z.string(), + /** Where a renamed file was. */ + from: z.string().optional(), + /** Change staged for the next commit. */ + staged: GitChange.optional(), + /** Change in the working files, not staged (`U`: untracked). */ + unstaged: GitChange.optional(), + /** A merge left conflict markers in it. */ + conflicted: z.boolean(), +}); +export type GitFile = z.output; + +export const GitStatus = z.object({ + /** False: the project folder is not a repository (everything else is empty). */ + repository: z.boolean(), + /** Current branch; undefined on a detached HEAD. */ + branch: z.string().optional(), + /** Short hash of the current commit; undefined before the first commit. */ + head: z.string().optional(), + upstream: z.string().optional(), + ahead: z.number().int(), + behind: z.number().int(), + /** A merge is in progress (a pull that conflicted). */ + merging: z.boolean(), + files: z.array(GitFile), +}); +export type GitStatus = z.output; + +export const GitBranch = z.object({ + /** `main`, or `origin/main` for a branch of a remote. */ + name: z.string(), + current: z.boolean(), + upstream: z.string().optional(), + remote: z.boolean(), +}); +export type GitBranch = z.output; + +export const GitCommit = z.object({ + hash: z.string(), + shortHash: z.string(), + subject: z.string(), + author: z.string(), + /** Seconds since epoch. */ + time: z.number(), + /** Branches and tags pointing at it (`main`, `origin/main`, `tag: v1`). */ + refs: z.array(z.string()), +}); +export type GitCommit = z.output; + +export const GitStash = z.object({ index: z.number().int(), message: z.string() }); +export type GitStash = z.output; diff --git a/packages/protocol/src/git/index.ts b/packages/protocol/src/git/index.ts new file mode 100644 index 00000000..0088be04 --- /dev/null +++ b/packages/protocol/src/git/index.ts @@ -0,0 +1,2 @@ +export * from './git.schema'; +export * from './git.contract'; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 00000000..9268315e --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,10 @@ +export * from './fs'; +export * from './path'; +export * from './plugins'; +export * from './project'; +export * from './session'; +export * from './settings'; +export * from './code'; +export * from './runtime'; +export * from './git'; +export * from './registry'; diff --git a/packages/protocol/src/path/index.ts b/packages/protocol/src/path/index.ts new file mode 100644 index 00000000..b924980f --- /dev/null +++ b/packages/protocol/src/path/index.ts @@ -0,0 +1 @@ +export * from './project-path'; diff --git a/packages/protocol/src/path/project-path.ts b/packages/protocol/src/path/project-path.ts new file mode 100644 index 00000000..8390f35c --- /dev/null +++ b/packages/protocol/src/path/project-path.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +/** + * A path relative to the project root, POSIX separators, no `.`/`..` segments, no leading + * slash. The empty string is the root. The server still jails every path to the project. + */ +export const ProjectPath = z + .string() + .max(4096) + .refine( + (path) => + path === '' || + (!path.startsWith('/') && + !path.endsWith('/') && + !path.includes('\\') && + path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')), + 'must be a normalized project-relative path', + ); + +export type ProjectPath = z.output; + +export const joinPath = (...parts: string[]): string => + parts + .flatMap((part) => part.split('/')) + .filter(Boolean) + .join('/'); + +export const dirname = (path: string): string => path.split('/').slice(0, -1).join('/'); + +export const basename = (path: string): string => path.split('/').at(-1) ?? ''; diff --git a/packages/protocol/src/plugins/index.ts b/packages/protocol/src/plugins/index.ts new file mode 100644 index 00000000..1564f769 --- /dev/null +++ b/packages/protocol/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from './plugins.schema'; +export * from './plugins.contract'; diff --git a/packages/protocol/src/plugins/plugins.contract.ts b/packages/protocol/src/plugins/plugins.contract.ts new file mode 100644 index 00000000..2d5faeda --- /dev/null +++ b/packages/protocol/src/plugins/plugins.contract.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { PluginListing, PluginSuggestion } from './plugins.schema'; + +export const PluginsContract = defineContract('plugins', { + methods: { + /** Every plugin, plus the open project's own when `project` is set. */ + list: { + input: z.object({ project: z.string().optional() }).nullable(), + output: z.array(PluginListing), + }, + suggestions: { input: z.object({ project: z.string() }), output: z.array(PluginSuggestion) }, + }, + streams: { + /** Fired when a local dev plugin was rebuilt: the client hot reloads it. */ + devChanges: { + params: z.null(), + event: z.object({ name: z.string(), listing: PluginListing }), + }, + }, +}); diff --git a/packages/protocol/src/plugins/plugins.schema.ts b/packages/protocol/src/plugins/plugins.schema.ts new file mode 100644 index 00000000..44039a26 --- /dev/null +++ b/packages/protocol/src/plugins/plugins.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +/** + * Where a plugin was found: with the editor, in the user's home (`~/.nanoforge/editor/plugins`), + * in the open project (`/.nanoforge/plugins`), or a local dev folder. + */ +export const PluginSource = z.enum(['bundled', 'installed', 'project', 'dev']); + +/** A plugin package found by the server; the client validates `manifest` with the kernel. */ +export const PluginListing = z.object({ + source: PluginSource, + /** URL (relative to the editor origin) of the plugin package root, ending with `/`. */ + baseUrl: z.string(), + manifest: z.unknown(), + /** Set when the manifest file could not be read. */ + error: z.string().optional(), +}); +export type PluginListing = z.output; + +/** A plugin that packages in the project's `nf_modules` suggest (`suggestedPlugins`). */ +export const PluginSuggestion = z.object({ + name: z.string(), + /** Every range asked for, by package. */ + suggestedBy: z.array(z.object({ package: z.string(), range: z.string() })), +}); +export type PluginSuggestion = z.output; diff --git a/packages/protocol/src/project/index.ts b/packages/protocol/src/project/index.ts new file mode 100644 index 00000000..e344b6aa --- /dev/null +++ b/packages/protocol/src/project/index.ts @@ -0,0 +1,2 @@ +export * from './project.schema'; +export * from './project.contract'; diff --git a/packages/protocol/src/project/project.contract.ts b/packages/protocol/src/project/project.contract.ts new file mode 100644 index 00000000..84280d35 --- /dev/null +++ b/packages/protocol/src/project/project.contract.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId, ProjectModel, ProjectRef, RecentProject } from './project.schema'; + +export const ProjectsContract = defineContract('projects', { + methods: { + open: { + input: ProjectRef, + output: z.object({ id: ProjectId, name: z.string() }), + }, + recent: { input: z.null(), output: z.array(RecentProject) }, + forget: { input: z.object({ id: ProjectId }), output: z.null() }, + model: { input: z.object({ id: ProjectId }), output: ProjectModel }, + /** + * Local editors: installs the project's dependencies with its package manager, found from + * its lockfile. `manager` is null when no lockfile says which one to run; the output goes + * to the project's console as a task. + */ + install: { + input: z.object({ id: ProjectId }), + output: z.object({ manager: z.string().nullable(), ok: z.boolean() }), + }, + /** Offline editors: runs `nf new` under the projects root, then opens the project. */ + create: { + input: z.object({ + name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'lowercase letters, digits and dashes'), + language: z.enum(['ts', 'js']).default('ts'), + /** Multiplayer: adds a server app. */ + server: z.boolean().default(false), + install: z.boolean().default(true), + /** Installs the dependencies and runs the project's scripts. */ + packageManager: z.enum(['npm', 'pnpm', 'yarn', 'bun']).default('npm'), + }), + output: z.object({ id: ProjectId, name: z.string() }), + }, + /** Online editors: projects of the signed-in account. */ + gateways: { + input: z.null(), + output: z.array( + z.object({ gatewayId: z.string(), name: z.string(), description: z.string() }), + ), + }, + }, + streams: { + /** The project model, re-sent whenever discovery re-runs. */ + model: { params: z.object({ id: ProjectId }), event: ProjectModel, coalesce: true }, + }, +}); diff --git a/packages/protocol/src/project/project.schema.ts b/packages/protocol/src/project/project.schema.ts new file mode 100644 index 00000000..4dd5b5cf --- /dev/null +++ b/packages/protocol/src/project/project.schema.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +import { ProjectPath } from '../path/project-path'; + +export const ProjectId = z.string().regex(/^[\w-]{6,64}$/); +export type ProjectId = z.output; + +export const AppType = z.enum(['client', 'server', 'lib']); + +/** An app (client, server) or shared lib of a game workspace. */ +export const AppModel = z.object({ + /** Path of the app relative to the project root ('' for single-app projects). */ + id: z.string(), + name: z.string(), + type: AppType, + root: ProjectPath, + language: z.enum(['ts', 'js']), + /** Paths relative to the project root. */ + dirs: z.object({ + assets: ProjectPath, + components: ProjectPath, + systems: ProjectPath, + scenes: ProjectPath, + }), + entryFile: ProjectPath.optional(), + editorEntryFile: ProjectPath.optional(), + outDir: ProjectPath.optional(), + /** Engine libraries (`@nanoforge-dev/*` runtime packages) and their version or range. */ + engineLibs: z.record(z.string(), z.string()), + /** + * The shared libraries (ADR 0004) the app uses, by package name: those its `nanoforge.config` + * lists in `libs` (a library: those its package.json depends on). A library nothing mentions is + * given to every app. + */ + libraries: z.array(z.string()).default([]), + /** A shared library nothing mentions (`libs`, package.json): every app gets it (see `libraries`). */ + unclaimed: z.boolean().optional(), +}); +export type AppModel = z.output; + +export const ProjectDiagnostic = z.object({ + path: z.string(), + message: z.string(), + severity: z.enum(['error', 'warning']), +}); + +export const ProjectModel = z.object({ + id: ProjectId, + name: z.string(), + /** Display location: absolute path (offline) or gateway name (online). */ + location: z.string(), + apps: z.array(AppModel), + diagnostics: z.array(ProjectDiagnostic), +}); +export type ProjectModel = z.output; + +export const ProjectRef = z.union([ + z.object({ path: z.string().min(1) }), + z.object({ gatewayId: z.string().min(1) }), +]); +export type ProjectRef = z.output; + +export const RecentProject = z.object({ + id: ProjectId, + name: z.string(), + location: z.string(), + ref: ProjectRef, + openedAt: z.date(), +}); +export type RecentProject = z.output; diff --git a/packages/protocol/src/registry/index.ts b/packages/protocol/src/registry/index.ts new file mode 100644 index 00000000..61d44a2f --- /dev/null +++ b/packages/protocol/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from './registry.schema'; +export * from './registry.contract'; diff --git a/packages/protocol/src/registry/registry.contract.ts b/packages/protocol/src/registry/registry.contract.ts new file mode 100644 index 00000000..8d6a0e9f --- /dev/null +++ b/packages/protocol/src/registry/registry.contract.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; +import { ItemName, ItemType, RegistryItem, SearchResult } from '@nanoforge-dev/registry/types'; + +import { ProjectId } from '../project/project.schema'; +import { InstalledPackage, PluginScope } from './registry.schema'; + +const PluginTarget = z.object({ + name: ItemName, + scope: PluginScope, + /** The project, for the `project` scope. */ + project: ProjectId.optional(), +}); +const ProjectPackage = z.object({ project: ProjectId, name: ItemName }); + +/** + * The registry of plugins and packages, and installing from it (local editors only for + * installs). Fails with `UNAVAILABLE` when the registry cannot be reached. + */ +export const RegistryContract = defineContract('registry', { + methods: { + search: { + input: z.object({ + type: ItemType, + q: z.string().default(''), + page: z.number().int().min(1).default(1), + }), + output: SearchResult, + }, + /** An item with its readme and versions; `compatible` is the newest this editor can run. */ + details: { + input: z.object({ name: ItemName }), + output: RegistryItem.extend({ compatible: z.string().optional() }), + }, + /** Installs the newest version this editor can run, or `version`. Takes effect on reload. */ + installPlugin: { + input: PluginTarget.extend({ version: z.string().optional() }), + output: z.object({ version: z.string() }), + }, + uninstallPlugin: { input: PluginTarget, output: z.null() }, + /** The packages of a project (from its lock), with what is newer in the registry. */ + packages: { input: z.object({ project: ProjectId }), output: z.array(InstalledPackage) }, + installPackage: { + input: ProjectPackage.extend({ range: z.string().optional() }), + output: z.array(InstalledPackage), + }, + uninstallPackage: { input: ProjectPackage, output: z.array(InstalledPackage) }, + /** To the newest version in its range, or to the newest of all with `latest`. */ + updatePackage: { + input: ProjectPackage.extend({ latest: z.boolean().default(false) }), + output: z.array(InstalledPackage), + }, + /** Installs what the lock lists and `nf_modules` lacks. */ + restorePackages: { input: z.object({ project: ProjectId }), output: z.array(InstalledPackage) }, + }, +}); diff --git a/packages/protocol/src/registry/registry.schema.ts b/packages/protocol/src/registry/registry.schema.ts new file mode 100644 index 00000000..c20d11c4 --- /dev/null +++ b/packages/protocol/src/registry/registry.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { + RegistryItem, + RegistrySummary, + RegistryVersion, + SearchResult, +} from '@nanoforge-dev/registry/types'; + +export { RegistryItem, RegistrySummary, RegistryVersion, SearchResult as RegistrySearchResult }; + +/** A package of a project, as installed, with the newer versions the registry has. */ +export const InstalledPackage = z.object({ + name: z.string(), + version: z.string(), + /** The range the project asks for; absent for a package only others need. */ + range: z.string().optional(), + /** Installed packages that need it. */ + dependents: z.array(z.string()), + /** Whether its folder is in `nf_modules`. */ + present: z.boolean(), + /** The newest version its range allows, when newer. */ + wanted: z.string().optional(), + /** The newest version of all, when newer. */ + latest: z.string().optional(), +}); +export type InstalledPackage = z.output; + +/** Where a plugin is installed: for the user (every project), or in one project. */ +export const PluginScope = z.enum(['user', 'project']); +export type PluginScope = z.output; diff --git a/packages/protocol/src/runtime/engine-bridge.const.ts b/packages/protocol/src/runtime/engine-bridge.const.ts new file mode 100644 index 00000000..51f8eec3 --- /dev/null +++ b/packages/protocol/src/runtime/engine-bridge.const.ts @@ -0,0 +1,2 @@ +/** Version of the engine bridge protocol this editor speaks (engine `EDITOR_PROTOCOL_VERSION`). */ +export const RUNTIME_PROTOCOL_VERSION = 1; diff --git a/packages/protocol/src/runtime/engine-bridge.type.ts b/packages/protocol/src/runtime/engine-bridge.type.ts new file mode 100644 index 00000000..f6108674 --- /dev/null +++ b/packages/protocol/src/runtime/engine-bridge.type.ts @@ -0,0 +1,107 @@ +/** Optional engine features the editor asks for in its `welcome` (engine `EditorFeatures`). */ +export interface EngineFeatures { + frameStats?: { intervalMs: number }; + /** Console output of the game; `values` also asks for the logged values and their call site. */ + logs?: boolean | { values?: boolean }; + /** + * Network packets (`network-trace` events, each with the first `maxBytes` bytes when given) + * and totals every second (`network-stats`). + */ + networkTrace?: { sampleRate?: number; maxPerSecond?: number; maxBytes?: number }; + /** ECS live world (`ecs-world` events), sent when it changed, at most every `intervalMs`. */ + ecsWorld?: { intervalMs: number }; + /** Time spent in each ECS system (`ecs-system-stats` events) every `intervalMs`. */ + ecsSystemStats?: { intervalMs: number }; + /** The client's viewport (`viewport` events), at once and on every change. */ + viewport?: boolean; + /** Loaded scenes and scene vars (`scenes` events), sent when they changed, at most every `intervalMs`. */ + scenes?: { intervalMs: number }; +} + +/** Engine `EditorScenes` (`@nanoforge-dev/scene`): the `scenes` event. */ +export interface EngineScenes { + /** The loaded scenes, root first, by id. */ + readonly loaded: readonly { readonly id: string; readonly params: EngineWorldValue }[]; + readonly vars: readonly { + readonly key: string; + readonly value: EngineWorldValue; + /** The id of the scene it belongs to. */ + readonly owner?: string; + readonly persistent: boolean; + }[]; + /** Ids of the scenes the game can load. */ + readonly known: readonly string[]; +} + +/** + * Engine `EditorViewport` (its `ViewportState`): a point `(x, y)` of the game is drawn at + * `(contentLeft + originX + x * scaleX, contentTop + originY + y * scaleY)` CSS px of the game's + * container. + */ +export interface EngineViewport { + readonly designWidth: number; + readonly designHeight: number; + readonly scaleX: number; + readonly scaleY: number; + readonly contentLeft: number; + readonly contentTop: number; + readonly originX: number; + readonly originY: number; + readonly visibleWidth: number; + readonly visibleHeight: number; +} + +/** Engine `EditorNetworkTrace`: one packet a game sent or received. */ +export interface EngineNetworkTrace { + direction: 'in' | 'out'; + transport: 'tcp' | 'udp'; + /** Server side: the client the packet goes to or comes from. */ + clientId?: string; + size: number; + /** First 16 bytes, hex encoded. */ + head: string; + /** The first `maxBytes` bytes, hex encoded (engines that send them). */ + data?: string; + /** ms since epoch. */ + time: number; +} + +/** Engine `EditorNetworkStats`: totals of every packet over a window, traced or not. */ +export interface EngineNetworkStats { + windowMs: number; + time: number; + tcp: Record<'in' | 'out', { packets: number; bytes: number }>; + udp: Record<'in' | 'out', { packets: number; bytes: number }>; + /** Packets left out of the `network-trace` events. */ + untraced: number; +} + +/** Engine `EditorSystemStats`: time per call of each ECS system over a window (ms). */ +export interface EngineSystemStats { + windowMs: number; + systems: { index: number; name: string; calls: number; avg: number; max: number }[]; +} + +/** A JSON view of a component value (engine `EditorValue`). */ +export type EngineWorldValue = + null | boolean | number | string | EngineWorldValue[] | { [key: string]: EngineWorldValue }; + +/** Engine `EditorWorld`: the running ECS world. */ +export interface EngineWorld { + entities: { + id: number; + /** The id of the scene that spawned it (`EcsScene` of `@nanoforge-dev/ecs/scene`). */ + scene?: string; + components: { name: string; value: Record }[]; + }[]; + systems: { index: number; name: string; enabled: boolean }[]; +} + +/** Engine `EditorFrameStats`: tick timings over a sampling window (ms). */ +export interface EngineFrameStats { + windowMs: number; + ticks: number; + tps: number; + tick: { avg: number; max: number }; + libraries: Record; +} diff --git a/packages/protocol/src/runtime/index.ts b/packages/protocol/src/runtime/index.ts new file mode 100644 index 00000000..4cb448d2 --- /dev/null +++ b/packages/protocol/src/runtime/index.ts @@ -0,0 +1,5 @@ +export * from './engine-bridge.const'; +export * from './engine-bridge.type'; +export * from './runtime.schema'; +export * from './runtime.contract'; +export * from './runtime-output'; diff --git a/packages/protocol/src/runtime/runtime-output.ts b/packages/protocol/src/runtime/runtime-output.ts new file mode 100644 index 00000000..71f6bb1e --- /dev/null +++ b/packages/protocol/src/runtime/runtime-output.ts @@ -0,0 +1,9 @@ +/** URL path of an app's build output (`app` is the app id, `''` for single-app projects). */ +export const runtimeOutputPath = (project: string, app: string): string => + `/runtime/${project}/${encodeURIComponent(app || '.')}/`; + +/** Inverse of {@link runtimeOutputPath}'s app segment. */ +export const decodeRuntimeApp = (segment: string): string => { + const app = decodeURIComponent(segment); + return app === '.' ? '' : app; +}; diff --git a/packages/protocol/src/runtime/runtime.contract.ts b/packages/protocol/src/runtime/runtime.contract.ts new file mode 100644 index 00000000..f05a7f3e --- /dev/null +++ b/packages/protocol/src/runtime/runtime.contract.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId } from '../project/project.schema'; +import { BuildStatus, EnvOverrides, GameServerState, OutputManifest } from './runtime.schema'; + +const Target = z.object({ project: ProjectId, app: z.string() }); + +export const RuntimeEvent = z.discriminatedUnion('type', [ + z.object({ type: z.literal('build'), status: BuildStatus }), + z.object({ + type: z.literal('log'), + /** The app, or the command line of a `cli` run (`nf install …`). */ + app: z.string(), + source: z.enum(['build', 'server', 'cli']), + stream: z.enum(['stdout', 'stderr']), + text: z.string(), + }), + z.object({ + type: z.literal('server'), + app: z.string(), + state: GameServerState, + exitCode: z.number().nullable().optional(), + }), + /** An engine → editor event of the game server (`toEditor`). */ + z.object({ + type: z.literal('bridge'), + app: z.string(), + event: z.string(), + args: z.array(z.unknown()), + }), +]); +export type RuntimeEvent = z.output; + +export const RuntimeContract = defineContract('runtime', { + methods: { + /** Builds apps now (all runnable apps by default) and resolves with their status. */ + build: { + input: z.object({ project: ProjectId, apps: z.array(z.string()).optional() }), + output: z.array(BuildStatus), + }, + status: { + input: z.object({ project: ProjectId }), + output: z.object({ + builds: z.array(BuildStatus), + servers: z.array(z.object({ app: z.string(), state: GameServerState })), + }), + }, + manifest: { input: Target, output: OutputManifest }, + /** Environment of an app as the game sees it (prefixes stripped). */ + env: { + input: Target.extend({ overrides: EnvOverrides.default({}) }), + output: z.record(z.string(), z.string()), + }, + startServer: { + input: Target.extend({ overrides: EnvOverrides.default({}) }), + output: z.null(), + }, + /** Asks the game to stop, kills it after a timeout; resolves once it exited. */ + stopServer: { input: Target, output: z.null() }, + /** Sends an editor → engine event to the game server (`fromEditor`). */ + sendServer: { + input: Target.extend({ event: z.string().min(1), args: z.array(z.unknown()).default([]) }), + output: z.null(), + }, + }, + streams: { + /** + * Builds, logs and game server events of a project. While subscribed, apps are rebuilt + * when their sources change. + */ + events: { params: z.object({ project: ProjectId }), event: RuntimeEvent }, + }, +}); diff --git a/packages/protocol/src/runtime/runtime.schema.ts b/packages/protocol/src/runtime/runtime.schema.ts new file mode 100644 index 00000000..d6945eee --- /dev/null +++ b/packages/protocol/src/runtime/runtime.schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +/** Base editor → engine commands handled by every NanoForge app. */ +export const RuntimeCommand = z.enum(['welcome', 'pause', 'resume', 'step', 'stop']); +export type RuntimeCommand = z.output; + +/** Run state reported by the engine (`state` bridge event). */ +export const EngineRunState = z.enum(['running', 'paused', 'stopped']); +export type EngineRunState = z.output; + +export const BuildDiagnostic = z.object({ + /** Project-relative path when the file is inside the project. */ + path: z.string().optional(), + line: z.number().int().optional(), + column: z.number().int().optional(), + message: z.string(), + severity: z.enum(['error', 'warning']), +}); +export type BuildDiagnostic = z.output; + +export const BuildStatus = z.object({ + app: z.string(), + state: z.enum(['idle', 'building', 'ok', 'error']), + /** Content hash of the last successful output: changes when the game changes. */ + version: z.string().nullable(), + diagnostics: z.array(BuildDiagnostic), + /** End of the last build (ms since epoch). */ + finishedAt: z.number().nullable(), + durationMs: z.number().nullable(), +}); +export type BuildStatus = z.output; + +export const OutputFile = z.object({ + /** Path relative to the output directory, `/`-separated. */ + path: z.string(), + size: z.number().int().nonnegative(), + /** sha1 of the content: cache key of the file. */ + hash: z.string(), +}); +export type OutputFile = z.output; + +/** Build output of an app, served at `baseUrl`. */ +export const OutputManifest = z.object({ + app: z.string(), + version: z.string(), + /** Absolute path on the server, ends with `/`. */ + baseUrl: z.string(), + entry: z.string(), + files: z.array(OutputFile), +}); +export type OutputManifest = z.output; + +export const GameServerState = z.enum(['starting', 'running', 'paused', 'stopping', 'exited']); +export type GameServerState = z.output; + +/** Environment overrides (`runtime.env` setting): `NANOFORGE_*` keys, as in `.env`. */ +export const EnvOverrides = z.record(z.string(), z.string()); diff --git a/packages/protocol/src/session/index.ts b/packages/protocol/src/session/index.ts new file mode 100644 index 00000000..8794094d --- /dev/null +++ b/packages/protocol/src/session/index.ts @@ -0,0 +1,2 @@ +export * from './session.schema'; +export * from './session.contract'; diff --git a/packages/protocol/src/session/session.contract.ts b/packages/protocol/src/session/session.contract.ts new file mode 100644 index 00000000..38e2f486 --- /dev/null +++ b/packages/protocol/src/session/session.contract.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { SessionInfo } from './session.schema'; + +export const SessionContract = defineContract('session', { + methods: { + info: { input: z.null(), output: SessionInfo, public: true }, + logout: { input: z.null(), output: z.null(), public: true }, + }, +}); diff --git a/packages/protocol/src/session/session.schema.ts b/packages/protocol/src/session/session.schema.ts new file mode 100644 index 00000000..4615a849 --- /dev/null +++ b/packages/protocol/src/session/session.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const EditorMode = z.enum(['OFFLINE', 'ONLINE']); +export type EditorMode = z.output; + +export const User = z.object({ id: z.string(), name: z.string(), email: z.string().optional() }); +export type User = z.output; + +export const SessionInfo = z.object({ + mode: EditorMode, + version: z.string(), + /** Null when ONLINE and not signed in on the NanoForge website. */ + user: User.nullable(), + /** ONLINE: where to sign in (the NanoForge projects website sets the auth cookies). */ + loginUrl: z.string().nullable(), + /** + * What the services behind the editor offer: `settings` (account settings sync between + * machines; without it a hosted editor keeps them on its own disk), `registry` (the registry + * of plugins and packages answers). + */ + features: z.array(z.enum(['settings', 'registry'])).default([]), +}); +export type SessionInfo = z.output; diff --git a/packages/protocol/src/settings/index.ts b/packages/protocol/src/settings/index.ts new file mode 100644 index 00000000..dcb10f62 --- /dev/null +++ b/packages/protocol/src/settings/index.ts @@ -0,0 +1,2 @@ +export * from './settings.schema'; +export * from './settings.contract'; diff --git a/packages/protocol/src/settings/settings.contract.ts b/packages/protocol/src/settings/settings.contract.ts new file mode 100644 index 00000000..e50d6d47 --- /dev/null +++ b/packages/protocol/src/settings/settings.contract.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId } from '../project/project.schema'; +import { AccountSettingsDocument, SettingValues } from './settings.schema'; + +export const SettingsContract = defineContract('settings', { + methods: { + accountGet: { input: z.null(), output: AccountSettingsDocument }, + /** Fails with CONFLICT (data: the current document) when `baseRevision` is outdated. */ + accountPut: { + input: z.object({ baseRevision: z.string().nullable(), values: SettingValues }), + output: z.object({ revision: z.string() }), + }, + /** Hosted editors only: this user's settings for a project (local ones use a file). */ + projectLocalGet: { input: z.object({ project: ProjectId }), output: SettingValues }, + projectLocalPut: { + input: z.object({ project: ProjectId, values: SettingValues }), + output: z.null(), + }, + }, + streams: { + /** The account document changed (another tab or device): pull it. */ + accountChanges: { params: z.null(), event: z.object({ revision: z.string() }), coalesce: true }, + }, +}); diff --git a/packages/protocol/src/settings/settings.schema.ts b/packages/protocol/src/settings/settings.schema.ts new file mode 100644 index 00000000..b20541a4 --- /dev/null +++ b/packages/protocol/src/settings/settings.schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const SettingValues = z.record(z.string(), z.unknown()); + +/** The account settings document. `revision` is opaque (null before the first save). */ +export const AccountSettingsDocument = z.object({ + revision: z.string().nullable(), + values: SettingValues, +}); +export type AccountSettingsDocument = z.output; + +/** Limits of a settings document (also enforced by the NanoForge API). */ +export const SETTINGS_LIMITS = { maxBytes: 256 * 1024, maxKeys: 2000 } as const; diff --git a/packages/protocol/test/path/project-path.spec.ts b/packages/protocol/test/path/project-path.spec.ts new file mode 100644 index 00000000..4913b8f1 --- /dev/null +++ b/packages/protocol/test/path/project-path.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { ProjectPath, basename, dirname, joinPath } from '../../src/path/project-path'; + +describe('ProjectPath', () => { + it.each(['', 'a', 'apps/client/src/main.ts', '.nanoforge/editor/settings.json'])( + 'accepts %j', + (path) => expect(ProjectPath.safeParse(path).success).toBe(true), + ); + + it.each(['/etc/passwd', '../x', 'a/../b', 'a/./b', 'a//b', 'a/', 'a\\b'])('rejects %j', (path) => + expect(ProjectPath.safeParse(path).success).toBe(false), + ); + + it('helpers', () => { + expect(joinPath('apps/client', '/src/', 'main.ts')).toBe('apps/client/src/main.ts'); + expect(dirname('apps/client/main.ts')).toBe('apps/client'); + expect(dirname('main.ts')).toBe(''); + expect(basename('apps/client/main.ts')).toBe('main.ts'); + }); +}); diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/registry/.prettierignore b/packages/registry/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/registry/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/registry/LICENSE b/packages/registry/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/registry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/registry/README.md b/packages/registry/README.md new file mode 100644 index 00000000..9d562b8a --- /dev/null +++ b/packages/registry/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/registry` is an internal package of the NanoForge Editor. + +NanoForge registry client and installer for packages and plugins (no editor dependency: meant to move to the CLI). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/registry +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/registry/package.json b/packages/registry/package.json new file mode 100644 index 00000000..a4ce0e55 --- /dev/null +++ b/packages/registry/package.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/registry", + "version": "0.0.0", + "description": "NanoForge Editor - NanoForge registry client and installer for packages and plugins (no editor dependency: meant to move to the CLI)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./tsconfig-paths": "./src/tsconfig-paths.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/registry" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "test:contract": "vitest run --config ../../vitest.config.ts test/contract" + }, + "dependencies": { + "fflate": "catalog:libs", + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/node": "catalog:core", + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/registry/src/archive/archive.ts b/packages/registry/src/archive/archive.ts new file mode 100644 index 00000000..ed138d1e --- /dev/null +++ b/packages/registry/src/archive/archive.ts @@ -0,0 +1,98 @@ +import { strFromU8, unzipSync, zipSync } from 'fflate'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { ItemManifest, MANIFEST_FILE } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; + +export const sha256 = (bytes: Uint8Array): string => + createHash('sha256').update(bytes).digest('hex'); + +/** A zip entry that would be written outside of the target folder, or in an odd place. */ +const unsafe = (path: string): boolean => + path.startsWith('/') || + path.includes('\\') || + /^[A-Za-z]:/.test(path) || + path.split('/').some((segment) => segment === '..' || segment === '.'); + +/** The files of an archive by path (folders left out); rejects unsafe paths. */ +export const readArchive = (bytes: Uint8Array): Map => { + let entries: Record; + try { + entries = unzipSync(bytes); + } catch { + throw new RegistryError('INVALID', 'The archive is not a zip file'); + } + const files = new Map(); + for (const [path, content] of Object.entries(entries)) { + if (path.endsWith('/')) continue; + if (unsafe(path)) + throw new RegistryError('INVALID', `The archive holds an unsafe path: ${path}`); + files.set(path, content); + } + return files; +}; + +/** The manifest at the root of an archive. */ +export const manifestOf = (files: ReadonlyMap): ItemManifest => { + const raw = files.get(MANIFEST_FILE); + if (!raw) throw new RegistryError('INVALID', `The archive has no ${MANIFEST_FILE}`); + let json: unknown; + try { + json = JSON.parse(strFromU8(raw)); + } catch { + throw new RegistryError('INVALID', `${MANIFEST_FILE} is not valid JSON`); + } + const parsed = ItemManifest.safeParse(json); + if (!parsed.success) { + throw new RegistryError( + 'INVALID', + `${MANIFEST_FILE} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`, + ); + } + return parsed.data; +}; + +/** + * Writes the files of an archive as a folder, replacing what was there. The manifest is written + * last: a folder without it is an install that did not finish, and is installed again. + * + * Files are written in place rather than in a temporary folder renamed at the end: file + * watchers (the editor's) miss the content of a folder that appears through a rename. + */ +export const writeFolder = async ( + files: ReadonlyMap, + target: string, +): Promise => { + await rm(target, { recursive: true, force: true }); + const ordered = [...files].sort( + ([a], [b]) => Number(a === MANIFEST_FILE) - Number(b === MANIFEST_FILE), + ); + try { + for (const [path, content] of ordered) { + const file = join(target, path); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + } + } catch (error) { + await rm(target, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +}; + +/** Zips a folder with stable bytes (sorted paths, no dates), so its hash only follows its content. */ +export const zipFolder = async (folder: string): Promise => { + const files: Record = {}; + const walk = async (dir: string, prefix: string) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(join(dir, entry.name), path); + else if (entry.isFile()) + files[path] = [await readFile(join(dir, entry.name)), { mtime: new Date(315532800000) }]; + } + }; + await walk(folder, ''); + return zipSync(files, { level: 6 }); +}; diff --git a/packages/registry/src/archive/index.ts b/packages/registry/src/archive/index.ts new file mode 100644 index 00000000..993afe63 --- /dev/null +++ b/packages/registry/src/archive/index.ts @@ -0,0 +1 @@ +export * from './archive'; diff --git a/packages/registry/src/client/index.ts b/packages/registry/src/client/index.ts new file mode 100644 index 00000000..ff6f4d98 --- /dev/null +++ b/packages/registry/src/client/index.ts @@ -0,0 +1 @@ +export * from './registry-client'; diff --git a/packages/registry/src/client/registry-client.ts b/packages/registry/src/client/registry-client.ts new file mode 100644 index 00000000..6f8bf91b --- /dev/null +++ b/packages/registry/src/client/registry-client.ts @@ -0,0 +1,93 @@ +import semver from 'semver'; + +import { type RegistryItem, SearchQuery, type SearchResult } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { fromPackument, fromSearchObject } from '../wire/packument'; +import { Packument, ROUTES, WireSearch } from '../wire/wire.schema'; + +export interface RegistryClientOptions { + /** Base URL of the API holding `/registry/…`. */ + readonly baseUrl: string; + readonly fetch?: typeof fetch; + /** Headers of every request (reading needs none; a registry key to publish). */ + readonly headers?: () => Record; +} + +/** The registry over HTTP (the contract of `docs/api/registry.md`). */ +export class RegistryClient implements Registry { + constructor(private readonly _options: RegistryClientOptions) {} + + async search(query: SearchQuery = {}): Promise { + const { type, q, page, limit } = SearchQuery.parse(query); + const params = new URLSearchParams({ + text: q, + from: String((page - 1) * limit), + size: String(limit), + }); + if (type) params.set('type', type); + const response = await this._request(`${ROUTES.search}?${params}`); + const found = this._parse(WireSearch, await this._json(response)); + return { items: found.objects.map(fromSearchObject), total: found.total }; + } + + async get(name: string): Promise { + return fromPackument(await this._packument(name), semver.rcompare); + } + + async download(name: string, version: string): Promise { + const found = (await this._packument(name)).versions[version]; + if (!found) throw new RegistryError('NOT_FOUND', `${name}@${version} was not found`); + const response = await this._request(found.dist.file, `${name}@${version}`); + return new Uint8Array(await response.arrayBuffer()); + } + + private async _packument(name: string): Promise { + const response = await this._request(ROUTES.item(name), name); + return this._parse(Packument, await this._json(response)); + } + + private async _request(path: string, subject?: string): Promise { + let response: Response; + try { + response = await (this._options.fetch ?? fetch)( + new URL(path, this._options.baseUrl).toString(), + { headers: this._options.headers?.() ?? {} }, + ); + } catch (error) { + throw new RegistryError( + 'UNAVAILABLE', + `The registry cannot be reached: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (response.status === 404) { + throw subject + ? new RegistryError('NOT_FOUND', `${subject} was not found`) + : new RegistryError('UNAVAILABLE', 'This registry does not answer searches yet'); + } + if (!response.ok) + throw new RegistryError('UNAVAILABLE', `The registry answered ${response.status}`); + return response; + } + + private async _json(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return undefined; + } + } + + private _parse( + schema: { safeParse(value: unknown): { success: boolean; data?: T } }, + value: unknown, + ): T { + const parsed = schema.safeParse(value); + if (!parsed.success) + throw new RegistryError( + 'UNAVAILABLE', + 'The registry sent an answer this version cannot read', + ); + return parsed.data as T; + } +} diff --git a/packages/registry/src/directory/directory-registry.ts b/packages/registry/src/directory/directory-registry.ts new file mode 100644 index 00000000..635bf4b7 --- /dev/null +++ b/packages/registry/src/directory/directory-registry.ts @@ -0,0 +1,173 @@ +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import semver from 'semver'; + +import { sha256, zipFolder } from '../archive/archive'; +import { + ItemManifest, + ItemName, + MANIFEST_FILE, + type RegistryItem, + type RegistrySummary, + type RegistryVersion, + SearchQuery, + type SearchResult, +} from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; + +interface Entry { + readonly item: RegistryItem; + /** Folder of each version. */ + readonly folders: ReadonlyMap; +} + +/** + * A registry read from a folder, for tests and for working without the real one: + * `/@scope/name//` holds the files of each version (its manifest, and a + * `README.md`), and `/@scope/name/registry.json` the optional `{ author, downloads }`. + * Version folders added or removed are followed; call `reload()` after changing files inside one. + */ +export class DirectoryRegistry implements Registry { + private _entries: Promise> | undefined; + private _listed = ''; + private readonly _archives = new Map>(); + + constructor(readonly dir: string) {} + + /** Reads the folder again on the next call (it changed). */ + reload(): void { + this._entries = undefined; + this._archives.clear(); + } + + async search(query: SearchQuery = {}): Promise { + const { type, q, page, limit } = SearchQuery.parse(query); + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const all = [...(await this._load()).values()] + .map(({ item }): RegistrySummary => ({ + name: item.name, + type: item.type, + version: item.version, + description: item.description, + author: item.author, + downloads: item.downloads, + updatedAt: item.updatedAt, + })) + .filter( + (item) => + (!type || item.type === type) && + words.every((word) => `${item.name} ${item.description}`.toLowerCase().includes(word)), + ) + .sort((a, b) => b.downloads - a.downloads || a.name.localeCompare(b.name)); + return { items: all.slice((page - 1) * limit, page * limit), total: all.length }; + } + + async get(name: string): Promise { + const entry = (await this._load()).get(name); + if (!entry) throw new RegistryError('NOT_FOUND', `${name} was not found`); + return entry.item; + } + + async download(name: string, version: string): Promise { + const folder = (await this._load()).get(name)?.folders.get(version); + if (!folder) throw new RegistryError('NOT_FOUND', `${name}@${version} was not found`); + return this._archive(folder); + } + + private _archive(folder: string): Promise { + let archive = this._archives.get(folder); + if (!archive) this._archives.set(folder, (archive = zipFolder(folder))); + return archive; + } + + private async _load(): Promise> { + const listing = await this._listing(); + if (!this._entries || listing !== this._listed) { + this._listed = listing; + this._entries = this._read(); + } + return this._entries; + } + + /** The version folders there are, as one string to compare. */ + private async _listing(): Promise { + const names = (dir: string) => + readdir(dir, { withFileTypes: true }).then( + (entries) => entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + () => [] as string[], + ); + const found: string[] = []; + for (const scope of await names(this.dir)) { + for (const name of await names(join(this.dir, scope))) { + for (const version of await names(join(this.dir, scope, name))) + found.push(`${scope}/${name}/${version}`); + } + } + return found.sort().join('\n'); + } + + private async _read(): Promise> { + const entries = new Map(); + if (!existsSync(this.dir)) return entries; + const folders = async (dir: string) => + (await readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + for (const scope of (await folders(this.dir)).filter((name) => name.startsWith('@'))) { + for (const short of await folders(join(this.dir, scope))) { + const name = `${scope}/${short}`; + if (!ItemName.safeParse(name).success) continue; + const root = join(this.dir, scope, short); + const versions: (RegistryVersion & { manifest: ItemManifest; folder: string })[] = []; + for (const version of await folders(root)) { + const folder = join(root, version); + const manifest = ItemManifest.safeParse( + await readFile(join(folder, MANIFEST_FILE), 'utf8') + .then((text) => JSON.parse(text) as unknown) + .catch(() => undefined), + ); + if (!manifest.success || manifest.data.name !== name || manifest.data.version !== version) + continue; + versions.push({ + version, + publishedAt: (await stat(join(folder, MANIFEST_FILE))).mtime.toISOString(), + sha256: sha256(await this._archive(folder)), + dependencies: manifest.data.dependencies ?? {}, + engines: manifest.data.engines ?? {}, + manifest: manifest.data, + folder, + }); + } + if (!versions.length) continue; + versions.sort((a, b) => semver.rcompare(a.version, b.version)); + const latest = versions[0]!; + const extra = (await readFile(join(root, 'registry.json'), 'utf8') + .then((text) => JSON.parse(text) as unknown) + .catch(() => ({}))) as { author?: string; downloads?: number }; + entries.set(name, { + item: { + name, + type: latest.manifest.type, + version: latest.version, + description: latest.manifest.description ?? '', + author: extra.author ?? '', + downloads: extra.downloads ?? 0, + updatedAt: latest.publishedAt, + readme: await readFile(join(latest.folder, 'README.md'), 'utf8').catch(() => ''), + versions: versions.map((entry) => ({ + version: entry.version, + publishedAt: entry.publishedAt, + sha256: entry.sha256, + dependencies: entry.dependencies, + engines: entry.engines, + })), + }, + folders: new Map(versions.map((version) => [version.version, version.folder])), + }); + } + } + return entries; + } +} diff --git a/packages/registry/src/directory/index.ts b/packages/registry/src/directory/index.ts new file mode 100644 index 00000000..dc8bfd6b --- /dev/null +++ b/packages/registry/src/directory/index.ts @@ -0,0 +1 @@ +export * from './directory-registry'; diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts new file mode 100644 index 00000000..6668a4a5 --- /dev/null +++ b/packages/registry/src/index.ts @@ -0,0 +1,10 @@ +export * from './item'; +export * from './registry'; +export * from './archive'; +export * from './client'; +export * from './directory'; +export * from './serve'; +export * from './install'; +export * from './tsconfig-paths'; +export * from './project'; +export * from './wire'; diff --git a/packages/registry/src/install/index.ts b/packages/registry/src/install/index.ts new file mode 100644 index 00000000..c18ba7bf --- /dev/null +++ b/packages/registry/src/install/index.ts @@ -0,0 +1 @@ +export * from './install-item'; diff --git a/packages/registry/src/install/install-item.ts b/packages/registry/src/install/install-item.ts new file mode 100644 index 00000000..3daaddb6 --- /dev/null +++ b/packages/registry/src/install/install-item.ts @@ -0,0 +1,75 @@ +import semver from 'semver'; + +import { manifestOf, readArchive, sha256, writeFolder } from '../archive/archive'; +import { + type ItemManifest, + ItemName, + type ItemType, + type RegistryItem, + type RegistryVersion, +} from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; + +/** The newest version of an item that fits a range and, when given, what runs it (`engines`). */ +export const pickVersion = ( + item: RegistryItem, + range = '*', + engines: Readonly> = {}, +): RegistryVersion | undefined => + item.versions + .filter( + (version) => + semver.satisfies(version.version, range, { includePrerelease: false }) && + Object.entries(engines).every( + ([engine, current]) => + !version.engines[engine] || + semver.satisfies(current, version.engines[engine], { includePrerelease: true }), + ), + ) + .sort((a, b) => semver.rcompare(a.version, b.version))[0]; + +export interface InstallRequest { + readonly name: string; + readonly version: string; + readonly type: ItemType; + /** The folder the item becomes: `…/@scope/name`. */ + readonly target: string; + /** Hash the archive must have (the registry's, or the lock's). */ + readonly sha256?: string; +} + +/** + * Downloads a version and writes it as a folder. Nothing is written unless the archive has the + * expected hash, holds only safe paths, and its manifest says the name, version and type asked + * for. + */ +export const installItem = async ( + registry: Registry, + request: InstallRequest, +): Promise => { + const name = ItemName.safeParse(request.name); + if (!name.success) throw new RegistryError('INVALID', `${request.name} is not a valid name`); + const bytes = await registry.download(request.name, request.version); + const hash = sha256(bytes); + if (request.sha256 && request.sha256 !== hash) { + throw new RegistryError( + 'INTEGRITY', + `${request.name}@${request.version} does not have the expected content (hash mismatch)`, + ); + } + const files = readArchive(bytes); + const manifest = manifestOf(files); + if ( + manifest.name !== request.name || + manifest.version !== request.version || + manifest.type !== request.type + ) { + throw new RegistryError( + 'INVALID', + `The archive of ${request.name}@${request.version} holds ${manifest.type} ${manifest.name}@${manifest.version}`, + ); + } + await writeFolder(files, request.target); + return manifest; +}; diff --git a/packages/registry/src/item/index.ts b/packages/registry/src/item/index.ts new file mode 100644 index 00000000..6f94be23 --- /dev/null +++ b/packages/registry/src/item/index.ts @@ -0,0 +1 @@ +export * from './item.schema'; diff --git a/packages/registry/src/item/item.schema.ts b/packages/registry/src/item/item.schema.ts new file mode 100644 index 00000000..3455b0e8 --- /dev/null +++ b/packages/registry/src/item/item.schema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +/** `@scope/name`: the name of everything in the registry. */ +export const ItemName = z + .string() + .regex(/^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, 'expected @scope/name, lowercase'); +export type ItemName = z.output; + +export const ItemType = z.enum(['plugin', 'package']); +export type ItemType = z.output; + +export const ItemVersion = z + .string() + .regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, 'expected a version like 1.2.3'); + +/** Other registry items an item needs, with version ranges. */ +export const Dependencies = z.record(ItemName, z.string()); +export type Dependencies = z.output; + +/** What the registry says of an item in lists. */ +export const RegistrySummary = z.object({ + name: ItemName, + type: ItemType, + /** The newest version. */ + version: ItemVersion, + description: z.string().default(''), + author: z.string().default(''), + downloads: z.number().int().nonnegative().default(0), + /** ISO date of the newest version. */ + updatedAt: z.string().default(''), +}); +export type RegistrySummary = z.output; + +export const RegistryVersion = z.object({ + version: ItemVersion, + publishedAt: z.string().default(''), + /** sha256 of the archive, hex. */ + sha256: z.string().regex(/^[0-9a-f]{64}$/), + dependencies: Dependencies.default({}), + /** What the version needs to run, e.g. `{ "editor": ">=0.1.0" }`. */ + engines: z.record(z.string(), z.string()).default({}), +}); +export type RegistryVersion = z.output; + +export const RegistryItem = RegistrySummary.extend({ + readme: z.string().default(''), + /** Every version, the newest first. */ + versions: z.array(RegistryVersion).min(1), +}); +export type RegistryItem = z.output; + +export const SearchQuery = z.object({ + type: ItemType.optional(), + /** Words searched in names and descriptions. */ + q: z.string().default(''), + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(100).default(30), +}); +export type SearchQuery = z.input; + +export const SearchResult = z.object({ + items: z.array(RegistrySummary), + total: z.number().int().nonnegative(), +}); +export type SearchResult = z.output; + +/** The part of `nanoforge.manifest.json` the installer checks. */ +export const ItemManifest = z + .object({ + type: ItemType, + name: ItemName, + version: ItemVersion, + description: z.string().optional(), + dependencies: Dependencies.optional(), + engines: z.record(z.string(), z.string()).optional(), + }) + .loose(); +export type ItemManifest = z.output; + +export const MANIFEST_FILE = 'nanoforge.manifest.json'; diff --git a/packages/registry/src/project/index.ts b/packages/registry/src/project/index.ts new file mode 100644 index 00000000..09f14863 --- /dev/null +++ b/packages/registry/src/project/index.ts @@ -0,0 +1,3 @@ +export * from './project-packages.const'; +export * from './project-packages.type'; +export * from './project-packages'; diff --git a/packages/registry/src/project/packages-file.schema.ts b/packages/registry/src/project/packages-file.schema.ts new file mode 100644 index 00000000..a004fba7 --- /dev/null +++ b/packages/registry/src/project/packages-file.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Dependencies, ItemName, ItemVersion } from '../item/item.schema'; + +export const PackagesList = z.object({ packages: Dependencies.default({}) }).loose(); +export const PackagesLock = z.object({ + lockVersion: z.literal(1), + packages: z + .record( + ItemName, + z.object({ + version: ItemVersion, + sha256: z.string(), + dependencies: Dependencies.default({}), + }), + ) + .default({}), +}); +export type Lock = z.output['packages']; diff --git a/packages/registry/src/project/project-packages.const.ts b/packages/registry/src/project/project-packages.const.ts new file mode 100644 index 00000000..43878205 --- /dev/null +++ b/packages/registry/src/project/project-packages.const.ts @@ -0,0 +1,5 @@ +/** The packages a project asks for, with version ranges. Written by hand or by tools. */ +export const PACKAGES_FILE = 'nanoforge.packages.json'; +/** What is installed: exact versions and hashes. Written by tools only. */ +export const LOCK_FILE = 'nanoforge.packages.lock.json'; +export const MODULES_DIR = 'nf_modules'; diff --git a/packages/registry/src/project/project-packages.ts b/packages/registry/src/project/project-packages.ts new file mode 100644 index 00000000..444ec463 --- /dev/null +++ b/packages/registry/src/project/project-packages.ts @@ -0,0 +1,315 @@ +import { existsSync } from 'node:fs'; +import { readFile, rm, rmdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import semver from 'semver'; + +import { installItem } from '../install/install-item'; +import { ItemName, MANIFEST_FILE, type RegistryItem } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { withPathsEntry, withoutPathsEntry } from '../tsconfig-paths'; +import { type Lock, PackagesList, PackagesLock } from './packages-file.schema'; +import { LOCK_FILE, MODULES_DIR, PACKAGES_FILE } from './project-packages.const'; +import type { InstalledPackage, OutdatedPackage } from './project-packages.type'; + +const queues = new Map>(); + +/** + * The packages of a project: its list (`nanoforge.packages.json`), its lock, and the folders in + * `nf_modules`. One version of a package per project; dependencies are installed side by side. + * Changes of the same project run one after the other. + */ +export class ProjectPackages { + constructor( + readonly root: string, + private readonly _registry: Registry, + ) {} + + async list(): Promise { + const [wanted, lock] = await Promise.all([this._readList(), this._readLock()]); + return Object.entries(lock) + .map(([name, entry]) => ({ + name, + version: entry.version, + ...(wanted[name] !== undefined && { range: wanted[name] }), + dependents: Object.entries(lock) + .filter(([, other]) => name in other.dependencies) + .map(([other]) => other) + .sort(), + present: existsSync(join(this._folder(name), MANIFEST_FILE)), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** The installed packages with the newer versions the registry has. */ + async outdated(): Promise { + const lock = await this._readLock(); + return Promise.all( + (await this.list()).map(async (installed): Promise => { + const item = await this._registry.get(installed.name).catch((error: unknown) => { + if (error instanceof RegistryError && error.code === 'NOT_FOUND') return undefined; + throw error; + }); + if (!item) return installed; + const ranges = [ + ...(installed.range !== undefined ? [installed.range] : []), + ...installed.dependents.map((other) => lock[other]!.dependencies[installed.name]!), + ]; + const wanted = item.versions + .map((entry) => entry.version) + .filter((version) => ranges.every((range) => semver.satisfies(version, range))) + .sort(semver.rcompare)[0]; + const newer = (version: string | undefined) => + version && semver.gt(version, installed.version) ? version : undefined; + return { + ...installed, + ...(newer(wanted) && { wanted: newer(wanted)! }), + ...(newer(item.version) && { latest: newer(item.version)! }), + }; + }), + ); + } + + /** Adds a package to the project (`^` without a range) and installs what it needs. */ + install(name: string, range?: string): Promise { + return this._queue(async () => { + this._checkName(name); + const item = await this._registry.get(name); + if (item.type !== 'package') + throw new RegistryError('INVALID', `${name} is a ${item.type}, not a package`); + const wanted = await this._readList(); + wanted[name] = range ?? `^${item.version}`; + await this._apply(wanted, new Set([name])); + return this.list(); + }); + } + + /** + * Removes a package of the project. Refused while another installed package needs it; + * dependencies nobody needs any more go with it. + */ + uninstall(name: string): Promise { + return this._queue(async () => { + this._checkName(name); + const wanted = await this._readList(); + const lock = await this._readLock(); + const dependents = Object.entries(lock) + .filter(([other, entry]) => other !== name && name in entry.dependencies) + .map(([other]) => other); + if (dependents.length) { + throw new RegistryError( + 'IN_USE', + `${name} is needed by ${dependents.join(', ')}: uninstall ${dependents.length > 1 ? 'them' : 'it'} first`, + ); + } + if (!(name in wanted) && !(name in lock)) + throw new RegistryError('NOT_FOUND', `${name} is not installed`); + const remaining = Object.fromEntries( + Object.entries(wanted).filter(([other]) => other !== name), + ); + await this._apply(remaining, new Set()); + return this.list(); + }); + } + + /** Moves a package to the newest version in its range, or to the newest of all (`latest`). */ + update(name: string, latest = false): Promise { + return this._queue(async () => { + this._checkName(name); + const wanted = await this._readList(); + if (latest && name in wanted) { + const item = await this._registry.get(name); + wanted[name] = `^${item.version}`; + } + await this._apply(wanted, new Set([name])); + return this.list(); + }); + } + + /** Installs what the lock lists and `nf_modules` lacks (a fresh clone). */ + restore(): Promise { + return this._queue(async () => { + const lock = await this._readLock(); + for (const [name, entry] of Object.entries(lock)) { + if (existsSync(join(this._folder(name), MANIFEST_FILE))) continue; + await installItem(this._registry, { + name, + version: entry.version, + type: 'package', + target: this._folder(name), + sha256: entry.sha256, + }); + } + await this._syncProjectFiles(lock); + return this.list(); + }); + } + + private _folder(name: string): string { + return join(this.root, MODULES_DIR, ...name.split('/')); + } + + private _checkName(name: string): void { + if (!ItemName.safeParse(name).success) + throw new RegistryError('INVALID', `${name} is not a valid package name`); + } + + private _queue(run: () => Promise): Promise { + const previous = queues.get(this.root) ?? Promise.resolve(); + const next = previous.then(run, run); + queues.set( + this.root, + next.catch(() => undefined), + ); + return next; + } + + /** + * Resolves the wanted packages and their dependencies to one version each, installs what + * changed, removes what is no longer needed, and writes the list, the lock and the paths. + * Installed versions are kept when they still fit, except for the names in `refresh`. + */ + private async _apply( + wanted: Record, + refresh: ReadonlySet, + ): Promise { + const lock = await this._readLock(); + const items = new Map(); + const item = async (name: string) => { + let found = items.get(name); + if (!found) items.set(name, (found = await this._registry.get(name))); + return found; + }; + + let picks = new Map(); + for (let round = 0; ; round++) { + if (round > 50) + throw new RegistryError('CONFLICT', 'The dependencies of these packages never settle'); + const ranges = new Map(); + const ask = (name: string, range: string, by: string) => + ranges.set(name, [...(ranges.get(name) ?? []), { range, by }]); + for (const [name, range] of Object.entries(wanted)) ask(name, range, 'the project'); + for (const [name, version] of picks) { + const dependencies = + (await item(name)).versions.find((entry) => entry.version === version)?.dependencies ?? + {}; + for (const [dependency, range] of Object.entries(dependencies)) + ask(dependency, range, `${name}@${version}`); + } + const next = new Map(); + for (const [name, asked] of ranges) { + const found = await item(name); + if (found.type !== 'package') + throw new RegistryError('INVALID', `${name} is a ${found.type}, not a package`); + const fits = (version: string) => + asked.every(({ range }) => semver.satisfies(version, range)); + const locked = lock[name]?.version; + const version = + locked && !refresh.has(name) && fits(locked) + ? locked + : found.versions + .map((entry) => entry.version) + .filter(fits) + .sort(semver.rcompare)[0]; + if (!version) { + throw new RegistryError( + 'CONFLICT', + `No version of ${name} fits ${asked + .map(({ range, by }) => `${range} (asked by ${by})`) + .join(' and ')}`, + ); + } + next.set(name, version); + } + const settled = + next.size === picks.size && + [...next].every(([name, version]) => picks.get(name) === version); + picks = next; + if (settled) break; + } + + const nextLock: Lock = {}; + for (const [name, version] of [...picks].sort(([a], [b]) => a.localeCompare(b))) { + const entry = (await item(name)).versions.find((candidate) => candidate.version === version)!; + const current = lock[name]; + const upToDate = + current?.version === version && + current.sha256 === entry.sha256 && + existsSync(join(this._folder(name), MANIFEST_FILE)); + if (!upToDate) { + await installItem(this._registry, { + name, + version, + type: 'package', + target: this._folder(name), + sha256: entry.sha256, + }); + } + nextLock[name] = { version, sha256: entry.sha256, dependencies: entry.dependencies }; + } + for (const name of Object.keys(lock)) { + if (!picks.has(name)) { + await rm(this._folder(name), { recursive: true, force: true }); + await rmdir(dirname(this._folder(name))) + .then(() => rmdir(join(this.root, MODULES_DIR))) + .catch(() => undefined); + } + } + + await this._writeJson(PACKAGES_FILE, { + ...(await this._readRawList()), + packages: sorted(wanted), + }); + await this._writeJson(LOCK_FILE, { lockVersion: 1, packages: nextLock }); + await this._syncProjectFiles(nextLock, Object.keys(lock)); + } + + /** Import paths of the installed packages (root tsconfig). The project's .gitignore is its own. */ + private async _syncProjectFiles(lock: Lock, previous: readonly string[] = []): Promise { + const tsconfig = join(this.root, 'tsconfig.json'); + const original = await readFile(tsconfig, 'utf8').catch(() => undefined); + let text = original ?? '{}\n'; + for (const name of previous) { + if (!(name in lock)) text = withoutPathsEntry(text, `${name}/*`); + } + for (const name of Object.keys(lock)) + text = withPathsEntry(text, `${name}/*`, `./${MODULES_DIR}/${name}/*`); + if (text !== original && (original !== undefined || Object.keys(lock).length)) + await writeFile(tsconfig, text); + } + + private async _readRawList(): Promise> { + const text = await readFile(join(this.root, PACKAGES_FILE), 'utf8').catch(() => undefined); + if (text === undefined) return {}; + try { + return JSON.parse(text) as Record; + } catch { + throw new RegistryError('INVALID', `${PACKAGES_FILE} is not valid JSON`); + } + } + + private async _readList(): Promise> { + const parsed = PackagesList.safeParse(await this._readRawList()); + if (!parsed.success) + throw new RegistryError( + 'INVALID', + `${PACKAGES_FILE} is not valid: ${parsed.error.issues[0]?.message}`, + ); + return { ...parsed.data.packages }; + } + + private async _readLock(): Promise { + const text = await readFile(join(this.root, LOCK_FILE), 'utf8').catch(() => undefined); + if (text === undefined) return {}; + const parsed = PackagesLock.safeParse(JSON.parse(text) as unknown); + if (!parsed.success) throw new RegistryError('INVALID', `${LOCK_FILE} is not valid`); + return parsed.data.packages; + } + + private _writeJson(file: string, value: unknown): Promise { + return writeFile(join(this.root, file), `${JSON.stringify(value, null, 2)}\n`); + } +} + +const sorted = (record: Record): Record => + Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))); diff --git a/packages/registry/src/project/project-packages.type.ts b/packages/registry/src/project/project-packages.type.ts new file mode 100644 index 00000000..6da85ed8 --- /dev/null +++ b/packages/registry/src/project/project-packages.type.ts @@ -0,0 +1,17 @@ +export interface InstalledPackage { + readonly name: string; + readonly version: string; + /** The range the project asks for; undefined for a package only others need. */ + readonly range?: string; + /** Installed packages that need it. */ + readonly dependents: readonly string[]; + /** Whether its folder is in `nf_modules` (false after a fresh clone: `restore`). */ + readonly present: boolean; +} + +export interface OutdatedPackage extends InstalledPackage { + /** The newest version its range allows, when newer than the installed one. */ + readonly wanted?: string; + /** The newest version of all, when newer than the installed one. */ + readonly latest?: string; +} diff --git a/packages/registry/src/registry/index.ts b/packages/registry/src/registry/index.ts new file mode 100644 index 00000000..bc6b81a2 --- /dev/null +++ b/packages/registry/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from './registry.type'; +export * from './registry.exception'; diff --git a/packages/registry/src/registry/registry.exception.ts b/packages/registry/src/registry/registry.exception.ts new file mode 100644 index 00000000..90df9e6c --- /dev/null +++ b/packages/registry/src/registry/registry.exception.ts @@ -0,0 +1,12 @@ +import type { RegistryErrorCode } from './registry.type'; + +/** A failure with a reason a caller can act on (`code`) and a message for people. */ +export class RegistryError extends Error { + constructor( + readonly code: RegistryErrorCode, + message: string, + ) { + super(message); + this.name = 'RegistryError'; + } +} diff --git a/packages/registry/src/registry/registry.type.ts b/packages/registry/src/registry/registry.type.ts new file mode 100644 index 00000000..83598eda --- /dev/null +++ b/packages/registry/src/registry/registry.type.ts @@ -0,0 +1,13 @@ +import { type RegistryItem, type SearchQuery, type SearchResult } from '../item/item.schema'; + +export type RegistryErrorCode = + 'NOT_FOUND' | 'UNAVAILABLE' | 'INVALID' | 'INTEGRITY' | 'CONFLICT' | 'IN_USE'; + +/** Where items come from: an HTTP registry, or a folder standing in for it. */ +export interface Registry { + search(query?: SearchQuery): Promise; + /** Rejects with `NOT_FOUND` for an unknown name. */ + get(name: string): Promise; + /** The archive of a version: a zip with `nanoforge.manifest.json` at its root. */ + download(name: string, version: string): Promise; +} diff --git a/packages/registry/src/serve/index.ts b/packages/registry/src/serve/index.ts new file mode 100644 index 00000000..d70c7032 --- /dev/null +++ b/packages/registry/src/serve/index.ts @@ -0,0 +1 @@ +export * from './serve-registry'; diff --git a/packages/registry/src/serve/serve-registry.ts b/packages/registry/src/serve/serve-registry.ts new file mode 100644 index 00000000..99795406 --- /dev/null +++ b/packages/registry/src/serve/serve-registry.ts @@ -0,0 +1,51 @@ +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { toPackument, toSearchObject } from '../wire/packument'; + +const json = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + +/** + * The registry's read routes over any registry, as a `fetch` handler: a stand-in server for + * tests, and the reference implementation of the contract (`docs/api/registry.md`). + */ +export const serveRegistry = + (registry: Registry) => + async (input: string | URL | Request): Promise => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + const path = decodeURIComponent(url.pathname); + try { + if (path === '/registry/-/v1/search') { + const type = url.searchParams.get('type'); + const size = Math.min(Math.max(Number(url.searchParams.get('size') ?? 30) || 30, 1), 100); + const from = Math.max(Number(url.searchParams.get('from') ?? 0) || 0, 0); + const found = await registry.search({ + ...(type === 'plugin' || type === 'package' ? { type } : {}), + q: url.searchParams.get('text') ?? '', + page: Math.floor(from / size) + 1, + limit: size, + }); + return json(200, { objects: found.items.map(toSearchObject), total: found.total }); + } + const archive = /^\/registry\/(@[^/]+\/([^/]+))\/-\/([^/]+)\.zip$/.exec(path); + if (archive) { + const [, name, short, file] = archive; + if (!file!.startsWith(`${short}-`)) return json(404, { error: 'No such file' }); + const bytes = await registry.download(name!, file!.slice(short!.length + 1)); + return new Response(bytes as BodyInit, { + status: 200, + headers: { 'content-type': 'application/zip' }, + }); + } + const item = /^\/registry\/(@[^/]+\/[^/]+)$/.exec(path); + if (item) return json(200, toPackument(await registry.get(item[1]!))); + return json(404, { error: 'No such route' }); + } catch (error) { + if (error instanceof RegistryError && error.code === 'NOT_FOUND') + return json(404, { error: error.message }); + return json(500, { error: error instanceof Error ? error.message : String(error) }); + } + }; diff --git a/packages/registry/src/tsconfig-paths.ts b/packages/registry/src/tsconfig-paths.ts new file mode 100644 index 00000000..54167932 --- /dev/null +++ b/packages/registry/src/tsconfig-paths.ts @@ -0,0 +1,96 @@ +/** Parses a JSON file that may have comments and trailing commas (tsconfig.json). */ +export const parseLooseJson = (text: string): Record => { + try { + return JSON.parse(text) as Record; + } catch { + const cleaned = text + .replace( + /("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, + (_match, string: string | undefined) => string ?? '', + ) + .replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(cleaned) as Record; + } +}; + +const pathsOf = (text: string): Record => + ((parseLooseJson(text).compilerOptions as { paths?: Record } | undefined) + ?.paths ?? {}) as Record; + +/** + * Adds a `compilerOptions.paths` entry to a tsconfig text, keeping its comments and formatting: + * inserted into the existing `paths` or `compilerOptions` when there is one. Falls back to + * rewriting the JSON when the text is too unusual to edit in place. + */ +export const withPathsEntry = (text: string, pattern: string, target: string): string => { + if (pathsOf(text)[pattern]?.[0] === target) return text; + if (pathsOf(text)[pattern]) text = withoutPathsEntry(text, pattern); + const entry = `${JSON.stringify(pattern)}: [${JSON.stringify(target)}]`; + const indentOf = (offset: number) => + /^[ \t]*/.exec(text.slice(text.lastIndexOf('\n', offset) + 1))![0]; + const insertAfterBrace = (match: RegExpExecArray, inner: string) => { + const brace = match.index + match[0].length; + const empty = /^\s*\}/.test(text.slice(brace)); + const indent = `${indentOf(match.index)} `; + return `${text.slice(0, brace)}\n${indent}${inner}${empty ? '\n' + indentOf(match.index) : ','}${text.slice(brace)}`; + }; + const candidates = [ + () => { + const match = /"paths"\s*:\s*\{/.exec(text); + return match ? insertAfterBrace(match, entry) : undefined; + }, + () => { + const match = /"compilerOptions"\s*:\s*\{/.exec(text); + return match ? insertAfterBrace(match, `"paths": { ${entry} }`) : undefined; + }, + () => { + const match = /^\s*\{/.exec(text); + return match + ? insertAfterBrace(match, `"compilerOptions": { "paths": { ${entry} } }`) + : undefined; + }, + ]; + for (const candidate of candidates) { + const edited = candidate(); + if (!edited) continue; + try { + if (pathsOf(edited)[pattern]?.[0] === target) return edited; + } catch {} + } + const config = parseLooseJson(text); + const options = (config.compilerOptions ?? {}) as Record; + const paths = (options.paths ?? {}) as Record; + config.compilerOptions = { ...options, paths: { ...paths, [pattern]: [target] } }; + return `${JSON.stringify(config, null, 2)}\n`; +}; + +/** Removes a `compilerOptions.paths` entry, keeping the rest of the text as it is. */ +export const withoutPathsEntry = (text: string, pattern: string): string => { + let paths: Record; + try { + paths = pathsOf(text); + } catch { + return text; + } + if (!(pattern in paths)) return text; + const key = JSON.stringify(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const entry = new RegExp(`[ \\t]*${key}\\s*:\\s*\\[[^\\]]*\\][ \\t]*(,?)[ \\t]*\\r?\\n?`); + const match = entry.exec(text); + if (match) { + let before = text.slice(0, match.index); + if (!match[1]) before = before.replace(/,(\s*)$/, '$1'); + const edited = before + text.slice(match.index + match[0].length); + try { + if (!(pattern in pathsOf(edited))) return edited; + } catch {} + } + const config = parseLooseJson(text); + const options = (config.compilerOptions ?? {}) as Record; + const rest = Object.fromEntries( + Object.entries((options.paths ?? {}) as Record).filter( + ([key]) => key !== pattern, + ), + ); + config.compilerOptions = { ...options, paths: rest }; + return `${JSON.stringify(config, null, 2)}\n`; +}; diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts new file mode 100644 index 00000000..fa3158e6 --- /dev/null +++ b/packages/registry/src/types.ts @@ -0,0 +1,3 @@ +/** The registry's data types: no Node code, so the editor's page and protocol can import them. */ +export * from './item'; +export * from './registry'; diff --git a/packages/registry/src/wire/index.ts b/packages/registry/src/wire/index.ts new file mode 100644 index 00000000..e643a5a4 --- /dev/null +++ b/packages/registry/src/wire/index.ts @@ -0,0 +1,2 @@ +export * from './wire.schema'; +export * from './packument'; diff --git a/packages/registry/src/wire/packument.ts b/packages/registry/src/wire/packument.ts new file mode 100644 index 00000000..99ec5920 --- /dev/null +++ b/packages/registry/src/wire/packument.ts @@ -0,0 +1,75 @@ +import { type z } from 'zod'; + +import type { RegistryItem, RegistrySummary } from '../item/item.schema'; +import { type Packument, ROUTES, type WireSearch } from './wire.schema'; + +export const toPackument = (item: RegistryItem): z.input => ({ + name: item.name, + type: item.type, + description: item.description, + author: { name: item.author }, + readme: item.readme, + 'dist-tags': { latest: item.version }, + versions: Object.fromEntries( + item.versions.map((version) => [ + version.version, + { + name: item.name, + version: version.version, + type: item.type, + dependencies: version.dependencies, + engines: version.engines, + dist: { file: ROUTES.archive(item.name, version.version), sha256: version.sha256 }, + }, + ]), + ), + time: Object.fromEntries(item.versions.map((version) => [version.version, version.publishedAt])), + downloads: { total: item.downloads }, +}); + +export const fromPackument = (packument: Packument, compare: (a: string, b: string) => number) => { + const versions = Object.values(packument.versions) + .map((version) => ({ + version: version.version, + publishedAt: packument.time[version.version] ?? '', + sha256: version.dist.sha256, + dependencies: version.dependencies, + engines: version.engines, + })) + .sort((a, b) => compare(a.version, b.version)); + const latest = packument['dist-tags'].latest; + const item: RegistryItem = { + name: packument.name, + type: packument.type, + version: latest, + description: packument.description, + author: packument.author?.name ?? '', + downloads: packument.downloads?.total ?? 0, + updatedAt: packument.time[latest] ?? '', + readme: packument.readme, + versions, + }; + return item; +}; + +export const toSearchObject = (item: RegistrySummary): WireSearch['objects'][number] => ({ + package: { + name: item.name, + type: item.type, + version: item.version, + description: item.description, + date: item.updatedAt, + author: { name: item.author }, + }, + downloads: { total: item.downloads }, +}); + +export const fromSearchObject = (entry: WireSearch['objects'][number]): RegistrySummary => ({ + name: entry.package.name, + type: entry.package.type, + version: entry.package.version, + description: entry.package.description, + author: entry.package.author?.name ?? '', + downloads: entry.downloads?.total ?? 0, + updatedAt: entry.package.date, +}); diff --git a/packages/registry/src/wire/wire.schema.ts b/packages/registry/src/wire/wire.schema.ts new file mode 100644 index 00000000..9960bc98 --- /dev/null +++ b/packages/registry/src/wire/wire.schema.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +import { Dependencies, ItemName, ItemType, ItemVersion } from '../item/item.schema'; + +/** + * What goes over HTTP: routes and documents after the npm registry's (a "packument" per item, + * archives under `/-/`, search under `/-/v1/search`), so people and tools that know npm find + * their way. Differences: every item has a `type`, archives are zip files, and their hash is a + * hex sha256. The contract is `docs/api/registry.md`. + */ +export const ROUTES = { + search: '/registry/-/v1/search', + item: (name: string) => `/registry/${name}`, + /** `@scope/name` 1.2.3 → `/registry/@scope/name/-/name-1.2.3.zip`. */ + archive: (name: string, version: string) => + `/registry/${name}/-/${name.split('/')[1]}-${version}.zip`, +}; + +const Person = z.object({ name: z.string().default('') }).loose(); + +export const WireVersion = z + .object({ + name: ItemName, + version: ItemVersion, + type: ItemType, + dependencies: Dependencies.default({}), + engines: z.record(z.string(), z.string()).default({}), + dist: z + .object({ + /** URL of the archive, absolute or relative to the registry's base URL. */ + file: z.string(), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + size: z.number().int().nonnegative().optional(), + }) + .loose(), + }) + .loose(); +export type WireVersion = z.output; + +export const Packument = z + .object({ + name: ItemName, + type: ItemType, + description: z.string().default(''), + author: Person.optional(), + readme: z.string().default(''), + 'dist-tags': z.object({ latest: ItemVersion }).loose(), + versions: z.record(z.string(), WireVersion), + /** ISO dates by version (npm also keeps `created` and `modified` here). */ + time: z.record(z.string(), z.string()).default({}), + downloads: z.object({ total: z.number().int().nonnegative().default(0) }).optional(), + }) + .loose(); +export type Packument = z.output; + +export const WireSearch = z.object({ + objects: z.array( + z + .object({ + package: z + .object({ + name: ItemName, + type: ItemType, + version: ItemVersion, + description: z.string().default(''), + date: z.string().default(''), + author: Person.optional(), + }) + .loose(), + downloads: z.object({ total: z.number().int().nonnegative().default(0) }).optional(), + }) + .loose(), + ), + total: z.number().int().nonnegative(), +}); +export type WireSearch = z.output; diff --git a/packages/registry/test/contract/registry-contract.ts b/packages/registry/test/contract/registry-contract.ts new file mode 100644 index 00000000..eb963269 --- /dev/null +++ b/packages/registry/test/contract/registry-contract.ts @@ -0,0 +1,111 @@ +import { strFromU8, unzipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; + +import { sha256 } from '../../src'; + +/** + * What the editor and the CLI need from the registry (docs/api/registry.md), as scenarios that + * only speak HTTP and only read. They run against the reference implementation + * (`serveRegistry`) in this package's tests, and against a real registry when + * `CONTRACT_REGISTRY_URL` is set (see `registry.contract.test.ts`). + */ +export interface RegistryTarget { + readonly baseUrl: string; + readonly fetch: (url: string) => Promise; + /** A published item to read: `@scope/name`. */ + readonly item: string; +} + +export const registryContract = (target: () => RegistryTarget): void => { + const get = (path: string) => { + const { baseUrl, fetch } = target(); + return fetch(new URL(path, baseUrl).toString()); + }; + const json = async (path: string) => { + const response = await get(path); + return { status: response.status, body: (await response.json()) as Record }; + }; + const VERSION = { + name: expect.any(String) as string, + version: expect.stringMatching(/^\d+\.\d+\.\d+/) as string, + type: expect.stringMatching(/^(plugin|package)$/) as string, + dist: { + file: expect.any(String) as string, + sha256: expect.stringMatching(/^[0-9a-f]{64}$/) as string, + }, + }; + + describe('reading needs no key and no user', () => { + it('describes an item: its versions, where their archives are, the latest one', async () => { + const { item } = target(); + const { status, body } = await json(`/registry/${item}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + name: item, + type: expect.stringMatching(/^(plugin|package)$/) as string, + 'dist-tags': { latest: expect.any(String) as string }, + versions: expect.any(Object) as object, + }); + const versions = body.versions as Record; + const latest = (body['dist-tags'] as { latest: string }).latest; + expect(versions[latest]).toMatchObject({ ...VERSION, name: item, version: latest }); + for (const [version, entry] of Object.entries(versions)) + expect(entry).toMatchObject({ ...VERSION, version }); + expect((await json(`/registry/${item.replace('/', '%2F')}`)).status).toBe(200); + }); + + it('answers 404 for an item that does not exist', async () => { + expect((await get('/registry/@contract-test/no-such-item')).status).toBe(404); + }); + + it('serves the archive: a zip with the hash the item gave and its manifest at the root', async () => { + const { item, baseUrl, fetch } = target(); + const { body } = await json(`/registry/${item}`); + const latest = (body['dist-tags'] as { latest: string }).latest; + const { dist } = ( + body.versions as Record + )[latest]!; + const response = await fetch(new URL(dist.file, baseUrl).toString()); + expect(response.status).toBe(200); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(sha256(bytes)).toBe(dist.sha256); + const manifest = unzipSync(bytes)['nanoforge.manifest.json']; + expect(manifest).toBeDefined(); + expect(JSON.parse(strFromU8(manifest!))).toMatchObject({ name: item, version: latest }); + }); + + it('searches by text and type, in pages', async () => { + const { item } = target(); + const short = item.split('/')[1]!; + const { status, body } = await json( + `/registry/-/v1/search?text=${encodeURIComponent(short)}&size=50`, + ); + expect(status).toBe(200); + expect(body).toMatchObject({ total: expect.any(Number) as number }); + const objects = body.objects as { package: { name: string; type: string } }[]; + const found = objects.find((entry) => entry.package.name === item); + expect(found).toMatchObject({ + package: { + name: item, + version: expect.any(String) as string, + type: expect.stringMatching(/^(plugin|package)$/) as string, + }, + }); + + const other = found!.package.type === 'plugin' ? 'package' : 'plugin'; + const filtered = await json( + `/registry/-/v1/search?text=${encodeURIComponent(short)}&type=${other}&size=50`, + ); + expect( + (filtered.body.objects as { package: { type: string } }[]).every( + (entry) => entry.package.type === other, + ), + ).toBe(true); + + const page = await json('/registry/-/v1/search?text=&size=1&from=0'); + expect((page.body.objects as unknown[]).length).toBeLessThanOrEqual(1); + const none = await json('/registry/-/v1/search?text=zz-no-such-thing-zz'); + expect(none.body).toMatchObject({ objects: [], total: 0 }); + }); + }); +}; diff --git a/packages/registry/test/contract/registry.contract.spec.ts b/packages/registry/test/contract/registry.contract.spec.ts new file mode 100644 index 00000000..709dc4e7 --- /dev/null +++ b/packages/registry/test/contract/registry.contract.spec.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe } from 'vitest'; + +import { DirectoryRegistry, serveRegistry } from '../../src'; +import { type RegistryTarget, registryContract } from './registry-contract'; + +/** + * Runs the registry contract. By default against `serveRegistry` over a folder, the reference + * implementation. Against a real registry (read-only) with: + * + * CONTRACT_REGISTRY_URL=https://api.staging.nanoforge.eu \ + * CONTRACT_REGISTRY_ITEM=@scope/name \ + * pnpm --filter @nanoforge-dev/registry test:contract + */ +const env = process.env; +let folder: string | undefined; +let reference: RegistryTarget | undefined; + +const local = (): RegistryTarget => { + if (reference) return reference; + folder = mkdtempSync(join(tmpdir(), 'nf-registry-contract-')); + for (const version of ['1.0.0', '1.1.0']) { + const dir = join(folder, '@contract/shapes', version); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'nanoforge.manifest.json'), + JSON.stringify({ type: 'package', name: '@contract/shapes', version, description: 'Shapes' }), + ); + writeFileSync(join(dir, 'circle.ts'), 'export class Circle {}\n'); + } + const serve = serveRegistry(new DirectoryRegistry(folder)); + return (reference = { baseUrl: 'https://registry.test', fetch: serve, item: '@contract/shapes' }); +}; + +afterAll(() => folder && rmSync(folder, { recursive: true, force: true })); + +describe( + env.CONTRACT_REGISTRY_URL ? `registry at ${env.CONTRACT_REGISTRY_URL}` : 'registry (reference)', + () => + registryContract( + env.CONTRACT_REGISTRY_URL + ? () => ({ + baseUrl: env.CONTRACT_REGISTRY_URL!, + fetch: (url) => fetch(url), + item: env.CONTRACT_REGISTRY_ITEM ?? '', + }) + : local, + ), +); diff --git a/packages/registry/test/registry.spec.ts b/packages/registry/test/registry.spec.ts new file mode 100644 index 00000000..a0f68671 --- /dev/null +++ b/packages/registry/test/registry.spec.ts @@ -0,0 +1,426 @@ +import { zipSync } from 'fflate'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + DirectoryRegistry, + LOCK_FILE, + PACKAGES_FILE, + ProjectPackages, + type Registry, + RegistryClient, + RegistryError, + installItem, + pickVersion, + serveRegistry, + sha256, + withPathsEntry, + withoutPathsEntry, +} from '../src'; + +let workspace: string; +let registryDir: string; +let registry: DirectoryRegistry; + +/** Publishes a version in the folder registry. */ +const publish = ( + name: string, + version: string, + options: { + type?: 'package' | 'plugin'; + dependencies?: Record; + engines?: Record; + description?: string; + files?: Record; + } = {}, +) => { + const folder = join(registryDir, name, version); + mkdirSync(folder, { recursive: true }); + writeFileSync( + join(folder, 'nanoforge.manifest.json'), + JSON.stringify({ + type: options.type ?? 'package', + name, + version, + description: options.description ?? `${name} for tests`, + ...(options.dependencies && { dependencies: options.dependencies }), + ...(options.engines && { engines: options.engines }), + }), + ); + for (const [path, text] of Object.entries(options.files ?? { 'index.ts': `// ${version}\n` })) { + mkdirSync(join(folder, path, '..'), { recursive: true }); + writeFileSync(join(folder, path), text); + } +}; + +const project = (name: string, tsconfig?: string) => { + const root = join(workspace, name); + mkdirSync(root, { recursive: true }); + if (tsconfig !== undefined) writeFileSync(join(root, 'tsconfig.json'), tsconfig); + return root; +}; +const read = (root: string, file: string) => readFileSync(join(root, file), 'utf8'); +const json = (root: string, file: string) => JSON.parse(read(root, file)) as Record; + +beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'nf-registry-')); + registryDir = join(workspace, 'registry'); + publish('@acme/shapes', '1.0.0'); + publish('@acme/shapes', '1.1.0', { + files: { 'components/circle.ts': 'export class Circle {}\n' }, + }); + publish('@acme/shapes', '2.0.0'); + publish('@acme/render', '1.0.0', { + dependencies: { '@acme/shapes': '^1.0.0' }, + description: 'Draws shapes', + }); + publish('@acme/physics', '1.0.0', { dependencies: { '@acme/shapes': '^2.0.0' } }); + publish('@acme/tools', '1.0.0', { type: 'plugin', engines: { editor: '>=0.1.0' } }); + publish('@acme/tools', '2.0.0', { type: 'plugin', engines: { editor: '>=9.0.0' } }); + writeFileSync( + join(registryDir, '@acme/render/registry.json'), + JSON.stringify({ author: 'Acme', downloads: 42 }), + ); + writeFileSync(join(registryDir, '@acme/render/1.0.0/README.md'), '# Render\n'); + mkdirSync(join(registryDir, '@acme/shapes/notes'), { recursive: true }); + registry = new DirectoryRegistry(registryDir); +}); +afterAll(() => rmSync(workspace, { recursive: true, force: true })); + +describe('registries', () => { + const sources: Record Registry> = { + 'a folder': () => registry, + http: () => + new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: serveRegistry(registry) as typeof fetch, + }), + }; + + it.each(Object.keys(sources))('searches, describes and downloads from %s', async (label) => { + const source = sources[label]!(); + const all = await source.search(); + expect(all.total).toBe(4); + expect(all.items.map((item) => item.name)).toEqual([ + '@acme/render', + '@acme/physics', + '@acme/shapes', + '@acme/tools', + ]); + expect((await source.search({ type: 'plugin' })).items.map((item) => item.name)).toEqual([ + '@acme/tools', + ]); + expect((await source.search({ q: 'draws SHAPES' })).items.map((item) => item.name)).toEqual([ + '@acme/render', + ]); + expect((await source.search({ limit: 1, page: 2 })).items.map((item) => item.name)).toEqual([ + '@acme/physics', + ]); + + const render = await source.get('@acme/render'); + expect(render).toMatchObject({ + type: 'package', + version: '1.0.0', + author: 'Acme', + downloads: 42, + readme: '# Render\n', + }); + expect(render.versions[0]!.dependencies).toEqual({ '@acme/shapes': '^1.0.0' }); + const shapes = await source.get('@acme/shapes'); + expect(shapes.versions.map((entry) => entry.version)).toEqual(['2.0.0', '1.1.0', '1.0.0']); + + const archive = await source.download('@acme/shapes', '1.1.0'); + expect(sha256(archive)).toBe(shapes.versions[1]!.sha256); + await expect(source.get('@acme/none')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(source.download('@acme/shapes', '9.9.9')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('speaks the npm registry layout over http', async () => { + const serve = serveRegistry(registry); + const get = async (path: string) => { + const response = await serve(`https://registry.test${path}`); + return { status: response.status, body: (await response.json()) as Record }; + }; + const packument = await get('/registry/@acme%2Frender'); + expect(packument.body).toMatchObject({ + name: '@acme/render', + type: 'package', + 'dist-tags': { latest: '1.0.0' }, + author: { name: 'Acme' }, + versions: { + '1.0.0': { + dependencies: { '@acme/shapes': '^1.0.0' }, + dist: { file: '/registry/@acme/render/-/render-1.0.0.zip' }, + }, + }, + }); + const search = await get('/registry/-/v1/search?text=draws&size=5'); + expect(search.body).toMatchObject({ + total: 1, + objects: [{ package: { name: '@acme/render', version: '1.0.0' }, downloads: { total: 42 } }], + }); + expect((await get('/registry/@acme/none')).status).toBe(404); + expect( + (await serve('https://registry.test/registry/@acme/render/-/other-1.0.0.zip')).status, + ).toBe(404); + }); + + it('takes a registry without the search route as not available', async () => { + const old = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => new Response('{}', { status: 404 })) as typeof fetch, + }); + await expect(old.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + await expect(old.get('@acme/render')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('says so when the registry cannot be reached or answers nonsense', async () => { + const down = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => { + throw new Error('offline'); + }) as typeof fetch, + }); + await expect(down.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + const odd = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => new Response('{"objects":"no"}', { status: 200 })) as typeof fetch, + }); + await expect(odd.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + }); + + it('follows versions published in the folder', async () => { + publish('@acme/late', '1.0.0'); + expect((await registry.get('@acme/late')).version).toBe('1.0.0'); + publish('@acme/late', '1.1.0'); + expect((await registry.get('@acme/late')).version).toBe('1.1.0'); + rmSync(join(registryDir, '@acme/late'), { recursive: true }); + await expect(registry.get('@acme/late')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('picks the newest version that fits a range and this editor', async () => { + const shapes = await registry.get('@acme/shapes'); + expect(pickVersion(shapes)?.version).toBe('2.0.0'); + expect(pickVersion(shapes, '^1.0.0')?.version).toBe('1.1.0'); + expect(pickVersion(shapes, '^3.0.0')).toBeUndefined(); + const tools = await registry.get('@acme/tools'); + expect(pickVersion(tools, '*', { editor: '0.1.0' })?.version).toBe('1.0.0'); + expect(pickVersion(tools, '*', { editor: '9.1.0' })?.version).toBe('2.0.0'); + }); +}); + +describe('installItem', () => { + it('writes the folder, and replaces it', async () => { + const target = join(workspace, 'plugins/@acme/tools'); + await installItem(registry, { name: '@acme/tools', version: '1.0.0', type: 'plugin', target }); + expect(json(target, 'nanoforge.manifest.json')).toMatchObject({ version: '1.0.0' }); + writeFileSync(join(target, 'leftover.txt'), 'old'); + await installItem(registry, { name: '@acme/tools', version: '2.0.0', type: 'plugin', target }); + expect(json(target, 'nanoforge.manifest.json')).toMatchObject({ version: '2.0.0' }); + expect(existsSync(join(target, 'leftover.txt'))).toBe(false); + }); + + it('writes nothing for a wrong hash, a wrong type, a wrong manifest or an unsafe archive', async () => { + const target = join(workspace, 'refused/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + await expect( + installItem(registry, { ...request, sha256: '0'.repeat(64) }), + ).rejects.toMatchObject({ code: 'INTEGRITY' }); + await expect(installItem(registry, { ...request, type: 'plugin' })).rejects.toMatchObject({ + code: 'INVALID', + }); + await expect(installItem(registry, { ...request, name: '../evil' })).rejects.toMatchObject({ + code: 'INVALID', + }); + const manifest = new TextEncoder().encode( + JSON.stringify({ type: 'package', name: '@acme/shapes', version: '1.0.0' }), + ); + const serving = (files: Record): Registry => ({ + ...registry, + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => zipSync(files), + }); + for (const path of ['../outside.txt', '/etc/passwd', 'a/../../b.txt', 'C:\\x.txt']) { + await expect( + installItem(serving({ 'nanoforge.manifest.json': manifest, [path]: manifest }), request), + ).rejects.toMatchObject({ code: 'INVALID' }); + } + await expect(installItem(serving({ 'other.json': manifest }), request)).rejects.toThrow( + /no nanoforge.manifest.json/, + ); + await expect( + installItem( + { + ...serving({}), + download: async () => new TextEncoder().encode('not a zip'), + }, + request, + ), + ).rejects.toThrow(/not a zip/); + expect(existsSync(join(workspace, 'refused'))).toBe(false); + expect(existsSync(join(workspace, 'outside.txt'))).toBe(false); + }); +}); + +describe('ProjectPackages', () => { + it('installs a package with what it needs, side by side, and keeps the list and the lock', async () => { + const root = project( + 'game', + '{\n // paths of the project\n "compilerOptions": {\n "strict": true\n }\n}\n', + ); + writeFileSync(join(root, '.gitignore'), 'node_modules\n'); + const packages = new ProjectPackages(root, registry); + const installed = await packages.install('@acme/render'); + expect(installed).toEqual([ + { name: '@acme/render', version: '1.0.0', range: '^1.0.0', dependents: [], present: true }, + { name: '@acme/shapes', version: '1.1.0', dependents: ['@acme/render'], present: true }, + ]); + expect(existsSync(join(root, 'nf_modules/@acme/shapes/components/circle.ts'))).toBe(true); + expect(json(root, PACKAGES_FILE)).toEqual({ packages: { '@acme/render': '^1.0.0' } }); + expect(json(root, LOCK_FILE)).toMatchObject({ + lockVersion: 1, + packages: { + '@acme/render': { version: '1.0.0', dependencies: { '@acme/shapes': '^1.0.0' } }, + '@acme/shapes': { version: '1.1.0' }, + }, + }); + const tsconfig = read(root, 'tsconfig.json'); + expect(tsconfig).toContain('// paths of the project'); + expect(tsconfig).toContain('"@acme/render/*": ["./nf_modules/@acme/render/*"]'); + expect(tsconfig).toContain('"@acme/shapes/*": ["./nf_modules/@acme/shapes/*"]'); + expect(read(root, '.gitignore')).toBe('node_modules\n'); + + await expect(packages.uninstall('@acme/shapes')).rejects.toMatchObject({ code: 'IN_USE' }); + expect(await packages.uninstall('@acme/render')).toEqual([]); + expect(existsSync(join(root, 'nf_modules/@acme/render'))).toBe(false); + expect(existsSync(join(root, 'nf_modules/@acme/shapes'))).toBe(false); + expect(read(root, 'tsconfig.json')).not.toContain('@acme'); + expect(read(root, 'tsconfig.json')).toContain('// paths of the project'); + expect(read(root, '.gitignore')).toBe('node_modules\n'); + await expect(packages.uninstall('@acme/render')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('refuses versions that cannot live together, plugins, and unknown names', async () => { + const root = project('conflict'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/render'); + await expect(packages.install('@acme/physics')).rejects.toThrow( + /No version of @acme\/shapes fits .*\^1\.0\.0 \(asked by @acme\/render@1\.0\.0\).*\^2\.0\.0/, + ); + expect((await packages.list()).map((entry) => entry.name)).toEqual([ + '@acme/render', + '@acme/shapes', + ]); + expect(json(root, PACKAGES_FILE)).toEqual({ packages: { '@acme/render': '^1.0.0' } }); + await expect(packages.install('@acme/tools')).rejects.toThrow(/is a plugin, not a package/); + await expect(packages.install('@acme/none')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(packages.install('../../etc')).rejects.toMatchObject({ code: 'INVALID' }); + }); + + it('keeps installed versions until asked to update, in the range or to the latest', async () => { + const root = project('update'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '~1.0.0'); + expect(await packages.outdated()).toEqual([ + { + name: '@acme/shapes', + version: '1.0.0', + range: '~1.0.0', + dependents: [], + present: true, + latest: '2.0.0', + }, + ]); + writeFileSync( + join(root, PACKAGES_FILE), + JSON.stringify({ packages: { '@acme/shapes': '^1.0.0' } }), + ); + expect((await packages.outdated())[0]).toMatchObject({ wanted: '1.1.0', latest: '2.0.0' }); + expect((await packages.list())[0]!.version).toBe('1.0.0'); + expect((await packages.update('@acme/shapes'))[0]).toMatchObject({ version: '1.1.0' }); + expect((await packages.update('@acme/shapes', true))[0]).toMatchObject({ + version: '2.0.0', + range: '^2.0.0', + }); + expect(json(root, 'nf_modules/@acme/shapes/nanoforge.manifest.json')).toMatchObject({ + version: '2.0.0', + }); + }); + + it('offers a dependency only the versions its dependents accept', async () => { + const root = project('dependency'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + await packages.install('@acme/render'); + writeFileSync(join(root, PACKAGES_FILE), JSON.stringify({ packages: { '@acme/render': '*' } })); + const shapes = (await packages.outdated()).find((entry) => entry.name === '@acme/shapes')!; + expect(shapes).toMatchObject({ version: '1.0.0', wanted: '1.1.0', latest: '2.0.0' }); + const updated = await packages.update('@acme/shapes'); + expect(updated.find((entry) => entry.name === '@acme/shapes')).toMatchObject({ + version: '1.1.0', + dependents: ['@acme/render'], + }); + }); + + it('restores nf_modules from the lock, checking the hashes', async () => { + const root = project('restore'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/render'); + rmSync(join(root, 'nf_modules'), { recursive: true }); + expect((await packages.list()).map((entry) => entry.present)).toEqual([false, false]); + expect((await packages.restore()).map((entry) => entry.present)).toEqual([true, true]); + expect(existsSync(join(root, 'nf_modules/@acme/shapes/components/circle.ts'))).toBe(true); + + rmSync(join(root, 'nf_modules'), { recursive: true }); + const lock = json(root, LOCK_FILE) as unknown as { + packages: Record; + }; + lock.packages['@acme/shapes']!.sha256 = 'f'.repeat(64); + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await expect(packages.restore()).rejects.toBeInstanceOf(RegistryError); + }); + + it('runs changes of one project one after the other', async () => { + const root = project('queue'); + const packages = new ProjectPackages(root, registry); + const [first, second] = await Promise.all([ + packages.install('@acme/shapes', '^1.0.0'), + packages.install('@acme/render'), + ]); + expect(first.map((entry) => entry.name)).toEqual(['@acme/shapes']); + expect(second.map((entry) => entry.name)).toEqual(['@acme/render', '@acme/shapes']); + expect(json(root, PACKAGES_FILE)).toEqual({ + packages: { '@acme/render': '^1.0.0', '@acme/shapes': '^1.0.0' }, + }); + }); +}); + +describe('tsconfig paths', () => { + it('adds and removes entries, keeping comments', () => { + const text = + '{\n // comment\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./libs/a/src/*"]\n }\n }\n}\n'; + const added = withPathsEntry(text, '@acme/b/*', './nf_modules/@acme/b/*'); + expect(added).toContain('// comment'); + expect(added).toContain('"@acme/b/*": ["./nf_modules/@acme/b/*"],'); + expect(withPathsEntry(added, '@acme/b/*', './nf_modules/@acme/b/*')).toBe(added); + expect(withoutPathsEntry(added, '@acme/b/*')).toBe(text); + const two = + '{\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./a/*"],\n "@x/b/*": ["./b/*"]\n }\n },\n "exclude": ["dist"]\n}\n'; + expect(withoutPathsEntry(two, '@x/b/*')).toBe( + '{\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./a/*"]\n }\n },\n "exclude": ["dist"]\n}\n', + ); + const removed = withoutPathsEntry(added, '@x/a/*'); + expect(JSON.parse(removed.replace('// comment', ''))).toEqual({ + compilerOptions: { paths: { '@acme/b/*': ['./nf_modules/@acme/b/*'] } }, + }); + expect(withoutPathsEntry(text, '@none/*')).toBe(text); + expect(JSON.parse(withPathsEntry('{}\n', '@a/b/*', './x/*'))).toEqual({ + compilerOptions: { paths: { '@a/b/*': ['./x/*'] } }, + }); + }); +}); diff --git a/packages/registry/tsconfig.json b/packages/registry/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/registry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/rpc/.prettierignore b/packages/rpc/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/rpc/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/rpc/LICENSE b/packages/rpc/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/rpc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rpc/README.md b/packages/rpc/README.md new file mode 100644 index 00000000..389f69fc --- /dev/null +++ b/packages/rpc/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-rpc` is an internal package of the NanoForge Editor. + +Typed client/server RPC and WebSocket channels. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/rpc +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/rpc/package.json b/packages/rpc/package.json new file mode 100644 index 00000000..1db6425f --- /dev/null +++ b/packages/rpc/package.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-rpc", + "version": "0.0.0", + "description": "NanoForge Editor - Typed client/server RPC and WebSocket channels", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/rpc" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/rpc/src/client/index.ts b/packages/rpc/src/client/index.ts new file mode 100644 index 00000000..7e42bab2 --- /dev/null +++ b/packages/rpc/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './rpc-client.type'; +export * from './rpc-client'; diff --git a/packages/rpc/src/client/rpc-client.ts b/packages/rpc/src/client/rpc-client.ts new file mode 100644 index 00000000..58e6c13a --- /dev/null +++ b/packages/rpc/src/client/rpc-client.ts @@ -0,0 +1,227 @@ +import type { z } from 'zod'; + +import { + type Disposable, + Emitter, + type Event, + type Observable, + ObservableValue, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; + +import { decode, encode } from '../codec/codec'; +import type { ClientMessage, Contract, ServerMessage } from '../contract/contract.type'; +import { RPC_HTTP_PREFIX, RPC_WS_PATH } from '../contract/rpc.const'; +import { + RPC_ERROR_STATUS, + RpcError, + type RpcErrorCode, + type RpcErrorPayload, +} from '../error/rpc.exception'; +import type { ConnectionState, RpcApi, RpcClientOptions, SocketLike } from './rpc-client.type'; + +interface ActiveSubscription { + readonly message: Extract; + readonly onEvent: (data: unknown) => void; + readonly onError: (error: RpcError) => void; + readonly onEnd: () => void; +} + +const OPEN = 1; + +const statusCode = (status: number): RpcErrorCode => + (Object.entries(RPC_ERROR_STATUS).find(([, value]) => value === status)?.[0] as RpcErrorCode) ?? + (status >= 500 ? 'INTERNAL' : 'BAD_REQUEST'); + +export class RpcClient implements Disposable { + private readonly _fetch: typeof fetch; + private readonly _subscriptions = new Map(); + private readonly _state = new ObservableValue('idle'); + private readonly _onDidReconnect = new Emitter(); + private _socket: SocketLike | undefined; + private _nextId = 1; + private _attempt = 0; + private _heartbeat: ReturnType | undefined; + private _reconnectTimer: ReturnType | undefined; + private _awaitingPong = false; + private _disposed = false; + + /** Fires after the socket reconnected and subscriptions were restored. */ + readonly onDidReconnect: Event = this._onDidReconnect.event; + + constructor(private readonly _options: RpcClientOptions) { + this._fetch = _options.fetch ?? globalThis.fetch.bind(globalThis); + } + + get state(): Observable { + return this._state.readonly(); + } + + /** Typed proxy over a contract's methods. */ + api(contract: C): RpcApi { + const api: Record = {}; + for (const method of Object.keys(contract.methods)) { + api[method] = (input: unknown, options?: { signal?: AbortSignal }) => + this.call(`${contract.namespace}.${method}`, input, options?.signal); + } + return api as RpcApi; + } + + async call(path: string, input: unknown, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await this._fetch(new URL(`${RPC_HTTP_PREFIX}${path}`, this._options.baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json', ...this._options.headers?.() }, + body: encode(input ?? null), + ...(signal && { signal }), + }); + } catch (error) { + if (signal?.aborted) throw new RpcError('CANCELLED', 'Request cancelled'); + throw new RpcError('UNAVAILABLE', `Editor server unreachable: ${String(error)}`); + } + const text = await response.text(); + let payload: { result?: unknown; error?: RpcErrorPayload | string }; + try { + payload = decode(text); + } catch { + throw new RpcError(statusCode(response.status), `Unexpected response (${response.status})`); + } + if (typeof payload.error === 'object') throw RpcError.from(payload.error); + if (!response.ok || payload.error !== undefined) { + throw new RpcError(statusCode(response.status), String(payload.error ?? response.statusText)); + } + return payload.result; + } + + /** Subscribes to a stream; the subscription survives reconnections. */ + subscribe( + contract: C, + topic: K, + params: z.input, + onEvent: (event: z.output) => void, + handlers: { onError?: (error: RpcError) => void; onEnd?: () => void } = {}, + ): Disposable { + const id = this._nextId++; + const message: ClientMessage = { t: 'sub', id, ns: contract.namespace, topic, params }; + this._subscriptions.set(id, { + message, + onEvent: onEvent as (data: unknown) => void, + onError: handlers.onError ?? (() => undefined), + onEnd: handlers.onEnd ?? (() => undefined), + }); + this._ensureSocket(); + this._sendRaw(message); + return toDisposable(() => { + if (!this._subscriptions.delete(id)) return; + this._sendRaw({ t: 'unsub', id }); + }); + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._reconnectTimer); + this._stopHeartbeat(); + this._subscriptions.clear(); + const socket = this._socket; + this._socket = undefined; + socket?.close(); + this._state.set('closed'); + this._onDidReconnect.dispose(); + } + + private _ensureSocket(): void { + if (this._socket || this._disposed) return; + const url = new URL(RPC_WS_PATH, this._options.baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const create = + this._options.createSocket ?? ((target: string) => new WebSocket(target) as SocketLike); + const socket = create(url.href); + this._socket = socket; + this._state.set(this._attempt === 0 ? 'connecting' : 'reconnecting'); + + socket.onopen = () => { + const reconnected = this._attempt > 0; + this._attempt = 0; + this._state.set('open'); + for (const subscription of this._subscriptions.values()) this._sendRaw(subscription.message); + this._startHeartbeat(); + if (reconnected) this._onDidReconnect.fire(); + }; + socket.onmessage = (event) => this._receive(String(event.data)); + socket.onerror = () => socket.close(); + socket.onclose = () => { + if (this._socket !== socket) return; + this._socket = undefined; + this._stopHeartbeat(); + if (this._disposed) return; + if (!this._subscriptions.size) { + this._state.set('idle'); + return; + } + this._scheduleReconnect(); + }; + } + + private _scheduleReconnect(): void { + const delays = this._options.reconnectDelays ?? [250, 500, 1000, 2000, 5000]; + const delay = delays[Math.min(this._attempt, delays.length - 1)]!; + this._attempt++; + this._state.set('reconnecting'); + this._reconnectTimer = setTimeout(() => this._ensureSocket(), delay); + } + + private _receive(raw: string): void { + let message: ServerMessage; + try { + message = JSON.parse(raw) as ServerMessage; + } catch { + return; + } + if (message.t === 'pong') { + this._awaitingPong = false; + return; + } + const subscription = this._subscriptions.get(message.id); + if (!subscription) return; + switch (message.t) { + case 'event': + subscription.onEvent(decode(JSON.stringify(message.data))); + return; + case 'error': + this._subscriptions.delete(message.id); + subscription.onError(RpcError.from(message.error as RpcErrorPayload)); + return; + case 'end': + this._subscriptions.delete(message.id); + subscription.onEnd(); + } + } + + private _sendRaw(message: ClientMessage): void { + if (this._socket?.readyState === OPEN) this._socket.send(JSON.stringify(message)); + } + + private _startHeartbeat(): void { + this._stopHeartbeat(); + const interval = this._options.heartbeatMs ?? 20_000; + this._heartbeat = setInterval(() => { + if (this._awaitingPong) { + this._socket?.close(); + return; + } + this._awaitingPong = true; + this._sendRaw({ t: 'ping' }); + }, interval); + } + + private _stopHeartbeat(): void { + clearInterval(this._heartbeat); + this._heartbeat = undefined; + this._awaitingPong = false; + } +} + +/** The editor's RPC client, for plugins calling their own server entry. */ +export const RpcClientToken = createToken('rpc.client'); diff --git a/packages/rpc/src/client/rpc-client.type.ts b/packages/rpc/src/client/rpc-client.type.ts new file mode 100644 index 00000000..8f91cd14 --- /dev/null +++ b/packages/rpc/src/client/rpc-client.type.ts @@ -0,0 +1,35 @@ +import type { z } from 'zod'; + +import type { Contract } from '../contract/contract.type'; + +export type RpcApi = { + [K in keyof C['methods']]: ( + input: z.input, + options?: { signal?: AbortSignal }, + ) => Promise>; +}; + +export type ConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed'; + +/** Minimal WebSocket surface (browser WebSocket, `ws`, test doubles). */ +export interface SocketLike { + readonly readyState: number; + onopen: ((event: unknown) => void) | null; + onclose: ((event: unknown) => void) | null; + onerror: ((event: unknown) => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + send(data: string): void; + close(): void; +} + +export interface RpcClientOptions { + /** Origin of the editor server, e.g. `http://localhost:5173`. */ + baseUrl: string; + fetch?: typeof fetch; + /** Extra headers per call (active project, …). */ + headers?: () => Record; + createSocket?: (url: string) => SocketLike; + heartbeatMs?: number; + /** Reconnect delays in ms; the last one repeats. */ + reconnectDelays?: readonly number[]; +} diff --git a/packages/rpc/src/codec/codec.ts b/packages/rpc/src/codec/codec.ts new file mode 100644 index 00000000..6cf79365 --- /dev/null +++ b/packages/rpc/src/codec/codec.ts @@ -0,0 +1,41 @@ +/** + * JSON with a binary-safe envelope: `Uint8Array`/`ArrayBuffer` become `{ "$bin": base64 }` and + * `Date` becomes `{ "$date": iso }`, so file contents can travel through JSON RPC calls. + */ +const toBase64 = (bytes: Uint8Array): string => { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +}; + +const fromBase64 = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +const isPlainTagged = (value: unknown, tag: string): value is Record => + typeof value === 'object' && + value !== null && + Object.keys(value).length === 1 && + typeof (value as Record)[tag] === 'string'; + +export const encode = (value: unknown): string => + JSON.stringify(value, function (this: Record, key, current: unknown) { + const raw = this[key]; + if (raw instanceof Date) return { $date: raw.toISOString() }; + if (current instanceof Uint8Array) return { $bin: toBase64(current) }; + if (current instanceof ArrayBuffer) return { $bin: toBase64(new Uint8Array(current)) }; + return current; + }); + +export const decode = (text: string): T => + JSON.parse(text, (_key, value: unknown) => { + if (isPlainTagged(value, '$bin')) return fromBase64(value.$bin!); + if (isPlainTagged(value, '$date')) return new Date(value.$date!); + return value; + }) as T; diff --git a/packages/rpc/src/codec/index.ts b/packages/rpc/src/codec/index.ts new file mode 100644 index 00000000..d612ef20 --- /dev/null +++ b/packages/rpc/src/codec/index.ts @@ -0,0 +1 @@ +export * from './codec'; diff --git a/packages/rpc/src/contract/contract.type.ts b/packages/rpc/src/contract/contract.type.ts new file mode 100644 index 00000000..01aba149 --- /dev/null +++ b/packages/rpc/src/contract/contract.type.ts @@ -0,0 +1,62 @@ +import type { z } from 'zod'; + +export interface MethodDefinition< + I extends z.ZodType = z.ZodType, + O extends z.ZodType = z.ZodType, +> { + readonly input: I; + readonly output: O; + /** Callable without an authenticated session. */ + readonly public?: boolean; +} + +export interface StreamDefinition< + P extends z.ZodType = z.ZodType, + E extends z.ZodType = z.ZodType, +> { + readonly params: P; + readonly event: E; + /** + * When the client cannot keep up, only the latest pending event is kept (state snapshots). + * Otherwise events are queued (change logs). + */ + readonly coalesce?: boolean; +} + +export type MethodMap = Record; +export type StreamMap = Record; + +export interface Contract< + N extends string = string, + M extends MethodMap = MethodMap, + S extends StreamMap = StreamMap, +> { + readonly namespace: N; + readonly methods: M; + readonly streams: S; +} + +export type MethodInput = z.input< + C['methods'][K]['input'] +>; +export type MethodOutput = z.output< + C['methods'][K]['output'] +>; +export type StreamParams = z.input< + C['streams'][K]['params'] +>; +export type StreamEvent = z.output< + C['streams'][K]['event'] +>; + +/** Wire messages of the WebSocket channel. */ +export type ClientMessage = + | { t: 'sub'; id: number; ns: string; topic: string; params: unknown } + | { t: 'unsub'; id: number } + | { t: 'ping' }; + +export type ServerMessage = + | { t: 'event'; id: number; data: unknown } + | { t: 'error'; id: number; error: { code: string; message: string; data?: unknown } } + | { t: 'end'; id: number } + | { t: 'pong' }; diff --git a/packages/rpc/src/contract/define-contract.ts b/packages/rpc/src/contract/define-contract.ts new file mode 100644 index 00000000..6e3f8436 --- /dev/null +++ b/packages/rpc/src/contract/define-contract.ts @@ -0,0 +1,17 @@ +import type { Contract, MethodMap, StreamMap } from './contract.type'; + +export const NAMESPACE_PATTERN = + /^[a-z][\w-]*(\.[a-z][\w-]*)*(\.@[a-z0-9][\w-]*\/[a-z0-9][\w-]*)?$/; + +/** Declares a typed RPC contract, shared by the client and the server implementation. */ +export const defineContract = ( + namespace: N, + definition: { methods: M; streams?: S }, +): Contract => { + if (!NAMESPACE_PATTERN.test(namespace)) throw new Error(`Invalid RPC namespace "${namespace}"`); + return Object.freeze({ + namespace, + methods: definition.methods, + streams: (definition.streams ?? {}) as S, + }); +}; diff --git a/packages/rpc/src/contract/index.ts b/packages/rpc/src/contract/index.ts new file mode 100644 index 00000000..89ac3165 --- /dev/null +++ b/packages/rpc/src/contract/index.ts @@ -0,0 +1,3 @@ +export * from './contract.type'; +export * from './define-contract'; +export * from './rpc.const'; diff --git a/packages/rpc/src/contract/rpc.const.ts b/packages/rpc/src/contract/rpc.const.ts new file mode 100644 index 00000000..f223e3d6 --- /dev/null +++ b/packages/rpc/src/contract/rpc.const.ts @@ -0,0 +1,4 @@ +/** Header carrying the active project id on HTTP calls. */ +export const PROJECT_HEADER = 'x-nf-project'; +export const RPC_HTTP_PREFIX = '/rpc/'; +export const RPC_WS_PATH = '/rpc/ws'; diff --git a/packages/rpc/src/error/index.ts b/packages/rpc/src/error/index.ts new file mode 100644 index 00000000..673991fb --- /dev/null +++ b/packages/rpc/src/error/index.ts @@ -0,0 +1 @@ +export * from './rpc.exception'; diff --git a/packages/rpc/src/error/rpc.exception.ts b/packages/rpc/src/error/rpc.exception.ts new file mode 100644 index 00000000..fd8d19b9 --- /dev/null +++ b/packages/rpc/src/error/rpc.exception.ts @@ -0,0 +1,51 @@ +export const RPC_ERROR_STATUS = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + CANCELLED: 499, + INTERNAL: 500, + UNAVAILABLE: 503, + TIMEOUT: 504, +} as const; + +export type RpcErrorCode = keyof typeof RPC_ERROR_STATUS; + +export interface RpcErrorPayload { + code: RpcErrorCode; + message: string; + data?: unknown; +} + +/** Error crossing the RPC boundary. Any other error is reported to clients as INTERNAL. */ +export class RpcError extends Error { + static from(payload: RpcErrorPayload): RpcError { + const code = payload.code in RPC_ERROR_STATUS ? payload.code : 'INTERNAL'; + return new RpcError(code, payload.message, payload.data); + } + + static is(error: unknown, code?: RpcErrorCode): error is RpcError { + return error instanceof RpcError && (code === undefined || error.code === code); + } + constructor( + readonly code: RpcErrorCode, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = 'RpcError'; + } + + get status(): number { + return RPC_ERROR_STATUS[this.code]; + } + + toJSON(): RpcErrorPayload { + return this.data === undefined + ? { code: this.code, message: this.message } + : { code: this.code, message: this.message, data: this.data }; + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts new file mode 100644 index 00000000..daf3e63d --- /dev/null +++ b/packages/rpc/src/index.ts @@ -0,0 +1,5 @@ +export * from './client'; +export * from './codec'; +export * from './contract'; +export * from './error'; +export * from './router'; diff --git a/packages/rpc/src/router/index.ts b/packages/rpc/src/router/index.ts new file mode 100644 index 00000000..ca254b22 --- /dev/null +++ b/packages/rpc/src/router/index.ts @@ -0,0 +1,3 @@ +export * from './router.type'; +export * from './rpc-router'; +export * from './rpc-session'; diff --git a/packages/rpc/src/router/router-entries.ts b/packages/rpc/src/router/router-entries.ts new file mode 100644 index 00000000..ed94bafd --- /dev/null +++ b/packages/rpc/src/router/router-entries.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import type { MethodDefinition, StreamDefinition } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import type { HandlerContext, StreamCleanup, StreamSink } from './router.type'; + +export interface MethodEntry { + definition: MethodDefinition; + handler: (input: unknown, context: HandlerContext) => unknown; +} + +export interface StreamEntry { + definition: StreamDefinition; + handler: ( + params: unknown, + context: HandlerContext, + sink: StreamSink, + ) => StreamCleanup | Promise; +} + +export const toRpcError = (error: unknown): RpcError => { + if (error instanceof RpcError) return error; + if (error instanceof z.ZodError) { + return new RpcError('BAD_REQUEST', 'Invalid input', z.flattenError(error)); + } + return new RpcError('INTERNAL', 'Internal error'); +}; diff --git a/packages/rpc/src/router/router.type.ts b/packages/rpc/src/router/router.type.ts new file mode 100644 index 00000000..610e3e46 --- /dev/null +++ b/packages/rpc/src/router/router.type.ts @@ -0,0 +1,59 @@ +import { type z } from 'zod'; + +import type { Disposable } from '@nanoforge-dev/editor-kernel'; + +import type { Contract } from '../contract/contract.type'; + +export interface CallInfo { + readonly namespace: string; + readonly name: string; + readonly kind: 'method' | 'stream'; + readonly public: boolean; +} + +export type HandlerContext = Ctx & { readonly signal: AbortSignal }; + +/** What a stream handler may return to be notified when the subscription ends. */ +export type StreamCleanup = Disposable | (() => void) | undefined; + +export interface StreamSink { + emit(event: E): void; + /** Ends the stream from the server side. */ + end(): void; + error(error: unknown): void; +} + +type MethodHandlers = { + [K in keyof C['methods']]: ( + input: z.output, + context: HandlerContext, + ) => z.input | Promise>; +}; + +type StreamHandlers = { + [K in keyof C['streams']]: ( + params: z.output, + context: HandlerContext, + sink: StreamSink>, + ) => StreamCleanup | Promise; +}; + +export type Implementation = { + methods: MethodHandlers; +} & (keyof C['streams'] extends never + ? { streams?: StreamHandlers } + : { streams: StreamHandlers }); + +export interface RouterOptions { + /** Throws (typically `RpcError('UNAUTHORIZED')`) to refuse a call or subscription. */ + authorize?: (context: Ctx, call: CallInfo) => void | Promise; + /** Reports unexpected (non RpcError) failures. */ + onError?: (error: unknown, call: CallInfo) => void; + /** Maximum queued events per subscription before dropping the oldest. */ + maxQueuedEvents?: number; +} + +export interface HttpResult { + readonly status: number; + readonly body: string; +} diff --git a/packages/rpc/src/router/rpc-router.ts b/packages/rpc/src/router/rpc-router.ts new file mode 100644 index 00000000..2b3cb9e4 --- /dev/null +++ b/packages/rpc/src/router/rpc-router.ts @@ -0,0 +1,92 @@ +import { type Disposable, toDisposable } from '@nanoforge-dev/editor-kernel'; + +import { decode, encode } from '../codec/codec'; +import type { Contract } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import { type MethodEntry, type StreamEntry, toRpcError } from './router-entries'; +import type { CallInfo, HttpResult, Implementation, RouterOptions } from './router.type'; +import { RpcSession } from './rpc-session'; + +/** + * Transport-agnostic RPC server: HTTP calls go through `handle`, WebSocket connections through + * `createSession`. The server validates inputs and outputs against the contract. + */ +export class RpcRouter { + private readonly _methods = new Map>(); + private readonly _streams = new Map>(); + + constructor(private readonly _options: RouterOptions = {}) {} + + implement(contract: C, implementation: Implementation): Disposable { + const keys: string[] = []; + const register = (map: Map, name: string, entry: T) => { + const key = `${contract.namespace}.${name}`; + if (map.has(key)) throw new Error(`RPC "${key}" is already implemented`); + map.set(key, entry); + keys.push(key); + }; + for (const [name, definition] of Object.entries(contract.methods)) { + const handler = (implementation.methods as Record['handler']>)[name]; + if (!handler) throw new Error(`Missing handler for ${contract.namespace}.${name}`); + register(this._methods, name, { definition, handler }); + } + for (const [name, definition] of Object.entries(contract.streams)) { + const handler = ( + implementation.streams as Record['handler']> | undefined + )?.[name]; + if (!handler) throw new Error(`Missing stream handler for ${contract.namespace}.${name}`); + register(this._streams, name, { definition, handler }); + } + return toDisposable(() => { + for (const key of keys) { + this._methods.delete(key); + this._streams.delete(key); + } + }); + } + + /** Runs `.` with an encoded input body. */ + async handle( + path: string, + body: string, + context: Ctx, + signal: AbortSignal = new AbortController().signal, + ): Promise { + const entry = this._methods.get(path); + const dot = path.lastIndexOf('.'); + const call: CallInfo = { + namespace: path.slice(0, dot), + name: path.slice(dot + 1), + kind: 'method', + public: entry?.definition.public ?? false, + }; + try { + if (!entry) throw new RpcError('NOT_FOUND', `Unknown RPC method "${path}"`); + await this._options.authorize?.(context, call); + let raw: unknown; + try { + raw = body ? decode(body) : undefined; + } catch { + throw new RpcError('BAD_REQUEST', 'Malformed request body'); + } + const input = entry.definition.input.parse(raw); + const result = await entry.handler(input, { ...context, signal }); + if (signal.aborted) throw new RpcError('CANCELLED', 'Request cancelled'); + const output = entry.definition.output.parse(result); + return { status: 200, body: encode({ result: output }) }; + } catch (error) { + const rpcError = toRpcError(error); + if (rpcError.code === 'INTERNAL') this._options.onError?.(error, call); + return { status: rpcError.status, body: encode({ error: rpcError.toJSON() }) }; + } + } + + /** + * Creates the server side of a WebSocket connection. `send` returns false when the socket is + * back-pressured; call `drain` when it can accept data again. + */ + createSession(context: Ctx, send: (message: string) => boolean): RpcSession { + const lookup = (key: string) => this._streams.get(key) as StreamEntry | undefined; + return new RpcSession(lookup, context, send, this._options as RouterOptions); + } +} diff --git a/packages/rpc/src/router/rpc-session.ts b/packages/rpc/src/router/rpc-session.ts new file mode 100644 index 00000000..dd207595 --- /dev/null +++ b/packages/rpc/src/router/rpc-session.ts @@ -0,0 +1,156 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; + +import { encode } from '../codec/codec'; +import type { ClientMessage, ServerMessage } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import { type StreamEntry, toRpcError } from './router-entries'; +import type { CallInfo, RouterOptions, StreamCleanup, StreamSink } from './router.type'; + +interface Subscription { + readonly controller: AbortController; + readonly coalesce: boolean; + queue: string[]; + cleanup?: StreamCleanup; + closed: boolean; +} + +export class RpcSession implements Disposable { + private readonly _subscriptions = new Map(); + private _blocked = false; + private _closed = false; + + constructor( + private readonly _lookup: (key: string) => StreamEntry | undefined, + private readonly _context: object, + private readonly _send: (message: string) => boolean, + private readonly _options: RouterOptions, + ) {} + + get subscriptionCount(): number { + return this._subscriptions.size; + } + + async receive(raw: string): Promise { + let message: ClientMessage; + try { + message = JSON.parse(raw) as ClientMessage; + } catch { + return; + } + switch (message.t) { + case 'ping': + this._write({ t: 'pong' }); + return; + case 'unsub': + this._close(message.id); + return; + case 'sub': + await this._subscribe(message); + return; + } + } + + /** Flushes queued events after back-pressure. */ + drain(): void { + this._blocked = false; + for (const subscription of this._subscriptions.values()) { + while (subscription.queue.length && !this._blocked) { + this._blocked = !this._send(subscription.queue.shift()!); + } + if (this._blocked) return; + } + } + + dispose(): void { + this._closed = true; + for (const id of [...this._subscriptions.keys()]) this._close(id); + } + + private async _subscribe(message: Extract): Promise { + const key = `${message.ns}.${message.topic}`; + const entry = this._lookup(key); + const call: CallInfo = { + namespace: message.ns, + name: message.topic, + kind: 'stream', + public: false, + }; + if (this._subscriptions.has(message.id)) return; + const subscription: Subscription = { + controller: new AbortController(), + coalesce: entry?.definition.coalesce ?? false, + queue: [], + closed: false, + }; + this._subscriptions.set(message.id, subscription); + try { + if (!entry) throw new RpcError('NOT_FOUND', `Unknown RPC stream "${key}"`); + await this._options.authorize?.(this._context, call); + const params = entry.definition.params.parse(message.params); + const sink: StreamSink = { + emit: (event) => { + if (subscription.controller.signal.aborted) return; + this._emit(message.id, subscription, entry.definition.event.parse(event)); + }, + end: () => { + this._write({ t: 'end', id: message.id }); + this._close(message.id); + }, + error: (error) => this._fail(message.id, error, call), + }; + const cleanup = await entry.handler( + params, + { ...this._context, signal: subscription.controller.signal }, + sink, + ); + if (subscription.controller.signal.aborted) { + disposeCleanup(cleanup); + } else if (cleanup) { + subscription.cleanup = cleanup; + } + } catch (error) { + this._fail(message.id, error, call); + } + } + + private _emit(id: number, subscription: Subscription, event: unknown): void { + const text = JSON.stringify({ t: 'event', id, data: JSON.parse(encode(event)) }); + if (this._blocked) { + if (subscription.coalesce) subscription.queue = [text]; + else { + subscription.queue.push(text); + const max = this._options.maxQueuedEvents ?? 10_000; + if (subscription.queue.length > max) subscription.queue.shift(); + } + return; + } + this._blocked = !this._send(text); + } + + private _fail(id: number, error: unknown, call: CallInfo): void { + const rpcError = toRpcError(error); + if (rpcError.code === 'INTERNAL') this._options.onError?.(error, call); + this._write({ t: 'error', id, error: rpcError.toJSON() }); + this._close(id); + } + + private _write(message: ServerMessage): void { + if (this._closed && message.t !== 'end') return; + this._blocked = !this._send(JSON.stringify(message)) || this._blocked; + } + + private _close(id: number): void { + const subscription = this._subscriptions.get(id); + if (!subscription || subscription.closed) return; + subscription.closed = true; + subscription.controller.abort(); + this._subscriptions.delete(id); + disposeCleanup(subscription.cleanup); + } +} + +const disposeCleanup = (cleanup: StreamCleanup) => { + if (!cleanup) return; + if (typeof cleanup === 'function') cleanup(); + else cleanup.dispose(); +}; diff --git a/packages/rpc/test/codec/codec.spec.ts b/packages/rpc/test/codec/codec.spec.ts new file mode 100644 index 00000000..a7feb67a --- /dev/null +++ b/packages/rpc/test/codec/codec.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { decode, encode } from '../../src/codec/codec'; + +describe('codec', () => { + it('round-trips binary, dates and plain JSON', () => { + const bytes = new Uint8Array(70_000).map((_, i) => i % 256); + const value = { bytes, when: new Date('2026-09-26T10:00:00Z'), nested: [{ a: 1 }, null] }; + const decoded = decode(encode(value)); + expect(decoded.bytes).toBeInstanceOf(Uint8Array); + expect(decoded.bytes).toEqual(bytes); + expect(decoded.when).toEqual(value.when); + expect(decoded.nested).toEqual(value.nested); + }); + + it('keeps objects that only look like tags if they have other keys', () => { + expect(decode(encode({ $bin: 'x', other: 1 }))).toEqual({ $bin: 'x', other: 1 }); + }); +}); diff --git a/packages/rpc/test/fixtures/harness.ts b/packages/rpc/test/fixtures/harness.ts new file mode 100644 index 00000000..49ba55ac --- /dev/null +++ b/packages/rpc/test/fixtures/harness.ts @@ -0,0 +1,77 @@ +import { RpcClient } from '../../src/client/rpc-client'; +import type { SocketLike } from '../../src/client/rpc-client.type'; +import type { RpcRouter } from '../../src/router/rpc-router'; +import type { RpcSession } from '../../src/router/rpc-session'; + +/** In-memory socket connected to a router session. */ +export class FakeSocket implements SocketLike { + readyState = 0; + onopen: SocketLike['onopen'] = null; + onclose: SocketLike['onclose'] = null; + onerror: SocketLike['onerror'] = null; + onmessage: SocketLike['onmessage'] = null; + session: RpcSession | undefined; + /** When true, the server side reports back-pressure. */ + backpressure = false; + readonly sent: string[] = []; + + constructor( + private readonly router: RpcRouter<{ user: string }>, + private readonly context: { user: string }, + ) { + queueMicrotask(() => this.open()); + } + + open(): void { + this.session = this.router.createSession(this.context, (message) => { + if (this.readyState !== 1) return false; + queueMicrotask(() => this.onmessage?.({ data: message })); + return !this.backpressure; + }); + this.readyState = 1; + this.onopen?.({}); + } + + send(data: string): void { + this.sent.push(data); + void this.session?.receive(data); + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.session?.dispose(); + queueMicrotask(() => this.onclose?.({})); + } +} + +export const connect = ( + router: RpcRouter<{ user: string }>, + context = { user: 'alice' }, + options: { heartbeatMs?: number } = {}, +) => { + const sockets: FakeSocket[] = []; + const client = new RpcClient({ + baseUrl: 'http://editor.test', + reconnectDelays: [1], + ...options, + fetch: async (url, init) => { + const path = new URL(String(url)).pathname.replace('/rpc/', ''); + const result = await router.handle( + path, + String(init?.body ?? ''), + context, + init?.signal ?? undefined, + ); + return new Response(result.body, { status: result.status }); + }, + createSocket: () => { + const socket = new FakeSocket(router, context); + sockets.push(socket); + return socket; + }, + }); + return { client, sockets }; +}; + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/rpc/test/rpc.spec.ts b/packages/rpc/test/rpc.spec.ts new file mode 100644 index 00000000..c7c761c9 --- /dev/null +++ b/packages/rpc/test/rpc.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { toDisposable } from '@nanoforge-dev/editor-kernel'; + +import { RpcError, RpcRouter, defineContract } from '../src'; +import { connect, flush } from './fixtures/harness'; + +const Files = defineContract('fs', { + methods: { + read: { + input: z.object({ path: z.string().min(1) }), + output: z.object({ content: z.instanceof(Uint8Array), size: z.number() }), + }, + fail: { input: z.null(), output: z.null() }, + version: { input: z.null(), output: z.string(), public: true }, + }, + streams: { + changes: { + params: z.object({ glob: z.string() }), + event: z.object({ path: z.string() }), + }, + status: { params: z.null(), event: z.number(), coalesce: true }, + }, +}); + +const createRouter = () => { + const emitters = new Map void>(); + let emitStatus: (n: number) => void = () => undefined; + const cleanup = vi.fn(); + const internal = vi.fn(); + const router = new RpcRouter<{ user: string }>({ + authorize: (context, call) => { + if (!call.public && context.user !== 'alice') throw new RpcError('UNAUTHORIZED', 'login'); + }, + onError: internal, + }); + router.implement(Files, { + methods: { + read: ({ path }) => ({ content: new TextEncoder().encode(path), size: path.length }), + fail: () => { + throw new Error('database exploded'); + }, + version: () => '1.0.0', + }, + streams: { + changes: ({ glob }, _context, sink) => { + emitters.set(glob, (path) => sink.emit({ path })); + return toDisposable(() => { + emitters.delete(glob); + cleanup(glob); + }); + }, + status: (_params, _context, sink) => { + emitStatus = (n) => sink.emit(n); + }, + }, + }); + return { router, emitters, cleanup, internal, emitStatus: (n: number) => emitStatus(n) }; +}; + +describe('RPC calls', () => { + it('calls typed methods with binary payloads', async () => { + const { router } = createRouter(); + const { client } = connect(router); + const result = await client.api(Files).read({ path: 'main.ts' }); + expect(new TextDecoder().decode(result.content)).toBe('main.ts'); + expect(result.size).toBe(7); + }); + + it('validates inputs', async () => { + const { router } = createRouter(); + const { client } = connect(router); + await expect(client.api(Files).read({ path: '' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('hides internal errors and reports them', async () => { + const { router, internal } = createRouter(); + const { client } = connect(router); + const error = await client + .api(Files) + .fail(null) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(RpcError); + expect(error).toMatchObject({ code: 'INTERNAL', message: 'Internal error' }); + expect(internal).toHaveBeenCalledOnce(); + }); + + it('authorizes non public methods', async () => { + const { router } = createRouter(); + const { client } = connect(router, { user: 'mallory' }); + await expect(client.api(Files).version(null)).resolves.toBe('1.0.0'); + await expect(client.api(Files).read({ path: 'x' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + await expect(client.call('fs.missing', null)).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); + +describe('RPC streams', () => { + it('delivers events until unsubscribed', async () => { + const { router, emitters, cleanup } = createRouter(); + const { client } = connect(router); + const events: string[] = []; + const subscription = client.subscribe(Files, 'changes', { glob: '**/*.ts' }, (e) => + events.push(e.path), + ); + await flush(); + emitters.get('**/*.ts')!('a.ts'); + await flush(); + subscription.dispose(); + await flush(); + expect(events).toEqual(['a.ts']); + expect(cleanup).toHaveBeenCalledWith('**/*.ts'); + }); + + it('resubscribes after a reconnection', async () => { + const { router, emitters } = createRouter(); + const { client, sockets } = connect(router); + const events: string[] = []; + const reconnected = vi.fn(); + client.onDidReconnect(reconnected); + client.subscribe(Files, 'changes', { glob: '*' }, (e) => events.push(e.path)); + await flush(); + sockets[0]!.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(sockets).toHaveLength(2); + expect(reconnected).toHaveBeenCalledOnce(); + emitters.get('*')!('after.ts'); + await flush(); + expect(events).toEqual(['after.ts']); + expect(client.state.get()).toBe('open'); + }); + + it('reports stream errors', async () => { + const { router } = createRouter(); + const { client } = connect(router, { user: 'mallory' }); + const onError = vi.fn(); + client.subscribe(Files, 'changes', { glob: '*' }, () => undefined, { onError }); + await flush(); + await flush(); + expect(onError.mock.calls[0]![0]).toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('coalesces snapshot streams under back-pressure', async () => { + const { router, emitStatus } = createRouter(); + const { client, sockets } = connect(router); + const values: number[] = []; + client.subscribe(Files, 'status', null, (n) => values.push(n)); + await flush(); + const socket = sockets[0]!; + socket.backpressure = true; + emitStatus(1); // accepted, then the socket reports back-pressure + emitStatus(2); + emitStatus(3); + socket.backpressure = false; + socket.session!.drain(); + await flush(); + expect(values).toEqual([1, 3]); + }); + + it('closes dead connections through the heartbeat', async () => { + vi.useFakeTimers(); + const { router } = createRouter(); + const { client, sockets } = connect(router, undefined, { heartbeatMs: 1000 }); + client.subscribe(Files, 'changes', { glob: '*' }, () => undefined); + await vi.advanceTimersByTimeAsync(0); + sockets[0]!.session!.receive = async () => undefined; + await vi.advanceTimersByTimeAsync(2100); + expect(sockets[0]!.readyState).toBe(3); + vi.useRealTimers(); + }); +}); diff --git a/packages/rpc/tsconfig.json b/packages/rpc/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/rpc/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/runtime/.prettierignore b/packages/runtime/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/runtime/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/runtime/LICENSE b/packages/runtime/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/runtime/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 00000000..fd22d02b --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-runtime` is an internal package of the NanoForge Editor. + +Game runtime: build orchestration, client runner, server process manager. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/runtime +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 00000000..8cc92d52 --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-runtime", + "version": "0.0.0", + "description": "NanoForge Editor - Game runtime: build orchestration, client runner, server process manager", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/runtime" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@jridgewell/trace-mapping": "catalog:libs", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/editor-settings": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "@types/node": "catalog:core", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/runtime/src/bridge/bridge-emitter.type.ts b/packages/runtime/src/bridge/bridge-emitter.type.ts new file mode 100644 index 00000000..b3e97e87 --- /dev/null +++ b/packages/runtime/src/bridge/bridge-emitter.type.ts @@ -0,0 +1,9 @@ +/** Engine `EventEmitter` shape (`@nanoforge-dev/common`), the two sides of the editor bridge. */ +export interface BridgeEmitter { + on(event: string, listener: (...args: unknown[]) => void): void; + off(event: string, listener: (...args: unknown[]) => void): void; + emit(event: string, ...args: unknown[]): void; + runEvents(): void; +} + +export type Listener = (...args: unknown[]) => void; diff --git a/packages/runtime/src/bridge/direct-emitter.ts b/packages/runtime/src/bridge/direct-emitter.ts new file mode 100644 index 00000000..2e61feb5 --- /dev/null +++ b/packages/runtime/src/bridge/direct-emitter.ts @@ -0,0 +1,19 @@ +import type { BridgeEmitter } from './bridge-emitter.type'; + +/** + * Engine → editor: delivered to the editor in a microtask (nothing drains it), so editor code + * never runs inside an engine tick (the engine tells game console output from the editor's). + */ +export class DirectEmitter implements BridgeEmitter { + constructor(private readonly _deliver: (event: string, args: unknown[]) => void) {} + + on(): void {} + + off(): void {} + + emit(event: string, ...args: unknown[]): void { + queueMicrotask(() => this._deliver(event, args)); + } + + runEvents(): void {} +} diff --git a/packages/runtime/src/bridge/index.ts b/packages/runtime/src/bridge/index.ts new file mode 100644 index 00000000..453da406 --- /dev/null +++ b/packages/runtime/src/bridge/index.ts @@ -0,0 +1,3 @@ +export { type BridgeEmitter } from './bridge-emitter.type'; +export * from './queued-emitter'; +export * from './direct-emitter'; diff --git a/packages/runtime/src/bridge/queued-emitter.ts b/packages/runtime/src/bridge/queued-emitter.ts new file mode 100644 index 00000000..82b2e7fa --- /dev/null +++ b/packages/runtime/src/bridge/queued-emitter.ts @@ -0,0 +1,36 @@ +import type { BridgeEmitter, Listener } from './bridge-emitter.type'; + +/** Editor → engine: queued, drained by the engine at the start of each tick. */ +export class QueuedEmitter implements BridgeEmitter { + private readonly _listeners = new Map(); + private _queue: [string, unknown[]][] = []; + + on(event: string, listener: Listener): void { + this._listeners.set(event, [...(this._listeners.get(event) ?? []), listener]); + } + + off(event: string, listener: Listener): void { + this._listeners.set( + event, + (this._listeners.get(event) ?? []).filter((registered) => registered !== listener), + ); + } + + emit(event: string, ...args: unknown[]): void { + this._queue.push([event, args]); + } + + runEvents(): void { + const pending = this._queue; + this._queue = []; + for (const [event, args] of pending) { + for (const listener of this._listeners.get(event) ?? []) { + try { + listener(...args); + } catch (error) { + console.error(error); + } + } + } + } +} diff --git a/packages/runtime/src/client/game-client-runner.ts b/packages/runtime/src/client/game-client-runner.ts new file mode 100644 index 00000000..bed1340c --- /dev/null +++ b/packages/runtime/src/client/game-client-runner.ts @@ -0,0 +1,126 @@ +import { serializeLogValue } from '@nanoforge-dev/editor-kernel'; +import type { OutputManifest } from '@nanoforge-dev/editor-protocol'; + +import { DirectEmitter } from '../bridge/direct-emitter'; +import { QueuedEmitter } from '../bridge/queued-emitter'; +import type { ClientRunTarget, ClientRunnerOptions } from './game-client-runner.type'; + +let runs = 0; + +/** Files of a build as the loader gives them to the game: `/path` → versioned URL. */ +export const clientFiles = (manifest: OutputManifest, origin: string): Map => + new Map( + manifest.files.map((file) => [ + `/${file.path}`, + `${origin}${manifest.baseUrl}${file.path}?v=${file.hash}`, + ]), + ); + +/** + * Runs a built client in the editor page. Every run imports a fresh module instance (module + * state never leaks between runs) into its own container, which is removed on stop. + */ +export class GameClientRunner { + private readonly _fromEditor = new QueuedEmitter(); + private _mount: HTMLDivElement | undefined; + private _bridged = false; + private _stopped = false; + private _stopWaiters: (() => void)[] = []; + private _removeErrorListeners: (() => void) | undefined; + + constructor(private readonly _options: ClientRunnerOptions) {} + + /** Whether the game answered with the editor bridge hello. */ + get bridged(): boolean { + return this._bridged; + } + + /** Imports and starts the game; resolves when its `main` resolved. */ + async start(target: ClientRunTarget): Promise { + const { manifest, origin } = target; + const baseUrl = `${origin}${manifest.baseUrl}`; + const mount = target.host.ownerDocument.createElement('div'); + mount.dataset.nfGame = manifest.app; + mount.style.cssText = 'position:absolute;inset:0;overflow:hidden'; + target.host.append(mount); + this._mount = mount; + this._watchErrors(baseUrl); + + const entry = manifest.files.find((file) => file.path === manifest.entry); + const url = `${baseUrl}${manifest.entry}?v=${entry?.hash ?? manifest.version}&run=${++runs}`; + const module = await this._options.importModule(url); + if (typeof module.main !== 'function') throw new Error(`${manifest.entry} exports no main`); + const toEditor = new DirectEmitter((event, args) => this._onEngineEvent(event, args)); + await module.main({ + container: mount, + files: clientFiles(manifest, origin), + env: target.env, + editor: { + toEditor, + fromEditor: this._fromEditor, + serializeLogValue: (value) => serializeLogValue(value), + }, + }); + } + + send(event: string, args: readonly unknown[] = []): void { + this._fromEditor.emit(event, ...args); + } + + /** + * Asks the game to stop and waits for its `stopped` state (libraries cleared), then removes + * its container. Returns false when the game did not stop in time (it may still be running). + */ + async stop(timeoutMs = 3000): Promise { + let clean = this._stopped; + if (!clean && this._bridged) { + this.send('stop'); + clean = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + this._stopWaiters.push(() => { + clearTimeout(timer); + resolve(true); + }); + }); + } + this._mount?.remove(); + this._mount = undefined; + this._removeErrorListeners?.(); + this._removeErrorListeners = undefined; + return clean; + } + + private _onEngineEvent(event: string, args: unknown[]): void { + if (event === 'hello') this._bridged = true; + if (event === 'state' && args[0] === 'stopped') { + this._stopped = true; + for (const waiter of this._stopWaiters.splice(0)) waiter(); + } + this._options.onEvent(event, args); + } + + /** Reports uncaught errors whose stack points into the game bundle. */ + private _watchErrors(baseUrl: string): void { + const target = + this._options.errorTarget ?? + ('addEventListener' in globalThis ? (globalThis as unknown as Window) : undefined); + if (!target) return; + const fromGame = (error: unknown, filename?: string) => + filename?.startsWith(baseUrl) || + (error instanceof Error && (error.stack ?? '').includes(baseUrl)); + const onError = (event: Event) => { + const { error, filename } = event as ErrorEvent; + if (fromGame(error, filename)) this._options.onError(error ?? (event as ErrorEvent).message); + }; + const onRejection = (event: Event) => { + const { reason } = event as PromiseRejectionEvent; + if (fromGame(reason)) this._options.onError(reason); + }; + target.addEventListener('error', onError); + target.addEventListener('unhandledrejection', onRejection); + this._removeErrorListeners = () => { + target.removeEventListener('error', onError); + target.removeEventListener('unhandledrejection', onRejection); + }; + } +} diff --git a/packages/runtime/src/client/game-client-runner.type.ts b/packages/runtime/src/client/game-client-runner.type.ts new file mode 100644 index 00000000..1526574c --- /dev/null +++ b/packages/runtime/src/client/game-client-runner.type.ts @@ -0,0 +1,39 @@ +import type { LogValue } from '@nanoforge-dev/editor-kernel'; +import type { OutputManifest } from '@nanoforge-dev/editor-protocol'; + +import type { BridgeEmitter } from '../bridge/bridge-emitter.type'; + +/** What a built client exports (`ClientRunOptions` of the engine). */ +export interface GameClientModule { + main(options: { + container: HTMLDivElement; + files: Map; + env: Record; + editor: { + toEditor: BridgeEmitter; + fromEditor: BridgeEmitter; + /** Snapshots logged values in the editor's format (the engine has no serializer of its own). */ + serializeLogValue: (value: unknown) => LogValue; + }; + }): Promise | void; +} + +export interface ClientRunnerOptions { + /** Loads the game bundle (`import()` in browsers). */ + readonly importModule: (url: string) => Promise; + /** Engine → editor events (`hello`, `state`, custom events). */ + readonly onEvent: (event: string, args: unknown[]) => void; + /** Uncaught errors thrown by the game code. */ + readonly onError: (error: unknown) => void; + /** Where uncaught errors are observed (defaults to `globalThis`). */ + readonly errorTarget?: Pick; +} + +export interface ClientRunTarget { + /** Element the game container is added to. */ + readonly host: HTMLElement; + readonly manifest: OutputManifest; + readonly env: Record; + /** Origin the build output is served from (`location.origin`). */ + readonly origin: string; +} diff --git a/packages/runtime/src/client/index.ts b/packages/runtime/src/client/index.ts new file mode 100644 index 00000000..1dfb3aaf --- /dev/null +++ b/packages/runtime/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './game-client-runner.type'; +export * from './game-client-runner'; diff --git a/packages/runtime/src/compatibility/check-compatibility.ts b/packages/runtime/src/compatibility/check-compatibility.ts new file mode 100644 index 00000000..e765cfdd --- /dev/null +++ b/packages/runtime/src/compatibility/check-compatibility.ts @@ -0,0 +1,43 @@ +import { RUNTIME_PROTOCOL_VERSION } from '@nanoforge-dev/editor-protocol'; + +import type { GameCompatibility } from './game-compatibility.type'; + +const ADD_LIBRARY = + 'Register the editor library in its main.ts: app.use(new EditorLibrary()), from @nanoforge-dev/editor-lib (NanoForge engine 2.0 and later).'; +const UPDATE_ENGINE = + 'Update the NanoForge engine packages of the project (@nanoforge-dev/core and the other @nanoforge-dev libraries).'; + +/** Compares the `hello` of a game (undefined when it never said hello) with the editor. */ +export const checkCompatibility = ( + source: 'client' | 'server', + app: string, + hello: { protocolVersion?: number } | undefined, + editorVersion = RUNTIME_PROTOCOL_VERSION, +): GameCompatibility => { + const side = source === 'client' ? 'game client' : 'game server'; + if (!hello) { + return { + source, + app, + status: 'legacy', + message: `The ${side} has no editor bridge: Pause, Step and the live views are unavailable${source === 'client' ? ", and Stop can't end it (it keeps running until Run › Reload editor runtime)" : ''}. ${ADD_LIBRARY}`, + }; + } + const version = hello.protocolVersion ?? 0; + if (version === editorVersion) return { source, app, status: 'ok', protocolVersion: version }; + return version < editorVersion + ? { + source, + app, + status: 'older', + protocolVersion: version, + message: `The ${side} speaks editor protocol ${version}, older than this editor (${editorVersion}): some editor features may not work. ${UPDATE_ENGINE}`, + } + : { + source, + app, + status: 'newer', + protocolVersion: version, + message: `The ${side} speaks editor protocol ${version}, newer than this editor (${editorVersion}): update the NanoForge editor.`, + }; +}; diff --git a/packages/runtime/src/compatibility/game-compatibility.type.ts b/packages/runtime/src/compatibility/game-compatibility.type.ts new file mode 100644 index 00000000..58477505 --- /dev/null +++ b/packages/runtime/src/compatibility/game-compatibility.type.ts @@ -0,0 +1,15 @@ +/** How well a running game and the editor understand each other. */ +export interface GameCompatibility { + readonly source: 'client' | 'server'; + readonly app: string; + /** + * - `ok`: same protocol. + * - `legacy`: the game never said `hello`: it does not register the editor library + * (`EditorLibrary` of `@nanoforge-dev/editor-lib`), or its engine predates the bridge. + * - `older` / `newer`: the engine speaks an older / newer protocol than the editor. + */ + readonly status: 'ok' | 'legacy' | 'older' | 'newer'; + readonly protocolVersion?: number; + /** What it means and what to do, for the user (absent when `ok`). */ + readonly message?: string; +} diff --git a/packages/runtime/src/compatibility/index.ts b/packages/runtime/src/compatibility/index.ts new file mode 100644 index 00000000..42067925 --- /dev/null +++ b/packages/runtime/src/compatibility/index.ts @@ -0,0 +1,3 @@ +export * from './game-compatibility.type'; +export * from './check-compatibility'; +export * from './merge-features'; diff --git a/packages/runtime/src/compatibility/merge-features.ts b/packages/runtime/src/compatibility/merge-features.ts new file mode 100644 index 00000000..107dc7c8 --- /dev/null +++ b/packages/runtime/src/compatibility/merge-features.ts @@ -0,0 +1,51 @@ +import type { EngineFeatures } from '@nanoforge-dev/editor-protocol'; + +/** + * Merges the engine features requested by the editor and plugins: the shortest frame stats + * interval, logs if anyone wants them, the highest network trace rates and payload size, the + * shortest live world and scenes intervals. + */ +export const mergeFeatures = (requests: readonly EngineFeatures[]): EngineFeatures => { + const merged: EngineFeatures = {}; + for (const request of requests) { + if (request.frameStats) { + merged.frameStats = { + intervalMs: Math.min( + merged.frameStats?.intervalMs ?? Infinity, + request.frameStats.intervalMs, + ), + }; + } + if (request.logs) merged.logs = true; + if (request.viewport) merged.viewport = true; + if (request.ecsWorld) { + merged.ecsWorld = { + intervalMs: Math.min(merged.ecsWorld?.intervalMs ?? Infinity, request.ecsWorld.intervalMs), + }; + } + if (request.scenes) { + merged.scenes = { + intervalMs: Math.min(merged.scenes?.intervalMs ?? Infinity, request.scenes.intervalMs), + }; + } + if (request.ecsSystemStats) { + merged.ecsSystemStats = { + intervalMs: Math.min( + merged.ecsSystemStats?.intervalMs ?? Infinity, + request.ecsSystemStats.intervalMs, + ), + }; + } + if (request.networkTrace) { + const current = merged.networkTrace ?? {}; + merged.networkTrace = { + sampleRate: Math.max(current.sampleRate ?? 0, request.networkTrace.sampleRate ?? 1), + maxPerSecond: Math.max(current.maxPerSecond ?? 0, request.networkTrace.maxPerSecond ?? 100), + ...((current.maxBytes ?? request.networkTrace.maxBytes) !== undefined && { + maxBytes: Math.max(current.maxBytes ?? 0, request.networkTrace.maxBytes ?? 0), + }), + }; + } + } + return merged; +}; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 00000000..88390595 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,6 @@ +export * from './bridge'; +export * from './client'; +export * from './compatibility'; +export * from './service'; +export * from './settings'; +export * from './source-map'; diff --git a/packages/runtime/src/service/index.ts b/packages/runtime/src/service/index.ts new file mode 100644 index 00000000..37f5a536 --- /dev/null +++ b/packages/runtime/src/service/index.ts @@ -0,0 +1,2 @@ +export * from './runtime-service.type'; +export * from './runtime-service'; diff --git a/packages/runtime/src/service/play.exception.ts b/packages/runtime/src/service/play.exception.ts new file mode 100644 index 00000000..1ccb4210 --- /dev/null +++ b/packages/runtime/src/service/play.exception.ts @@ -0,0 +1,10 @@ +import type { BuildDiagnostic } from '@nanoforge-dev/editor-protocol'; + +export class PlayError extends Error { + constructor( + message: string, + readonly diagnostics?: readonly BuildDiagnostic[], + ) { + super(message); + } +} diff --git a/packages/runtime/src/service/runtime-service.ts b/packages/runtime/src/service/runtime-service.ts new file mode 100644 index 00000000..142fa481 --- /dev/null +++ b/packages/runtime/src/service/runtime-service.ts @@ -0,0 +1,592 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type LogValue, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type AppModel, + type BuildStatus, + type EngineFeatures, + type EngineFrameStats, + RUNTIME_PROTOCOL_VERSION, + RuntimeContract, + type RuntimeEvent, +} from '@nanoforge-dev/editor-protocol'; + +import { GameClientRunner } from '../client/game-client-runner'; +import type { GameClientModule } from '../client/game-client-runner.type'; +import { checkCompatibility } from '../compatibility/check-compatibility'; +import type { GameCompatibility } from '../compatibility/game-compatibility.type'; +import { mergeFeatures } from '../compatibility/merge-features'; +import { SourceMaps } from '../source-map/source-maps'; +import type { SourceLocation } from '../source-map/source-maps.type'; +import { PlayError } from './play.exception'; +import type { + GameEvent, + PlayMode, + PlaySession, + PlayState, + RuntimeLog, + RuntimeServiceOptions, +} from './runtime-service.type'; + +const IDLE: PlaySession = { + state: 'idle', + mode: undefined, + apps: [], + controllable: false, + compatibility: [], +}; + +/** What the editor itself asks every game for (plugins add theirs with `useFeatures`). */ +const EDITOR_FEATURES: EngineFeatures = { frameStats: { intervalMs: 1000 } }; +const ACTIVE: readonly PlayState[] = ['building', 'starting', 'running', 'paused', 'stopping']; + +export const isPlaying = (state: PlayState): boolean => ACTIVE.includes(state); + +/** + * Builds and plays the games of a project: the client runs in the Game screen of the editor, + * the server in a process of the editor server. Plugins observe the session, listen to game + * events and send commands through it. + */ +export class RuntimeService implements Disposable { + /** Bridge protocol version the editor speaks. */ + readonly protocolVersion = RUNTIME_PROTOCOL_VERSION; + + private readonly _session = new ObservableValue(IDLE); + private readonly _builds = new ObservableValue>(new Map()); + private readonly _onLog = new Emitter(); + private readonly _onEvent = new Emitter(); + /** The last `ecs-world` of each game: the engine sends it again only when it changes. */ + private readonly _worlds = new Map(); + private readonly _onServerStateChange = new Emitter<[string, number | null | undefined]>(); + private readonly _host = new ObservableValue(undefined); + private readonly _store = new DisposableStore(); + private readonly _engineStates = new Map(); + private readonly _hellos = new Map(); + private readonly _featureRequests = new Set([EDITOR_FEATURES]); + private readonly _frameStats = new ObservableValue>( + new Map(), + ); + private _stage: HTMLElement | undefined; + private _client: { app: string; runner: GameClientRunner } | undefined; + private _server: string | undefined; + private _serverBridged = false; + private _run = 0; + private readonly _sourceMaps: SourceMaps; + + readonly onLog: Event = this._onLog.event; + readonly onEvent: Event = this._onEvent.event; + + constructor(private readonly _options: RuntimeServiceOptions) { + this._sourceMaps = new SourceMaps({ + projectId: _options.projectId, + apps: _options.apps, + origin: _options.origin ?? globalThis.location?.origin ?? '', + ...(_options.fetchText && { fetchText: _options.fetchText }), + }); + this._store.add( + _options.rpc.subscribe(RuntimeContract, 'events', { project: _options.projectId }, (event) => + this._onServerEvent(event), + ), + ); + void this._api + .status({ project: _options.projectId }) + .then(({ builds }) => this._builds.set(new Map(builds.map((build) => [build.app, build])))) + .catch(() => undefined); + } + + get session(): Observable { + return this._session.readonly(); + } + + /** Last build of each app. */ + get builds(): Observable> { + return this._builds.readonly(); + } + + /** + * The project file behind a position in a built game bundle (a stack frame), through the + * bundle's source map. `file` is the bundle's URL (client) or path (server). Undefined when + * the build has no source map or the position is not in a project file. + */ + sourceLocation(file: string, line: number, column?: number): Promise { + return this._sourceMaps.locate(file, line, column); + } + + /** Latest frame stats of each running game, keyed `client` / `server`. */ + get frameStats(): Observable> { + return this._frameStats.readonly(); + } + + /** + * Asks the running games (and the next ones) for engine features: frame stats, logs, + * network traces. Requests of the editor and plugins are merged; dispose to withdraw. + */ + /** + * The live world a game last sent in this run (`ecs-world`). A listener that starts while + * the game is paused gets no event (a world that does not change is not sent again): it + * reads this one first. + */ + lastWorlds(): readonly GameEvent[] { + return [...this._worlds.values()]; + } + + useFeatures(features: EngineFeatures): Disposable { + const request = { ...features }; + this._featureRequests.add(request); + this._sendWelcomes(); + return { + dispose: () => { + if (this._featureRequests.delete(request)) this._sendWelcomes(); + }, + }; + } + + /** Element hosting the client game (set by the Game screen). */ + get host(): Observable { + return this._host.readonly(); + } + + /** + * The Game screen gives the element the client shows in. Games render in a stage element + * owned by the service and moved between views, so a game keeps running (and keeps its + * canvas) while the Game screen is hidden. + */ + attach(host: HTMLElement): Disposable { + if (!this._stage) { + this._stage = host.ownerDocument.createElement('div'); + this._stage.dataset.nfGameStage = ''; + this._stage.style.cssText = 'position:relative;width:100%;height:100%;overflow:hidden'; + } + host.append(this._stage); + this._host.set(host); + return { + dispose: () => { + if (this._host.get() !== host) return; + this._stage?.remove(); + this._host.set(undefined); + }, + }; + } + + /** Mode `play` uses by default for this project. */ + defaultMode(): PlayMode { + const setting = this._options.playMode?.() ?? 'auto'; + if (setting !== 'auto') return setting; + return this._app('server') ? 'server+client' : 'client'; + } + + /** Builds then starts the games of the mode. Resolves once they run (or crashed). */ + async play(mode: PlayMode = this.defaultMode()): Promise { + if (isPlaying(this._session.get().state)) return; + const run = ++this._run; + const client = mode === 'server' ? undefined : this._app('client'); + const server = mode === 'client' ? undefined : this._app('server'); + const apps = [server, client].flatMap((app) => (app ? [app.id] : [])); + this._engineStates.clear(); + this._hellos.clear(); + this._worlds.clear(); + this._frameStats.set(new Map()); + this._set({ state: 'building', mode, apps, controllable: false, compatibility: [] }); + try { + if (mode !== 'server' && !client) throw new PlayError('This project has no client app'); + if (mode !== 'client' && !server) throw new PlayError('This project has no server app'); + const statuses = await this._api.build({ project: this._options.projectId, apps }); + const failed = statuses.filter((status) => status.state === 'error'); + if (failed.length) { + throw new PlayError( + 'The game has build errors', + failed.flatMap((status) => status.diagnostics), + ); + } + if (run !== this._run) return; + this._set({ state: 'starting' }); + if (server) await this._startServer(server, run); + if (client && run === this._run) await this._startClient(client, run); + if (run !== this._run) return; + await this._hellosOf([ + ...(server ? ['server' as const] : []), + ...(client ? ['client' as const] : []), + ]); + if (run !== this._run) return; + const compatibility = this._compatibility(); + for (const check of compatibility) { + if (check.message) this._log(check.source, check.app, 'warn', check.message); + } + this._set({ state: 'running', controllable: this._controllable(), compatibility }); + } catch (error) { + if (run !== this._run) return; + await this._teardown(); + this._crash(error); + } + } + + pause(): void { + this.send('pause'); + } + + resume(): void { + this.send('resume'); + } + + /** Runs one tick of every paused game. */ + step(): void { + this.send('step'); + } + + /** Stops every game; resolves once they stopped. */ + async stop(): Promise { + const { state } = this._session.get(); + if (state === 'idle' || state === 'stopping') return; + this._run++; + if (state === 'crashed') { + this._set(IDLE); + return; + } + this._set({ state: 'stopping' }); + await this._teardown(); + this._frameStats.set(new Map()); + this._set(IDLE); + } + + async restart(): Promise { + const mode = this._session.get().mode; + await this.stop(); + await this.play(mode); + } + + /** Sends an editor → engine event to the running games (all by default). */ + send(event: string, args: readonly unknown[] = [], target: 'client' | 'server' | 'all' = 'all') { + if (target !== 'server') this._client?.runner.send(event, args); + if (target !== 'client' && this._server) { + void this._api + .sendServer({ project: this._options.projectId, app: this._server, event, args: [...args] }) + .catch((error: unknown) => this._options.logger.warn(`Could not send ${event}`, error)); + } + } + + dispose(): void { + this._run++; + void this._teardown(); + this._store.dispose(); + this._onLog.dispose(); + this._onEvent.dispose(); + this._onServerStateChange.dispose(); + } + + private get _api() { + return this._options.rpc.api(RuntimeContract); + } + + /** The apps of a type that can be played. */ + playable(type: 'client' | 'server'): AppModel[] { + return this._options.apps().filter((app) => app.type === type && app.outDir !== undefined); + } + + /** The app Play starts for a type: the chosen one, else the first. */ + appFor(type: 'client' | 'server'): AppModel | undefined { + const apps = this.playable(type); + const selected = this._options.selectedApp?.(type); + return apps.find((app) => app.id === selected) ?? apps[0]; + } + + private _app(type: 'client' | 'server'): AppModel | undefined { + return this.appFor(type); + } + + private _set(patch: Partial): void { + const next = { ...this._session.get(), ...patch }; + if (next.state !== 'crashed') { + delete (next as { error?: string }).error; + delete (next as { diagnostics?: unknown }).diagnostics; + } + this._session.set(next); + } + + private _crash(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this._set({ + state: 'crashed', + controllable: false, + error: message, + ...(error instanceof PlayError && error.diagnostics && { diagnostics: error.diagnostics }), + }); + this._options.logger.error(`Game stopped: ${message}`); + } + + /** How each playing game and the editor understand each other, from the hellos received. */ + private _compatibility(): GameCompatibility[] { + return [ + ...(this._server + ? [checkCompatibility('server', this._server, this._hellos.get('server'))] + : []), + ...(this._client + ? [checkCompatibility('client', this._client.app, this._hellos.get('client'))] + : []), + ]; + } + + /** + * Waits for the hello of each game, for a short while: one that never says it has no editor + * bridge, and is played without pause. + */ + private _hellosOf(sources: readonly ('client' | 'server')[]): Promise { + const missing = () => sources.some((source) => !this._hellos.has(source)); + if (!missing()) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + listener.dispose(); + resolve(); + }; + const timer = setTimeout(done, this._options.helloTimeoutMs ?? 1500); + const listener = this._onEvent.event(({ event }) => { + if (event === 'hello' && !missing()) done(); + }); + }); + } + + private _controllable(): boolean { + return (!this._client || this._client.runner.bridged) && (!this._server || this._serverBridged); + } + + private async _startServer(app: AppModel, run: number): Promise { + this._server = app.id; + this._serverBridged = false; + const started = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new PlayError('The game server did not start in time')), + this._options.serverStartTimeoutMs ?? 15_000, + ); + const listener = this._onServerState((state, exitCode) => { + if (state === 'running' || state === 'exited' || run !== this._run) { + clearTimeout(timer); + listener.dispose(); + if (state === 'exited') { + reject(new PlayError(`The game server exited (code ${exitCode ?? 'unknown'})`)); + } else resolve(); + } + }); + }); + started.catch(() => undefined); + await Promise.all([ + this._api.startServer({ + project: this._options.projectId, + app: app.id, + overrides: this._options.envOverrides?.() ?? {}, + }), + started, + ]); + } + + private async _startClient(app: AppModel, run: number): Promise { + if (!this._host.get()) await this._waitForHost(); + const [manifest, env] = await Promise.all([ + this._api.manifest({ project: this._options.projectId, app: app.id }), + this._api.env({ + project: this._options.projectId, + app: app.id, + overrides: this._options.envOverrides?.() ?? {}, + }), + ]); + if (run !== this._run) return; + const runner = new GameClientRunner({ + importModule: + this._options.importModule ?? + ((url) => import(/* @vite-ignore */ url) as Promise), + onEvent: (event, args) => this._onGameEvent('client', app.id, event, args), + onError: (error) => this._onClientError(app.id, error), + }); + this._client = { app: app.id, runner }; + await runner.start({ + host: this._stage!, + manifest, + env, + origin: this._options.origin ?? globalThis.location.origin, + }); + } + + private _waitForHost(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + subscription(); + reject(new PlayError('Open the Game screen to play the client')); + }, 10_000); + const subscription = this._host.subscribe((host) => { + if (!host) return; + clearTimeout(timer); + queueMicrotask(() => subscription()); + resolve(); + }); + }); + } + + private async _teardown(): Promise { + const client = this._client; + const server = this._server; + this._client = undefined; + this._server = undefined; + await Promise.all([ + client?.runner.stop().then((clean) => { + if (clean) return; + this._options.logger.warn( + client.runner.bridged + ? 'The game client did not stop in time; use Run > Reload editor runtime if it keeps running.' + : "This game's engine cannot be stopped by the editor (no editor bridge): it keeps running hidden until Run > Reload editor runtime. Update the NanoForge engine to fix it.", + ); + }), + server && + this._api + .stopServer({ project: this._options.projectId, app: server }) + .catch((error: unknown) => + this._options.logger.warn('Could not stop the game server', error), + ), + ]); + } + + private _onServerState(listener: (state: string, exitCode?: number | null) => void): Disposable { + return this._onServerStateChange.event(([state, code]) => listener(state, code)); + } + + private _onServerEvent(event: RuntimeEvent): void { + switch (event.type) { + case 'build': { + const builds = new Map(this._builds.get()); + builds.set(event.status.app, event.status); + this._builds.set(builds); + if (event.status.state !== 'building') this._sourceMaps.invalidate(event.status.app); + return; + } + case 'log': + if (event.source === 'server') { + const level = + event.stream !== 'stderr' + ? 'info' + : /\b(error|exception|uncaught|fatal|panic)\b|^\s+at\s/i.test(event.text) + ? 'error' + : 'warn'; + this._log('server', event.app, level, event.text); + } else { + const level = /^\s*error\b/i.test(event.text) + ? 'error' + : /^\s*warn(ing)?\b/i.test(event.text) + ? 'warn' + : 'info'; + this._log(event.source, event.app, level, event.text); + } + return; + case 'bridge': + if (this._session.get().apps.includes(event.app)) { + if (event.event === 'hello') this._serverBridged = true; + this._onGameEvent('server', event.app, event.event, event.args); + } + return; + case 'server': + if (event.app !== this._server) return; + this._onServerStateChange.fire([event.state, event.exitCode]); + if (event.state === 'exited' && ['running', 'paused'].includes(this._session.get().state)) { + const run = ++this._run; + void this._teardown().then(() => { + if (run === this._run) + this._crash(new PlayError(`The game server exited (code ${event.exitCode ?? '?'})`)); + }); + } + return; + } + } + + private _onGameEvent( + source: 'client' | 'server', + app: string, + event: string, + args: unknown[], + ): void { + if (event === 'hello') { + this._hellos.set(source, (args[0] as { protocolVersion?: number } | undefined) ?? {}); + this._sendWelcome(source); + if (this._session.get().state === 'running') { + this._set({ controllable: this._controllable(), compatibility: this._compatibility() }); + } + } + if (event === 'frame-stats' && args[0]) { + const stats = new Map(this._frameStats.get()); + stats.set(source, args[0] as EngineFrameStats); + this._frameStats.set(stats); + } + if (event === 'log' && source === 'client') { + const { level, message, values, caller } = (args[0] ?? {}) as { + level?: string; + message?: string; + values?: unknown; + caller?: string; + }; + const frame = typeof caller === 'string' ? /^(.+?):(\d+):(\d+)$/.exec(caller) : null; + this._onLog.fire({ + source: 'client', + app, + level: level === 'error' || level === 'warn' || level === 'debug' ? level : 'info', + text: String(message ?? ''), + ...(Array.isArray(values) && { values: values as LogValue[] }), + ...(frame && { + location: { file: frame[1]!, line: Number(frame[2]), column: Number(frame[3]) }, + }), + }); + } + if (event === 'state' && typeof args[0] === 'string') { + this._engineStates.set(`${source}:${app}`, args[0]); + this._syncPause(); + } + if (event === 'ecs-world') this._worlds.set(source, { source, app, event, args }); + this._onEvent.fire({ source, app, event, args }); + } + + private _sendWelcomes(): void { + for (const source of this._hellos.keys()) this._sendWelcome(source as 'client' | 'server'); + } + + /** + * Answers the hello of a game with the protocol version and the merged features. Console + * output is forwarded by clients only (with the logged values, when the engine can send + * them): the output of game servers is already streamed. + */ + private _sendWelcome(source: 'client' | 'server'): void { + const features = mergeFeatures([...this._featureRequests]); + const welcome = { + protocolVersion: this.protocolVersion, + features: { ...features, logs: source === 'client' ? { values: true } : false }, + }; + this.send('welcome', [welcome], source); + } + + /** The session is paused when every game reports it is paused. */ + private _syncPause(): void { + const { state } = this._session.get(); + if (state !== 'running' && state !== 'paused') return; + const states = [...this._engineStates.values()]; + const paused = states.length > 0 && states.every((value) => value === 'paused'); + this._set({ state: paused ? 'paused' : 'running' }); + } + + private _onClientError(app: string, error: unknown): void { + const text = error instanceof Error ? (error.stack ?? error.message) : String(error); + this._log('client', app, 'error', text); + } + + private _log( + source: RuntimeLog['source'], + app: string, + level: RuntimeLog['level'], + text: string, + ) { + if (!text.trim()) return; + this._onLog.fire({ source, app, level, text }); + } +} + +export const RuntimeServiceToken = createToken('runtime.service'); diff --git a/packages/runtime/src/service/runtime-service.type.ts b/packages/runtime/src/service/runtime-service.type.ts new file mode 100644 index 00000000..76b1aaf0 --- /dev/null +++ b/packages/runtime/src/service/runtime-service.type.ts @@ -0,0 +1,71 @@ +import type { LogLocation, LogValue, Logger } from '@nanoforge-dev/editor-kernel'; +import type { AppModel, BuildDiagnostic } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { GameClientModule } from '../client/game-client-runner.type'; +import type { GameCompatibility } from '../compatibility/game-compatibility.type'; + +/** What runs on play: the client in the Game screen, the game server, or both. */ +export type PlayMode = 'client' | 'server' | 'server+client'; + +export type PlayState = + 'idle' | 'building' | 'starting' | 'running' | 'paused' | 'stopping' | 'crashed'; + +export interface PlaySession { + readonly state: PlayState; + readonly mode: PlayMode | undefined; + /** Apps being played. */ + readonly apps: readonly string[]; + /** Every running game answered the bridge hello: pause, resume and step are available. */ + readonly controllable: boolean; + /** Protocol check of each running game (set once they run). */ + readonly compatibility: readonly GameCompatibility[]; + /** Why the session crashed. */ + readonly error?: string; + readonly diagnostics?: readonly BuildDiagnostic[]; +} + +export interface RuntimeLog { + /** `cli`: a command the editor server ran in the project (`app` is then its command line). */ + readonly source: 'build' | 'server' | 'client' | 'cli'; + readonly app: string; + readonly level: 'debug' | 'info' | 'warn' | 'error'; + readonly text: string; + /** The logged values (game clients whose engine sends them): `text` is their one-line form. */ + readonly values?: readonly LogValue[]; + /** Where the game logged it: a position in its built bundle (see `sourceLocation`). */ + readonly location?: LogLocation; +} + +/** An engine → editor event of a running game. */ +export interface GameEvent { + readonly source: 'client' | 'server'; + readonly app: string; + readonly event: string; + readonly args: readonly unknown[]; +} + +export interface RuntimeServiceOptions { + readonly projectId: string; + readonly rpc: RpcClient; + /** Apps of the project (current project model). */ + readonly apps: () => readonly AppModel[]; + readonly logger: Logger; + /** Default play mode (`runtime.playMode` setting); `auto` plays the server when there is one. */ + readonly playMode?: () => PlayMode | 'auto'; + /** + * The app to play of a type, by id, in a project with several clients or servers + * (`runtime.clientApp`, `runtime.serverApp`). Nothing, or an app that is gone: the first one. + */ + readonly selectedApp?: (type: 'client' | 'server') => string | undefined; + /** `NANOFORGE_*` overrides (`runtime.env` setting). */ + readonly envOverrides?: () => Record; + readonly importModule?: (url: string) => Promise; + readonly origin?: string; + /** Reads a file of the build output (source maps); `fetch` by default. */ + readonly fetchText?: (url: string) => Promise; + /** How long a started game may take to say hello before it is played without the bridge. */ + readonly helloTimeoutMs?: number; + /** How long a started game server may take to report it runs. */ + readonly serverStartTimeoutMs?: number; +} diff --git a/packages/runtime/src/settings/index.ts b/packages/runtime/src/settings/index.ts new file mode 100644 index 00000000..38ad1d70 --- /dev/null +++ b/packages/runtime/src/settings/index.ts @@ -0,0 +1 @@ +export * from './runtime-settings'; diff --git a/packages/runtime/src/settings/runtime-settings.ts b/packages/runtime/src/settings/runtime-settings.ts new file mode 100644 index 00000000..cec7e8ce --- /dev/null +++ b/packages/runtime/src/settings/runtime-settings.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { defineSetting } from '@nanoforge-dev/editor-settings'; + +/** Settings of the game runtime (play controls). */ +export const RuntimeSettings = { + playMode: defineSetting<'auto' | 'client' | 'server' | 'server+client'>({ + key: 'runtime.playMode', + schema: z.enum(['auto', 'client', 'server', 'server+client']), + default: 'auto', + title: 'Play mode', + description: + 'What Play starts: the client, the game server, or both (auto: both when the project has a server).', + category: 'Runtime', + scopes: ['account', 'machine', 'project', 'projectLocal'], + }), + clientApp: defineSetting({ + key: 'runtime.clientApp', + schema: z.string(), + default: '', + title: 'Client app', + description: + 'The client Play starts, in a project with several (its folder, like apps/client). Empty: the first one.', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + }), + serverApp: defineSetting({ + key: 'runtime.serverApp', + schema: z.string(), + default: '', + title: 'Server app', + description: + 'The server Play starts, in a project with several (its folder, like apps/server). Empty: the first one.', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + }), + saveBeforePlay: defineSetting({ + key: 'runtime.saveBeforePlay', + schema: z.boolean(), + default: true, + title: 'Save all before Play', + description: 'Play saves every open file first, so the game always matches what you see.', + category: 'Runtime', + }), + env: defineSetting({ + key: 'runtime.env', + schema: z.record(z.string().regex(/^NANOFORGE_\w+$/), z.string()), + default: {}, + title: 'Environment overrides', + description: + 'NANOFORGE_* variables applied on top of the project .env when playing (NANOFORGE_CLIENT_*, NANOFORGE_SERVER_*).', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + mergeStrategy: 'deep', + }), +}; diff --git a/packages/runtime/src/source-map/index.ts b/packages/runtime/src/source-map/index.ts new file mode 100644 index 00000000..aac5e428 --- /dev/null +++ b/packages/runtime/src/source-map/index.ts @@ -0,0 +1,2 @@ +export * from './source-maps.type'; +export * from './source-maps'; diff --git a/packages/runtime/src/source-map/source-maps.ts b/packages/runtime/src/source-map/source-maps.ts new file mode 100644 index 00000000..c9e98379 --- /dev/null +++ b/packages/runtime/src/source-map/source-maps.ts @@ -0,0 +1,95 @@ +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; + +import { type AppModel, decodeRuntimeApp, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; + +import type { SourceLocation, SourceMapsOptions } from './source-maps.type'; + +const defaultFetch = async (url: string): Promise => { + const response = await fetch(url); + return response.ok ? response.text() : undefined; +}; + +/** Joins POSIX paths and resolves `.` and `..`; undefined when it leaves the root. */ +const resolvePath = (...parts: string[]): string | undefined => { + const segments: string[] = []; + for (const segment of parts.join('/').split('/')) { + if (!segment || segment === '.') continue; + if (segment !== '..') segments.push(segment); + else if (!segments.pop()) return undefined; + } + return segments.join('/'); +}; + +/** + * Maps positions in built game bundles back to project files, through the source maps written + * next to the bundles (`nf build --editor`). A bundle is named by its URL (clients, imported + * from the editor server) or by its path on disk (servers). + */ +export class SourceMaps { + private readonly _maps = new Map>(); + + constructor(private readonly _options: SourceMapsOptions) {} + + /** Forgets the maps of an app (it was rebuilt). */ + invalidate(app: string): void { + for (const key of this._maps.keys()) if (key.startsWith(`${app}\n`)) this._maps.delete(key); + } + + async locate(file: string, line: number, column = 1): Promise { + const bundle = this._bundle(file); + if (!bundle) return undefined; + const map = await this._map(bundle.app, bundle.file); + if (!map) return undefined; + const position = originalPositionFor(map, { line, column: Math.max(column - 1, 0) }); + if (!position.source || position.line === null) return undefined; + const folder = bundle.file.split('/').slice(0, -1); + const path = resolvePath(bundle.app.outDir ?? '', ...folder, position.source); + if (path === undefined || /(^|\/)node_modules\//.test(path)) return undefined; + return { path, line: position.line, column: (position.column ?? 0) + 1 }; + } + + /** The app and output file a bundle URL or path belongs to. */ + private _bundle(file: string): { app: AppModel; file: string } | undefined { + const apps = this._options.apps().filter((app) => app.outDir !== undefined); + const prefix = `/runtime/${this._options.projectId}/`; + let pathname = file; + if (/^https?:\/\//.test(file)) { + try { + pathname = new URL(file).pathname; + } catch { + return undefined; + } + if (!pathname.startsWith(prefix)) return undefined; + const [segment, ...rest] = pathname.slice(prefix.length).split('/'); + const id = decodeRuntimeApp(segment ?? ''); + const app = apps.find((candidate) => candidate.id === id); + return app && rest.length ? { app, file: rest.map(decodeURIComponent).join('/') } : undefined; + } + pathname = pathname.replace(/^file:\/\//, '').replace(/\\/g, '/'); + const owner = apps + .map((app) => ({ app, at: pathname.lastIndexOf(`/${app.outDir}/`) })) + .filter(({ at }) => at >= 0) + .sort((a, b) => b.app.outDir!.length - a.app.outDir!.length)[0]; + if (!owner) return undefined; + return { + app: owner.app, + file: pathname.slice(owner.at + owner.app.outDir!.length + 2), + }; + } + + private _map(app: AppModel, file: string): Promise { + const key = `${app.id}\n${file}`; + let map = this._maps.get(key); + if (!map) { + const url = `${this._options.origin}${runtimeOutputPath(this._options.projectId, app.id)}${file + .split('/') + .map(encodeURIComponent) + .join('/')}.map`; + map = (this._options.fetchText ?? defaultFetch)(url) + .then((text) => (text ? new TraceMap(text) : undefined)) + .catch(() => undefined); + this._maps.set(key, map); + } + return map; + } +} diff --git a/packages/runtime/src/source-map/source-maps.type.ts b/packages/runtime/src/source-map/source-maps.type.ts new file mode 100644 index 00000000..5c082a12 --- /dev/null +++ b/packages/runtime/src/source-map/source-maps.type.ts @@ -0,0 +1,17 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** A position in a project file (1-based line and column). */ +export interface SourceLocation { + readonly path: string; + readonly line: number; + readonly column: number; +} + +export interface SourceMapsOptions { + readonly projectId: string; + readonly apps: () => readonly AppModel[]; + /** Origin the build output is served from. */ + readonly origin: string; + /** Reads a served file; undefined when it doesn't exist. */ + readonly fetchText?: (url: string) => Promise; +} diff --git a/packages/runtime/test/compatibility/check-compatibility.spec.ts b/packages/runtime/test/compatibility/check-compatibility.spec.ts new file mode 100644 index 00000000..3d1a7705 --- /dev/null +++ b/packages/runtime/test/compatibility/check-compatibility.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { checkCompatibility } from '../../src/compatibility/check-compatibility'; + +describe('checkCompatibility', () => { + it('tells legacy, older and newer engines apart', () => { + expect(checkCompatibility('client', 'c', { protocolVersion: 2 }, 2).status).toBe('ok'); + expect(checkCompatibility('client', 'c', undefined, 2)).toMatchObject({ + status: 'legacy', + message: expect.stringContaining('app.use(new EditorLibrary())'), + }); + expect(checkCompatibility('server', 's', { protocolVersion: 1 }, 2)).toMatchObject({ + status: 'older', + message: expect.stringContaining('Update the NanoForge engine'), + }); + expect(checkCompatibility('server', 's', { protocolVersion: 3 }, 2)).toMatchObject({ + status: 'newer', + message: expect.stringContaining('update the NanoForge editor'), + }); + }); +}); diff --git a/packages/runtime/test/compatibility/merge-features.spec.ts b/packages/runtime/test/compatibility/merge-features.spec.ts new file mode 100644 index 00000000..332e4b34 --- /dev/null +++ b/packages/runtime/test/compatibility/merge-features.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeFeatures } from '../../src/compatibility/merge-features'; + +describe('mergeFeatures', () => { + it('keeps the most demanding request of each feature', () => { + expect( + mergeFeatures([ + { frameStats: { intervalMs: 1000 } }, + { frameStats: { intervalMs: 250 }, networkTrace: { sampleRate: 0.1 } }, + { logs: true, networkTrace: { maxPerSecond: 500 } }, + ]), + ).toEqual({ + frameStats: { intervalMs: 250 }, + logs: true, + networkTrace: { sampleRate: 1, maxPerSecond: 500 }, + }); + expect( + mergeFeatures([{ ecsWorld: { intervalMs: 200 } }, { ecsWorld: { intervalMs: 100 } }]), + ).toEqual({ + ecsWorld: { intervalMs: 100 }, + }); + expect( + mergeFeatures([ + { ecsSystemStats: { intervalMs: 500 }, networkTrace: { maxBytes: 64 } }, + { ecsSystemStats: { intervalMs: 250 }, networkTrace: { maxBytes: 512 } }, + { networkTrace: {} }, + ]), + ).toEqual({ + ecsSystemStats: { intervalMs: 250 }, + networkTrace: { sampleRate: 1, maxPerSecond: 100, maxBytes: 512 }, + }); + expect(mergeFeatures([{}, { viewport: true }])).toEqual({ viewport: true }); + expect( + mergeFeatures([{ scenes: { intervalMs: 250 } }, { scenes: { intervalMs: 100 } }]), + ).toEqual({ scenes: { intervalMs: 100 } }); + expect(mergeFeatures([])).toEqual({}); + }); +}); diff --git a/packages/runtime/test/service/runtime-service.spec.ts b/packages/runtime/test/service/runtime-service.spec.ts new file mode 100644 index 00000000..32469d7c --- /dev/null +++ b/packages/runtime/test/service/runtime-service.spec.ts @@ -0,0 +1,300 @@ +import { chmodSync, cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { LoggerService } from '@nanoforge-dev/editor-kernel'; +import { type ProjectModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { TEST_ORIGIN, createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import type { GameClientModule } from '../../src/client/game-client-runner.type'; +import { RuntimeService } from '../../src/service/runtime-service'; +import type { GameEvent, PlaySession, RuntimeLog } from '../../src/service/runtime-service.type'; + +const SERVER_CORE_TEST = join(import.meta.dirname, '../../../server-core/test'); +const GAME = join(SERVER_CORE_TEST, 'fixtures/runtime/bridge-game.js'); + +/** A view as the Game screen gives it (with a minimal DOM). */ +const fakeHost = () => { + interface FakeElement { + dataset: Record; + style: object; + children: FakeElement[]; + removed: boolean; + ownerDocument: typeof document; + append(child: FakeElement): void; + remove(): void; + } + const created: FakeElement[] = []; + const document = { + createElement: (): FakeElement => { + const element: FakeElement = { + dataset: {}, + style: {}, + children: [], + removed: false, + ownerDocument: document, + append: (child) => { + child.removed = false; + element.children.push(child); + }, + remove: () => (element.removed = true), + }; + created.push(element); + return element; + }, + }; + const host = document.createElement(); + /** Game containers created by the runs (the stage holds them). */ + const mounts = () => created.filter((element) => 'nfGame' in element.dataset); + return { host, mounts }; +}; + +let workspace: string; +let editor: EditorServer; +let client: RpcClient; +let model: ProjectModel; +const imported: { url: string; options: Parameters[0] }[] = []; + +/** Loads the served bundle like a browser would (fresh module instance per run). */ +const importModule = async (url: string): Promise => { + const response = (await editor.fetch(new Request(url)))!; + if (response.status !== 200) throw new Error(`GET ${url}: ${response.status}`); + const source = `${await response.text()}\n// ${url}`; + const module = (await import( + `data:text/javascript,${encodeURIComponent(source)}` + )) as GameClientModule; + return { + main: (options) => { + imported.push({ url, options }); + return module.main(options); + }, + }; +}; + +const createService = (overrides: Partial[0]> = {}) => + new RuntimeService({ + projectId: model.id, + rpc: client, + apps: () => model.apps, + logger: new LoggerService().getLogger('runtime'), + importModule, + origin: TEST_ORIGIN, + ...overrides, + }); + +const until = async (service: RuntimeService, state: PlaySession['state']) => { + for (const start = Date.now(); service.session.get().state !== state;) { + if (Date.now() - start > 10_000) { + throw new Error(`Still ${service.session.get().state}, expected ${state}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(SERVER_CORE_TEST, '.tmp-play-')); + const root = join(workspace, 'pong'); + cpSync(join(SERVER_CORE_TEST, 'fixtures/pong-network'), root, { recursive: true }); + cpSync(GAME, join(root, 'apps/client/game.js')); + cpSync(GAME, join(root, 'apps/server/game.js')); + writeFileSync(join(root, '.env'), 'NANOFORGE_CLIENT_PORT=1\nNANOFORGE_SERVER_PORT=2\n'); + const cli = join(workspace, 'nf.cjs'); + cpSync(join(SERVER_CORE_TEST, 'fixtures/runtime/nf.cjs'), cli); + chmodSync(cli, 0o755); + editor = createEditorServer({ + env: loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'), + NF_CLI_PATH: cli, + }, + workspace, + ), + version: '0.0.0-test', + }); + client = createLoopbackClient(editor); + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + model = await client.api(ProjectsContract).model({ id }); +}); + +afterAll(() => { + client.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('RuntimeService', () => { + it('plays server and client, pauses, steps and stops them, three times', async () => { + const service = createService(); + const { host, mounts } = fakeHost(); + service.attach(host as unknown as HTMLElement); + const events: GameEvent[] = []; + service.onEvent((event) => events.push(event)); + const logs: RuntimeLog[] = []; + service.onLog((log) => logs.push(log)); + const states: string[] = []; + service.session.subscribe((session) => { + if (states.at(-1) !== session.state) states.push(session.state); + }); + + expect(service.defaultMode()).toBe('server+client'); + for (let run = 1; run <= 3; run++) { + imported.length = 0; + await service.play(); + expect(service.session.get().error).toBeUndefined(); + expect(service.session.get()).toMatchObject({ + state: 'running', + mode: 'server+client', + apps: ['apps/server', 'apps/client'], + controllable: true, + }); + const { url, options } = imported[0]!; + expect(url).toMatch(new RegExp(`/runtime/${model.id}/apps%2Fclient/main\\.js\\?v=\\w+&run=`)); + expect(options.env).toEqual({ PORT: '1' }); + expect(options.files.get('/data.txt')).toMatch( + /^http:\/\/editor\.test\/runtime\/.+data\.txt\?v=\w+$/, + ); + expect(mounts()).toHaveLength(run); + expect(mounts()[run - 1]!.dataset.nfGame).toBe('apps/client'); + + service.pause(); + await until(service, 'paused'); + service.step(); + await expect + .poll(() => events.filter((event) => event.event === 'stepped').map((e) => e.source)) + .toEqual(expect.arrayContaining(['client', 'server'])); + events.length = 0; + service.resume(); + await until(service, 'running'); + + expect(service.session.get().compatibility.map((check) => check.status)).toEqual([ + 'ok', + 'ok', + ]); + await expect + .poll(() => [...service.frameStats.get().keys()].sort()) + .toEqual(['client', 'server']); + await expect + .poll(() => logs.filter((log) => log.source === 'client' && log.text === 'from the game')) + .toHaveLength(run); + + await service.stop(); + expect(service.session.get().state).toBe('idle'); + expect(mounts().at(-1)!.removed).toBe(true); + expect( + events.filter((event) => event.event === 'state' && event.args[0] === 'stopped'), + ).toHaveLength(2); + expect( + await editor.runtime + .get(editor.projects.get({ mode: 'OFFLINE' } as never, model.id)) + .servers(), + ).toEqual([]); + } + expect(states.slice(0, 7)).toEqual([ + 'idle', + 'building', + 'starting', + 'running', + 'paused', + 'running', + 'stopping', + ]); + service.dispose(); + }); + + it('plays the chosen client and server of a project with several', () => { + const second = (type: 'client' | 'server') => { + const first = model.apps.find((app) => app.type === type)!; + return { ...first, id: `apps/${type}-2`, root: `apps/${type}-2`, name: `${type}-2` }; + }; + const apps = [...model.apps, second('client'), second('server')]; + let chosen: Record = {}; + const service = createService({ apps: () => apps, selectedApp: (type) => chosen[type] }); + expect(service.playable('client').map((app) => app.id)).toEqual([ + 'apps/client', + 'apps/client-2', + ]); + expect(service.appFor('client')?.id).toBe('apps/client'); + expect(service.appFor('server')?.id).toBe('apps/server'); + chosen = { client: 'apps/client-2', server: 'apps/server-2' }; + expect(service.appFor('client')?.id).toBe('apps/client-2'); + expect(service.appFor('server')?.id).toBe('apps/server-2'); + chosen = { client: 'apps/gone' }; + expect(service.appFor('client')?.id).toBe('apps/client'); + service.dispose(); + }); + + it('moves the game stage between Game views', () => { + const service = createService(); + const first = fakeHost().host; + const view = service.attach(first as unknown as HTMLElement); + const stage = first.children[0]!; + view.dispose(); + expect(stage.removed).toBe(true); + expect(service.host.get()).toBeUndefined(); + + const second = fakeHost().host; + service.attach(second as unknown as HTMLElement); + expect(second.children).toEqual([stage]); + expect(stage.removed).toBe(false); + service.dispose(); + }); + + it('plays games whose engine does not speak the bridge, without pause', async () => { + const legacy = join(SERVER_CORE_TEST, 'fixtures/runtime/legacy-game.js'); + for (const app of ['client', 'server']) { + cpSync(legacy, join(workspace, `pong/apps/${app}/game.js`)); + } + const service = createService({ serverStartTimeoutMs: 3000, helloTimeoutMs: 100 }); + const { mounts, host } = fakeHost(); + service.attach(host as unknown as HTMLElement); + for (let run = 1; run <= 2; run++) { + await service.play('server+client'); + expect(service.session.get().error).toBeUndefined(); + expect(service.session.get()).toMatchObject({ state: 'running', controllable: false }); + expect(service.session.get().compatibility).toEqual([ + expect.objectContaining({ source: 'server', status: 'legacy' }), + expect.objectContaining({ + source: 'client', + status: 'legacy', + message: expect.stringContaining('Reload editor runtime'), + }), + ]); + await service.stop(); + expect(service.session.get().state).toBe('idle'); + expect(mounts().at(-1)!.removed).toBe(true); + } + for (const app of ['client', 'server']) + cpSync(GAME, join(workspace, `pong/apps/${app}/game.js`)); + service.dispose(); + }); + + it('crashes with the build diagnostics when the game does not build', async () => { + const file = join(workspace, 'pong/apps/client/game.js'); + writeFileSync(file, '// SYNTAX ERROR\n'); + const service = createService(); + service.attach(fakeHost().host as unknown as HTMLElement); + await service.play('client'); + expect(service.session.get()).toMatchObject({ + state: 'crashed', + error: 'The game has build errors', + diagnostics: [{ path: 'apps/client/game.js', message: 'Unexpected ;' }], + }); + await service.stop(); + expect(service.session.get().state).toBe('idle'); + cpSync(GAME, file); + service.dispose(); + }); + + it('crashes when the game server exits on its own', async () => { + const service = createService({ envOverrides: () => ({ NANOFORGE_SERVER_CRASH: '1' }) }); + await service.play('server'); + await until(service, 'crashed'); + expect(service.session.get().error).toBe('The game server exited (code 3)'); + service.dispose(); + }); +}); diff --git a/packages/runtime/test/source-map/source-maps.spec.ts b/packages/runtime/test/source-map/source-maps.spec.ts new file mode 100644 index 00000000..d3904b34 --- /dev/null +++ b/packages/runtime/test/source-map/source-maps.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { SourceMaps } from '../../src/source-map/source-maps'; + +const app = (id: string, outDir: string): AppModel => + ({ id, name: id, type: 'client', root: id, language: 'ts', outDir }) as AppModel; + +const MAP = JSON.stringify({ + version: 3, + sources: ['../src/main.ts', '../../../node_modules/lib/index.js', '../../../../outside.ts'], + names: [], + mappings: 'AAAA;AACA;AACA;ACFA;ACAA', +}); + +const setup = () => { + const fetched: string[] = []; + const maps = new SourceMaps({ + projectId: 'p1', + origin: 'http://localhost:4790', + apps: () => [app('apps/client', 'apps/client/dist'), app('apps/server', 'apps/server/dist')], + fetchText: async (url) => { + fetched.push(url); + return url.endsWith('/main.js.map') ? MAP : undefined; + }, + }); + return { maps, fetched }; +}; + +describe('SourceMaps', () => { + it('maps a frame of a client bundle URL to the project file', async () => { + const { maps, fetched } = setup(); + const url = 'http://localhost:4790/runtime/p1/apps%2Fclient/main.js?v=ab&run=2'; + expect(await maps.locate(url, 3, 1)).toEqual({ + path: 'apps/client/src/main.ts', + line: 3, + column: 1, + }); + expect(fetched).toEqual(['http://localhost:4790/runtime/p1/apps%2Fclient/main.js.map']); + await maps.locate(url, 2, 1); + expect(fetched).toHaveLength(1); + maps.invalidate('apps/client'); + await maps.locate(url, 2, 1); + expect(fetched).toHaveLength(2); + }); + + it('maps a frame of a server bundle path', async () => { + const { maps } = setup(); + expect(await maps.locate('/home/me/pong/apps/server/dist/main.js', 2, 5)).toEqual({ + path: 'apps/server/src/main.ts', + line: 2, + column: 1, + }); + }); + + it('gives nothing for libraries, files outside the project, or bundles without a map', async () => { + const { maps } = setup(); + const url = 'http://localhost:4790/runtime/p1/apps%2Fclient/main.js'; + expect(await maps.locate(url, 4, 1)).toBeUndefined(); + expect(await maps.locate(url, 5, 1)).toBeUndefined(); + expect(await maps.locate(url, 99, 1)).toBeUndefined(); + expect( + await maps.locate('http://localhost:4790/runtime/p1/apps%2Fclient/other.js', 1, 1), + ).toBeUndefined(); + expect(await maps.locate('http://localhost:4790/runtime/p2/x/main.js', 1, 1)).toBeUndefined(); + expect(await maps.locate('/usr/lib/thing.js', 1, 1)).toBeUndefined(); + }); +}); diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 00000000..3c88a550 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/.cliff-jumperrc.json b/packages/sdk/.cliff-jumperrc.json new file mode 100644 index 00000000..77626601 --- /dev/null +++ b/packages/sdk/.cliff-jumperrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/favware/cliff-jumper/main/assets/cliff-jumper.schema.json", + "name": "editor-sdk", + "org": "nanoforge-dev", + "packagePath": "packages/sdk", + "tagTemplate": "{{new-version}}", + "identifierBase": false +} diff --git a/packages/sdk/.prettierignore b/packages/sdk/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/sdk/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 00000000..6361e43e --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..d07a785d --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,52 @@ +
+
+

+ NanoForge +

+
+

+ npm version + npm downloads + Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-sdk` is part of the NanoForge Editor. + +Public API for NanoForge editor plugins. + +## Installation + +**Node.js 26 or newer is required.** + +```sh +npm install @nanoforge-dev/editor-sdk +yarn add @nanoforge-dev/editor-sdk +pnpm add @nanoforge-dev/editor-sdk +bun add @nanoforge-dev/editor-sdk +``` + +## Links + +- [GitHub][source] +- [npm][npm] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/sdk +[npm]: https://www.npmjs.com/package/@nanoforge-dev/editor-sdk +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/sdk/cliff.toml b/packages/sdk/cliff.toml new file mode 100644 index 00000000..8da3ea14 --- /dev/null +++ b/packages/sdk/cliff.toml @@ -0,0 +1,79 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +{% if version %}\ + # [{{ version | trim_start_matches(pat="v") }}]\ + {% if previous %}\ + {% if previous.version %}\ + ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\ + {% else %}\ + ({{ self::remote_url() }}/tree/{{ version }})\ + {% endif %}\ + {% endif %} \ + - ({{ timestamp | date(format="%Y-%m-%d") }}) +{% else %}\ + # [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ## {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}\ + **{{commit.scope}}:** \ + {% endif %}\ + {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ + {% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\ + {% if commit.breaking %}\ + {% for footer in commit.footers %}\ + {% if footer.breaking %}\ + \n{% raw %} {% endraw %}- **{{ footer.token }}{{ footer.separator }}** {{ footer.value }}\ + {% endif %}\ + {% endfor %}\ + {% endif %}\ + {% endfor %} +{% endfor %}\ +{% if github.contributors | filter(attribute="is_first_time", value=true) | length %}\ + \n### New Contributors\n + {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}\ + - @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }} + {% endfor %}\ +{% endif %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features"}, + { message = "^fix", group = "Bug Fixes"}, + { message = "^docs", group = "Documentation"}, + { message = "^perf", group = "Performance"}, + { message = "^refactor", group = "Refactor"}, + { message = "^types", group = "Typings"}, + { message = ".*deprecated", body = ".*deprecated", group = "Deprecation"}, + { message = "^revert", skip = true}, + { message = "^style", group = "Styling"}, + { message = "^test", group = "Testing"}, + { message = "^chore", skip = true}, + { message = "^ci", skip = true}, + { message = "^build", skip = true}, + { body = ".*security", group = "Security"}, +] +filter_commits = true +protect_breaking_commits = true +tag_pattern = "^[0-9]+" +ignore_tags = "" +topo_order = false +sort_commits = "newest" + +[remote.github] +owner = "NanoForge-dev" +repo = "Editor" diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..96519ae4 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-sdk", + "version": "0.0.0", + "description": "NanoForge Editor - Public API for editor plugins", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json", + "./ui": "./src/ui.ts", + "./worker": "./src/worker.ts" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/sdk" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "tsdown && node ./scripts/build-types.ts", + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/sdk/*'", + "release": "cliff-jumper" + }, + "dependencies": { + "@nanoforge-dev/editor-code": "workspace:*", + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-meta": "workspace:*", + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/editor-runtime": "workspace:*", + "@nanoforge-dev/editor-settings": "workspace:*", + "@nanoforge-dev/editor-ui": "workspace:*" + }, + "devDependencies": { + "@favware/cliff-jumper": "catalog:ci", + "tsdown": "catalog:build", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/types/sdk/src/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json", + "./worker": { + "types": "./dist/types/sdk/src/worker.d.ts", + "default": "./dist/worker.js" + } + } + }, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/sdk/scripts/build-types.ts b/packages/sdk/scripts/build-types.ts new file mode 100644 index 00000000..08d9f506 --- /dev/null +++ b/packages/sdk/scripts/build-types.ts @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; + +const root = join(import.meta.dirname, '..'); +const out = join(root, 'dist', 'types'); +rmSync(out, { recursive: true, force: true }); +execFileSync(join(root, 'node_modules/.bin/tsc'), ['-p', 'tsconfig.types.json'], { + cwd: root, + stdio: 'inherit', +}); + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : path.endsWith('.d.ts') ? [path] : []; + }); + +const SPECIFIER = + /(['"])@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)(\/engine)?\1/g; +for (const file of walk(out)) { + const text = readFileSync(file, 'utf8'); + const rewritten = text.replace(SPECIFIER, (_match, quote: string, name: string, sub?: string) => { + const target = relative(dirname(file), join(out, name, 'src', sub ? 'engine' : '', 'index.js')); + return `${quote}${target.startsWith('.') ? target : `./${target}`}${quote}`; + }); + if (rewritten !== text) writeFileSync(file, rewritten); +} +console.log(`SDK types written to ${relative(root, out)}`); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..5f882f5c --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,289 @@ +/** + * Public API of the NanoForge editor for plugins. + * + * At runtime this module is provided by the editor (plugins built with + * `@nanoforge-dev/editor-vite-plugin` never bundle it), so every plugin shares the same + * services, registries and tokens. + */ +import { + CoreServices, + type EventBus, + type ExtensionPoint, + type PluginContext, + type PluginModule, + type ServiceToken, +} from '@nanoforge-dev/editor-kernel'; + +export { + type Disposable, + DisposableObject, + DisposableStore, + MutableDisposable, + combineDisposables, + disposeAll, + isDisposable, + toDisposable, + Emitter, + type EmitterOptions, + type Event, + Events, + type Listener, + type Equality, + type Observable, + ObservableValue, + changes, + constant, + derived, + observe, + type ProvideOptions, + type ServiceAccessor, + type ServiceDecorator, + type ServiceFactory, + type ServiceToken, + createToken, + type Contribution, + type ExtensionPoint, + type Validator, + defineExtensionPoint, + type ContextKeyService, + type WhenExpression, + parseWhen, + COMMAND_METADATA, + type CommandHandler, + type CommandMetadata, + type CommandMetadataContribution, + type MessageBundle, + type MessageParams, + formatMessage, + type LogEntry, + LogLevel, + type LogLocation, + type LogValue, + type LogValueLimits, + type Logger, + type LoggerService, + formatLogValue, + isExpandableLogValue, + serializeLogValue, + type AppInfo, + type AppType, + type MissingEngineLib, + type PluginContext, + type PluginManifest, + type PluginModule, + isEligible, + missingEngineLibs, + type PluginHost, + PluginHostToken, + type PluginInfo, + switchObservable, +} from '@nanoforge-dev/editor-kernel'; + +export { + type ClientProject, + type ProjectFs, + type ProjectService, + ProjectServiceToken, + type WriteOptions, +} from '@nanoforge-dev/editor-project'; +export type { + AppModel, + FileChange, + FileContent, + FileEntry, + ProjectModel, +} from '@nanoforge-dev/editor-protocol'; +export { basename, dirname, joinPath, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; + +export { + type NewApp, + type NewLibrary, + type Undo, + WorkspaceError, + type WorkspaceIo, + createApp, + createLibrary, + librariesOf, + libraryImporters, + removeApp, + removeLibrary, + renameApp, + renameLibrary, + runnableApps, + setLibraryUse, + validateFolder, + validatePackageName, + workspaceIo, +} from '@nanoforge-dev/editor-project'; + +export { + type GitBranch, + type GitChange, + type GitCommit, + GitContract, + type GitFile, + type GitStash, + type GitStatus, +} from '@nanoforge-dev/editor-protocol'; + +export { SessionContract, type SessionInfo } from '@nanoforge-dev/editor-protocol'; + +export { + type InstalledPackage, + type PluginScope, + RegistryContract, + type RegistryItem, + type RegistrySearchResult, + type RegistrySummary, + type RegistryVersion, +} from '@nanoforge-dev/editor-protocol'; + +export { + type CatalogItem, + type CatalogService, + CatalogServiceToken, + type CatalogState, + appRefName, + itemFitsApp, + CODEGEN_TARGETS, + type CodeDiagnostic, + type CodeSymbol, + type CodeService, + CodeServiceToken, + type CodegenTarget, + DOCUMENT_EDITORS, + type DiagnosticsService, + type DocumentBackend, + DiagnosticsServiceToken, + type DocumentChange, + type DocumentEditor, + type DocumentService, + DocumentServiceToken, + type NodeRef, + type OpenDocument, + JSON_SCHEMAS, + type JsonSchemaContribution, + documentHistoryId, + editorsFor, + matchesGlob, + selectTarget, +} from '@nanoforge-dev/editor-code'; + +export type { + Element, + ElementLayout, + ElementType, + EnumMember, + ItemMeta, + ItemRef, + MetaDiagnostic, + MetaSource, + ParamGroup, + Side, +} from '@nanoforge-dev/editor-meta'; +export { ownerObjects } from '@nanoforge-dev/editor-meta/pure'; + +export { + type GameEvent, + type PlayMode, + type PlaySession, + type PlayState, + type RuntimeLog, + type SourceLocation, + type RuntimeService, + RuntimeServiceToken, + RuntimeSettings, + isPlaying, +} from '@nanoforge-dev/editor-runtime'; +export { + type BuildDiagnostic, + type BuildStatus, + type EngineFeatures, + type EngineFrameStats, + type EngineNetworkStats, + type EngineNetworkTrace, + type EngineScenes, + type EngineSystemStats, + type EngineViewport, + type EngineWorld, + type EngineWorldValue, + RUNTIME_PROTOCOL_VERSION, + type RuntimeCommand, +} from '@nanoforge-dev/editor-protocol'; + +export { + type CommandOrigin, + type CommandPreview, + FileHistoryTracker, + HISTORY_CONTEXT_KEY, + type HistoryCommand, + type HistoryContext, + type HistoryContextInfo, + type HistoryEntry, + type HistoryService, + HistoryServiceToken, + type HistoryStack, + TEXT_INPUT_FOCUS_KEY, + type TextEdit, + type Transaction, + applyTextEdits, + compositeCommand, +} from '@nanoforge-dev/editor-history'; + +export { + CoreSettings, + type MergeStrategy, + type SettingDefinition, + type SettingInspection, + type SettingScope, + type SettingsService, + SettingsServiceToken, + type SettingsRegistry, + SettingsRegistryToken, + AccountSyncToken, + type SyncStatus, + type MergeConflict, + type ImportPreview, + jsonSchemaOf, + type WritableScope, + defineSetting, +} from '@nanoforge-dev/editor-settings'; + +export { + type Contract, + type RpcApi, + type RpcClient, + RpcClientToken, + RpcError, + type RpcErrorCode, + defineContract, +} from '@nanoforge-dev/editor-rpc'; + +/** + * Editor-wide events. Extend it by module augmentation: + * + * ```ts + * declare module '@nanoforge-dev/editor-sdk' { + * interface EditorEvents { + * 'ecs.entitySelected': { app: string; entity: string }; + * } + * } + * ``` + */ + +export interface EditorEvents {} + +/** Services every editor provides, resolvable with `context.services.get(...)`. */ +export const EditorServices = { + ...CoreServices, + EventBus: CoreServices.EventBus as unknown as ServiceToken>, +} as const; + +/** Declares a plugin entry. Export the result as `default` or spread it into named exports. */ +export const definePlugin = (plugin: PluginModule): PluginModule => plugin; + +/** Helper type: the value type of an extension point. */ +export type ContributionOf

= P extends ExtensionPoint ? T : never; + +/** Helper type: the activation context, for plugin authors' own helpers. */ +export type EditorPluginContext = PluginContext; diff --git a/packages/sdk/src/ui.ts b/packages/sdk/src/ui.ts new file mode 100644 index 00000000..3c18af27 --- /dev/null +++ b/packages/sdk/src/ui.ts @@ -0,0 +1,117 @@ +/** + * UI API of the NanoForge editor for plugins: widgets and screens, menus, toolbar, status bar, + * keybindings, notifications, prompts, styles, themes and the component kit. + * + * Like the rest of the SDK, the editor provides this module at runtime: plugin widgets use the + * editor's own components and services. + */ +export { + FILE_ACTIONS, + FILE_DECORATIONS, + FILE_ICONS, + FILE_TEMPLATES, + type FileAction, + type FileDecoration, + type FileDecorationProvider, + type FileIcon, + type FileTemplate, + type TemplateFile, + CODE_EDITOR_ACTIVE_FILE, + CODE_EDITOR_SIDE_PANELS, + type CodeEditorSidePanel, + SCENE_EDITORS, + type SceneEditor, + VIEWPORT_OVERLAYS, + VIEWPORT_TOOLS, + type ViewportOverlay, + type ViewportScreen, + type ViewportTool, + KEYBINDINGS, + type Keybinding, + KeybindingSchema, + MENU_BAR, + MENU_ITEMS, + type MenuBarEntry, + type MenuItem, + MenuItemSchema, + type MountedWidget, + STATUS_BAR_ITEMS, + type StatusBarItem, + TOOLBAR_ITEMS, + type ToolbarItem, + WIDGETS, + WIDGET_VIEWS, + type WidgetDescriptor, + WidgetDescriptorSchema, + type WidgetInstance, + type WidgetView, + SETTINGS_PAGES, + type SettingsPage, + type SettingsPageApi, + type EditorAction, + actionId, + actionLabel, + isActionEnabled, + listActions, + DEFAULT_KEYMAP_PRESET, + KEYMAP_PRESETS, + type KeybindingConflict, + type KeymapOverride, + type KeymapPreset, + type ResolvedKeybinding, + type KeymapLayers, + type ShortcutRef, + bindingsConflict, + findConflicts, + isActionChanged, + resetAction, + resolveLayers, + setShortcut, + formatKeybinding, + parseKeybinding, + portableStroke, + resolveKeymap, + strokeFromEvent, + type KeybindingService, + KeybindingServiceToken, + type LayoutController, + LayoutControllerToken, + type NotificationService, + NotificationServiceToken, + type PromptService, + PromptServiceToken, + type StyleService, + StyleServiceToken, + type ThemeDefinition, + type ThemeService, + ThemeServiceToken, + THEMES, + type WorkbenchService, + WorkbenchServiceToken, + registerIcons, + resolveIcon, + Button, + Checkbox, + ColorPicker, + Combobox, + ContextMenu, + Dialog, + EmptyState, + Icon, + IconButton, + Input, + Menu, + type MenuEntry, + NumberInput, + PropertyGrid, + type PropertyField, + Select, + Splitter, + Switch, + Tabs, + Tooltip, + Tree, + type DropPosition, + type TreeNode, + Vector2Input, +} from '@nanoforge-dev/editor-ui'; diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts new file mode 100644 index 00000000..2e54fcd8 --- /dev/null +++ b/packages/sdk/src/worker.ts @@ -0,0 +1,54 @@ +/** + * API of plugin worker entries (`entry.worker` in the manifest), running in the code worker. + * At runtime this module and `ts-morph` are provided by the editor's worker. + * + * ```ts + * import { defineWorkerPlugin } from '@nanoforge-dev/editor-sdk/worker'; + * + * export default defineWorkerPlugin({ + * activate(sdk) { + * sdk.registerAnalyzer('acme.components', ({ file, literals }) => …); + * }, + * }); + * ``` + */ +import type { WorkerPluginModule } from '@nanoforge-dev/editor-code/engine'; + +export { + type AnalyzeContext, + type Analyzer, + type CodeDiagnostic, + CodeError, + EditBuilder, + type LiteralValue, + type NodeRef, + type PrintOptions, + type TextEdit, + type TransformContext, + type Transformer, + type WorkerPluginModule, + type WorkerSdk, + literalToValue, + quoteStyle, + readExportedLiteral, + valueToLiteral, +} from '@nanoforge-dev/editor-code/engine'; + +export { + type Doc, + type DocTag, + type ItemDeclaration, + type ItemMeta, + type ItemOwner, + type ItemRef, + type OwnerContext, + type OwnerInput, + docOf, + firstTag, + hasTag, + leadingDocComments, + parseDocComment, + tagTexts, +} from '@nanoforge-dev/editor-meta'; + +export const defineWorkerPlugin = (plugin: WorkerPluginModule): WorkerPluginModule => plugin; diff --git a/packages/sdk/test/index.spec.ts b/packages/sdk/test/index.spec.ts new file mode 100644 index 00000000..006b0dac --- /dev/null +++ b/packages/sdk/test/index.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { EventBus, createCoreContainer } from '@nanoforge-dev/editor-kernel'; + +import { EditorServices, definePlugin } from '../src'; + +declare module '../src' { + interface EditorEvents { + 'sdk.test': { ok: boolean }; + } +} + +describe('sdk', () => { + it('types the editor event bus with augmented EditorEvents', () => { + const services = createCoreContainer(); + const bus = services.get(EditorServices.EventBus); + expect(bus).toBeInstanceOf(EventBus); + let received = false; + bus.on('sdk.test', ({ ok }) => (received = ok)); + bus.emit('sdk.test', { ok: true }); + expect(received).toBe(true); + services.dispose(); + }); + + it('definePlugin is an identity helper', () => { + const plugin = { activate: () => undefined }; + expect(definePlugin(plugin)).toBe(plugin); + }); +}); + +describe('public API', () => { + it('only changes on purpose (update the snapshot when adding to the SDK)', async () => { + const sdk = await import('../src'); + expect(Object.keys(sdk).sort()).toMatchInlineSnapshot(` + [ + "AccountSyncToken", + "CODEGEN_TARGETS", + "COMMAND_METADATA", + "CatalogServiceToken", + "CodeServiceToken", + "CoreSettings", + "DOCUMENT_EDITORS", + "DiagnosticsServiceToken", + "DisposableObject", + "DisposableStore", + "DocumentServiceToken", + "EditorServices", + "Emitter", + "Events", + "FileHistoryTracker", + "GitContract", + "HISTORY_CONTEXT_KEY", + "HistoryServiceToken", + "JSON_SCHEMAS", + "LogLevel", + "MutableDisposable", + "ObservableValue", + "PluginHostToken", + "ProjectServiceToken", + "RUNTIME_PROTOCOL_VERSION", + "RegistryContract", + "RpcClientToken", + "RpcError", + "RuntimeServiceToken", + "RuntimeSettings", + "SessionContract", + "SettingsRegistryToken", + "SettingsServiceToken", + "TEXT_INPUT_FOCUS_KEY", + "WorkspaceError", + "appRefName", + "applyTextEdits", + "basename", + "changes", + "combineDisposables", + "compositeCommand", + "constant", + "createApp", + "createLibrary", + "createToken", + "defineContract", + "defineExtensionPoint", + "definePlugin", + "defineSetting", + "derived", + "dirname", + "disposeAll", + "documentHistoryId", + "editorsFor", + "formatLogValue", + "formatMessage", + "isDisposable", + "isEligible", + "isExpandableLogValue", + "isPlaying", + "itemFitsApp", + "joinPath", + "jsonSchemaOf", + "librariesOf", + "libraryImporters", + "matchesGlob", + "missingEngineLibs", + "observe", + "ownerObjects", + "parseWhen", + "removeApp", + "removeLibrary", + "renameApp", + "renameLibrary", + "runnableApps", + "runtimeOutputPath", + "selectTarget", + "serializeLogValue", + "setLibraryUse", + "switchObservable", + "toDisposable", + "validateFolder", + "validatePackageName", + "workspaceIo", + ] + `); + }); +}); diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/sdk/tsconfig.types.json b/packages/sdk/tsconfig.types.json new file mode 100644 index 00000000..8e90fb51 --- /dev/null +++ b/packages/sdk/tsconfig.types.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "sourceMap": false, + "rootDir": "..", + "outDir": "dist/types", + "types": [], + "paths": { + "@nanoforge-dev/editor-kernel": ["../kernel/src/index.ts"], + "@nanoforge-dev/editor-project": ["../project/src/index.ts"], + "@nanoforge-dev/editor-protocol": ["../protocol/src/index.ts"], + "@nanoforge-dev/editor-rpc": ["../rpc/src/index.ts"], + "@nanoforge-dev/editor-settings": ["../settings/src/index.ts"], + "@nanoforge-dev/editor-history": ["../history/src/index.ts"], + "@nanoforge-dev/editor-code": ["../code/src/index.ts"], + "@nanoforge-dev/editor-code/engine": ["../code/src/engine/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts new file mode 100644 index 00000000..58dd3912 --- /dev/null +++ b/packages/sdk/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown'; + +/** + * JavaScript of the published SDK (types are built by scripts/build-types.ts). At runtime plugins + * never load it: the editor provides the SDK as a shared module. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/worker.ts'], + format: ['esm'], + platform: 'neutral', + target: 'es2023', + dts: false, + sourcemap: true, + clean: true, + fixedExtension: false, + deps: { + alwaysBundle: [/^@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)$/], + neverBundle: ['semver', 'zod', 'ts-morph'], + }, +}); diff --git a/packages/server-core/.prettierignore b/packages/server-core/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/server-core/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/server-core/LICENSE b/packages/server-core/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/server-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/server-core/README.md b/packages/server-core/README.md new file mode 100644 index 00000000..9168478e --- /dev/null +++ b/packages/server-core/README.md @@ -0,0 +1,37 @@ +

+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-server-core` is an internal package of the NanoForge Editor. + +Editor server services: sessions, fs, cli, git, api client, plugin server entries. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/server-core +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/server-core/package.json b/packages/server-core/package.json new file mode 100644 index 00000000..632d2974 --- /dev/null +++ b/packages/server-core/package.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-server-core", + "version": "0.0.0", + "description": "NanoForge Editor - Editor server services: sessions, fs, cli, git, api client, plugin server entries", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/server-core" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "test:contract": "vitest run --config ../../vitest.config.ts test/contract" + }, + "dependencies": { + "@nanoforge-dev/config": "catalog:config", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*", + "chokidar": "catalog:server", + "fflate": "catalog:libs", + "ignore": "catalog:libs", + "prettier": "catalog:server", + "semver": "catalog:libs", + "typescript": "catalog:build", + "unrun": "catalog:build", + "yaml": "catalog:server", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/bun": "catalog:core", + "@types/node": "catalog:core", + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/server-core/src/api/api-client.ts b/packages/server-core/src/api/api-client.ts new file mode 100644 index 00000000..f8bac13c --- /dev/null +++ b/packages/server-core/src/api/api-client.ts @@ -0,0 +1,114 @@ +import { RpcError, type RpcErrorCode } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import type { AuthTokens, Session } from '../session/session-store'; + +const API_STATUS_CODES: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 412: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', +}; + +export interface ApiRequest { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + query?: Record; + /** Send the session tokens (default true). */ + auth?: boolean; + signal?: AbortSignal; +} + +export interface TokenResponse extends AuthTokens { + tokenExpiresAt: string; +} + +export interface GatewayProject { + id: string; + code: string; + name: string; + description: string; + gatewayProjectRegistryUrl: string; + gatewayProjectRegistryMetadata: { dir: string | null }; + token: string; +} + +/** + * Client of api.nanoforge.eu. Authenticated calls refresh the access token once on 401 and + * update the session tokens (the caller forwards them as cookies). + */ +export class ApiClient { + constructor( + private readonly _env: EditorEnv, + private readonly _fetch: typeof fetch = globalThis.fetch.bind(globalThis), + ) {} + + async request(session: Session | null, path: string, request: ApiRequest = {}): Promise { + const auth = request.auth ?? true; + if (auth && !session?.tokens) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + let response = await this._send(path, request, auth ? session!.tokens!.accessToken : null); + if (auth && response.status === 401) { + await this.refresh(session!); + response = await this._send(path, request, session!.tokens!.accessToken); + } + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new RpcError( + API_STATUS_CODES[response.status] ?? + (response.status >= 500 ? 'UNAVAILABLE' : 'BAD_REQUEST'), + `NanoForge API ${request.method ?? 'GET'} ${path} failed (${response.status})`, + detail ? { detail: detail.slice(0, 500) } : undefined, + ); + } + return response.status === 204 ? (undefined as T) : ((await response.json()) as T); + } + + async refresh(session: Session): Promise { + const refreshToken = session.tokens?.refreshToken; + if (!refreshToken) throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + const response = await this._send( + '/auth/refresh-token', + { + method: 'POST', + body: { refreshToken }, + }, + null, + ); + if (!response.ok) { + session.tokens = null; + session.user = null; + throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + } + const tokens = (await response.json()) as TokenResponse; + session.tokens = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }; + return session.tokens; + } + + gatewayProjects(session: Session): Promise { + return this.request(session, '/projects'); + } + + gatewayProject(session: Session, id: string): Promise { + return this.request(session, `/editor/projects/${encodeURIComponent(id)}`); + } + + private _send(path: string, request: ApiRequest, accessToken: string | null): Promise { + const url = new URL(path, this._env.apiUrl); + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return this._fetch(url, { + method: request.method ?? 'GET', + headers: { + 'content-type': 'application/json', + ...(this._env.apiKey && { 'api-key': this._env.apiKey }), + ...(accessToken && { authorization: `Bearer ${accessToken}` }), + }, + ...(request.body !== undefined && { body: JSON.stringify(request.body) }), + ...(request.signal && { signal: request.signal }), + }); + } +} diff --git a/packages/server-core/src/api/index.ts b/packages/server-core/src/api/index.ts new file mode 100644 index 00000000..19601001 --- /dev/null +++ b/packages/server-core/src/api/index.ts @@ -0,0 +1 @@ +export * from './api-client'; diff --git a/packages/server-core/src/env/editor-env.type.ts b/packages/server-core/src/env/editor-env.type.ts new file mode 100644 index 00000000..dda1edbe --- /dev/null +++ b/packages/server-core/src/env/editor-env.type.ts @@ -0,0 +1,25 @@ +import type { ApiFeature } from './env.schema'; + +export interface EditorEnv { + readonly production: boolean; + readonly mode: 'OFFLINE' | 'ONLINE'; + readonly host: string; + readonly port: number; + readonly fsRoot: string; + readonly dataDir: string; + readonly apiUrl: string; + readonly apiKey: string | undefined; + /** The API features the editor may call. */ + readonly apiFeatures: readonly ApiFeature[]; + /** Undefined: no registry to ask (the API does not have it yet). */ + readonly registryUrl: string | undefined; + /** A folder used as the registry, when set. */ + readonly registryDir: string | undefined; + readonly loginUrl: string; + readonly sessionSecret: string; + readonly cliPath: string | undefined; + readonly gitPath: string; + readonly allowedOrigins: readonly string[]; + readonly devPlugins: readonly string[]; + readonly bundledPluginsDir: string | undefined; +} diff --git a/packages/server-core/src/env/env.exception.ts b/packages/server-core/src/env/env.exception.ts new file mode 100644 index 00000000..c4325993 --- /dev/null +++ b/packages/server-core/src/env/env.exception.ts @@ -0,0 +1,6 @@ +export class EnvError extends Error { + constructor(readonly issues: string[]) { + super(`Invalid editor environment:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`); + this.name = 'EnvError'; + } +} diff --git a/packages/server-core/src/env/env.schema.ts b/packages/server-core/src/env/env.schema.ts new file mode 100644 index 00000000..4b57da9d --- /dev/null +++ b/packages/server-core/src/env/env.schema.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +const optionalString = z + .string() + .optional() + .transform((value) => (value?.trim() ? value.trim() : undefined)); + +const list = z + .string() + .optional() + .transform((value) => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + +/** + * What the NanoForge API offers the editor, beyond sign-in and projects. A feature that is not + * listed in `API_FEATURES` is not called: the editor keeps to itself for it. + * - `settings`: account settings sync (docs/api/settings-sync.md). Without it, a hosted editor + * keeps account settings on its own disk. + * - `registry`: the registry of plugins and packages (docs/api/registry.md). Without it, the + * marketplace says it is not available (unless `REGISTRY_URL` or `REGISTRY_DIR` is set). + */ +export const API_FEATURES = ['settings', 'registry'] as const; +export type ApiFeature = (typeof API_FEATURES)[number]; + +/** Environment of the editor server, validated at startup. */ +export const EnvSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), + /** OFFLINE: local projects on disk. ONLINE: hosted, projects synced with git gateways. */ + PUBLIC_MODE: z.enum(['OFFLINE', 'ONLINE']).default('OFFLINE'), + HOST: optionalString, + PORT: z.coerce.number().int().min(0).max(65_535).default(3000), + /** Projects must live under this directory (defaults to the working directory). */ + FS_ROOT: optionalString, + /** Editor data: recent projects, installed plugins… */ + DATA_DIR: optionalString, + API_URL: z.string().url().default('https://api.nanoforge.eu'), + API_KEY: optionalString, + /** The API features that are deployed, comma separated (`settings,registry`). None by default. */ + API_FEATURES: list, + /** The registry of plugins and packages, when it is not the API's (`registry` feature). */ + REGISTRY_URL: optionalString, + /** A folder standing in for the registry (`/@scope/name//`): tests, offline work. */ + REGISTRY_DIR: optionalString, + /** NanoForge projects website, where ONLINE users sign in. */ + PUBLIC_PM_URL: z.string().url().default('https://projects.nanoforge.eu'), + SESSION_SECRET: optionalString, + NF_CLI_PATH: optionalString, + GIT_PATH: optionalString, + /** Extra origins allowed to call the server (dev proxies). */ + ALLOWED_ORIGINS: list, + /** Directories of local plugins under development (their `dist/` is served). */ + DEV_PLUGINS: list, + /** Directory of the plugins bundled with the editor build. */ + BUNDLED_PLUGINS_DIR: optionalString, + }) + .superRefine((env, context) => { + for (const feature of env.API_FEATURES) { + if (!(API_FEATURES as readonly string[]).includes(feature)) { + context.addIssue({ + code: 'custom', + path: ['API_FEATURES'], + message: `unknown feature "${feature}" (known: ${API_FEATURES.join(', ')})`, + }); + } + } + if (env.PUBLIC_MODE === 'ONLINE' && !env.API_KEY) { + context.addIssue({ code: 'custom', path: ['API_KEY'], message: 'required in ONLINE mode' }); + } + }); diff --git a/packages/server-core/src/env/index.ts b/packages/server-core/src/env/index.ts new file mode 100644 index 00000000..3f0ca501 --- /dev/null +++ b/packages/server-core/src/env/index.ts @@ -0,0 +1,4 @@ +export * from './env.schema'; +export * from './editor-env.type'; +export * from './env.exception'; +export * from './load-env'; diff --git a/packages/server-core/src/env/load-env.ts b/packages/server-core/src/env/load-env.ts new file mode 100644 index 00000000..45388a5f --- /dev/null +++ b/packages/server-core/src/env/load-env.ts @@ -0,0 +1,42 @@ +import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +import type { EditorEnv } from './editor-env.type'; +import { EnvError } from './env.exception'; +import { type ApiFeature, EnvSchema } from './env.schema'; + +export const loadEnv = ( + source: Record = process.env, + cwd = process.cwd(), +): EditorEnv => { + const result = EnvSchema.safeParse(source); + if (!result.success) { + throw new EnvError( + result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + ); + } + const env = result.data; + const absolute = (path: string) => (isAbsolute(path) ? path : resolve(cwd, path)); + return { + production: env.NODE_ENV === 'production', + mode: env.PUBLIC_MODE, + host: env.HOST ?? (env.PUBLIC_MODE === 'OFFLINE' ? '127.0.0.1' : '0.0.0.0'), + port: env.PORT, + fsRoot: absolute(env.FS_ROOT ?? '.'), + dataDir: absolute(env.DATA_DIR ?? join(homedir(), '.nanoforge', 'editor')), + apiUrl: env.API_URL, + apiKey: env.API_KEY, + apiFeatures: env.API_FEATURES as ApiFeature[], + registryUrl: + env.REGISTRY_URL ?? (env.API_FEATURES.includes('registry') ? env.API_URL : undefined), + registryDir: env.REGISTRY_DIR && absolute(env.REGISTRY_DIR), + loginUrl: env.PUBLIC_PM_URL, + sessionSecret: env.SESSION_SECRET ?? randomBytes(32).toString('hex'), + cliPath: env.NF_CLI_PATH, + gitPath: env.GIT_PATH ?? 'git', + allowedOrigins: env.ALLOWED_ORIGINS, + devPlugins: env.DEV_PLUGINS.map(absolute), + bundledPluginsDir: env.BUNDLED_PLUGINS_DIR && absolute(env.BUNDLED_PLUGINS_DIR), + }; +}; diff --git a/packages/server-core/src/fs/archive.ts b/packages/server-core/src/fs/archive.ts new file mode 100644 index 00000000..4834e4dd --- /dev/null +++ b/packages/server-core/src/fs/archive.ts @@ -0,0 +1,25 @@ +import { zip } from 'fflate'; +import { readFile } from 'node:fs/promises'; + +import type { ProjectFileSystem } from './fs-service'; + +/** Zips a folder of the project (entries hidden by `.nfignore` stay out). */ +export const archiveFolder = async ( + fs: ProjectFileSystem, + path: string, +): Promise> => { + const entries = await fs.list(path, true); + const files: Record = {}; + const prefix = path ? `${path}/` : ''; + for (const entry of entries) { + if (entry.kind !== 'file') continue; + files[entry.path.slice(prefix.length)] = new Uint8Array( + await readFile(await fs.jail.resolve(entry.path)), + ); + } + return new Promise((resolve, reject) => + zip(files, { level: 6 }, (error, data) => + error ? reject(error) : resolve(data as Uint8Array), + ), + ); +}; diff --git a/packages/server-core/src/fs/fs-service.ts b/packages/server-core/src/fs/fs-service.ts new file mode 100644 index 00000000..c450ac6f --- /dev/null +++ b/packages/server-core/src/fs/fs-service.ts @@ -0,0 +1,269 @@ +import ignore, { type Ignore } from 'ignore'; +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import type { FileContent, FileEntry } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import { sha1 } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { PathJail } from './path-jail'; + +export const EDITOR_DIR = '.nanoforge/editor'; +export const TRASH_DIR = `${EDITOR_DIR}/trash`; +/** Hides files from the whole editor (gitignore syntax); optional. */ +export const NFIGNORE_FILE = '.nfignore'; +/** Installed bundles: usable, never modified (copy an entry into the project to change it). */ +export const PACKAGES_DIR = 'nf_modules'; +const EDITOR_GITIGNORE = 'trash/\nlocal.json\n'; + +const MAX_READ_BYTES = 64 * 1024 * 1024; + +const notFound = (path: string) => new RpcError('NOT_FOUND', `No such file or directory: ${path}`); + +const errno = (error: unknown) => (error as NodeJS.ErrnoException).code; + +const mapError = (error: unknown, path: string): never => { + switch (errno(error)) { + case 'ENOENT': + throw notFound(path); + case 'EEXIST': + case 'ENOTEMPTY': + case 'ERR_FS_CP_EEXIST': + throw new RpcError('CONFLICT', `Already exists: ${path}`); + case 'EISDIR': + case 'ENOTDIR': + throw new RpcError('BAD_REQUEST', `Wrong entry kind: ${path}`); + case 'EACCES': + case 'EPERM': + throw new RpcError('FORBIDDEN', `Permission denied: ${path}`); + default: + throw error; + } +}; + +const assertWritable = (path: string) => { + if (path === PACKAGES_DIR || path.startsWith(`${PACKAGES_DIR}/`)) { + throw new RpcError( + 'FORBIDDEN', + `${path} is part of an installed package (nf_modules), which cannot be changed. Copy it into your project to modify it.`, + ); + } +}; + +/** File operations of one project, jailed to its root. */ +export class ProjectFileSystem { + readonly jail: PathJail; + private readonly _writes = new KeyedMutex(); + private _ignore: { mtime: number; rules: Ignore } | undefined; + + constructor(readonly root: string) { + this.jail = new PathJail(root); + } + + /** Whether `.nfignore` hides a path (directories are tested with and without a trailing /). */ + isIgnored(path: string, directory?: boolean): boolean { + const rules = this._ignore?.rules; + if (!rules || !path || path === NFIGNORE_FILE) return false; + if (directory !== false && rules.ignores(`${path}/`)) return true; + return directory !== true && rules.ignores(path); + } + + /** Reloads `.nfignore` when it changed (called before listings and by the watcher). */ + async refreshIgnore(): Promise { + const file = join(this.root, NFIGNORE_FILE); + const info = await stat(file).catch(() => undefined); + if (!info) { + this._ignore = undefined; + return; + } + if (this._ignore?.mtime === info.mtimeMs) return; + const text = await readFile(file, 'utf8').catch(() => ''); + this._ignore = { mtime: info.mtimeMs, rules: ignore().add(text) }; + } + + async stat(path: string): Promise { + const absolute = await this.jail.resolve(path); + try { + const info = await stat(absolute); + return { + path, + kind: info.isDirectory() ? 'directory' : 'file', + size: info.size, + mtime: info.mtimeMs, + }; + } catch (error) { + if (errno(error) === 'ENOENT') return null; + return mapError(error, path); + } + } + + async list(path: string, recursive = false): Promise { + const absolute = await this.jail.resolve(path); + await this.refreshIgnore(); + const entries: FileEntry[] = []; + const walk = async (dir: string, rel: string) => { + let names; + try { + names = await readdir(dir, { withFileTypes: true }); + } catch (error) { + return mapError(error, rel); + } + for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isSymbolicLink()) continue; // never follow links while listing + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + const info = await stat(join(dir, entry.name)).catch(() => undefined); + if (!info) continue; + const kind = info.isDirectory() ? 'directory' : 'file'; + if (this.isIgnored(childRel, kind === 'directory')) continue; + entries.push({ path: childRel, kind, size: info.size, mtime: info.mtimeMs }); + if (recursive && kind === 'directory' && !SKIPPED_IN_RECURSIVE_LIST.has(entry.name)) { + await walk(join(dir, entry.name), childRel); + } + } + }; + await walk(absolute, path); + return entries; + } + + async read(path: string): Promise { + const absolute = await this.jail.resolve(path); + try { + const info = await stat(absolute); + if (info.isDirectory()) throw new RpcError('BAD_REQUEST', `Is a directory: ${path}`); + if (info.size > MAX_READ_BYTES) + throw new RpcError('PAYLOAD_TOO_LARGE', `File too large: ${path}`); + const content = new Uint8Array(await readFile(absolute)); + return { + path, + kind: 'file', + size: info.size, + mtime: info.mtimeMs, + content, + hash: sha1(content), + }; + } catch (error) { + if (error instanceof RpcError) throw error; + return mapError(error, path); + } + } + + /** + * Atomic write (temp file + rename). With `expectedHash`, fails with CONFLICT when the file + * changed since it was read (`null` means "must not exist"). + */ + async write( + path: string, + content: Uint8Array | string, + options: { expectedHash?: string | null; createParents?: boolean } = {}, + ): Promise { + if (path === '') throw new RpcError('BAD_REQUEST', 'Cannot write the project root'); + assertWritable(path); + const absolute = await this.jail.resolve(path); + return this._writes.run(absolute, async () => { + if (options.expectedHash !== undefined) { + const current = await readFile(absolute).then( + (buffer) => sha1(buffer), + (error: unknown) => (errno(error) === 'ENOENT' ? null : mapError(error, path)), + ); + if (current !== options.expectedHash) { + throw new RpcError('CONFLICT', `File changed on disk: ${path}`, { hash: current }); + } + } + if (options.createParents !== false) await mkdir(dirname(absolute), { recursive: true }); + const temp = join( + dirname(absolute), + `.${basename(absolute)}.nf-${randomBytes(4).toString('hex')}`, + ); + try { + await writeFile(temp, content); + await rename(temp, absolute); + } catch (error) { + await rm(temp, { force: true }); + return mapError(error, path); + } + const info = await stat(absolute); + return { path, kind: 'file', size: info.size, mtime: info.mtimeMs, hash: sha1(content) }; + }); + } + + async mkdir(path: string): Promise { + assertWritable(path); + const absolute = await this.jail.resolve(path); + await mkdir(absolute, { recursive: true }).catch((error: unknown) => mapError(error, path)); + return (await this.stat(path))!; + } + + async rename(from: string, to: string, overwrite = false): Promise { + assertWritable(from); + assertWritable(to); + const source = await this.jail.resolve(from); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(from); + if (!overwrite && existsSync(target)) throw new RpcError('CONFLICT', `Already exists: ${to}`); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target).catch((error: unknown) => mapError(error, from)); + return (await this.stat(to))!; + } + + async copy(from: string, to: string, overwrite = false): Promise { + assertWritable(to); + const source = await this.jail.resolve(from); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(from); + await cp(source, target, { recursive: true, force: overwrite, errorOnExist: !overwrite }).catch( + (error: unknown) => mapError(error, from), + ); + return (await this.stat(to))!; + } + + /** + * Moves the entry into the project trash (recoverable), never deletes directly. Returns its + * place in the trash, for `restore`. + */ + async delete(path: string): Promise<{ trashPath: string }> { + if (path === '' || path === TRASH_DIR || path.startsWith(`${TRASH_DIR}/`)) { + throw new RpcError('BAD_REQUEST', `Cannot delete ${path || 'the project root'}`); + } + assertWritable(path); + const source = await this.jail.resolve(path); + if (!existsSync(source)) throw notFound(path); + const trash = await this.jail.resolve(TRASH_DIR); + await mkdir(trash, { recursive: true }); + await this._ensureEditorGitignore(); + const target = join( + trash, + `${Date.now()}-${randomBytes(3).toString('hex')}-${basename(source)}`, + ); + await rename(source, target).catch((error: unknown) => mapError(error, path)); + return { trashPath: `${TRASH_DIR}/${basename(target)}` }; + } + + /** Brings a deleted entry back from the trash (undo of `delete`). */ + async restore(trashPath: string, to: string): Promise { + if ( + !trashPath.startsWith(`${TRASH_DIR}/`) || + trashPath.slice(TRASH_DIR.length + 1).includes('/') + ) { + throw new RpcError('BAD_REQUEST', `Not in the trash: ${trashPath}`); + } + assertWritable(to); + const source = await this.jail.resolve(trashPath); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(trashPath); + if (existsSync(target)) throw new RpcError('CONFLICT', `Already exists: ${to}`); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target).catch((error: unknown) => mapError(error, to)); + return (await this.stat(to))!; + } + + private async _ensureEditorGitignore(): Promise { + const file = await this.jail.resolve(`${EDITOR_DIR}/.gitignore`); + if (!existsSync(file)) await writeFile(file, EDITOR_GITIGNORE); + } +} + +/** Heavy folders a recursive listing does not descend into. */ +const SKIPPED_IN_RECURSIVE_LIST = new Set(['node_modules', '.git']); diff --git a/packages/server-core/src/fs/index.ts b/packages/server-core/src/fs/index.ts new file mode 100644 index 00000000..a79713ec --- /dev/null +++ b/packages/server-core/src/fs/index.ts @@ -0,0 +1,5 @@ +export * from './fs-service'; +export * from './path-jail'; +export * from './watcher'; +export * from './archive'; +export * from './reveal'; diff --git a/packages/server-core/src/fs/path-jail.ts b/packages/server-core/src/fs/path-jail.ts new file mode 100644 index 00000000..b69b2a4d --- /dev/null +++ b/packages/server-core/src/fs/path-jail.ts @@ -0,0 +1,53 @@ +import { realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +const isInside = (root: string, target: string) => { + const rel = relative(root, target); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +}; + +/** Closest existing ancestor's real path + the remaining (not yet existing) segments. */ +const realpathLenient = async (target: string): Promise => { + const missing: string[] = []; + let current = target; + for (;;) { + try { + return join(await realpath(current), ...missing.reverse()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = dirname(current); + if (parent === current) throw error; + missing.push(current.slice(parent.length + 1)); + current = parent; + } + } +}; + +/** + * Resolves project-relative paths to absolute ones inside `root`, rejecting `..` escapes and + * symlinks pointing outside of the project. + */ +export class PathJail { + private _realRoot: string | undefined; + + constructor(readonly root: string) {} + + async resolve(path: string): Promise { + const target = resolve(this.root, path); + if (!isInside(this.root, target)) throw this._escape(path); + this._realRoot ??= await realpath(this.root); + if (!isInside(this._realRoot, await realpathLenient(target))) throw this._escape(path); + return target; + } + + /** Project-relative POSIX path of an absolute path inside the root. */ + relative(absolute: string): string { + return relative(this.root, absolute).split(sep).join('/'); + } + + private _escape(path: string) { + return new RpcError('FORBIDDEN', `Path escapes the project: ${path}`); + } +} diff --git a/packages/server-core/src/fs/reveal.ts b/packages/server-core/src/fs/reveal.ts new file mode 100644 index 00000000..1101a30b --- /dev/null +++ b/packages/server-core/src/fs/reveal.ts @@ -0,0 +1,22 @@ +import { spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** Opens the OS file manager on an entry (its folder for files; selected where supported). */ +export const revealInFileManager = async (absolute: string): Promise => { + const directory = (await stat(absolute)).isDirectory(); + const [command, args] = + process.platform === 'darwin' + ? ['open', directory ? [absolute] : ['-R', absolute]] + : process.platform === 'win32' + ? ['explorer', directory ? [absolute] : [`/select,${absolute}`]] + : ['xdg-open', [directory ? absolute : dirname(absolute)]]; + await new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +}; diff --git a/packages/server-core/src/fs/watcher.ts b/packages/server-core/src/fs/watcher.ts new file mode 100644 index 00000000..15bf1c64 --- /dev/null +++ b/packages/server-core/src/fs/watcher.ts @@ -0,0 +1,115 @@ +import { watch } from 'chokidar'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import type { FileChange } from '@nanoforge-dev/editor-protocol'; + +import type { PathJail } from './path-jail'; + +export const DEFAULT_IGNORED_SEGMENTS = [ + 'node_modules', + '.git', + '.turbo', + 'coverage', + '.svelte-kit', +]; + +export interface WatcherOptions { + /** Project-relative directories to ignore besides the defaults (build outputs…). */ + ignoredPaths?: () => readonly string[]; + /** Paths hidden from the editor (`.nfignore`), checked for each change. */ + isIgnored?: (path: string, directory: boolean) => boolean; + batchMs?: number; +} + +/** + * Watches a project and emits batched changes (default every 50 ms). Consecutive events on the + * same path collapse (`created` then `deleted` in the same batch cancel out). + */ +export class ProjectWatcher { + private readonly _onDidChange = new Emitter({ + onFirstListenerAdd: () => this._start(), + onLastListenerRemove: () => void this._stop(), + }); + private _watcher: ReturnType | undefined; + private _pending = new Map(); + private _timer: ReturnType | undefined; + private _ready: Promise = Promise.resolve(); + + /** Starts watching on the first listener and stops with the last one. */ + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + private readonly _jail: PathJail, + private readonly _options: WatcherOptions = {}, + ) {} + + /** Resolves once the initial scan is done (tests, first subscription). */ + get ready(): Promise { + return this._ready; + } + + dispose(): void { + this._onDidChange.dispose(); + void this._stop(); + } + + private _ignored = (absolute: string): boolean => { + const rel = this._jail.relative(absolute); + if (!rel || rel.startsWith('..')) return false; + const segments = rel.split('/'); + if (segments.some((segment) => DEFAULT_IGNORED_SEGMENTS.includes(segment))) return true; + if (rel === '.nanoforge/editor/trash' || rel.startsWith('.nanoforge/editor/trash/')) + return true; + if (/\.nf-[0-9a-f]{8}$/.test(rel)) return true; // atomic write temp files + return (this._options.ignoredPaths?.() ?? []).some( + (path) => rel === path || rel.startsWith(`${path}/`), + ); + }; + + private _start(): void { + const watcher = watch(this._jail.root, { + ignoreInitial: true, + ignored: this._ignored, + awaitWriteFinish: false, + atomic: true, + }); + this._watcher = watcher; + this._ready = new Promise((resolve) => watcher.once('ready', () => resolve())); + const push = (type: FileChange['type'], kind: FileChange['kind']) => (absolute: string) => + this._queue({ type, kind, path: this._jail.relative(absolute) }); + watcher + .on('add', push('created', 'file')) + .on('addDir', push('created', 'directory')) + .on('change', push('changed', 'file')) + .on('unlink', push('deleted', 'file')) + .on('unlinkDir', push('deleted', 'directory')); + } + + private async _stop(): Promise { + clearTimeout(this._timer); + this._pending.clear(); + const watcher = this._watcher; + this._watcher = undefined; + await watcher?.close(); + } + + private _queue(change: FileChange): void { + if (this._options.isIgnored?.(change.path, change.kind === 'directory')) return; + const previous = this._pending.get(change.path); + if (previous?.type === 'created' && change.type === 'deleted') { + this._pending.delete(change.path); + } else if (previous?.type === 'deleted' && change.type === 'created') { + this._pending.set(change.path, { ...change, type: 'changed' }); + } else if (previous?.type !== 'created' || change.type !== 'changed') { + this._pending.set(change.path, change); + } + this._timer ??= setTimeout(() => this._flush(), this._options.batchMs ?? 50); + } + + private _flush(): void { + this._timer = undefined; + const changes = [...this._pending.values()]; + this._pending.clear(); + if (changes.length) this._onDidChange.fire(changes); + } +} diff --git a/packages/server-core/src/git/git-service.ts b/packages/server-core/src/git/git-service.ts new file mode 100644 index 00000000..12dd74d0 --- /dev/null +++ b/packages/server-core/src/git/git-service.ts @@ -0,0 +1,427 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import type { + GitBranch, + GitChange, + GitCommit, + GitStash, + GitStatus, +} from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { runProcess } from '../process/run-process'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { redact, withToken } from './git-token'; +import { parseStatus } from './parse-status'; + +export interface GitStatusEntry { + readonly path: string; + /** Two-letter porcelain code, e.g. ` M`, `??`. */ + readonly code: string; +} + +/** Git CLI wrapper; operations on the same repository are serialized. */ +export class GitService { + private readonly _mutex = new KeyedMutex(); + + constructor( + private readonly _env: EditorEnv, + /** How long a command that talks to a remote may take before it is stopped. */ + private readonly _networkTimeoutMs = 60_000, + ) {} + + isRepository(path: string): boolean { + return existsSync(join(path, '.git')); + } + + /** Clones `url` into `path`, injecting a token for https remotes (GitHub style). */ + clone(url: string, path: string, token?: string): Promise { + const remote = token ? withToken(url, token) : url; + return this._mutex.run(path, async () => { + await this._git(this._env.fsRoot, ['clone', '--', remote, path]); + }); + } + + pull(path: string): Promise { + return this._mutex.run(path, async () => { + await this._git(path, ['pull', '--ff-only']); + }); + } + + status(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, ['status', '--porcelain=v1', '-z']); + return output + .split('\0') + .filter(Boolean) + .map((entry) => ({ code: entry.slice(0, 2), path: entry.slice(3) })); + }); + } + + /** Stages everything, commits when there are changes and pushes. Returns false when clean. */ + commitAndPush(path: string, message: string, branch = 'main'): Promise { + return this._mutex.run(path, async () => { + await this._git(path, ['add', '--all']); + const staged = await this._git(path, ['diff', '--cached', '--name-only']); + if (!staged.trim()) return false; + await this._git(path, ['commit', '-m', message]); + await this._git(path, ['push', '-u', 'origin', branch]); + return true; + }); + } + + /** The state of a folder's repository; `repository: false` when it has none of its own. */ + projectStatus(path: string): Promise { + if (!this.isRepository(path)) { + return Promise.resolve({ + repository: false, + ahead: 0, + behind: 0, + merging: false, + files: [], + }); + } + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'status', + '--porcelain=v2', + '--branch', + '-z', + '--untracked-files=all', + ]); + return { ...parseStatus(output), merging: existsSync(join(path, '.git', 'MERGE_HEAD')) }; + }); + } + + init(path: string): Promise { + return this._run(path, ['init', '-b', 'main']); + } + + stage(path: string, paths: readonly string[]): Promise { + return this._run(path, ['add', '-A', '--', ...paths]); + } + + unstage(path: string, paths: readonly string[]): Promise { + return this._run(path, ['reset', '-q', '--', ...paths]); + } + + /** + * Rolls files back to the last commit: changed and deleted files are restored (staged or + * not), a file added since stays on disk but leaves the index, an untracked one is deleted. + */ + discard(path: string, paths: readonly string[]): Promise { + return this._mutex.run(path, async () => { + const list = async (args: string[]) => + new Set((await this._git(path, args)).split('\0').filter(Boolean)); + const hasHead = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', 'HEAD'])).exitCode === 0; + const committed = hasHead + ? await list(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...paths]) + : new Set(); + const indexed = await list(['ls-files', '-z', '--', ...paths]); + const restore = paths.filter((file) => committed.has(file)); + const forget = paths.filter((file) => !committed.has(file) && indexed.has(file)); + const remove = paths.filter((file) => !committed.has(file) && !indexed.has(file)); + if (restore.length) { + await this._git(path, [ + 'restore', + '--source=HEAD', + '--staged', + '--worktree', + '--', + ...restore, + ]); + } + if (forget.length) await this._git(path, ['rm', '--cached', '-q', '-f', '--', ...forget]); + if (remove.length) await this._git(path, ['clean', '-f', '-d', '-q', '--', ...remove]); + }); + } + + /** + * Commits `paths` only (the rest of what is staged stays out of the commit), the staged + * changes when there are no `paths`, or everything with `all`. + */ + commit( + path: string, + message: string, + options: { all?: boolean; paths?: readonly string[]; amend?: boolean } = {}, + ): Promise { + return this._mutex.run(path, async () => { + const { all = false, paths, amend = false } = options; + const merging = existsSync(join(path, '.git', 'MERGE_HEAD')); + if (all) await this._git(path, ['add', '-A']); + else if (paths?.length) { + const indexed = new Set( + (await this._git(path, ['ls-files', '-z', '--', ...paths])).split('\0').filter(Boolean), + ); + const addable = paths.filter((file) => indexed.has(file) || existsSync(join(path, file))); + if (addable.length) await this._git(path, ['add', '-A', '--', ...addable]); + } + await this._git(path, [ + 'commit', + '-q', + '-m', + message, + ...(amend ? ['--amend'] : []), + ...(paths?.length && !all && !merging ? ['--', ...paths] : []), + ]); + }); + } + + fetch(path: string): Promise { + return this._mutex.run(path, async () => { + await this._git(path, ['fetch', '--prune', '--quiet'], true); + }); + } + + /** A merge pull. Resolves with `conflicts` when it stopped on conflicts. */ + mergePull(path: string): Promise<{ conflicts: boolean }> { + return this._mutex.run(path, async () => { + try { + await this._git(path, ['pull', '--no-rebase', '--no-edit', '--quiet'], true); + return { conflicts: false }; + } catch (error) { + if (existsSync(join(path, '.git', 'MERGE_HEAD'))) return { conflicts: true }; + throw error; + } + }); + } + + /** Pushes the current branch; its first push sets `origin` as its upstream. */ + push(path: string): Promise { + return this._mutex.run(path, async () => { + const branch = (await this._git(path, ['symbolic-ref', '--short', 'HEAD'])).trim(); + const upstream = await this._git(path, [ + 'for-each-ref', + '--format=%(upstream:short)', + `refs/heads/${branch}`, + ]); + await this._git( + path, + upstream.trim() ? ['push', '--quiet'] : ['push', '--quiet', '-u', 'origin', branch], + true, + ); + }); + } + + branches(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)%00%(HEAD)%00%(upstream:short)', + 'refs/heads', + 'refs/remotes', + ]); + const branches = output + .split('\n') + .filter(Boolean) + .map((line) => line.split('\0')) + .filter(([ref = '']) => !/^refs\/remotes\/.*\/HEAD$/.test(ref)) + .map(([ref = '', name = '', head, upstream]): GitBranch => ({ + name, + current: head === '*', + ...(upstream && { upstream }), + remote: ref.startsWith('refs/remotes/'), + })); + if (branches.some((branch) => !branch.remote)) return branches; + const unborn = await this._git(path, ['symbolic-ref', '--short', 'HEAD']).catch(() => ''); + return unborn.trim() + ? [{ name: unborn.trim(), current: true, remote: false }, ...branches] + : branches; + }); + } + + /** Switches to a local branch; a remote's branch (`origin/x`) becomes a local one tracking it. */ + switchBranch(path: string, name: string): Promise { + return this._mutex.run(path, async () => { + const remote = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', `refs/remotes/${name}`])) + .exitCode === 0; + const local = remote ? name.slice(name.indexOf('/') + 1) : name; + const exists = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', `refs/heads/${local}`])) + .exitCode === 0; + await this._git( + path, + remote && !exists + ? ['switch', '--quiet', '--track', '-c', local, name] + : ['switch', '--quiet', local], + ); + }); + } + + createBranch(path: string, name: string, from?: string): Promise { + return this._run(path, ['switch', '--quiet', '-c', name, ...(from ? [from] : [])]); + } + + deleteBranch(path: string, name: string, force = false): Promise { + return this._run(path, ['branch', '--quiet', force ? '-D' : '-d', name]); + } + + log(path: string, limit = 50, skip = 0, branch?: string): Promise { + return this._mutex.run(path, async () => { + const head = await this._git(path, ['rev-parse', '--verify', '--quiet', 'HEAD']).catch( + () => '', + ); + if (!head.trim()) return []; + const output = await this._git(path, [ + 'log', + `--max-count=${limit}`, + `--skip=${skip}`, + '--format=%H%x00%h%x00%an%x00%at%x00%D%x00%s%x1e', + ...(branch ? [branch, '--'] : []), + ]); + return output + .split('\x1e') + .map((record) => record.replace(/^\n/, '')) + .filter(Boolean) + .map((record): GitCommit => { + const [hash = '', shortHash = '', author = '', time, refs = '', subject = ''] = + record.split('\0'); + return { + hash, + shortHash, + author, + time: Number(time), + refs: refs + .split(', ') + .map((ref) => ref.replace(/^HEAD -> /, '')) + .filter((ref) => ref && ref !== 'HEAD'), + subject, + }; + }); + }); + } + + /** Commits of the current branch its upstream does not have yet (every commit without one). */ + async outgoing(path: string): Promise { + const { upstream, ahead } = await this.projectStatus(path); + if (upstream) return ahead ? this.log(path, Math.min(ahead, 200), 0) : []; + return this.log(path, 200, 0); + } + + commitFiles(path: string, hash: string): Promise<{ path: string; change: GitChange }[]> { + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'show', + '--name-status', + '--format=', + '-z', + '--find-renames', + hash, + ]); + const parts = output.split('\0').filter(Boolean); + const files: { path: string; change: GitChange }[] = []; + for (let index = 0; index < parts.length; index++) { + const code = parts[index]![0]; + if (code === 'R' || code === 'C') { + files.push({ path: parts[index + 2] ?? '', change: code === 'R' ? 'R' : 'A' }); + index += 2; + } else { + files.push({ + path: parts[++index] ?? '', + change: code === 'A' ? 'A' : code === 'D' ? 'D' : 'M', + }); + } + } + return files; + }); + } + + /** Text of a file at `HEAD`, in the index (`INDEX`) or before a commit (`^`). */ + show(path: string, file: string, revision: string): Promise { + return this._mutex.run(path, async () => { + const spec = revision === 'INDEX' ? `:${file}` : `${revision}:${file}`; + const result = await this._exec(path, ['show', spec]); + return result.exitCode === 0 ? result.stdout : null; + }); + } + + stashes(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, ['stash', 'list', '--format=%gs']); + return output + .split('\n') + .filter(Boolean) + .map((message, index) => ({ index, message })); + }); + } + + stash(path: string, message?: string): Promise { + return this._run(path, [ + 'stash', + 'push', + '--include-untracked', + '--quiet', + ...(message ? ['-m', message] : []), + ]); + } + + applyStash(path: string, index: number, pop = false): Promise { + return this._run(path, ['stash', pop ? 'pop' : 'apply', '--quiet', `stash@{${index}}`]); + } + + dropStash(path: string, index: number): Promise { + return this._run(path, ['stash', 'drop', '--quiet', `stash@{${index}}`]); + } + + private _run(path: string, args: string[]): Promise { + return this._mutex.run(path, async () => { + await this._git(path, args); + }); + } + + /** + * `network`: the command talks to a remote. It is stopped after a while (a remote that never + * answers must not hold the repository's queue), and ssh never waits for a passphrase or a + * host confirmation in the terminal the editor was started from. + */ + private async _exec(cwd: string, args: string[], network = false) { + const batchSsh = + network && + !process.env.GIT_SSH_COMMAND && + !process.env.GIT_SSH && + !(await this._configured(cwd, 'core.sshCommand')); + return runProcess(this._env.gitPath, args, { + cwd, + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_CEILING_DIRECTORIES: dirname(cwd), + LC_ALL: 'C', + ...(batchSsh && { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }), + }, + ...(network && { signal: AbortSignal.timeout(this._networkTimeoutMs) }), + }).done; + } + + private async _configured(cwd: string, key: string): Promise { + const result = await this._exec(cwd, ['config', '--get', key]).catch(() => undefined); + return result?.exitCode === 0 && result.stdout.trim() !== ''; + } + + private async _git(cwd: string, args: string[], network = false): Promise { + let result; + try { + result = await this._exec(cwd, args, network); + } catch (error) { + if (network && (error as { name?: string }).name === 'AbortError') { + throw new RpcError( + 'TIMEOUT', + `git ${args[0]} got no answer from the remote in ${Math.round(this._networkTimeoutMs / 1000)} s. ` + + 'If it needs a password or a passphrase, run it once in a terminal.', + ); + } + throw error; + } + if (result.exitCode !== 0) { + const stderr = redact(result.stderr || result.stdout).trim(); + const reason = stderr.replace(/^(error|fatal): /gm, '').slice(0, 600); + throw new RpcError('INTERNAL', reason || `git ${args[0]} failed`, { + stderr: stderr.slice(0, 2000), + }); + } + return result.stdout; + } +} diff --git a/packages/server-core/src/git/git-token.ts b/packages/server-core/src/git/git-token.ts new file mode 100644 index 00000000..dc22608c --- /dev/null +++ b/packages/server-core/src/git/git-token.ts @@ -0,0 +1,10 @@ +export const withToken = (url: string, token: string): string => { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return url; + parsed.username = 'oauth2'; + parsed.password = token; + return parsed.href; +}; + +/** Removes credentials from git output (clone errors echo the remote URL). */ +export const redact = (text: string) => text.replace(/\/\/[^/@\s]+@/g, '//***@'); diff --git a/packages/server-core/src/git/index.ts b/packages/server-core/src/git/index.ts new file mode 100644 index 00000000..ee8bf73c --- /dev/null +++ b/packages/server-core/src/git/index.ts @@ -0,0 +1,2 @@ +export * from './git-service'; +export { parseStatus } from './parse-status'; diff --git a/packages/server-core/src/git/parse-status.ts b/packages/server-core/src/git/parse-status.ts new file mode 100644 index 00000000..a2d6feb7 --- /dev/null +++ b/packages/server-core/src/git/parse-status.ts @@ -0,0 +1,57 @@ +import type { GitChange, GitFile, GitStatus } from '@nanoforge-dev/editor-protocol'; + +export const CHANGES: Record = { + M: 'M', + T: 'M', + A: 'A', + D: 'D', + R: 'R', + C: 'A', +}; + +/** Reads `git status --porcelain=v2 --branch -z`. */ +export const parseStatus = (output: string): Omit => { + const status: Omit = { + repository: true, + ahead: 0, + behind: 0, + files: [], + }; + const files: GitFile[] = []; + const entries = output.split('\0'); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + if (!entry) continue; + if (entry.startsWith('# branch.head ')) { + const name = entry.slice('# branch.head '.length); + if (name !== '(detached)') status.branch = name; + } else if (entry.startsWith('# branch.oid ')) { + const oid = entry.slice('# branch.oid '.length); + if (oid !== '(initial)') status.head = oid.slice(0, 7); + } else if (entry.startsWith('# branch.upstream ')) { + status.upstream = entry.slice('# branch.upstream '.length); + } else if (entry.startsWith('# branch.ab ')) { + const match = /\+(\d+) -(\d+)/.exec(entry); + status.ahead = Number(match?.[1] ?? 0); + status.behind = Number(match?.[2] ?? 0); + } else if (entry.startsWith('? ')) { + files.push({ path: entry.slice(2), unstaged: 'U', conflicted: false }); + } else if (entry.startsWith('u ')) { + files.push({ path: entry.split(' ').slice(10).join(' '), conflicted: true }); + } else if (entry.startsWith('1 ') || entry.startsWith('2 ')) { + const renamed = entry.startsWith('2 '); + const fields = entry.split(' '); + const [x, y] = fields[1]!; + const staged = CHANGES[x!]; + const unstaged = CHANGES[y!]; + files.push({ + path: fields.slice(renamed ? 9 : 8).join(' '), + ...(renamed && { from: entries[++index] ?? '' }), + ...(staged && { staged }), + ...(unstaged && { unstaged }), + conflicted: false, + }); + } + } + return { ...status, files: files.sort((a, b) => a.path.localeCompare(b.path)) }; +}; diff --git a/packages/server-core/src/http/static-files.ts b/packages/server-core/src/http/static-files.ts new file mode 100644 index 00000000..c6c0002b --- /dev/null +++ b/packages/server-core/src/http/static-files.ts @@ -0,0 +1,37 @@ +import { readFile, stat } from 'node:fs/promises'; +import { extname } from 'node:path'; + +const CONTENT_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +export const contentType = (file: string) => + CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'; + +/** Serves a file (Bun streams it; Node reads it) with the given cache policy. */ +export const fileResponse = async ( + file: string, + cacheControl: string, +): Promise => { + const info = await stat(file).catch(() => undefined); + if (!info?.isFile()) return undefined; + const headers = { 'content-type': contentType(file), 'cache-control': cacheControl }; + const bun = (globalThis as { Bun?: { file(path: string): Blob } }).Bun; + const body = bun ? bun.file(file) : new Uint8Array(await readFile(file)); + return new Response(body, { headers }); +}; diff --git a/packages/server-core/src/index.ts b/packages/server-core/src/index.ts new file mode 100644 index 00000000..9f7b62e1 --- /dev/null +++ b/packages/server-core/src/index.ts @@ -0,0 +1,11 @@ +export * from './api'; +export * from './env'; +export * from './fs'; +export * from './git'; +export * from './plugin'; +export * from './process'; +export * from './project'; +export * from './runtime'; +export * from './server'; +export * from './session'; +export * from './settings'; diff --git a/packages/server-core/src/plugin/index.ts b/packages/server-core/src/plugin/index.ts new file mode 100644 index 00000000..bc1bdcdf --- /dev/null +++ b/packages/server-core/src/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './plugin-sources'; +export * from './server-plugins'; diff --git a/packages/server-core/src/plugin/plugin-sources.ts b/packages/server-core/src/plugin/plugin-sources.ts new file mode 100644 index 00000000..40a47b94 --- /dev/null +++ b/packages/server-core/src/plugin/plugin-sources.ts @@ -0,0 +1,238 @@ +import { watch } from 'chokidar'; +import { existsSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + Emitter, + type Event, + type Logger, + PackageManifestSchema, + type PluginManifest, + parsePluginManifest, +} from '@nanoforge-dev/editor-kernel'; +import type { PluginListing, PluginSuggestion } from '@nanoforge-dev/editor-protocol'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { PACKAGES_DIR } from '../fs/fs-service'; +import { PathJail } from '../fs/path-jail'; + +export const MANIFEST_FILE = 'nanoforge.manifest.json'; +export type PluginSourceKind = PluginListing['source']; + +/** Where a project keeps its own plugins. */ +export const PROJECT_PLUGINS_DIR = '.nanoforge/plugins'; + +/** How deep a package's `include` is followed (packages can include packages). */ +const MAX_INCLUDE_DEPTH = 4; + +/** The project whose plugins are listed: its id (for URLs) and its root folder. */ +export interface PluginProject { + readonly id: string; + readonly root: string; +} + +/** A plugin found on disk: `dir` holds the manifest and the built bundles. */ +export interface LocatedPlugin { + readonly source: PluginSourceKind; + readonly dir: string; + readonly manifest: PluginManifest | undefined; + readonly listing: PluginListing; +} + +const PROJECT_URL_PREFIX = '/plugins/project/'; + +const urlFor = ( + source: PluginSourceKind, + manifest: { name: string; version: string }, + project?: PluginProject, +) => + source === 'project' + ? `${PROJECT_URL_PREFIX}${project!.id}/${manifest.name}/${manifest.version}/` + : `/plugins/${source}/${manifest.name}/${manifest.version}/`; + +/** + * Finds plugins in four places: + * - bundled with the editor (`/@/`); + * - installed in the user's home (`/plugins/@/`), for every project; + * - installed in the open project (`/.nanoforge/plugins/@/`); + * - local dev folders (`/dist`). + * + * Packages (`nf_modules`) never hold plugins: they can only suggest some (`suggestions`). + */ +export class PluginSources { + private _located = new Map(); + /** The project plugins last listed for each project, by base URL. */ + private readonly _projectLocated = new Map>(); + private readonly _onDevChange = new Emitter({ + onFirstListenerAdd: () => this._watchDev(), + onLastListenerRemove: () => void this._devWatcher?.close(), + }); + private _devWatcher: ReturnType | undefined; + + /** Fires when a dev plugin was rebuilt (debounced). */ + readonly onDevChange: Event = this._onDevChange.event; + + constructor( + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) {} + + get installedDir(): string { + return join(this._env.dataDir, 'plugins'); + } + + /** Every plugin, plus `project`'s own plugins when set. */ + async list(project?: PluginProject): Promise { + const located: LocatedPlugin[] = []; + await this._scan(located, 'bundled', this._env.bundledPluginsDir); + await this._scan(located, 'installed', this.installedDir); + for (const path of this._env.devPlugins) + located.push(await this._read('dev', join(path, 'dist'))); + this._located = new Map(located.map((plugin) => [plugin.listing.baseUrl, plugin])); + if (!project) return located; + + const own: LocatedPlugin[] = []; + await this._scan(own, 'project', join(project.root, PROJECT_PLUGINS_DIR), project); + this._projectLocated.set( + project.id, + new Map(own.map((plugin) => [plugin.listing.baseUrl, plugin])), + ); + return [...located, ...own]; + } + + /** + * The plugins that packages in the project's `nf_modules` suggest, merged by plugin name. + * Reads each installed package's manifest and follows `include`, never scans. + */ + async suggestions(projectRoot: string): Promise { + const byName = new Map(); + const visit = async (dir: string, depth: number) => { + let manifest; + try { + const raw: unknown = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8')); + if ((raw as { type?: unknown } | null)?.type !== 'package') return; + manifest = PackageManifestSchema.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._logger.warn(`Package at ${dir} ignored: ${message}`); + return; + } + for (const [name, range] of Object.entries(manifest.suggestedPlugins)) { + const entry = byName.get(name) ?? { name, suggestedBy: [] }; + entry.suggestedBy.push({ package: manifest.name, range }); + byName.set(name, entry); + } + if (depth >= MAX_INCLUDE_DEPTH) return; + for (const include of manifest.include) await visit(join(dir, include), depth + 1); + }; + const root = join(projectRoot, PACKAGES_DIR); + for (const scope of await directories(root)) { + for (const name of await directories(join(root, scope))) + await visit(join(root, scope, name), 0); + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Absolute file of a plugin URL (`/plugins//@///`, or + * `/plugins/project//…` when `canAccess(projectId)`). + */ + async resolveFile( + urlPath: string, + canAccess: (projectId: string) => boolean = () => false, + ): Promise<{ file: string; plugin: LocatedPlugin } | undefined> { + let located: Map; + if (urlPath.startsWith(PROJECT_URL_PREFIX)) { + const projectId = urlPath.slice(PROJECT_URL_PREFIX.length).split('/')[0] ?? ''; + if (!canAccess(projectId)) return undefined; + located = this._projectLocated.get(projectId) ?? new Map(); + } else { + if (!this._located.size) await this.list(); + located = this._located; + } + for (const [baseUrl, plugin] of located) { + if (!urlPath.startsWith(baseUrl)) continue; + const rel = decodeURIComponent(urlPath.slice(baseUrl.length)); + try { + const file = await new PathJail(plugin.dir).resolve(rel); + return existsSync(file) ? { file, plugin } : undefined; + } catch { + return undefined; + } + } + return undefined; + } + + private async _scan( + into: LocatedPlugin[], + source: PluginSourceKind, + root: string | undefined, + project?: PluginProject, + ): Promise { + if (!root || !existsSync(root)) return; + for (const scope of await directories(root)) { + for (const name of await directories(join(root, scope))) { + into.push(await this._read(source, join(root, scope, name), project)); + } + } + } + + private async _read( + source: PluginSourceKind, + dir: string, + project?: PluginProject, + ): Promise { + const fallbackName = `${source}/${dir.split(/[\\/]/).slice(-2).join('-')}`; + try { + const raw: unknown = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8')); + const manifest = parsePluginManifest(raw, join(dir, MANIFEST_FILE)); + return { + source, + dir, + manifest, + listing: { source, baseUrl: urlFor(source, manifest, project), manifest: raw }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._logger.warn(`Plugin at ${dir} ignored: ${message}`); + return { + source, + dir, + manifest: undefined, + listing: { + source, + baseUrl: `/plugins/invalid/${fallbackName}/`, + manifest: null, + error: message, + }, + }; + } + } + + private _watchDev(): void { + if (!this._env.devPlugins.length) return; + const dists = this._env.devPlugins.map((path) => join(path, 'dist')); + const timers = new Map>(); + this._devWatcher = watch(dists, { ignoreInitial: true }); + this._devWatcher.on('all', (_event, file) => { + const dist = dists.find((dir) => file.startsWith(dir)); + if (!dist) return; + clearTimeout(timers.get(dist)); + timers.set( + dist, + setTimeout(async () => { + const plugin = await this._read('dev', dist); + this._located.set(plugin.listing.baseUrl, plugin); + if (plugin.manifest) this._onDevChange.fire(plugin); + }, 200), + ); + }); + } +} + +const directories = async (path: string): Promise => + (await readdir(path, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort(); diff --git a/packages/server-core/src/plugin/server-plugins.ts b/packages/server-core/src/plugin/server-plugins.ts new file mode 100644 index 00000000..350fe5e5 --- /dev/null +++ b/packages/server-core/src/plugin/server-plugins.ts @@ -0,0 +1,115 @@ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + type Disposable, + DisposableStore, + type Logger, + type LoggerService, + type PluginManifest, +} from '@nanoforge-dev/editor-kernel'; +import type { Contract, Implementation, RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; +import type { LocatedPlugin } from './plugin-sources'; + +/** Server-side services a plugin server entry may use. */ +export interface ServerPluginServices { + readonly [name: string]: unknown; +} + +/** What a plugin's `entry.server` module receives in `activate`. */ +export interface ServerPluginContext { + readonly name: string; + readonly manifest: PluginManifest; + readonly subscriptions: DisposableStore; + readonly logger: Logger; + readonly services: S; + /** RPC namespace of the plugin: contracts must use it (`plugin.`). */ + readonly namespace: string; + implement( + contract: C, + implementation: Implementation, + ): Disposable; +} + +export interface ServerPluginModule { + activate?(context: ServerPluginContext): void | Promise; + deactivate?(): void | Promise; +} + +export const pluginNamespace = (name: string) => `plugin.${name}`; + +interface ActiveServerPlugin { + readonly module: ServerPluginModule; + readonly store: DisposableStore; +} + +/** Runs `entry.server` of enabled plugins inside the editor server (trusted plugins). */ +export class ServerPluginHost implements Disposable { + private readonly _active = new Map(); + + constructor( + private readonly _router: RpcRouter, + private readonly _services: ServerPluginServices, + private readonly _logs: LoggerService, + ) {} + + get active(): string[] { + return [...this._active.keys()]; + } + + async activate(plugin: LocatedPlugin): Promise { + const manifest = plugin.manifest; + const entry = manifest?.entry.server; + if (!manifest || !entry || this._active.has(manifest.name)) return; + const logger = this._logs.getLogger(`${manifest.name}(server)`); + const store = new DisposableStore(); + const namespace = pluginNamespace(manifest.name); + try { + const url = pathToFileURL(join(plugin.dir, entry)); + url.searchParams.set('v', String(Date.now())); + const imported = (await import(url.href)) as ServerPluginModule & { + default?: ServerPluginModule; + }; + const module = imported.default ?? imported; + const context: ServerPluginContext = { + name: manifest.name, + manifest, + subscriptions: store, + logger, + services: this._services, + namespace, + implement: (contract, implementation) => { + if (contract.namespace !== namespace) { + throw new Error( + `${manifest.name} can only implement contracts in the "${namespace}" namespace`, + ); + } + return store.add(this._router.implement(contract, implementation)); + }, + }; + await module.activate?.(context); + this._active.set(manifest.name, { module, store }); + logger.info('Server entry activated'); + } catch (error) { + store.dispose(); + logger.error('Server entry failed to activate', error); + } + } + + async deactivate(name: string): Promise { + const plugin = this._active.get(name); + if (!plugin) return; + this._active.delete(name); + try { + await plugin.module.deactivate?.(); + } finally { + plugin.store.dispose(); + } + } + + dispose(): void { + for (const name of [...this._active.keys()]) void this.deactivate(name); + } +} diff --git a/packages/server-core/src/process/cli-service.ts b/packages/server-core/src/process/cli-service.ts new file mode 100644 index 00000000..453cc4d8 --- /dev/null +++ b/packages/server-core/src/process/cli-service.ts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { type OutputLine, type ProcessResult, runProcess } from './run-process'; + +export interface CliRun { + readonly id: string; + /** What ran, as shown in the console: `nf`, or a package manager. */ + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string; + /** The caller reports the output itself (builds): not shown as a task in the console. */ + readonly silent: boolean; + readonly onLine: Event; + readonly done: Promise; + kill(): void; +} + +/** Runs the NanoForge CLI (`nf`), preferring the project's own installation. */ +export class CliService { + private readonly _onDidStart = new Emitter(); + private _counter = 0; + + /** Fires for every run: runs in a project are streamed to its console as tasks. */ + readonly onDidStart: Event = this._onDidStart.event; + + constructor(private readonly _env: EditorEnv) {} + + /** `node_modules/.bin/nf` of the closest ancestor of `cwd`, else `NF_CLI_PATH` or `nf`. */ + resolveBinary(cwd: string): string { + if (this._env.cliPath) return this._env.cliPath; + for (let dir = cwd; ; dir = dirname(dir)) { + const candidate = join( + dir, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'nf.cmd' : 'nf', + ); + if (existsSync(candidate)) return candidate; + if (dirname(dir) === dir) return 'nf'; + } + } + + run( + args: readonly string[], + options: { + cwd: string; + env?: Record; + signal?: AbortSignal; + silent?: boolean; + }, + ): CliRun { + return this._start('nf', this.resolveBinary(options.cwd), args, options); + } + + /** Runs another tool of the project (its package manager), reported like a CLI run. */ + exec( + command: string, + args: readonly string[], + options: { cwd: string; env?: Record; signal?: AbortSignal }, + ): CliRun { + return this._start(command, command, args, options); + } + + private _start( + command: string, + binary: string, + args: readonly string[], + options: { + cwd: string; + env?: Record; + signal?: AbortSignal; + silent?: boolean; + }, + ): CliRun { + const { silent = false, ...processOptions } = options; + const process = runProcess(binary, args, processOptions); + const run: CliRun = { + id: `cli-${++this._counter}`, + command, + args, + cwd: options.cwd, + silent, + ...process, + }; + this._onDidStart.fire(run); + return run; + } +} diff --git a/packages/server-core/src/process/index.ts b/packages/server-core/src/process/index.ts new file mode 100644 index 00000000..9ab8edba --- /dev/null +++ b/packages/server-core/src/process/index.ts @@ -0,0 +1,2 @@ +export * from './cli-service'; +export * from './run-process'; diff --git a/packages/server-core/src/process/run-process.ts b/packages/server-core/src/process/run-process.ts new file mode 100644 index 00000000..37a64e93 --- /dev/null +++ b/packages/server-core/src/process/run-process.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +export interface ProcessOptions { + cwd: string; + env?: Record; + signal?: AbortSignal; + /** Data written to stdin, then closed. */ + input?: string; +} + +export interface OutputLine { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + +export interface RunningProcess { + readonly onLine: Event; + readonly done: Promise; + kill(): void; +} + +export interface ProcessResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Spawns a process without a shell; output is split into lines and buffered. */ +export const runProcess = ( + command: string, + args: readonly string[], + options: ProcessOptions, +): RunningProcess => { + const onLine = new Emitter(); + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdio: ['pipe', 'pipe', 'pipe'], + ...(options.signal && { signal: options.signal }), + }); + const buffers = { stdout: '', stderr: '' }; + const pending = { stdout: '', stderr: '' }; + + const collect = (stream: 'stdout' | 'stderr') => (chunk: Buffer) => { + const text = chunk.toString('utf8'); + buffers[stream] += text; + const lines = (pending[stream] + text).split(/\r?\n/); + pending[stream] = lines.pop() ?? ''; + for (const line of lines) onLine.fire({ stream, text: line }); + }; + child.stdout.on('data', collect('stdout')); + child.stderr.on('data', collect('stderr')); + if (options.input !== undefined) child.stdin.end(options.input); + else child.stdin.end(); + + const done = new Promise((resolve, reject) => { + child.on('error', (error) => { + onLine.dispose(); + reject(error); + }); + child.on('close', (code) => { + for (const stream of ['stdout', 'stderr'] as const) { + if (pending[stream]) onLine.fire({ stream, text: pending[stream] }); + } + onLine.dispose(); + resolve({ exitCode: code ?? -1, stdout: buffers.stdout, stderr: buffers.stderr }); + }); + }); + + return { onLine: onLine.event, done, kill: () => child.kill() }; +}; diff --git a/packages/server-core/src/project/config-loader.ts b/packages/server-core/src/project/config-loader.ts new file mode 100644 index 00000000..5d13febd --- /dev/null +++ b/packages/server-core/src/project/config-loader.ts @@ -0,0 +1,159 @@ +import { readFile } from 'node:fs/promises'; +import { builtinModules } from 'node:module'; +import ts from 'typescript'; +import { unrun } from 'unrun'; + +import { type NanoforgeConfig, resolveConfig } from '@nanoforge-dev/config'; + +export const CONFIG_FILES = [ + 'nanoforge.config.ts', + 'nanoforge.config.mts', + 'nanoforge.config.js', + 'nanoforge.config.mjs', +]; + +export type ResolvedConfig = ReturnType; + +/** Loads a `nanoforge.config.*` file into a fully resolved config. */ +export interface ConfigLoader { + load(file: string): Promise; +} + +export class ConfigLoadError extends Error { + constructor( + readonly file: string, + message: string, + ) { + super(message); + this.name = 'ConfigLoadError'; + } +} + +const BUILTINS = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); + +const CONFIG_MODULE_ID = '\0nanoforge-config'; + +/** + * `@nanoforge-dev/config` as seen by configs: `defineConfig` is the identity, so configs load + * even before the project's dependencies are installed (fresh clone). + */ +const configModulePlugin = { + name: 'nanoforge-config-module', + resolveId: (id: string) => (id === '@nanoforge-dev/config' ? CONFIG_MODULE_ID : null), + load: (id: string) => + id === CONFIG_MODULE_ID ? 'export const defineConfig = (config) => config;' : null, +}; + +/** + * Executes the config like the CLI does (local editor: the user trusts their own project). + * + * Everything but Node built-ins is bundled: unrun imports its bundle from + * `/node_modules/.unrun`, so external packages would resolve from the editor's + * working directory instead of the project. + */ +export const executingConfigLoader: ConfigLoader = { + async load(file) { + let module: { default?: unknown }; + try { + ({ module } = await unrun<{ default?: unknown }>({ + path: file, + preset: 'bundle-require', + inputOptions: { + external: (id: string) => BUILTINS.has(id), + plugins: [configModulePlugin], + }, + })); + } catch (error) { + throw new ConfigLoadError( + file, + `Failed to load ${file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return validate(module.default, file); + }, +}; + +const CONFIG_TYPES = new Set(['workspace', 'client', 'server', 'lib']); + +/** + * Reads the config without executing it: only `export default defineConfig({...})` or + * `export default {...}` with literal values is supported. Used when projects are untrusted + * (hosted ONLINE editor). + */ +export const staticConfigLoader: ConfigLoader = { + async load(file) { + const source = ts.createSourceFile( + file, + await readFile(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + let expression: ts.Expression | undefined; + for (const statement of source.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) + expression = statement.expression; + } + if (!expression) throw new ConfigLoadError(file, 'Config file has no default export'); + if (ts.isCallExpression(expression) && expression.arguments.length === 1) { + expression = expression.arguments[0]!; + } + return validate(literal(expression, file), file); + }, +}; + +const validate = (value: unknown, file: string): ResolvedConfig => { + if (value === undefined) throw new ConfigLoadError(file, 'Config file has no default export'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ConfigLoadError(file, 'The default export must be an object'); + } + const type = (value as { type?: unknown }).type; + if (typeof type !== 'string' || !CONFIG_TYPES.has(type)) { + throw new ConfigLoadError(file, `Unknown config type ${JSON.stringify(type)}`); + } + return resolveConfig(value as NanoforgeConfig); +}; + +const literal = (node: ts.Expression, file: string): unknown => { + const fail = (): never => { + const { line, character } = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()); + throw new ConfigLoadError( + file, + `Only literal values are supported in configs of online projects (${line + 1}:${character + 1}: ${node.getText()})`, + ); + }; + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) + ) { + return literal(node.expression, file); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if ( + ts.isPrefixUnaryExpression(node) && + node.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(node.operand) + ) { + return -Number(node.operand.text); + } + if (ts.isArrayLiteralExpression(node)) + return node.elements.map((element) => literal(element, file)); + if (ts.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) return fail(); + const name = property.name; + const key = + ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) + ? name.text + : fail(); + result[key] = literal(property.initializer, file); + } + return result; + } + return fail(); +}; diff --git a/packages/server-core/src/project/discovery.ts b/packages/server-core/src/project/discovery.ts new file mode 100644 index 00000000..c1037bde --- /dev/null +++ b/packages/server-core/src/project/discovery.ts @@ -0,0 +1,298 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; + +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import { joinPath } from '@nanoforge-dev/editor-protocol'; + +import { expandDirectoryGlobs } from '../util/glob'; +import { + CONFIG_FILES, + ConfigLoadError, + type ConfigLoader, + type ResolvedConfig, +} from './config-loader'; + +/** `@nanoforge-dev/*` packages that are tooling, not engine libraries. */ +const TOOLING_PACKAGES = new Set([ + '@nanoforge-dev/actions', + '@nanoforge-dev/cli', + '@nanoforge-dev/config', + '@nanoforge-dev/schematics', + '@nanoforge-dev/utils-eslint-config', + '@nanoforge-dev/utils-prettier-config', +]); + +type Diagnostic = ProjectModel['diagnostics'][number]; + +interface PackageJson { + name?: string; + dependencies?: Record; + devDependencies?: Record; +} + +export interface DiscoveryInput { + readonly id: string; + readonly root: string; + readonly location: string; + readonly loader: ConfigLoader; +} + +const toPosix = (path: string) => path.split(sep).join('/'); + +export const findConfigFile = (directory: string): string | undefined => + CONFIG_FILES.map((file) => join(directory, file)).find((file) => existsSync(file)); + +const readJson = async (file: string): Promise => { + try { + return JSON.parse(await readFile(file, 'utf8')) as T; + } catch { + return undefined; + } +}; + +/** Installed version of a package, looked up from `from` up to the project root. */ +const installedVersion = async ( + name: string, + from: string, + root: string, +): Promise => { + for (let dir = from; ; dir = dirname(dir)) { + const pkg = await readJson<{ version?: string }>( + join(dir, 'node_modules', name, 'package.json'), + ); + if (pkg?.version) return pkg.version; + if (dir === root || dirname(dir) === dir) return undefined; + } +}; + +const engineLibs = async ( + appRoot: string, + projectRoot: string, + pkg: PackageJson | undefined, +): Promise> => { + const declared: Record = { ...pkg?.devDependencies, ...pkg?.dependencies }; + const names = new Set( + Object.keys(declared).filter( + (name) => name.startsWith('@nanoforge-dev/') && !TOOLING_PACKAGES.has(name), + ), + ); + const libs: Record = {}; + for (const name of [...names].sort()) { + libs[name] = (await installedVersion(name, appRoot, projectRoot)) ?? declared[name] ?? '*'; + } + return libs; +}; + +/** Every package an app's package.json depends on (narrowed to the project's libraries later). */ +const declaredPackages = (pkg: PackageJson | undefined): string[] => + Object.keys({ ...pkg?.devDependencies, ...pkg?.dependencies }); + +/** What an app says about the shared libraries it uses, before the project's are all known. */ +export interface LibraryUses { + /** The app's config file, for diagnostics. */ + readonly configFile: string; + /** Its config's `libs` (clients and servers): the written path and the folder it points at. */ + readonly libs?: readonly { readonly path: string; readonly root: string }[]; + /** Every package its package.json depends on. */ + readonly packages: readonly string[]; +} + +/** + * Fills each app's `libraries`: the shared libraries its `nanoforge.config` lists in `libs` + * (paths relative to the app, as the CLI writes them). A library depends on others through its + * package.json (a lib config has no `libs`). The package.json of a client or server should depend + * on the same libraries: where it does not, or where `libs` names a folder that is no library, + * a warning says so. A library nothing mentions (a project made before apps chose their + * libraries) is given to every app, so nothing disappears. + */ +export const resolveLibraries = ( + apps: AppModel[], + uses: ReadonlyMap, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const libraries = apps.filter((app) => app.type === 'lib'); + const mentioned = new Set(); + for (const app of apps) { + const use = uses.get(app.id); + const packages = use?.packages ?? []; + const declared = libraries.filter( + (library) => library !== app && packages.includes(library.name), + ); + if (app.type === 'lib' || !use?.libs) { + app.libraries = declared.map((library) => library.name); + for (const library of declared) mentioned.add(library.name); + continue; + } + const listed: AppModel[] = []; + for (const { path, root } of use.libs) { + const library = libraries.find((candidate) => candidate.root === root); + if (library) listed.push(library); + else + diagnostics.push({ + path: use.configFile, + message: `libs lists "${path}", which is not a shared library of the project.`, + severity: 'warning', + }); + } + for (const library of [...listed, ...declared]) mentioned.add(library.name); + for (const library of listed.filter((candidate) => !declared.includes(candidate))) + diagnostics.push({ + path: use.configFile, + message: `${app.name} uses ${library.name} (libs), but its package.json does not depend on it: add "${library.name}": "workspace:*".`, + severity: 'warning', + }); + for (const library of declared.filter((candidate) => !listed.includes(candidate))) + diagnostics.push({ + path: use.configFile, + message: `${app.name}'s package.json depends on ${library.name}, but its libs does not list ${library.root}: the editor does not give it to the app.`, + severity: 'warning', + }); + app.libraries = listed.map((library) => library.name); + } + const unclaimed = libraries.filter((library) => !mentioned.has(library.name)); + for (const library of unclaimed) library.unclaimed = true; + for (const app of apps) { + app.libraries = [ + ...new Set([ + ...app.libraries, + ...unclaimed.filter((library) => library !== app).map((library) => library.name), + ]), + ].sort(); + } + return diagnostics; +}; + +const toApp = async ( + projectRoot: string, + appRoot: string, + config: ResolvedConfig, + uses: Map, +): Promise => { + if (config.type === 'workspace') return undefined; + const root = toPosix(relative(projectRoot, appRoot)); + const pkg = await readJson(join(appRoot, 'package.json')); + const name = pkg?.name ?? (basename(appRoot) || 'app'); + const at = (path: string) => joinPath(root, path); + const configFile = toPosix(relative(projectRoot, findConfigFile(appRoot) ?? appRoot)); + uses.set(root, { + configFile, + packages: declaredPackages(pkg), + ...(config.type !== 'lib' && { + libs: config.libs.map((path) => ({ + path, + root: toPosix(relative(projectRoot, resolve(appRoot, path))), + })), + }), + }); + + if (config.type === 'lib') { + return { + id: root, + name, + type: 'lib', + root, + language: 'ts', + dirs: { + assets: at(config.dir.assets), + components: at(config.dir.components), + systems: at(config.dir.systems), + scenes: at(config.dir.scenes), + }, + engineLibs: await engineLibs(appRoot, projectRoot, pkg), + libraries: [], + }; + } + return { + id: root, + name, + type: config.type, + root, + language: config.language, + dirs: { + assets: at(config.dir.assets), + components: at(config.dir.components), + systems: at(config.dir.systems), + scenes: at(config.dir.scenes), + }, + entryFile: at(config.entryFile), + editorEntryFile: at(config.editor.entryFile), + outDir: at(config.out.dir), + engineLibs: await engineLibs(appRoot, projectRoot, pkg), + libraries: [], + }; +}; + +/** + * Builds the project model from `nanoforge.config.*` files: a workspace lists its apps + * through `packages` globs; a project whose root config is an app is a single-app project. + * Problems become diagnostics instead of failing the whole discovery. + */ +export const discoverProject = async (input: DiscoveryInput): Promise => { + const { root, loader } = input; + const diagnostics: Diagnostic[] = []; + const apps: AppModel[] = []; + const uses = new Map(); + const rootPkg = await readJson(join(root, 'package.json')); + const model = (): ProjectModel => ({ + id: input.id, + name: rootPkg?.name ?? basename(root), + location: input.location, + apps: apps.sort((a, b) => a.id.localeCompare(b.id)), + diagnostics, + }); + + const load = async (directory: string): Promise => { + const file = findConfigFile(directory); + const path = toPosix(relative(root, file ?? join(directory, CONFIG_FILES[0]!))); + if (!file) { + const legacy = existsSync(join(directory, 'nanoforge.config.json')); + diagnostics.push({ + path: legacy ? toPosix(relative(root, join(directory, 'nanoforge.config.json'))) : path, + message: legacy + ? 'This project was made by an older NanoForge CLI (nanoforge.config.json). The editor needs a project made by the CLI version 2 or later (nanoforge.config.ts): update the CLI and create the project again.' + : 'No nanoforge.config file found', + severity: 'error', + }); + return undefined; + } + try { + return await loader.load(file); + } catch (error) { + const message = + error instanceof ConfigLoadError || error instanceof Error ? error.message : String(error); + diagnostics.push({ path, message, severity: 'error' }); + return undefined; + } + }; + + const rootConfig = await load(root); + if (!rootConfig) return model(); + if (rootConfig.type !== 'workspace') { + const app = await toApp(root, root, rootConfig, uses); + if (app) apps.push(app); + return model(); + } + + for (const directory of await expandDirectoryGlobs(root, rootConfig.packages)) { + const appRoot = join(root, directory); + if (!findConfigFile(appRoot)) continue; // plain folders matched by a glob + const config = await load(appRoot); + if (!config) continue; + if (config.type === 'workspace') { + diagnostics.push({ + path: joinPath(directory, 'nanoforge.config.ts'), + message: 'Nested workspaces are not supported', + severity: 'warning', + }); + continue; + } + const app = await toApp(root, appRoot, config, uses); + if (app) apps.push(app); + } + diagnostics.push(...resolveLibraries(apps, uses)); + if (!apps.length) { + diagnostics.push({ path: '', message: 'The workspace has no apps', severity: 'warning' }); + } + return model(); +}; diff --git a/packages/server-core/src/project/formatter.ts b/packages/server-core/src/project/formatter.ts new file mode 100644 index 00000000..99470dfa --- /dev/null +++ b/packages/server-core/src/project/formatter.ts @@ -0,0 +1,90 @@ +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import * as bundledPrettier from 'prettier'; +import { parse as parseYaml } from 'yaml'; + +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +type Prettier = Pick; + +export interface FormatResult { + readonly text: string; + readonly formatter: 'project' | 'bundled' | 'none'; +} + +/** Static Prettier configs (no code): what hosted editors may read. */ +const STATIC_CONFIGS = ['.prettierrc', '.prettierrc.json', '.prettierrc.yaml', '.prettierrc.yml']; + +/** + * Formats files with Prettier. + * + * - `project`: the project's Prettier, config and plugins (executes project code, local only). + * - `static`: the bundled Prettier with the closest JSON/YAML config, without plugins. + */ +export class Formatter { + private readonly _projectPrettier = new Map>(); + + constructor(private readonly _mode: 'project' | 'static') {} + + async format(root: string, absolute: string, text: string): Promise { + const project = this._mode === 'project' ? await this._resolveProject(absolute) : undefined; + const prettier: Prettier = project ?? bundledPrettier; + const ignorePath = join(root, '.prettierignore'); + const info = await prettier + .getFileInfo(absolute, { ignorePath, resolveConfig: false }) + .catch(() => ({ ignored: false, inferredParser: null })); + if (info.ignored || !info.inferredParser) return { text, formatter: 'none' }; + const config = project + ? await prettier.resolveConfig(absolute, { editorconfig: true }) + : await readStaticConfig(root, absolute); + try { + const formatted = await prettier.format(text, { ...config, filepath: absolute }); + return { text: formatted, formatter: project ? 'project' : 'bundled' }; + } catch (error) { + throw new RpcError( + 'BAD_REQUEST', + `Prettier could not format the file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** The Prettier installed for the file (closest node_modules), else undefined. */ + private _resolveProject(absolute: string): Promise { + const from = dirname(absolute); + let prettier = this._projectPrettier.get(from); + if (!prettier) { + prettier = (async () => { + try { + const entry = createRequire(join(from, 'noop.js')).resolve('prettier'); + const module = (await import(pathToFileURL(entry).href)) as Partial & { + default?: Prettier; + }; + return typeof module.format === 'function' ? (module as Prettier) : module.default; + } catch { + return undefined; + } + })(); + this._projectPrettier.set(from, prettier); + } + return prettier; + } +} + +/** The closest static config from the file up to the project root, without plugins. */ +const readStaticConfig = async (root: string, absolute: string) => { + for (let dir = dirname(absolute); ; dir = dirname(dir)) { + for (const name of STATIC_CONFIGS) { + const text = await readFile(join(dir, name), 'utf8').catch(() => undefined); + if (text === undefined) continue; + const parsed: unknown = name.endsWith('.json') ? JSON.parse(text) : parseYaml(text); + if (!parsed || typeof parsed !== 'object') return {}; + const config = { ...(parsed as Record) }; + delete config.plugins; + delete config.overrides; + return config; + } + if (dir === root || dirname(dir) === dir) return {}; + } +}; diff --git a/packages/server-core/src/project/index.ts b/packages/server-core/src/project/index.ts new file mode 100644 index 00000000..0fab4270 --- /dev/null +++ b/packages/server-core/src/project/index.ts @@ -0,0 +1,4 @@ +export * from './config-loader'; +export * from './discovery'; +export * from './project-registry'; +export * from './formatter'; diff --git a/packages/server-core/src/project/project-registry.ts b/packages/server-core/src/project/project-registry.ts new file mode 100644 index 00000000..52c0a7e5 --- /dev/null +++ b/packages/server-core/src/project/project-registry.ts @@ -0,0 +1,241 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +import { + type Disposable, + DisposableStore, + type Logger, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectModel, ProjectRef, RecentProject, User } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import { NFIGNORE_FILE, ProjectFileSystem } from '../fs/fs-service'; +import { ProjectWatcher } from '../fs/watcher'; +import type { GitService } from '../git/git-service'; +import type { Session } from '../session/session-store'; +import { shortHash } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { type ConfigLoader, executingConfigLoader, staticConfigLoader } from './config-loader'; +import { discoverProject } from './discovery'; + +const MAX_RECENT = 20; +const CONFIG_CHANGE = /(^|\/)(nanoforge\.config\.[mc]?[jt]s|package\.json)$/; + +/** An opened project: its files, watcher and live model. */ +export class OpenProject implements Disposable { + readonly fs: ProjectFileSystem; + readonly watcher: ProjectWatcher; + private readonly _model: ObservableValue; + private readonly _store = new DisposableStore(); + private _rediscovery: ReturnType | undefined; + + constructor( + readonly id: string, + readonly root: string, + readonly ref: ProjectRef, + readonly location: string, + initial: ProjectModel, + private readonly _loader: ConfigLoader, + private readonly _logger: Logger, + ) { + this.fs = new ProjectFileSystem(root); + this._model = new ObservableValue(initial); + this.watcher = new ProjectWatcher(this.fs.jail, { + ignoredPaths: () => this._model.get().apps.flatMap((app) => (app.outDir ? [app.outDir] : [])), + isIgnored: (path, directory) => this.fs.isIgnored(path, directory), + }); + void this.fs.refreshIgnore(); + this._store.add( + this.watcher.onDidChange((changes) => { + if (changes.some((change) => CONFIG_CHANGE.test(change.path))) this._scheduleRediscovery(); + if (changes.some((change) => change.path === NFIGNORE_FILE)) void this.fs.refreshIgnore(); + }), + ); + } + + get name(): string { + return this._model.get().name; + } + + get model(): Observable { + return this._model.readonly(); + } + + async rediscover(): Promise { + const model = await discoverProject({ + id: this.id, + root: this.root, + location: this.location, + loader: this._loader, + }); + this._model.set(model); + return model; + } + + dispose(): void { + clearTimeout(this._rediscovery); + this._store.dispose(); + this.watcher.dispose(); + } + + private _scheduleRediscovery(): void { + clearTimeout(this._rediscovery); + this._rediscovery = setTimeout(() => { + this.rediscover().catch((error: unknown) => + this._logger.error(`Rediscovery of ${this.id} failed`, error), + ); + }, 200); + } +} + +interface RecentStore { + [userId: string]: (Omit & { openedAt: string })[]; +} + +/** + * Opens projects and keeps them alive. Project ids are stable: a hash of the real path + * (offline) or the gateway id (online), independent from sessions. + */ +export class ProjectRegistry implements Disposable { + private readonly _projects = new Map(); + private readonly _opening = new KeyedMutex(); + private readonly _loader: ConfigLoader; + + constructor( + private readonly _env: EditorEnv, + private readonly _git: GitService, + private readonly _api: ApiClient, + private readonly _logger: Logger, + ) { + this._loader = _env.mode === 'ONLINE' ? staticConfigLoader : executingConfigLoader; + } + + async open(session: Session, ref: ProjectRef): Promise { + const project = + 'path' in ref + ? await this._openLocal(ref.path) + : await this._openGateway(session, ref.gatewayId); + session.projects.add(project.id); + if (session.user) await this._remember(session.user, project); + return project; + } + + /** Returns an opened project the session may access, else throws NOT_FOUND. */ + get(session: Session, id: string): OpenProject { + const project = this._projects.get(id); + const allowed = session.mode === 'OFFLINE' || session.projects.has(id); + if (!project || !allowed) throw new RpcError('NOT_FOUND', `Project ${id} is not open`); + return project; + } + + async recent(user: User): Promise { + const store = await this._readRecent(); + return (store[user.id] ?? []).map((entry) => ({ + ...entry, + openedAt: new Date(entry.openedAt), + })); + } + + async forget(user: User, id: string): Promise { + const store = await this._readRecent(); + store[user.id] = (store[user.id] ?? []).filter((entry) => entry.id !== id); + await this._writeRecent(store); + } + + dispose(): void { + for (const project of this._projects.values()) project.dispose(); + this._projects.clear(); + } + + private async _openLocal(path: string): Promise { + if (this._env.mode === 'ONLINE') { + throw new RpcError('FORBIDDEN', 'Local projects cannot be opened on a hosted editor'); + } + const target = resolve(this._env.fsRoot, path); + const rel = relative(this._env.fsRoot, target); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new RpcError('FORBIDDEN', `Projects must be inside ${this._env.fsRoot}`); + } + const info = await stat(target).catch(() => undefined); + if (!info?.isDirectory()) throw new RpcError('NOT_FOUND', `No project directory at ${path}`); + const real = await realpath(target); + return this._getOrCreate(shortHash(real), real, { path: real }, real); + } + + private async _openGateway(session: Session, gatewayId: string): Promise { + if (this._env.mode !== 'ONLINE') { + throw new RpcError('FORBIDDEN', 'Gateway projects need an online editor'); + } + const gateway = await this._api.gatewayProject(session, gatewayId); + const id = `gw-${gatewayId.replace(/[^\w-]/g, '').slice(0, 60)}`; + return this._opening.run(id, async () => { + const root = join(this._env.fsRoot, id); + if (!this._git.isRepository(root)) { + await mkdir(dirname(root), { recursive: true }); + await this._git.clone(gateway.gatewayProjectRegistryUrl, root, gateway.token); + } else if (!this._projects.has(id)) { + await this._git.pull(root); + } + const subdir = gateway.gatewayProjectRegistryMetadata.dir; + const projectRoot = subdir ? join(root, subdir) : root; + return this._getOrCreate(id, projectRoot, { gatewayId }, gateway.name); + }); + } + + private _getOrCreate( + id: string, + root: string, + ref: ProjectRef, + location: string, + ): Promise { + return this._opening.run(`open:${id}`, async () => { + const existing = this._projects.get(id); + if (existing) return existing; + const model = await discoverProject({ id, root, location, loader: this._loader }); + const project = new OpenProject(id, root, ref, location, model, this._loader, this._logger); + this._projects.set(id, project); + this._logger.info(`Opened project ${model.name} (${id}) at ${root}`); + return project; + }); + } + + private async _remember(user: User, project: OpenProject): Promise { + const store = await this._readRecent(); + const entry = { + id: project.id, + name: project.name, + location: project.location, + ref: project.ref, + openedAt: new Date().toISOString(), + }; + store[user.id] = [ + entry, + ...(store[user.id] ?? []).filter((item) => item.id !== project.id), + ].slice(0, MAX_RECENT); + await this._writeRecent(store); + } + + private get _recentFile(): string { + return join(this._env.dataDir, 'recent-projects.json'); + } + + private async _readRecent(): Promise { + if (!existsSync(this._recentFile)) return {}; + try { + return JSON.parse(await readFile(this._recentFile, 'utf8')) as RecentStore; + } catch (error) { + this._logger.warn(`Ignoring unreadable ${basename(this._recentFile)}`, error); + return {}; + } + } + + private async _writeRecent(store: RecentStore): Promise { + await mkdir(this._env.dataDir, { recursive: true }); + await writeFile(this._recentFile, `${JSON.stringify(store, null, 2)}\n`); + } +} diff --git a/packages/server-core/src/project/type-files.ts b/packages/server-core/src/project/type-files.ts new file mode 100644 index 00000000..6b04665b --- /dev/null +++ b/packages/server-core/src/project/type-files.ts @@ -0,0 +1,50 @@ +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; + +const MAX_FILES = 400; +const MAX_BYTES = 8 * 1024 * 1024; + +/** + * Finds `node_modules/` from `fromDir` up to `root` and returns its package.json and + * type declaration files (paths relative to `root`). + */ +export const collectTypeFiles = async ( + root: string, + fromDir: string, + module: string, +): Promise<{ path: string; text: string }[]> => { + let packageDir: string | undefined; + for (let dir = fromDir; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', ...module.split('/')); + if (existsSync(join(candidate, 'package.json'))) { + packageDir = candidate; + break; + } + if (dir === root || dirname(dir) === dir || relative(root, dir).startsWith('..')) break; + } + if (!packageDir || relative(root, packageDir).startsWith('..')) return []; + + const files: { path: string; text: string }[] = []; + let bytes = 0; + const add = async (file: string) => { + if (files.length >= MAX_FILES || bytes > MAX_BYTES) return; + const text = await readFile(file, 'utf8'); + bytes += text.length; + files.push({ path: relative(root, file).split(sep).join('/'), text }); + }; + await add(join(packageDir, 'package.json')); + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (files.length >= MAX_FILES) return; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) await walk(path); + } else if (/\.d\.[cm]?ts$/.test(entry.name) && (await stat(path)).size < 2 * 1024 * 1024) { + await add(path); + } + } + }; + await walk(packageDir); + return files; +}; diff --git a/packages/server-core/src/rpc/core-rpc.type.ts b/packages/server-core/src/rpc/core-rpc.type.ts new file mode 100644 index 00000000..cd5b631e --- /dev/null +++ b/packages/server-core/src/rpc/core-rpc.type.ts @@ -0,0 +1,30 @@ +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import type { GitService } from '../git/git-service'; +import type { LocatedPlugin, PluginSources } from '../plugin/plugin-sources'; +import type { CliService } from '../process/cli-service'; +import type { ProjectRegistry } from '../project/project-registry'; +import type { RuntimeManager } from '../runtime/project-runtime'; +import type { SessionStore } from '../session/session-store'; +import type { + AccountSettingsBackend, + ProjectLocalSettingsStore, +} from '../settings/account-backends'; + +export interface CoreRpcDependencies { + readonly env: EditorEnv; + readonly version: string; + readonly sessions: SessionStore; + readonly projects: ProjectRegistry; + readonly plugins: PluginSources; + /** Runs a project plugin's server entry (local editors only). */ + readonly activateServerPlugin?: (plugin: LocatedPlugin) => Promise; + /** Stops a plugin's server entry (it was uninstalled, or is being replaced). */ + readonly deactivateServerPlugin?: (name: string) => Promise; + readonly accountSettings: AccountSettingsBackend; + readonly cli: CliService; + readonly api: ApiClient; + readonly projectLocalSettings: ProjectLocalSettingsStore; + readonly runtime: RuntimeManager; + readonly git: GitService; +} diff --git a/packages/server-core/src/rpc/implement-core-rpc.ts b/packages/server-core/src/rpc/implement-core-rpc.ts new file mode 100644 index 00000000..e1cd0abb --- /dev/null +++ b/packages/server-core/src/rpc/implement-core-rpc.ts @@ -0,0 +1,34 @@ +import { type Disposable, DisposableStore } from '@nanoforge-dev/editor-kernel'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; +import type { CoreRpcDependencies } from './core-rpc.type'; +import { implementCodeRpc } from './implementation/code.implementation'; +import { implementFsRpc } from './implementation/fs.implementation'; +import { implementGitRpc } from './implementation/git.implementation'; +import { implementPluginsRpc } from './implementation/plugins.implementation'; +import { implementProjectsRpc } from './implementation/projects.implementation'; +import { implementRegistryRpc } from './implementation/registry.implementation'; +import { implementRuntimeRpc } from './implementation/runtime.implementation'; +import { implementSessionRpc } from './implementation/session.implementation'; +import { implementSettingsRpc } from './implementation/settings.implementation'; + +export const implementCoreRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const store = new DisposableStore(); + for (const implement of [ + implementSessionRpc, + implementProjectsRpc, + implementFsRpc, + implementPluginsRpc, + implementSettingsRpc, + implementCodeRpc, + implementRuntimeRpc, + implementGitRpc, + implementRegistryRpc, + ]) + store.add(implement(router, deps)); + return store; +}; diff --git a/packages/server-core/src/rpc/implementation/code.implementation.ts b/packages/server-core/src/rpc/implementation/code.implementation.ts new file mode 100644 index 00000000..e8b7e632 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/code.implementation.ts @@ -0,0 +1,30 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { CodeContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { Formatter } from '../../project/formatter'; +import { collectTypeFiles } from '../../project/type-files'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the code contract. */ +export const implementCodeRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const formatter = new Formatter(env.mode === 'OFFLINE' ? 'project' : 'static'); + return router.implement(CodeContract, { + methods: { + typeFiles: async ({ project, from, module }, { session }) => { + const open = projects.get(session, project); + const fromDir = await open.fs.jail.resolve(from.split('/').slice(0, -1).join('/')); + return collectTypeFiles(open.root, fromDir, module); + }, + format: async ({ project, path, text }, { session }) => { + const open = projects.get(session, project); + return formatter.format(open.root, await open.fs.jail.resolve(path), text); + }, + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/fs.implementation.ts b/packages/server-core/src/rpc/implementation/fs.implementation.ts new file mode 100644 index 00000000..37cc1336 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/fs.implementation.ts @@ -0,0 +1,47 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { FsContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { revealInFileManager } from '../../fs/reveal'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the file system contract. */ +export const implementFsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + return router.implement(FsContract, { + methods: { + stat: ({ project, path }, { session }) => projects.get(session, project).fs.stat(path), + list: ({ project, path, recursive }, { session }) => + projects.get(session, project).fs.list(path, recursive), + read: ({ project, path }, { session }) => projects.get(session, project).fs.read(path), + write: ({ project, path, content, expectedHash, createParents }, { session }) => + projects.get(session, project).fs.write(path, content, { + ...(expectedHash !== undefined && { expectedHash }), + createParents, + }), + mkdir: ({ project, path }, { session }) => projects.get(session, project).fs.mkdir(path), + rename: ({ project, from, to, overwrite }, { session }) => + projects.get(session, project).fs.rename(from, to, overwrite), + copy: ({ project, from, to, overwrite }, { session }) => + projects.get(session, project).fs.copy(from, to, overwrite), + delete: ({ project, path }, { session }) => projects.get(session, project).fs.delete(path), + restore: ({ project, trashPath, path }, { session }) => + projects.get(session, project).fs.restore(trashPath, path), + reveal: async ({ project, path }, { session }) => { + if (env.mode !== 'OFFLINE') { + throw new RpcError('FORBIDDEN', 'Revealing files needs a local editor'); + } + await revealInFileManager(await projects.get(session, project).fs.jail.resolve(path)); + return null; + }, + }, + streams: { + changes: ({ project }, { session }, sink) => + projects.get(session, project).watcher.onDidChange((changes) => sink.emit({ changes })), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/git.implementation.ts b/packages/server-core/src/rpc/implementation/git.implementation.ts new file mode 100644 index 00000000..0cb67dd8 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/git.implementation.ts @@ -0,0 +1,55 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { GitContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the git contract. */ +export const implementGitRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const repo = (session: RequestContext['session'], project: string) => { + if (env.mode !== 'OFFLINE') throw new RpcError('FORBIDDEN', 'Git is for local editors'); + return projects.get(session, project).root; + }; + const { git } = deps; + const nothing = + (run: (root: string, input: I) => Promise) => + async (input: I, { session }: RequestContext) => { + await run(repo(session, input.project), input); + return null; + }; + return router.implement(GitContract, { + methods: { + status: ({ project }, { session }) => git.projectStatus(repo(session, project)), + init: nothing((root) => git.init(root)), + stage: nothing((root, { paths }) => git.stage(root, paths)), + unstage: nothing((root, { paths }) => git.unstage(root, paths)), + discard: nothing((root, { paths }) => git.discard(root, paths)), + commit: nothing((root, { message, all, paths, amend }) => + git.commit(root, message, { all, amend, ...(paths && { paths }) }), + ), + fetch: nothing((root) => git.fetch(root)), + pull: ({ project }, { session }) => git.mergePull(repo(session, project)), + push: nothing((root) => git.push(root)), + branches: ({ project }, { session }) => git.branches(repo(session, project)), + switchBranch: nothing((root, { name }) => git.switchBranch(root, name)), + createBranch: nothing((root, { name, from }) => git.createBranch(root, name, from)), + deleteBranch: nothing((root, { name, force }) => git.deleteBranch(root, name, force)), + log: ({ project, limit, skip, branch }, { session }) => + git.log(repo(session, project), limit, skip, branch), + outgoing: ({ project }, { session }) => git.outgoing(repo(session, project)), + commitFiles: ({ project, hash }, { session }) => + git.commitFiles(repo(session, project), hash), + show: ({ project, path, revision }, { session }) => + git.show(repo(session, project), path, revision), + stashes: ({ project }, { session }) => git.stashes(repo(session, project)), + stash: nothing((root, { message }) => git.stash(root, message)), + applyStash: nothing((root, { index, pop }) => git.applyStash(root, index, pop)), + dropStash: nothing((root, { index }) => git.dropStash(root, index)), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/plugins.implementation.ts b/packages/server-core/src/rpc/implementation/plugins.implementation.ts new file mode 100644 index 00000000..b9a44c91 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/plugins.implementation.ts @@ -0,0 +1,37 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { PluginsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the plugins contract. */ +export const implementPluginsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { projects } = deps; + return router.implement(PluginsContract, { + methods: { + list: async (input, { session }) => { + const project = input?.project ? projects.get(session, input.project) : undefined; + const located = await deps.plugins.list(project); + if (deps.env.mode === 'OFFLINE') { + for (const plugin of located) { + if (plugin.source === 'project' && plugin.manifest?.entry.server) + await deps.activateServerPlugin?.(plugin); + } + } + return located.map((plugin) => plugin.listing); + }, + suggestions: ({ project }, { session }) => + deps.plugins.suggestions(projects.get(session, project).root), + }, + streams: { + devChanges: (_params, _context, sink) => + deps.plugins.onDevChange((plugin) => { + if (plugin.manifest) sink.emit({ name: plugin.manifest.name, listing: plugin.listing }); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/projects.implementation.ts b/packages/server-core/src/rpc/implementation/projects.implementation.ts new file mode 100644 index 00000000..2f18788c --- /dev/null +++ b/packages/server-core/src/rpc/implementation/projects.implementation.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { type Disposable, observe } from '@nanoforge-dev/editor-kernel'; +import { ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; +import { packageManagerOf } from '../package-manager-of'; +import { requireUser } from '../require-user'; + +/** Implements the projects contract. */ +export const implementProjectsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + return router.implement(ProjectsContract, { + methods: { + open: async (ref, { session }) => { + const project = await projects.open(session, ref); + return { id: project.id, name: project.name }; + }, + recent: (_input, context) => projects.recent(requireUser(context)), + forget: async ({ id }, context) => { + await projects.forget(requireUser(context), id); + return null; + }, + model: ({ id }, { session }) => projects.get(session, id).model.get(), + install: async ({ id }, { session }) => { + if (env.mode !== 'OFFLINE') + throw new RpcError('FORBIDDEN', 'Dependencies are installed in local editors only'); + const { root } = projects.get(session, id); + const manager = packageManagerOf(root); + if (!manager) return { manager: null, ok: false }; + const result = await deps.cli.exec(manager, ['install'], { cwd: root }).done; + return { manager, ok: result.exitCode === 0 }; + }, + create: async ({ name, language, server, install, packageManager }, { session }) => { + if (env.mode !== 'OFFLINE') + throw new RpcError('FORBIDDEN', 'Create projects from the NanoForge website'); + if (existsSync(join(env.fsRoot, name))) + throw new RpcError('CONFLICT', `A folder named ${name} already exists`); + const run = deps.cli.run( + [ + 'new', + '--name', + name, + '--path', + name, + '--language', + language, + '--package-manager', + packageManager, + '--strict', + server ? '--server' : '--no-server', + '--no-git', + '--no-docker', + install ? '--no-skip-install' : '--skip-install', + ], + { cwd: env.fsRoot }, + ); + const result = await run.done; + if (result.exitCode !== 0) { + const output = (result.stderr || result.stdout).slice(-2000); + const reason = output + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + throw new RpcError('INTERNAL', `nf new failed${reason ? `: ${reason}` : ''}`, { + output, + }); + } + const project = await projects.open(session, { path: name }); + return { id: project.id, name: project.name }; + }, + gateways: async (_input, { session }) => + env.mode === 'ONLINE' + ? (await deps.api.gatewayProjects(session)).map((project) => ({ + gatewayId: project.id, + name: project.name, + description: project.description, + })) + : [], + }, + streams: { + model: ({ id }, { session }, sink) => + observe(projects.get(session, id).model, (model) => sink.emit(model)), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/registry.implementation.ts b/packages/server-core/src/rpc/implementation/registry.implementation.ts new file mode 100644 index 00000000..826eb753 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/registry.implementation.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { type InstalledPackage, RegistryContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; +import { + DirectoryRegistry, + type OutdatedPackage, + ProjectPackages, + type Registry, + RegistryClient, + RegistryError, + installItem, + pickVersion, +} from '@nanoforge-dev/registry'; + +import { PROJECT_PLUGINS_DIR } from '../../plugin/plugin-sources'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the registry contract. */ +export const implementRegistryRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const noRegistry = (): never => { + throw new RegistryError('UNAVAILABLE', 'The registry is not available yet'); + }; + const registry: Registry = env.registryDir + ? new DirectoryRegistry(env.registryDir) + : env.registryUrl + ? new RegistryClient({ baseUrl: env.registryUrl }) + : { search: noRegistry, get: noRegistry, download: noRegistry }; + const editorEngine = { editor: deps.version }; + /** Runs registry code, turning its failures into RPC errors with the same message. */ + const withRegistry = async (run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + if (!(error instanceof RegistryError)) throw error; + const code = ( + { + NOT_FOUND: 'NOT_FOUND', + UNAVAILABLE: 'UNAVAILABLE', + INVALID: 'BAD_REQUEST', + INTEGRITY: 'PRECONDITION_FAILED', + CONFLICT: 'CONFLICT', + IN_USE: 'CONFLICT', + } as const + )[error.code]; + throw new RpcError(code, error.message); + } + }; + const localOnly = () => { + if (env.mode !== 'OFFLINE') throw new RpcError('FORBIDDEN', 'Installing is for local editors'); + }; + const pluginFolder = ( + session: RequestContext['session'], + target: { name: string; scope: 'user' | 'project'; project?: string | undefined }, + ) => { + localOnly(); + if (target.scope === 'user') return join(deps.plugins.installedDir, ...target.name.split('/')); + if (!target.project) throw new RpcError('BAD_REQUEST', 'A project is needed for this scope'); + return join( + projects.get(session, target.project).root, + PROJECT_PLUGINS_DIR, + ...target.name.split('/'), + ); + }; + const packagesOf = (session: RequestContext['session'], project: string) => { + localOnly(); + return new ProjectPackages(projects.get(session, project).root, registry); + }; + /** The project's packages; without the registry, without what is newer. */ + const listPackages = async (packages: ProjectPackages): Promise => { + const listed: readonly OutdatedPackage[] = await packages + .outdated() + .catch(async (error: unknown) => { + if (error instanceof RegistryError && error.code === 'UNAVAILABLE') return packages.list(); + throw error; + }); + return listed.map((entry) => ({ ...entry, dependents: [...entry.dependents] })); + }; + return router.implement(RegistryContract, { + methods: { + search: ({ type, q, page }) => withRegistry(() => registry.search({ type, q, page })), + details: ({ name }) => + withRegistry(async () => { + const item = await registry.get(name); + const compatible = + item.type === 'plugin' ? pickVersion(item, '*', editorEngine)?.version : item.version; + return { ...item, ...(compatible && { compatible }) }; + }), + installPlugin: (input, { session }) => + withRegistry(async () => { + const target = pluginFolder(session, input); + const item = await registry.get(input.name); + if (item.type !== 'plugin') + throw new RegistryError('INVALID', `${input.name} is a package, not a plugin`); + const version = input.version + ? item.versions.find((entry) => entry.version === input.version) + : pickVersion(item, '*', editorEngine); + if (!version) { + throw new RegistryError( + 'NOT_FOUND', + input.version + ? `${input.name}@${input.version} was not found` + : `No version of ${input.name} runs on this editor (${deps.version})`, + ); + } + await installItem(registry, { + name: input.name, + version: version.version, + type: 'plugin', + target, + sha256: version.sha256, + }); + await deps.deactivateServerPlugin?.(input.name); + const project = input.project ? projects.get(session, input.project) : undefined; + const located = (await deps.plugins.list(project)).find( + (plugin) => plugin.dir === target && plugin.manifest?.entry.server, + ); + if (located) await deps.activateServerPlugin?.(located); + return { version: version.version }; + }), + uninstallPlugin: async (input, { session }) => { + const target = pluginFolder(session, input); + if (!existsSync(join(target, 'nanoforge.manifest.json'))) + throw new RpcError('NOT_FOUND', `${input.name} is not installed there`); + await rm(target, { recursive: true, force: true }); + await deps.deactivateServerPlugin?.(input.name); + return null; + }, + packages: ({ project }, { session }) => + withRegistry(() => listPackages(packagesOf(session, project))), + installPackage: ({ project, name, range }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.install(name, range); + return listPackages(packages); + }), + uninstallPackage: ({ project, name }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.uninstall(name); + return listPackages(packages); + }), + updatePackage: ({ project, name, latest }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.update(name, latest); + return listPackages(packages); + }), + restorePackages: ({ project }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.restore(); + return listPackages(packages); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/runtime.implementation.ts b/packages/server-core/src/rpc/implementation/runtime.implementation.ts new file mode 100644 index 00000000..11a2017b --- /dev/null +++ b/packages/server-core/src/rpc/implementation/runtime.implementation.ts @@ -0,0 +1,59 @@ +import { type Disposable, DisposableStore } from '@nanoforge-dev/editor-kernel'; +import { RuntimeContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { OpenProject } from '../../project/project-registry'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the runtime contract. */ +export const implementRuntimeRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const runtimeOf = (project: OpenProject) => { + if (env.mode !== 'OFFLINE') { + throw new RpcError('FORBIDDEN', 'Games can only be run from a local editor'); + } + return deps.runtime.get(project); + }; + return router.implement(RuntimeContract, { + methods: { + build: ({ project, apps }, { session }) => + runtimeOf(projects.get(session, project)).builds.build(apps), + status: ({ project }, { session }) => { + const runtime = runtimeOf(projects.get(session, project)); + return { + builds: runtime.builds.apps.map((app) => runtime.builds.status(app.id)), + servers: runtime.servers(), + }; + }, + manifest: ({ project, app }, { session }) => + runtimeOf(projects.get(session, project)).manifest(app), + env: ({ project, app, overrides }, { session }) => + runtimeOf(projects.get(session, project)).env(app, overrides), + startServer: async ({ project, app, overrides }, { session }) => { + await runtimeOf(projects.get(session, project)).startServer(app, overrides); + return null; + }, + stopServer: async ({ project, app }, { session }) => { + await runtimeOf(projects.get(session, project)).stopServer(app); + return null; + }, + sendServer: ({ project, app, event, args }, { session }) => { + runtimeOf(projects.get(session, project)).sendServer(app, event, args); + return null; + }, + }, + streams: { + events: ({ project }, { session }, sink) => { + const runtime = runtimeOf(projects.get(session, project)); + const subscription = new DisposableStore(); + subscription.add(runtime.builds.watch()); + subscription.add(runtime.onEvent((event) => sink.emit(event))); + return subscription; + }, + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/session.implementation.ts b/packages/server-core/src/rpc/implementation/session.implementation.ts new file mode 100644 index 00000000..913a4e8c --- /dev/null +++ b/packages/server-core/src/rpc/implementation/session.implementation.ts @@ -0,0 +1,32 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { SessionContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the session contract. */ +export const implementSessionRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env } = deps; + return router.implement(SessionContract, { + methods: { + info: (_input, { session }) => ({ + mode: env.mode, + version: deps.version, + user: session.user, + loginUrl: env.mode === 'ONLINE' ? env.loginUrl : null, + features: [ + ...(env.apiFeatures.includes('settings') ? (['settings'] as const) : []), + ...(env.registryDir || env.registryUrl ? (['registry'] as const) : []), + ], + }), + logout: (_input, context) => { + context.responseCookies.push(...deps.sessions.logout(context.session)); + return null; + }, + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/settings.implementation.ts b/packages/server-core/src/rpc/implementation/settings.implementation.ts new file mode 100644 index 00000000..653687f6 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/settings.implementation.ts @@ -0,0 +1,36 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { SettingsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the settings contract. */ +export const implementSettingsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { projects } = deps; + return router.implement(SettingsContract, { + methods: { + accountGet: (_input, { session }) => deps.accountSettings.get(session), + accountPut: ({ baseRevision, values }, { session }) => + deps.accountSettings.put(session, baseRevision, values), + projectLocalGet: ({ project }, { session }) => { + projects.get(session, project); + return deps.projectLocalSettings.get(session, project); + }, + projectLocalPut: async ({ project, values }, { session }) => { + projects.get(session, project); + await deps.projectLocalSettings.put(session, project, values); + return null; + }, + }, + streams: { + accountChanges: (_params, { session }, sink) => + deps.accountSettings.onDidChange(({ userId, revision }) => { + if (userId === session.user?.id) sink.emit({ revision }); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/package-manager-of.ts b/packages/server-core/src/rpc/package-manager-of.ts new file mode 100644 index 00000000..468d3c84 --- /dev/null +++ b/packages/server-core/src/rpc/package-manager-of.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The package manager a project uses, from its lockfile. */ +export const packageManagerOf = (root: string): string | undefined => + ( + [ + ['pnpm-lock.yaml', 'pnpm'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['yarn.lock', 'yarn'], + ['package-lock.json', 'npm'], + ] as const + ).find(([lockfile]) => existsSync(join(root, lockfile)))?.[1]; diff --git a/packages/server-core/src/rpc/require-user.ts b/packages/server-core/src/rpc/require-user.ts new file mode 100644 index 00000000..d2811b67 --- /dev/null +++ b/packages/server-core/src/rpc/require-user.ts @@ -0,0 +1,9 @@ +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; + +export const requireUser = (context: RequestContext) => { + const user = context.session.user; + if (!user) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + return user; +}; diff --git a/packages/server-core/src/runtime/build-orchestrator.ts b/packages/server-core/src/runtime/build-orchestrator.ts new file mode 100644 index 00000000..a0d7e9bf --- /dev/null +++ b/packages/server-core/src/runtime/build-orchestrator.ts @@ -0,0 +1,228 @@ +import { isAbsolute, join, relative, sep } from 'node:path'; + +import { + type Disposable, + Emitter, + type Event, + type Logger, + type Observable, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import type { + AppModel, + BuildDiagnostic, + BuildStatus, + FileChange, + ProjectModel, + RuntimeEvent, +} from '@nanoforge-dev/editor-protocol'; + +import type { CliService } from '../process/cli-service'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { BuildOutput } from './build-output'; + +/** The parts of an open project the orchestrator needs. */ +export interface BuildableProject { + readonly root: string; + readonly model: Observable; + readonly watcher: { readonly onDidChange: Event }; +} + +// eslint-disable-next-line no-control-regex -- terminal color codes +export const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; +const REBUILD_DELAY_MS = 150; + +/** Apps the editor can build and run: clients and servers with an output directory. */ +export const runnableApps = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type !== 'lib' && app.outDir !== undefined); + +/** + * Reads bun build errors from the CLI output: + * `error: ` followed by ` at ::`. + */ +export const parseBuildOutput = (output: string, projectRoot: string): BuildDiagnostic[] => { + const diagnostics: BuildDiagnostic[] = []; + const lines = output.replace(ANSI, '').split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const match = /^\s*(error|warn|warning): (.+)$/.exec(lines[index]!); + if (!match) continue; + const diagnostic: BuildDiagnostic = { + message: match[2]!.trim(), + severity: match[1] === 'error' ? 'error' : 'warning', + }; + const location = /^\s*at (.+):(\d+):(\d+)\s*$/.exec(lines[index + 1] ?? ''); + if (location) { + const file = relative(projectRoot, location[1]!); + diagnostics.push({ + ...diagnostic, + ...(!file.startsWith('..') && !isAbsolute(file) && { path: file.split(sep).join('/') }), + line: Number(location[2]), + column: Number(location[3]), + }); + index++; + } else diagnostics.push(diagnostic); + } + return diagnostics; +}; + +/** + * Builds the apps of a project with `nf build --editor`, one app at a time, and keeps their + * status. While watched, apps are rebuilt when their sources change (a change outside of every + * app, e.g. in a shared lib or the root config, rebuilds them all). + */ +export class BuildOrchestrator implements Disposable { + private readonly _statuses = new Map(); + private readonly _outputs = new Map(); + private readonly _onEvent = new Emitter(); + private readonly _mutex = new KeyedMutex(); + private readonly _dirty = new Set(); + private _watchers = 0; + private _watch: Disposable | undefined; + private _timer: ReturnType | undefined; + private _disposed = false; + + readonly onEvent: Event = this._onEvent.event; + + constructor( + private readonly _project: BuildableProject, + private readonly _cli: CliService, + private readonly _logger: Logger, + ) {} + + get apps(): AppModel[] { + return runnableApps(this._project.model.get()); + } + + app(id: string): AppModel | undefined { + return this.apps.find((app) => app.id === id); + } + + status(app: string): BuildStatus { + return ( + this._statuses.get(app) ?? { + app, + state: 'idle', + version: null, + diagnostics: [], + finishedAt: null, + durationMs: null, + } + ); + } + + /** Output directory of an app, with content hashes. */ + output(app: AppModel): BuildOutput { + const dir = join(this._project.root, app.outDir ?? 'dist'); + let output = this._outputs.get(app.id); + if (output?.dir !== dir) { + output = new BuildOutput(dir); + this._outputs.set(app.id, output); + } + return output; + } + + /** Builds the given apps (all runnable apps by default), after any build in progress. */ + build(ids?: readonly string[]): Promise { + const apps = ids ? this.apps.filter((app) => ids.includes(app.id)) : this.apps; + for (const app of apps) this._dirty.delete(app.id); + return this._mutex.run('build', async () => { + const statuses: BuildStatus[] = []; + for (const app of apps) { + if (this._disposed) break; + statuses.push(await this._buildApp(app)); + } + return statuses; + }); + } + + /** Rebuilds apps on source changes until the returned disposable is disposed. */ + watch(): Disposable { + if (this._watchers++ === 0) { + this._watch = this._project.watcher.onDidChange((changes) => this._onChanges(changes)); + } + return toDisposable(() => { + if (--this._watchers > 0) return; + this._watch?.dispose(); + this._watch = undefined; + clearTimeout(this._timer); + }); + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._timer); + this._watch?.dispose(); + this._onEvent.dispose(); + } + + private _onChanges(changes: readonly FileChange[]): void { + const apps = this.apps; + const roots = this._project.model.get().apps.map((app) => app.root); + for (const { path } of changes) { + if (path.startsWith('.nanoforge/')) continue; + const owner = roots + .filter((root) => root === '' || path === root || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length)[0]; + const affected = apps.filter((app) => owner !== undefined && app.root === owner); + for (const app of affected.length ? affected : apps) this._dirty.add(app.id); + } + if (!this._dirty.size) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => { + const dirty = [...this._dirty]; + this.build(dirty).catch((error: unknown) => this._logger.error('Rebuild failed', error)); + }, REBUILD_DELAY_MS); + } + + private _setStatus(status: BuildStatus): BuildStatus { + this._statuses.set(status.app, status); + this._onEvent.fire({ type: 'build', status }); + return status; + } + + private async _buildApp(app: AppModel): Promise { + const previous = this.status(app.id); + this._setStatus({ ...previous, state: 'building' }); + const started = Date.now(); + const run = this._cli.run(['build', '--editor', '--directory', app.root || '.'], { + cwd: this._project.root, + env: { FORCE_COLOR: '0', NO_COLOR: '1' }, + silent: true, + }); + const listener = run.onLine(({ stream, text }) => + this._onEvent.fire({ + type: 'log', + app: app.id, + source: 'build', + stream, + text: text.replace(ANSI, ''), + }), + ); + let exitCode: number; + let output: string; + try { + const result = await run.done; + exitCode = result.exitCode; + output = `${result.stdout}\n${result.stderr}`; + } catch (error) { + exitCode = -1; + output = `error: ${error instanceof Error ? error.message : String(error)}`; + } finally { + listener.dispose(); + } + const diagnostics = parseBuildOutput(output, this._project.root); + const failed = exitCode !== 0 || /Build failed/i.test(output.replace(ANSI, '')); + if (failed && !diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + diagnostics.push({ message: `Build of ${app.name} failed`, severity: 'error' }); + } + const files = failed ? [] : await this.output(app).files(); + return this._setStatus({ + app: app.id, + state: failed ? 'error' : 'ok', + version: failed ? previous.version : BuildOutput.version(files), + diagnostics, + finishedAt: Date.now(), + durationMs: Date.now() - started, + }); + } +} diff --git a/packages/server-core/src/runtime/build-output.ts b/packages/server-core/src/runtime/build-output.ts new file mode 100644 index 00000000..6811da12 --- /dev/null +++ b/packages/server-core/src/runtime/build-output.ts @@ -0,0 +1,58 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; + +import type { OutputFile } from '@nanoforge-dev/editor-protocol'; + +import { sha1 } from '../util/hash'; + +interface CachedHash { + readonly size: number; + readonly mtime: number; + readonly hash: string; +} + +/** Lists a build output directory with content hashes (re-hashing only changed files). */ +export class BuildOutput { + private readonly _hashes = new Map(); + + /** Hash of every file hash: identifies one build. */ + static version(files: readonly OutputFile[]): string { + return sha1(files.map((file) => `${file.path}:${file.hash}`).join('\n')).slice(0, 16); + } + + constructor(readonly dir: string) {} + + async files(): Promise { + const files: OutputFile[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const absolute = join(dir, entry.name); + if (entry.isDirectory()) await walk(absolute); + else if (entry.isFile()) files.push(await this._file(absolute)); + } + }; + await walk(this.dir); + for (const path of this._hashes.keys()) { + if (!files.some((file) => join(this.dir, file.path) === path)) this._hashes.delete(path); + } + return files.sort((a, b) => a.path.localeCompare(b.path)); + } + + /** Content hash of a file of the output (cached by size and mtime). */ + async hash(absolute: string): Promise { + return (await this._file(absolute).catch(() => undefined))?.hash; + } + + private async _file(absolute: string): Promise { + const info = await stat(absolute); + const path = relative(this.dir, absolute).split(sep).join('/'); + const cached = this._hashes.get(absolute); + if (cached && cached.size === info.size && cached.mtime === info.mtimeMs) { + return { path, size: info.size, hash: cached.hash }; + } + const hash = sha1(await readFile(absolute)); + this._hashes.set(absolute, { size: info.size, mtime: info.mtimeMs, hash }); + return { path, size: info.size, hash }; + } +} diff --git a/packages/server-core/src/runtime/game-env.ts b/packages/server-core/src/runtime/game-env.ts new file mode 100644 index 00000000..6c00b9bf --- /dev/null +++ b/packages/server-core/src/runtime/game-env.ts @@ -0,0 +1,68 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const PREFIX = 'NANOFORGE_'; +const CLIENT_PREFIX = `${PREFIX}CLIENT_`; +const SERVER_PREFIX = `${PREFIX}SERVER_`; + +/** Parses a `.env` file: `KEY=value`, `export KEY=value`, quotes, `#` comments. */ +export const parseDotenv = (text: string): Record => { + const values: Record = {}; + for (const raw of text.split(/\r?\n/)) { + const match = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)?\s*$/.exec(raw); + if (!match) continue; + let value = (match[2] ?? '').trim(); + const quote = value[0]; + if ( + (quote === '"' || quote === "'" || quote === '`') && + value.endsWith(quote) && + value.length > 1 + ) { + value = value.slice(1, -1); + if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); + } else { + value = value.replace(/\s+#.*$/, ''); + } + values[match[1]!] = value; + } + return values; +}; + +export interface GameEnv { + readonly client: Record; + readonly server: Record; +} + +/** + * Splits `NANOFORGE_*` variables between the client and the server and strips the prefixes, as + * `nf start` and the loaders do: `NANOFORGE_CLIENT_X` → client `X`, `NANOFORGE_SERVER_X` → + * server `X`, `NANOFORGE_X` → both. + */ +export const splitGameEnv = (raw: Record): GameEnv => { + const client: Record = {}; + const server: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!key.startsWith(PREFIX) || !value) continue; + if (key.startsWith(CLIENT_PREFIX)) client[key.slice(CLIENT_PREFIX.length)] = value; + else if (key.startsWith(SERVER_PREFIX)) server[key.slice(SERVER_PREFIX.length)] = value; + else { + client[key.slice(PREFIX.length)] ??= value; + server[key.slice(PREFIX.length)] ??= value; + } + } + return { client, server }; +}; + +/** + * Environment of a game app: the editor's own `NANOFORGE_*` variables, then the project's + * `.env`, then the overrides (`runtime.env` setting), split for the app's side. + */ +export const loadGameEnv = async ( + projectRoot: string, + side: 'client' | 'server', + overrides: Record = {}, + processEnv: Record = process.env, +): Promise> => { + const dotenv = await readFile(join(projectRoot, '.env'), 'utf8').then(parseDotenv, () => ({})); + return splitGameEnv({ ...processEnv, ...dotenv, ...overrides })[side]; +}; diff --git a/packages/server-core/src/runtime/game-server.ts b/packages/server-core/src/runtime/game-server.ts new file mode 100644 index 00000000..a122a737 --- /dev/null +++ b/packages/server-core/src/runtime/game-server.ts @@ -0,0 +1,242 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import type { GameServerState, RuntimeEvent } from '@nanoforge-dev/editor-protocol'; + +/** + * Runs in the game process (Bun): receives the entry point, files and env over IPC, then + * starts the game with an editor bridge whose `toEditor` events are sent back over IPC. + */ +const RUNNER_SOURCE = `import { pathToFileURL } from 'node:url'; + +class QueuedEmitter { + listeners = new Map(); + queue = []; + on(event, listener) { this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); } + off(event, listener) { this.listeners.set(event, (this.listeners.get(event) ?? []).filter((l) => l !== listener)); } + emit(event, ...args) { this.queue.push([event, args]); } + runEvents() { + for (const [event, args] of this.queue.splice(0)) { + for (const listener of this.listeners.get(event) ?? []) { + try { listener(...args); } catch (error) { console.error(error); } + } + } + } +} + +const send = (message) => { if (process.connected) process.send(message); }; +const fromEditor = new QueuedEmitter(); +const toEditor = { + on() {}, off() {}, runEvents() {}, + emit(event, ...args) { + send({ type: 'bridge', event, args }); + if (event === 'state' && args[0] === 'stopped') setTimeout(() => process.exit(0), 50); + }, +}; + +process.on('message', async (message) => { + if (message.type === 'command') return fromEditor.emit(message.event, ...message.args); + if (message.type !== 'init') return; + try { + const { main } = await import(pathToFileURL(message.main).href); + console.log('Starting server'); + await main({ files: new Map(Object.entries(message.files)), env: message.env, editor: { toEditor, fromEditor } }); + send({ type: 'started' }); + } catch (error) { + console.error(error); + process.exit(1); + } +}); +process.on('disconnect', () => process.exit(0)); +send({ type: 'ready' }); +`; + +const runners = new Map>(); + +/** Writes the runner script once per data directory (the server itself may be bundled). */ +const ensureRunner = (dataDir: string): Promise => { + let runner = runners.get(dataDir); + if (!runner) { + runner = (async () => { + const dir = join(dataDir, 'runtime'); + await mkdir(dir, { recursive: true }); + const path = join(dir, 'game-server-runner.mjs'); + await writeFile(path, RUNNER_SOURCE); + return path; + })(); + runners.set(dataDir, runner); + } + return runner; +}; + +export interface GameServerOptions { + readonly app: string; + /** Directory the game runs in (the app root). */ + readonly cwd: string; + readonly main: string; + /** Virtual path (`/libecs.wasm`) → absolute path. */ + readonly files: Record; + readonly env: Record; + readonly dataDir: string; + /** Grace period for a requested stop before the process is killed. */ + readonly stopTimeoutMs?: number; + readonly bunPath?: string; +} + +type RunnerMessage = + | { type: 'ready' } + /** `main` resolved: the game runs, whether or not its engine speaks the bridge. */ + | { type: 'started' } + | { type: 'bridge'; event: string; args: unknown[] }; + +/** + * A game server process started by the editor. `stop` asks the game to stop through the + * bridge, then kills the whole process group if it did not exit in time. + */ +export class GameServerProcess { + private readonly _onEvent = new Emitter(); + private _child: ChildProcess | undefined; + private _state: GameServerState = 'starting'; + private _bridge = false; + private _exited: Promise = Promise.resolve(null); + + readonly onEvent: Event = this._onEvent.event; + + constructor(private readonly _options: GameServerOptions) {} + + get state(): GameServerState { + return this._state; + } + + /** Whether the game answered with the editor bridge hello. */ + get bridged(): boolean { + return this._bridge; + } + + async start(): Promise { + const runner = await ensureRunner(this._options.dataDir); + const bun = this._options.bunPath ?? (process.versions.bun ? process.execPath : 'bun'); + const child = spawn(bun, [runner], { + cwd: this._options.cwd, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + detached: process.platform !== 'win32', + env: { ...process.env, FORCE_COLOR: '0' }, + }); + this._child = child; + this._setState('starting'); + for (const stream of ['stdout', 'stderr'] as const) { + let pending = ''; + child[stream]?.on('data', (chunk: Buffer) => { + const lines = (pending + chunk.toString('utf8')).split(/\r?\n/); + pending = lines.pop() ?? ''; + for (const text of lines) this._log(stream, text); + }); + child[stream]?.on('end', () => pending && this._log(stream, pending)); + } + child.on('message', (message: RunnerMessage) => this._onMessage(message)); + this._exited = new Promise((resolve) => { + child.on('error', (error) => { + this._log('stderr', `Could not start the game server: ${error.message}`); + this._setState('exited', null); + resolve(null); + }); + child.on('exit', (code) => { + this._setState('exited', code); + resolve(code); + }); + }); + } + + /** Kills the process group at once (editor shutdown). */ + kill(): void { + this._kill('SIGKILL'); + } + + /** Sends an editor → engine event (`fromEditor`). */ + send(event: string, args: readonly unknown[] = []): void { + if (this._child?.connected) this._child.send({ type: 'command', event, args }); + } + + /** Stops the game and resolves once the process exited. */ + async stop(): Promise { + const child = this._child; + if (!child || this._state === 'exited') return; + this._setState('stopping'); + if (this._bridge) this.send('stop'); + else this._kill('SIGTERM'); + const timeout = this._options.stopTimeoutMs ?? 5000; + if (await this._exitWithin(timeout)) return; + this._kill('SIGTERM'); + if (await this._exitWithin(2000)) return; + this._kill('SIGKILL'); + await this._exited; + } + + private async _exitWithin(ms: number): Promise { + let timer: ReturnType | undefined; + const timedOut = new Promise( + (resolve) => (timer = setTimeout(() => resolve(false), ms)), + ); + const exited = this._exited.then(() => true as const); + const result = await Promise.race([exited, timedOut]); + clearTimeout(timer); + return result; + } + + private _kill(signal: NodeJS.Signals): void { + const child = this._child; + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + } + + private _onMessage(message: RunnerMessage): void { + if (message.type === 'ready') { + this._child?.send({ + type: 'init', + main: this._options.main, + files: this._options.files, + env: this._options.env, + }); + return; + } + if (message.type === 'started') { + if (this._state === 'starting') this._setState('running'); + return; + } + if (message.type !== 'bridge') return; + if (message.event === 'hello') this._bridge = true; + if (message.event === 'state' && this._state !== 'stopping') { + if (message.args[0] === 'running') this._setState('running'); + else if (message.args[0] === 'paused') this._setState('paused'); + } + this._onEvent.fire({ + type: 'bridge', + app: this._options.app, + event: message.event, + args: message.args, + }); + } + + private _setState(state: GameServerState, exitCode?: number | null): void { + if (this._state === state && state !== 'starting') return; + if (this._state === 'exited' && state !== 'starting') return; + this._state = state; + this._onEvent.fire({ + type: 'server', + app: this._options.app, + state, + ...(exitCode !== undefined && { exitCode }), + }); + } + + private _log(stream: 'stdout' | 'stderr', text: string): void { + this._onEvent.fire({ type: 'log', app: this._options.app, source: 'server', stream, text }); + } +} diff --git a/packages/server-core/src/runtime/index.ts b/packages/server-core/src/runtime/index.ts new file mode 100644 index 00000000..4e4b7a01 --- /dev/null +++ b/packages/server-core/src/runtime/index.ts @@ -0,0 +1,5 @@ +export * from './build-orchestrator'; +export * from './build-output'; +export * from './game-env'; +export * from './game-server'; +export * from './project-runtime'; diff --git a/packages/server-core/src/runtime/project-runtime.ts b/packages/server-core/src/runtime/project-runtime.ts new file mode 100644 index 00000000..f3f635fc --- /dev/null +++ b/packages/server-core/src/runtime/project-runtime.ts @@ -0,0 +1,170 @@ +import { basename, isAbsolute, join, relative } from 'node:path'; + +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Logger, +} from '@nanoforge-dev/editor-kernel'; +import { + type AppModel, + type OutputManifest, + type RuntimeEvent, + runtimeOutputPath, +} from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import type { CliRun, CliService } from '../process/cli-service'; +import type { OpenProject } from '../project/project-registry'; +import { ANSI, BuildOrchestrator } from './build-orchestrator'; +import { BuildOutput } from './build-output'; +import { loadGameEnv } from './game-env'; +import { GameServerProcess } from './game-server'; + +/** File name bun gives to the bundle of an entry (`src/main.ts` → `main.js`). */ +export const outputEntry = (app: AppModel): string => + basename(app.editorEntryFile ?? app.entryFile ?? 'main.ts').replace(/\.[cm]?[jt]sx?$/, '.js'); + +/** Builds, outputs and game servers of one project. */ +export class ProjectRuntime implements Disposable { + readonly builds: BuildOrchestrator; + private readonly _servers = new Map(); + private readonly _onEvent = new Emitter(); + private readonly _store = new DisposableStore(); + + readonly onEvent: Event = this._onEvent.event; + + constructor( + readonly project: OpenProject, + cli: CliService, + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) { + this.builds = this._store.add(new BuildOrchestrator(project, cli, _logger)); + this._store.add(this.builds.onEvent((event) => this._onEvent.fire(event))); + this._store.add(cli.onDidStart((run) => this._followTask(run))); + } + + app(id: string): AppModel { + const app = this.builds.app(id); + if (!app) throw new RpcError('NOT_FOUND', `No runnable app ${id || '(root)'}`); + return app; + } + + async manifest(appId: string): Promise { + const app = this.app(appId); + const files = await this.builds.output(app).files(); + const entry = outputEntry(app); + if (!files.some((file) => file.path === entry)) { + throw new RpcError('NOT_FOUND', `${app.name} has no build output yet`); + } + return { + app: app.id, + version: BuildOutput.version(files), + baseUrl: runtimeOutputPath(this.project.id, app.id), + entry, + files, + }; + } + + env(appId: string, overrides: Record = {}): Promise> { + const app = this.app(appId); + return loadGameEnv(this.project.root, app.type === 'server' ? 'server' : 'client', overrides); + } + + /** Starts the game server of an app (restarts it when running). */ + async startServer(appId: string, overrides: Record = {}): Promise { + const app = this.app(appId); + if (app.type !== 'server') throw new RpcError('BAD_REQUEST', `${app.name} is not a server`); + await this.stopServer(appId); + const manifest = await this.manifest(appId); + const outDir = this.builds.output(app).dir; + const server = new GameServerProcess({ + app: app.id, + cwd: join(this.project.root, app.root), + main: join(outDir, manifest.entry), + files: Object.fromEntries( + manifest.files.map((file) => [`/${file.path}`, join(outDir, file.path)]), + ), + env: await this.env(appId, overrides), + dataDir: this._env.dataDir, + }); + this._servers.set(app.id, server); + const forward = server.onEvent((event) => { + this._onEvent.fire(event); + if (event.type === 'server' && event.state === 'exited') { + forward.dispose(); + if (this._servers.get(app.id) === server) this._servers.delete(app.id); + } + }); + await server.start(); + this._logger.info(`Started the game server of ${app.name}`); + } + + async stopServer(appId: string): Promise { + await this._servers.get(appId)?.stop(); + } + + sendServer(appId: string, event: string, args: readonly unknown[]): void { + const server = this._servers.get(appId); + if (!server) throw new RpcError('NOT_FOUND', `The game server of ${appId} is not running`); + server.send(event, args); + } + + servers(): { app: string; state: GameServerProcess['state'] }[] { + return [...this._servers].map(([app, server]) => ({ app, state: server.state })); + } + + dispose(): void { + for (const server of this._servers.values()) server.kill(); + this._servers.clear(); + this._store.dispose(); + this._onEvent.dispose(); + } + + /** Streams a CLI run made in this project (other than builds) as a console task. */ + private _followTask(run: CliRun): void { + const inside = relative(this.project.root, run.cwd); + if (run.silent || inside.startsWith('..') || isAbsolute(inside)) return; + const app = `${run.command} ${run.args.join(' ')}`; + const log = (stream: 'stdout' | 'stderr', text: string) => + this._onEvent.fire({ type: 'log', app, source: 'cli', stream, text }); + log('stdout', `$ ${app}`); + const listener = run.onLine(({ stream, text }) => log(stream, text.replace(ANSI, ''))); + run.done + .then(({ exitCode }) => { + if (exitCode !== 0) log('stderr', `error: ${app} exited with code ${exitCode}`); + }) + .catch((error: unknown) => + log('stderr', `error: ${error instanceof Error ? error.message : String(error)}`), + ) + .finally(() => listener.dispose()); + } +} + +/** One runtime per open project, created on first use. */ +export class RuntimeManager implements Disposable { + private readonly _runtimes = new Map(); + + constructor( + private readonly _cli: CliService, + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) {} + + get(project: OpenProject): ProjectRuntime { + let runtime = this._runtimes.get(project.id); + if (!runtime) { + runtime = new ProjectRuntime(project, this._cli, this._env, this._logger); + this._runtimes.set(project.id, runtime); + } + return runtime; + } + + dispose(): void { + for (const runtime of this._runtimes.values()) runtime.dispose(); + this._runtimes.clear(); + } +} diff --git a/packages/server-core/src/server/create-editor-server.ts b/packages/server-core/src/server/create-editor-server.ts new file mode 100644 index 00000000..4b9142a6 --- /dev/null +++ b/packages/server-core/src/server/create-editor-server.ts @@ -0,0 +1,294 @@ +import { join, resolve } from 'node:path'; + +import { DisposableStore, LoggerService, consoleSink } from '@nanoforge-dev/editor-kernel'; +import { decodeRuntimeApp } from '@nanoforge-dev/editor-protocol'; +import { PROJECT_HEADER, RPC_HTTP_PREFIX, RPC_WS_PATH, RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { ApiClient } from '../api/api-client'; +import { archiveFolder } from '../fs/archive'; +import { PathJail } from '../fs/path-jail'; +import { GitService } from '../git/git-service'; +import { fileResponse } from '../http/static-files'; +import { PluginSources } from '../plugin/plugin-sources'; +import { ServerPluginHost, type ServerPluginServices } from '../plugin/server-plugins'; +import { CliService } from '../process/cli-service'; +import { ProjectRegistry } from '../project/project-registry'; +import { implementCoreRpc } from '../rpc/implement-core-rpc'; +import type { BuildOutput } from '../runtime/build-output'; +import { RuntimeManager } from '../runtime/project-runtime'; +import { type RequestContext, authorize } from '../session/auth'; +import { serializeCookie } from '../session/cookies'; +import { + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + type Session, + SessionStore, +} from '../session/session-store'; +import { + ApiAccountBackend, + FileAccountBackend, + ProjectLocalSettingsStore, +} from '../settings/account-backends'; +import type { EditorServer, EditorServerOptions } from './editor-server.type'; + +const json = (status: number, body: unknown, headers: HeadersInit = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); + +export const createEditorServer = (options: EditorServerOptions): EditorServer => { + const { env } = options; + const store = new DisposableStore(); + const logs = options.logger ?? new LoggerService(); + if (!options.logger) store.add(logs.addSink(consoleSink)); + const logger = logs.getLogger('server'); + + const sessions = new SessionStore(env); + const api = new ApiClient(env, options.fetch); + const git = new GitService(env); + const cli = new CliService(env); + const projects = store.add(new ProjectRegistry(env, git, api, logs.getLogger('projects'))); + const plugins = new PluginSources(env, logs.getLogger('plugins')); + const runtime = store.add(new RuntimeManager(cli, env, logs.getLogger('runtime'))); + const router = new RpcRouter({ + authorize, + onError: (error, call) => logger.error(`RPC ${call.namespace}.${call.name} failed`, error), + }); + store.add( + implementCoreRpc(router, { + env, + version: options.version, + sessions, + projects, + plugins, + activateServerPlugin: (plugin) => serverPlugins.activate(plugin), + deactivateServerPlugin: (name) => serverPlugins.deactivate(name), + cli, + api, + accountSettings: + env.mode === 'ONLINE' && env.apiFeatures.includes('settings') + ? new ApiAccountBackend(api) + : new FileAccountBackend(env), + projectLocalSettings: new ProjectLocalSettingsStore(env), + runtime, + git, + }), + ); + const services: ServerPluginServices = { projects, cli, git, api }; + const serverPlugins = store.add(new ServerPluginHost(router, services, logs)); + const staticJail = options.staticDir ? new PathJail(resolve(options.staticDir)) : undefined; + const pruneTimer = setInterval(() => sessions.prune(), 3600_000); + pruneTimer.unref?.(); + + /** Blocks cross-site requests: a local editor must not be driven by random web pages. */ + const originAllowed = (request: Request): boolean => { + const origin = request.headers.get('origin'); + if (!origin) return true; // non-browser clients + const host = request.headers.get('host') ?? new URL(request.url).host; + try { + const url = new URL(origin); + return url.host === host || env.allowedOrigins.includes(origin); + } catch { + return false; + } + }; + + const withCookies = (response: Response, cookies: string[]): Response => { + for (const cookie of cookies) response.headers.append('set-cookie', cookie); + return response; + }; + + const tokenCookies = (session: Session, before: string | undefined): string[] => { + if (!session.tokens || session.tokens.accessToken === before) return []; + const secure = env.production; + return [ + serializeCookie(ACCESS_TOKEN_COOKIE, session.tokens.accessToken, { secure }), + serializeCookie(REFRESH_TOKEN_COOKIE, session.tokens.refreshToken, { secure }), + ]; + }; + + const handleRpc = async (request: Request, path: string): Promise => { + const { session, setCookies } = sessions.resolve(request.headers.get('cookie')); + const before = session.tokens?.accessToken; + const context: RequestContext = { session, responseCookies: [] }; + const result = await router.handle(path, await request.text(), context, request.signal); + const response = new Response(result.body, { + status: result.status, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); + return withCookies(response, [ + ...setCookies, + ...context.responseCookies, + ...tokenCookies(session, before), + ]); + }; + + const handlePlugin = async (request: Request, pathname: string): Promise => { + const { session } = sessions.resolve(request.headers.get('cookie')); + const resolved = await plugins.resolveFile(pathname, (projectId) => { + try { + projects.get(session, projectId); + return true; + } catch { + return false; + } + }); + if (!resolved) return json(404, { error: 'Not found' }); + const cache = + resolved.plugin.source === 'dev' ? 'no-cache' : 'public, max-age=31536000, immutable'; + return (await fileResponse(resolved.file, cache)) ?? json(404, { error: 'Not found' }); + }; + + /** Build outputs of running games: `/runtime///`. */ + const handleRuntime = async (request: Request, url: URL): Promise => { + const [projectId, appSegment, ...rest] = url.pathname.slice('/runtime/'.length).split('/'); + if (!projectId || !appSegment || !rest.length || env.mode !== 'OFFLINE') + return json(404, { error: 'Not found' }); + const { session } = sessions.resolve(request.headers.get('cookie')); + let file: string | undefined; + let output: BuildOutput; + try { + const projectRuntime = runtime.get(projects.get(session, projectId)); + output = projectRuntime.builds.output(projectRuntime.app(decodeRuntimeApp(appSegment))); + file = await new PathJail(output.dir).resolve(rest.map(decodeURIComponent).join('/')); + } catch { + return json(404, { error: 'Not found' }); + } + const version = url.searchParams.get('v'); + const cache = + version && version === (await output.hash(file)) + ? 'public, max-age=31536000, immutable' + : 'no-store'; + return (await fileResponse(file, cache)) ?? json(404, { error: 'Not found' }); + }; + + /** + * Files of an open project: `/files//` (thumbnails, previews). With + * `?download`, an attachment; a folder downloads as a .zip. + */ + const handleFiles = async (request: Request, url: URL): Promise => { + const [projectId, ...rest] = url.pathname.slice('/files/'.length).split('/'); + if (!projectId) return json(404, { error: 'Not found' }); + const { session } = sessions.resolve(request.headers.get('cookie')); + const path = rest.map(decodeURIComponent).join('/'); + const download = url.searchParams.has('download'); + try { + const project = projects.get(session, projectId); + const entry = await project.fs.stat(path); + if (!entry || (path && project.fs.isIgnored(path, entry.kind === 'directory'))) { + return json(404, { error: 'Not found' }); + } + const name = path.split('/').at(-1) || project.name; + const disposition = (file: string) => + `attachment; filename="${file.replace(/["\\\r\n]/g, '_')}"`; + if (entry.kind === 'directory') { + if (!download) return json(400, { error: 'Folders can only be downloaded' }); + const archive = await archiveFolder(project.fs, path); + return new Response(archive as Uint8Array, { + headers: { + 'content-type': 'application/zip', + 'content-disposition': disposition(`${name}.zip`), + 'cache-control': 'no-store', + }, + }); + } + const response = await fileResponse(await project.fs.jail.resolve(path), 'no-cache'); + if (!response) return json(404, { error: 'Not found' }); + if (download) response.headers.set('content-disposition', disposition(name)); + return response; + } catch { + return json(404, { error: 'Not found' }); + } + }; + + const handleStatic = async (pathname: string): Promise => { + if (!staticJail) return json(404, { error: 'Not found' }); + const rel = decodeURIComponent(pathname.slice(1)); + const file = await staticJail.resolve(rel).catch(() => undefined); + const immutable = rel.startsWith('_app/immutable/'); + const asset = + file && + (await fileResponse(file, immutable ? 'public, max-age=31536000, immutable' : 'no-cache')); + if (asset) return asset; + if (/\.[a-z0-9]+$/i.test(rel)) return json(404, { error: 'Not found' }); + return ( + (await fileResponse(join(staticJail.root, 'index.html'), 'no-cache')) ?? + json(404, { error: 'Not found' }) + ); + }; + + return { + env, + router, + projects, + plugins, + serverPlugins, + logs, + runtime, + cli, + + async start() { + for (const plugin of await plugins.list()) { + if (plugin.manifest?.entry.server) await serverPlugins.activate(plugin); + } + }, + + async fetch(request, upgrade) { + const url = new URL(request.url); + const { pathname } = url; + try { + if (pathname === '/healthz') return json(200, { ok: true, version: options.version }); + if (pathname === RPC_WS_PATH) { + if (!originAllowed(request)) return json(403, { error: 'Forbidden origin' }); + const { session, setCookies } = sessions.resolve(request.headers.get('cookie')); + const headers = new Headers(); + for (const cookie of setCookies) headers.append('set-cookie', cookie); + if (upgrade?.(request, { data: { session }, headers })) return undefined; + return json(426, { error: 'WebSocket upgrade required' }); + } + if (pathname.startsWith(RPC_HTTP_PREFIX)) { + if (request.method !== 'POST') return json(405, { error: 'Method not allowed' }); + if (!originAllowed(request)) return json(403, { error: 'Forbidden origin' }); + return await handleRpc(request, pathname.slice(RPC_HTTP_PREFIX.length)); + } + if (request.method !== 'GET' && request.method !== 'HEAD') + return json(405, { error: 'Method not allowed' }); + if (pathname.startsWith('/plugins/')) return await handlePlugin(request, pathname); + if (pathname.startsWith('/runtime/')) return await handleRuntime(request, url); + if (pathname.startsWith('/files/')) return await handleFiles(request, url); + return await handleStatic(pathname); + } catch (error) { + logger.error(`${request.method} ${pathname} failed`, error); + return json(500, { error: 'Internal error' }); + } + }, + + websocket: { + open(socket) { + socket.data.rpc = router.createSession( + { session: socket.data.session, responseCookies: [] }, + (message) => socket.send(message) > 0, + ); + }, + message(socket, message) { + void socket.data.rpc?.receive( + typeof message === 'string' ? message : new TextDecoder().decode(message), + ); + }, + drain(socket) { + socket.data.rpc?.drain(); + }, + close(socket) { + socket.data.rpc?.dispose(); + }, + }, + + dispose() { + clearInterval(pruneTimer); + store.dispose(); + }, + }; +}; + +export { PROJECT_HEADER }; diff --git a/packages/server-core/src/server/editor-server.type.ts b/packages/server-core/src/server/editor-server.type.ts new file mode 100644 index 00000000..b0713592 --- /dev/null +++ b/packages/server-core/src/server/editor-server.type.ts @@ -0,0 +1,59 @@ +import { type Disposable, type LoggerService } from '@nanoforge-dev/editor-kernel'; +import { type RpcRouter, type RpcSession } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { type PluginSources } from '../plugin/plugin-sources'; +import { type ServerPluginHost } from '../plugin/server-plugins'; +import { type CliService } from '../process/cli-service'; +import { type ProjectRegistry } from '../project/project-registry'; +import { type RuntimeManager } from '../runtime/project-runtime'; +import type { RequestContext } from '../session/auth'; +import type { Session } from '../session/session-store'; + +export interface SocketData { + readonly session: Session; + rpc?: RpcSession; +} + +/** The part of a WebSocket the server needs (Bun's ServerWebSocket satisfies it). */ +export interface ServerSocket { + readonly data: SocketData; + /** Bun returns -1 under back-pressure, 0 when dropped, the byte count otherwise. */ + send(message: string): number; + close(): void; +} + +export type Upgrade = ( + request: Request, + options: { data: SocketData; headers?: HeadersInit }, +) => boolean; + +export interface EditorServerOptions { + env: EditorEnv; + version: string; + /** Built SPA (index.html + assets). Omitted in dev, where Vite serves the UI. */ + staticDir?: string; + fetch?: typeof fetch; + logger?: LoggerService; +} + +export interface EditorServer extends Disposable { + readonly env: EditorEnv; + readonly router: RpcRouter; + readonly projects: ProjectRegistry; + readonly plugins: PluginSources; + readonly serverPlugins: ServerPluginHost; + readonly logs: LoggerService; + readonly runtime: RuntimeManager; + readonly cli: CliService; + readonly websocket: { + open(socket: ServerSocket): void; + message(socket: ServerSocket, message: string | Uint8Array): void; + drain(socket: ServerSocket): void; + close(socket: ServerSocket): void; + }; + /** Activates plugin server entries. */ + start(): Promise; + /** HTTP handler; returns undefined when the request was upgraded to a WebSocket. */ + fetch(request: Request, upgrade?: Upgrade): Promise; +} diff --git a/packages/server-core/src/server/index.ts b/packages/server-core/src/server/index.ts new file mode 100644 index 00000000..dabac009 --- /dev/null +++ b/packages/server-core/src/server/index.ts @@ -0,0 +1,2 @@ +export * from './editor-server.type'; +export * from './create-editor-server'; diff --git a/packages/server-core/src/session/auth.ts b/packages/server-core/src/session/auth.ts new file mode 100644 index 00000000..b9b11292 --- /dev/null +++ b/packages/server-core/src/session/auth.ts @@ -0,0 +1,17 @@ +import { type CallInfo, RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { Session } from './session-store'; + +export interface RequestContext { + readonly session: Session; + /** `Set-Cookie` values to add to the HTTP response (ignored over WebSocket). */ + readonly responseCookies: string[]; +} + +/** RPC guard: every non public method needs a signed-in user. */ +export const authorize = (context: RequestContext, call: CallInfo): void => { + if (call.public) return; + if (!context.session.user) { + throw new RpcError('UNAUTHORIZED', 'Sign in on the NanoForge website to use the editor'); + } +}; diff --git a/packages/server-core/src/session/cookies.ts b/packages/server-core/src/session/cookies.ts new file mode 100644 index 00000000..d3198ade --- /dev/null +++ b/packages/server-core/src/session/cookies.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const parseCookies = (header: string | null | undefined): Record => { + const cookies: Record = {}; + for (const part of (header ?? '').split(';')) { + const index = part.indexOf('='); + if (index < 0) continue; + const name = part.slice(0, index).trim(); + if (!name) continue; + try { + cookies[name] = decodeURIComponent(part.slice(index + 1).trim()); + } catch { + cookies[name] = part.slice(index + 1).trim(); + } + } + return cookies; +}; + +export interface CookieOptions { + maxAge?: number; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; + path?: string; + domain?: string; +} + +export const serializeCookie = (name: string, value: string, options: CookieOptions = {}) => + [ + `${name}=${encodeURIComponent(value)}`, + `Path=${options.path ?? '/'}`, + options.maxAge !== undefined && `Max-Age=${options.maxAge}`, + options.domain && `Domain=${options.domain}`, + options.httpOnly !== false && 'HttpOnly', + options.secure && 'Secure', + `SameSite=${options.sameSite ?? 'Lax'}`, + ] + .filter(Boolean) + .join('; '); + +const signature = (value: string, secret: string) => + createHmac('sha256', secret).update(value).digest('base64url'); + +export const sign = (value: string, secret: string): string => + `${value}.${signature(value, secret)}`; + +/** Returns the signed value, or undefined when the signature does not match. */ +export const unsign = (signed: string, secret: string): string | undefined => { + const index = signed.lastIndexOf('.'); + if (index <= 0) return undefined; + const value = signed.slice(0, index); + const expected = Buffer.from(signature(value, secret)); + const actual = Buffer.from(signed.slice(index + 1)); + return expected.length === actual.length && timingSafeEqual(expected, actual) ? value : undefined; +}; diff --git a/packages/server-core/src/session/index.ts b/packages/server-core/src/session/index.ts new file mode 100644 index 00000000..347a2a9b --- /dev/null +++ b/packages/server-core/src/session/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './session-store'; diff --git a/packages/server-core/src/session/session-store.ts b/packages/server-core/src/session/session-store.ts new file mode 100644 index 00000000..dc4efe16 --- /dev/null +++ b/packages/server-core/src/session/session-store.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto'; +import { userInfo } from 'node:os'; + +import type { User } from '@nanoforge-dev/editor-protocol'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { parseCookies, serializeCookie, sign, unsign } from './cookies'; + +export const SESSION_COOKIE = 'nf_editor_session'; +/** Set by the NanoForge projects website (shared parent domain) in ONLINE mode. */ +export const ACCESS_TOKEN_COOKIE = 'accessToken'; +export const REFRESH_TOKEN_COOKIE = 'refreshToken'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +export interface Session { + readonly id: string; + readonly mode: 'OFFLINE' | 'ONLINE'; + user: User | null; + tokens: AuthTokens | null; + /** Projects this session opened (ONLINE access control). */ + readonly projects: Set; + lastSeen: number; +} + +export interface SessionResolution { + readonly session: Session; + /** `Set-Cookie` headers to send back (new session, refreshed tokens…). */ + readonly setCookies: string[]; +} + +const IDLE_TTL_MS = 7 * 24 * 3600 * 1000; + +/** Best-effort, unverified read of a JWT payload (display only; the API verifies tokens). */ +const decodeJwtUser = (token: string): User | null => { + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()) as { + sub?: string; + id?: string; + email?: string; + name?: string; + username?: string; + }; + const id = payload.sub ?? payload.id; + if (!id) return null; + return { + id: String(id), + name: payload.name ?? payload.username ?? payload.email ?? 'NanoForge user', + ...(payload.email && { email: payload.email }), + }; + } catch { + return null; + } +}; + +/** + * In-memory sessions identified by a signed cookie. OFFLINE sessions belong to the local OS user; + * ONLINE sessions carry the API tokens found in the website cookies. + */ +export class SessionStore { + private readonly _sessions = new Map(); + + constructor(private readonly _env: EditorEnv) {} + + resolve(cookieHeader: string | null | undefined): SessionResolution { + const cookies = parseCookies(cookieHeader); + const setCookies: string[] = []; + const id = cookies[SESSION_COOKIE] && unsign(cookies[SESSION_COOKIE], this._env.sessionSecret); + let session = id ? this._sessions.get(id) : undefined; + if (!session) { + session = this._create(); + setCookies.push( + serializeCookie(SESSION_COOKIE, sign(session.id, this._env.sessionSecret), { + secure: this._env.production && this._env.mode === 'ONLINE', + }), + ); + } + session.lastSeen = Date.now(); + + if (session.mode === 'ONLINE') { + const accessToken = cookies[ACCESS_TOKEN_COOKIE]; + const refreshToken = cookies[REFRESH_TOKEN_COOKIE]; + if (accessToken || refreshToken) { + session.tokens = { accessToken: accessToken ?? '', refreshToken: refreshToken ?? '' }; + session.user = (accessToken && decodeJwtUser(accessToken)) || session.user; + } else { + session.tokens = null; + session.user = null; + } + } + return { session, setCookies }; + } + + logout(session: Session): string[] { + this._sessions.delete(session.id); + return [serializeCookie(SESSION_COOKIE, '', { maxAge: 0 })]; + } + + /** Drops sessions idle for more than a week. */ + prune(now = Date.now()): void { + for (const [id, session] of this._sessions) { + if (now - session.lastSeen > IDLE_TTL_MS) this._sessions.delete(id); + } + } + + private _create(): Session { + const offline = this._env.mode === 'OFFLINE'; + const session: Session = { + id: randomUUID(), + mode: this._env.mode, + user: offline ? { id: 'local', name: localUserName() } : null, + tokens: null, + projects: new Set(), + lastSeen: Date.now(), + }; + this._sessions.set(session.id, session); + return session; + } +} + +const localUserName = () => { + try { + return userInfo().username; + } catch { + return 'local'; + } +}; diff --git a/packages/server-core/src/settings/account-backends.ts b/packages/server-core/src/settings/account-backends.ts new file mode 100644 index 00000000..6560ed3a --- /dev/null +++ b/packages/server-core/src/settings/account-backends.ts @@ -0,0 +1,158 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import { type AccountSettingsDocument, SETTINGS_LIMITS } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import type { Session } from '../session/session-store'; +import { shortHash } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; + +type Values = AccountSettingsDocument['values']; + +/** Where account settings live: the NanoForge API (online) or the data dir (offline). */ +export interface AccountSettingsBackend { + /** Fires with the user id when a document was saved through this server. */ + readonly onDidChange: Event<{ userId: string; revision: string }>; + get(session: Session): Promise; + put(session: Session, baseRevision: string | null, values: Values): Promise<{ revision: string }>; +} + +const userOf = (session: Session) => { + if (!session.user) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + return session.user; +}; + +export const checkLimits = (values: Values): void => { + const keys = Object.keys(values).length; + const bytes = new TextEncoder().encode(JSON.stringify(values)).length; + if (keys > SETTINGS_LIMITS.maxKeys || bytes > SETTINGS_LIMITS.maxBytes) { + throw new RpcError('PAYLOAD_TOO_LARGE', `Settings too large (${keys} keys, ${bytes} bytes)`); + } +}; + +const conflict = (current: AccountSettingsDocument) => + new RpcError('CONFLICT', 'Settings changed on another device', current); + +/** Offline editors: one revisioned JSON document per local user in the data dir. */ +export class FileAccountBackend implements AccountSettingsBackend { + private readonly _mutex = new KeyedMutex(); + private readonly _onDidChange = new Emitter<{ userId: string; revision: string }>(); + + readonly onDidChange = this._onDidChange.event; + + constructor(private readonly _env: EditorEnv) {} + + get(session: Session): Promise { + return this._read(this._file(userOf(session).id)); + } + + async put( + session: Session, + baseRevision: string | null, + values: Values, + ): Promise<{ revision: string }> { + const user = userOf(session); + checkLimits(values); + const file = this._file(user.id); + return this._mutex.run(file, async () => { + const current = await this._read(file); + if (current.revision !== baseRevision) throw conflict(current); + const revision = String(Number(current.revision ?? '0') + 1); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify({ revision, values }, null, 2)}\n`); + this._onDidChange.fire({ userId: user.id, revision }); + return { revision }; + }); + } + + private _file(userId: string): string { + return join(this._env.dataDir, 'settings', 'accounts', `${shortHash(userId)}.json`); + } + + private async _read(file: string): Promise { + try { + const document = JSON.parse(await readFile(file, 'utf8')) as AccountSettingsDocument; + return { revision: document.revision ?? null, values: document.values ?? {} }; + } catch { + return { revision: null, values: {} }; + } + } +} + +/** + * Online editors: `GET/PUT /users/me/editor-settings` of the NanoForge API + * (contract: docs/api/settings-sync.md). + */ +export class ApiAccountBackend implements AccountSettingsBackend { + private readonly _onDidChange = new Emitter<{ userId: string; revision: string }>(); + + readonly onDidChange = this._onDidChange.event; + + constructor(private readonly _api: ApiClient) {} + + async get(session: Session): Promise { + try { + return await this._api.request(session, '/users/me/editor-settings'); + } catch (error) { + if (RpcError.is(error, 'NOT_FOUND')) return { revision: null, values: {} }; + throw error; + } + } + + async put( + session: Session, + baseRevision: string | null, + values: Values, + ): Promise<{ revision: string }> { + const user = userOf(session); + checkLimits(values); + try { + const result = await this._api.request<{ revision: string }>( + session, + '/users/me/editor-settings', + { + method: 'PUT', + body: { baseRevision, values }, + }, + ); + this._onDidChange.fire({ userId: user.id, revision: result.revision }); + return result; + } catch (error) { + if (RpcError.is(error, 'CONFLICT')) throw conflict(await this.get(session)); + throw error; + } + } +} + +/** Hosted editors: per user and project settings, outside the shared project checkout. */ +export class ProjectLocalSettingsStore { + private readonly _mutex = new KeyedMutex(); + + constructor(private readonly _env: EditorEnv) {} + + async get(session: Session, projectId: string): Promise { + try { + return JSON.parse(await readFile(this._file(session, projectId), 'utf8')) as Values; + } catch { + return {}; + } + } + + async put(session: Session, projectId: string, values: Values): Promise { + checkLimits(values); + const file = this._file(session, projectId); + return this._mutex.run(file, async () => { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(values, null, 2)}\n`); + }); + } + + private _file(session: Session, projectId: string): string { + const user = shortHash(userOf(session).id); + return join(this._env.dataDir, 'settings', 'project-local', user, `${projectId}.json`); + } +} diff --git a/packages/server-core/src/settings/index.ts b/packages/server-core/src/settings/index.ts new file mode 100644 index 00000000..ee58aac0 --- /dev/null +++ b/packages/server-core/src/settings/index.ts @@ -0,0 +1 @@ +export * from './account-backends'; diff --git a/packages/server-core/src/testing.ts b/packages/server-core/src/testing.ts new file mode 100644 index 00000000..93a831ca --- /dev/null +++ b/packages/server-core/src/testing.ts @@ -0,0 +1,85 @@ +/** + * Test helpers: an RpcClient connected in memory to an editor server (no network, no Bun). + * Used by server-core tests and by client packages' integration tests. + */ +import { RpcClient, type SocketLike } from '@nanoforge-dev/editor-rpc'; + +import type { EditorServer, ServerSocket, SocketData } from './server/editor-server.type'; + +export const TEST_ORIGIN = 'http://editor.test'; + +class LoopbackSocket implements SocketLike { + readyState = 0; + onopen: SocketLike['onopen'] = null; + onclose: SocketLike['onclose'] = null; + onerror: SocketLike['onerror'] = null; + onmessage: SocketLike['onmessage'] = null; + private _server: ServerSocket | undefined; + + constructor( + private readonly _editor: EditorServer, + url: string, + cookie: () => string, + ) { + const request = new Request(url.replace(/^ws/, 'http'), { + headers: { origin: TEST_ORIGIN, cookie: cookie() }, + }); + void _editor + .fetch(request, (_request, { data }) => { + this._server = this._serverSide(data); + return true; + }) + .then(() => { + if (!this._server) { + this.readyState = 3; + this.onclose?.({}); + return; + } + _editor.websocket.open(this._server); + this.readyState = 1; + this.onopen?.({}); + }); + } + + send(data: string): void { + if (this._server) this._editor.websocket.message(this._server, data); + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + if (this._server) this._editor.websocket.close(this._server); + this.onclose?.({}); + } + + private _serverSide(data: SocketData): ServerSocket { + return { + data, + send: (message) => { + queueMicrotask(() => this.onmessage?.({ data: message })); + return message.length; + }, + close: () => this.close(), + }; + } +} + +/** An RpcClient whose HTTP calls and WebSocket go straight to `editor` (cookies included). */ +export const createLoopbackClient = (editor: EditorServer): RpcClient => { + let cookie = ''; + return new RpcClient({ + baseUrl: TEST_ORIGIN, + reconnectDelays: [10], + fetch: async (url, init) => { + const request = new Request(String(url), { + ...init, + headers: { ...(init?.headers as Record), origin: TEST_ORIGIN, cookie }, + }); + const response = (await editor.fetch(request))!; + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]!; + return response; + }, + createSocket: (url) => new LoopbackSocket(editor, url, () => cookie), + }); +}; diff --git a/packages/server-core/src/util/glob.ts b/packages/server-core/src/util/glob.ts new file mode 100644 index 00000000..222f8271 --- /dev/null +++ b/packages/server-core/src/util/glob.ts @@ -0,0 +1,63 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const IGNORED = new Set(['node_modules', '.git', '.nanoforge', 'dist']); + +const segmentRegex = (segment: string) => + new RegExp( + `^${segment + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) + .join('[^/]*')}$`, + ); + +/** + * Expands workspace `packages` patterns (`apps/*`, `packages/**`, `libs/shared`) into + * directories relative to `root`. Only directories are matched. + */ +export const expandDirectoryGlobs = async ( + root: string, + patterns: readonly string[], +): Promise => { + const results = new Set(); + const walk = async (relative: string, segments: string[]): Promise => { + if (!segments.length) { + results.add(relative); + return; + } + const [segment, ...rest] = segments as [string, ...string[]]; + if (segment === '**') { + await walk(relative, rest); + for (const child of await children(root, relative)) + await walk(join(relative, child), segments); + return; + } + if (!segment.includes('*')) { + if ((await children(root, relative)).includes(segment)) + await walk(join(relative, segment), rest); + return; + } + const regex = segmentRegex(segment); + for (const child of await children(root, relative)) { + if (regex.test(child)) await walk(join(relative, child), rest); + } + }; + for (const pattern of patterns) { + await walk( + '', + pattern.split('/').filter((segment) => segment && segment !== '.'), + ); + } + return [...results].map((path) => path.split('\\').join('/')).sort(); +}; + +const children = async (root: string, relative: string): Promise => { + try { + const entries = await readdir(join(root, relative), { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && !IGNORED.has(entry.name)) + .map((entry) => entry.name); + } catch { + return []; + } +}; diff --git a/packages/server-core/src/util/hash.ts b/packages/server-core/src/util/hash.ts new file mode 100644 index 00000000..9f8721ca --- /dev/null +++ b/packages/server-core/src/util/hash.ts @@ -0,0 +1,7 @@ +import { createHash } from 'node:crypto'; + +export const sha1 = (content: Uint8Array | string): string => + createHash('sha1').update(content).digest('hex'); + +export const shortHash = (value: string, length = 16): string => + createHash('sha256').update(value).digest('hex').slice(0, length); diff --git a/packages/server-core/src/util/keyed-mutex.ts b/packages/server-core/src/util/keyed-mutex.ts new file mode 100644 index 00000000..9a9e1053 --- /dev/null +++ b/packages/server-core/src/util/keyed-mutex.ts @@ -0,0 +1,15 @@ +/** Serializes async work per key (git operations per project, writes per file…). */ +export class KeyedMutex { + private readonly _tails = new Map>(); + + run(key: string, task: () => Promise): Promise { + const previous = this._tails.get(key) ?? Promise.resolve(); + const result = previous.then(task, task); + const tail = result.catch(() => undefined); + this._tails.set(key, tail); + void tail.then(() => { + if (this._tails.get(key) === tail) this._tails.delete(key); + }); + return result; + } +} diff --git a/packages/server-core/test/contract/api-contract.ts b/packages/server-core/test/contract/api-contract.ts new file mode 100644 index 00000000..a4815491 --- /dev/null +++ b/packages/server-core/test/contract/api-contract.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; + +/** + * What the editor needs from the NanoForge API (docs/api/README.md), as scenarios that only + * speak HTTP. They run against the in-memory mock in the editor's own tests, and against a real + * API when `CONTRACT_API_URL` is set (see `api.contract.test.ts`). + * + * On a real API they use one test account and leave it as they found it: the settings document + * is put back at the end. + */ +export interface ApiTarget { + readonly baseUrl: string; + readonly fetch: typeof fetch; + readonly apiKey: string; + /** A valid access token of the test account. */ + readonly accessToken: string; + /** Tests token refresh when given. A real API rotates it: the old value stops working. */ + readonly refreshToken?: string | undefined; + /** A project of the test account, to test the project endpoints. */ + readonly projectId?: string | undefined; + /** Which features the API has (`API_FEATURES` of the editor): their scenarios run. */ + readonly features: readonly string[]; +} + +interface Answer { + status: number; + body: unknown; +} + +export const apiContract = (target: () => ApiTarget): void => { + const call = async ( + method: string, + path: string, + options: { body?: unknown; token?: string | null; apiKey?: string | null } = {}, + ): Promise => { + const { baseUrl, fetch, apiKey, accessToken } = target(); + const token = options.token === undefined ? accessToken : options.token; + const key = options.apiKey === undefined ? apiKey : options.apiKey; + const response = await fetch(new URL(path, baseUrl), { + method, + headers: { + 'content-type': 'application/json', + ...(key && { 'api-key': key }), + ...(token && { authorization: `Bearer ${token}` }), + }, + ...(options.body !== undefined && { body: JSON.stringify(options.body) }), + }); + const text = await response.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = text; + } + return { status: response.status, body }; + }; + const has = (feature: string) => target().features.includes(feature); + + describe('authentication', () => { + it('refuses a bad access token with 401', async () => { + const answer = await call('GET', '/projects', { token: 'not-a-token' }); + expect(answer.status).toBe(401); + }); + + it('refreshes a token pair, and refuses a bad refresh token with 401', async (context) => { + const { refreshToken } = target(); + if (!refreshToken) return context.skip(); + const bad = await call('POST', '/auth/refresh-token', { + token: null, + body: { refreshToken: 'not-a-token' }, + }); + expect(bad.status).toBe(401); + const answer = await call('POST', '/auth/refresh-token', { + token: null, + body: { refreshToken }, + }); + expect(answer.status).toBeLessThan(300); + expect(answer.body).toMatchObject({ + accessToken: expect.any(String) as string, + refreshToken: expect.any(String) as string, + tokenExpiresAt: expect.any(String) as string, + }); + }); + }); + + describe('projects', () => { + const PROJECT = { + id: expect.any(String) as string, + code: expect.any(String) as string, + name: expect.any(String) as string, + gatewayProjectRegistryUrl: expect.any(String) as string, + gatewayProjectRegistryMetadata: expect.any(Object) as object, + }; + + it('lists the projects of the user', async () => { + const answer = await call('GET', '/projects'); + expect(answer.status).toBe(200); + expect(Array.isArray(answer.body)).toBe(true); + for (const project of answer.body as unknown[]) expect(project).toMatchObject(PROJECT); + }); + + it('gives the editor a project with a token to clone it', async (context) => { + const { projectId } = target(); + if (!projectId) return context.skip(); + const answer = await call('GET', `/editor/projects/${encodeURIComponent(projectId)}`); + expect(answer.status).toBe(200); + expect(answer.body).toMatchObject({ ...PROJECT, id: projectId, token: expect.any(String) }); + const unknown = await call('GET', '/editor/projects/00000000-0000-4000-8000-000000000000'); + expect([403, 404]).toContain(unknown.status); + }); + }); + + describe('settings sync', () => { + const PATH = '/users/me/editor-settings'; + interface Document { + revision: string | null; + values: Record; + } + /** The document, or the empty one a 404 stands for. */ + const read = async (): Promise => { + const answer = await call('GET', PATH); + expect([200, 404]).toContain(answer.status); + if (answer.status === 404) return { revision: null, values: {} }; + expect(answer.body).toMatchObject({ + revision: expect.any(String) as string, + values: expect.any(Object) as object, + }); + return answer.body as Document; + }; + + it('needs a signed-in user', async (context) => { + if (!has('settings')) return context.skip(); + expect((await call('GET', PATH, { token: 'not-a-token' })).status).toBe(401); + expect( + (await call('PUT', PATH, { token: null, body: { baseRevision: null, values: {} } })).status, + ).toBe(401); + }); + + it('saves on the right revision, refuses a stale one with the current document', async (context) => { + if (!has('settings')) return context.skip(); + const original = await read(); + const values = { ...original.values, 'contract.test': Date.now() }; + try { + const saved = await call('PUT', PATH, { + body: { baseRevision: original.revision, values }, + }); + expect(saved.status).toBe(200); + const { revision } = saved.body as { revision: string }; + expect(typeof revision).toBe('string'); + expect(revision).not.toBe(original.revision); + expect(await read()).toEqual({ revision, values }); + + const stale = await call('PUT', PATH, { + body: { baseRevision: original.revision, values: { 'contract.test': 'stale' } }, + }); + expect(stale.status).toBe(409); + expect(stale.body).toEqual({ revision, values }); + expect(await read()).toEqual({ revision, values }); + } finally { + const current = await read(); + await call('PUT', PATH, { + body: { baseRevision: current.revision, values: original.values }, + }); + } + }); + + it('refuses a malformed body with 400 and an oversized one with 413', async (context) => { + if (!has('settings')) return context.skip(); + const before = await read(); + const malformed = await call('PUT', PATH, { + body: { baseRevision: before.revision, values: 'not an object' }, + }); + expect(malformed.status).toBe(400); + const tooMany = Object.fromEntries(Array.from({ length: 2001 }, (_, i) => [`k${i}`, i])); + const large = await call('PUT', PATH, { + body: { baseRevision: before.revision, values: tooMany }, + }); + expect(large.status).toBe(413); + expect(await read()).toEqual(before); + }); + }); +}; diff --git a/packages/server-core/test/contract/api.contract.spec.ts b/packages/server-core/test/contract/api.contract.spec.ts new file mode 100644 index 00000000..ec6a05fa --- /dev/null +++ b/packages/server-core/test/contract/api.contract.spec.ts @@ -0,0 +1,42 @@ +import { describe } from 'vitest'; + +import { PROJECT, createMockApi } from '../mocks/nanoforge-api'; +import { type ApiTarget, apiContract } from './api-contract'; + +/** + * Runs the API contract. By default against the in-memory mock, so the mock and the contract + * cannot drift apart. Against a real API with: + * + * CONTRACT_API_URL=https://api.staging.nanoforge.eu \ + * CONTRACT_API_KEY=… CONTRACT_ACCESS_TOKEN=… \ + * [CONTRACT_FEATURES=settings] [CONTRACT_PROJECT_ID=…] [CONTRACT_REFRESH_TOKEN=…] \ + * pnpm --filter @nanoforge-dev/editor-server-core test:contract + */ +const env = process.env; + +const real = (): ApiTarget => ({ + baseUrl: env.CONTRACT_API_URL!, + fetch: globalThis.fetch, + apiKey: env.CONTRACT_API_KEY ?? '', + accessToken: env.CONTRACT_ACCESS_TOKEN ?? '', + refreshToken: env.CONTRACT_REFRESH_TOKEN, + projectId: env.CONTRACT_PROJECT_ID, + features: (env.CONTRACT_FEATURES ?? '').split(',').map((feature) => feature.trim()), +}); + +let mock: ApiTarget | undefined; +const mocked = (): ApiTarget => + (mock ??= { + baseUrl: 'https://api.test', + fetch: createMockApi().fetch, + apiKey: 'test-key', + accessToken: 'access-1', + refreshToken: 'refresh-1', + projectId: PROJECT.id, + features: ['settings'], + }); + +describe( + env.CONTRACT_API_URL ? `NanoForge API at ${env.CONTRACT_API_URL}` : 'NanoForge API (mock)', + () => apiContract(env.CONTRACT_API_URL ? real : mocked), +); diff --git a/packages/server-core/test/fixtures/plugins/@acme/echo/index.js b/packages/server-core/test/fixtures/plugins/@acme/echo/index.js new file mode 100644 index 00000000..500af76d --- /dev/null +++ b/packages/server-core/test/fixtures/plugins/@acme/echo/index.js @@ -0,0 +1 @@ +export const activate = () => {}; diff --git a/packages/server-core/test/fixtures/plugins/@acme/echo/nanoforge.manifest.json b/packages/server-core/test/fixtures/plugins/@acme/echo/nanoforge.manifest.json new file mode 100644 index 00000000..f459bba6 --- /dev/null +++ b/packages/server-core/test/fixtures/plugins/@acme/echo/nanoforge.manifest.json @@ -0,0 +1,7 @@ +{ + "type": "plugin", + "name": "@acme/echo", + "version": "1.0.0", + "engines": { "editor": "*" }, + "entry": { "client": "index.js", "server": "server.js" } +} diff --git a/packages/server-core/test/fixtures/plugins/@acme/echo/server.js b/packages/server-core/test/fixtures/plugins/@acme/echo/server.js new file mode 100644 index 00000000..ce63eaae --- /dev/null +++ b/packages/server-core/test/fixtures/plugins/@acme/echo/server.js @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +export const EchoContract = defineContract('plugin.@acme/echo', { + methods: { echo: { input: z.object({ text: z.string() }), output: z.string() } }, +}); + +export default { + activate(context) { + context.implement(EchoContract, { + methods: { echo: ({ text }, { session }) => `${text} from ${session.user.name}` }, + }); + }, +}; diff --git a/src/lib/server/project/package/package-resolver.ts b/packages/server-core/test/fixtures/pong-network/.gitignore similarity index 100% rename from src/lib/server/project/package/package-resolver.ts rename to packages/server-core/test/fixtures/pong-network/.gitignore diff --git a/packages/server-core/test/fixtures/pong-network/.prettierignore b/packages/server-core/test/fixtures/pong-network/.prettierignore new file mode 100644 index 00000000..64b127c9 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/.prettierignore @@ -0,0 +1,11 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/.prettierignore b/packages/server-core/test/fixtures/pong-network/apps/client/.prettierignore new file mode 100644 index 00000000..64b127c9 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/.prettierignore @@ -0,0 +1,11 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md diff --git a/src/routes/+page.server.ts b/packages/server-core/test/fixtures/pong-network/apps/client/assets/.gitkeep similarity index 100% rename from src/routes/+page.server.ts rename to packages/server-core/test/fixtures/pong-network/apps/client/assets/.gitkeep diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/eslint.config.js b/packages/server-core/test/fixtures/pong-network/apps/client/eslint.config.js new file mode 100644 index 00000000..62ec06dc --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/eslint.config.js @@ -0,0 +1,3 @@ +import config from "@nanoforge-dev/utils-eslint-config"; + +export default config; diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/nanoforge.config.ts b/packages/server-core/test/fixtures/pong-network/apps/client/nanoforge.config.ts new file mode 100644 index 00000000..77607cbd --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "client", +}); diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/package.json b/packages/server-core/test/fixtures/pong-network/apps/client/package.json new file mode 100644 index 00000000..02bf8685 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "pong-network-client", + "version": "0.0.0", + "description": "", + "author": "", + "type": "module", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty \"src/**/*.ts\"", + "format": "prettier --write . && eslint --fix --format=pretty \"src/**/*.ts\"" + }, + "devDependencies": { + "@nanoforge-dev/asset": "workspace:*", + "@nanoforge-dev/common": "workspace:*", + "@nanoforge-dev/core": "workspace:*", + "@nanoforge-dev/env": "workspace:*", + "@nanoforge-dev/ecs": "workspace:*", + "@nanoforge-dev/network": "workspace:*", + "@nanoforge-dev/graphics-2d": "workspace:*", + "@nanoforge-dev/input": "workspace:*", + "@nanoforge-dev/utils-eslint-config": "workspace:*", + "@nanoforge-dev/utils-prettier-config": "workspace:*", + "@trivago/prettier-plugin-sort-imports": "catalog:lint", + "eslint": "catalog:lint", + "lint-staged": "catalog:ci", + "prettier": "catalog:lint", + "typescript": "catalog:core" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "src/**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/prettier.config.js b/packages/server-core/test/fixtures/pong-network/apps/client/prettier.config.js new file mode 100644 index 00000000..27d0e269 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/prettier.config.js @@ -0,0 +1,3 @@ +import config from "@nanoforge-dev/utils-prettier-config"; + +export default config; diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/src/components/components.ts b/packages/server-core/test/fixtures/pong-network/apps/client/src/components/components.ts new file mode 100644 index 00000000..616891b0 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/src/components/components.ts @@ -0,0 +1,66 @@ +import type { Circle, Rect } from "@nanoforge-dev/graphics-2d"; +import type { InputEnum } from "@nanoforge-dev/input"; + +import { layer } from "../main"; + +export class NetworkId { + name = "NetworkId"; + constructor(public id: number) {} +} + +export class Velocity { + name = "Velocity"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +export class Position { + name = "Position"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +export class CircleComponent { + name = "CircleComponent"; + component: Circle; + + constructor(component: Circle) { + this.component = component; + layer.add(this.component); + } +} + +export class RectangleComponent { + name = "RectangleComponent"; + component: Rect; + + constructor(component: Rect) { + this.component = component; + layer.add(this.component); + } +} + +export class Controller { + name = "Controller"; + up: InputEnum; + down: InputEnum; + lastPressedUp: boolean; + lastPressedDown: boolean; + + constructor(up: InputEnum, down: InputEnum) { + this.up = up; + this.down = down; + this.lastPressedUp = false; + this.lastPressedDown = false; + } +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/src/main.ts b/packages/server-core/test/fixtures/pong-network/apps/client/src/main.ts new file mode 100644 index 00000000..86ba70df --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/src/main.ts @@ -0,0 +1,109 @@ +import type { ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d"; +import { InputEnum, InputLibrary } from "@nanoforge-dev/input"; +import { NetworkClientLibrary } from "@nanoforge-dev/network/client"; + +import { + CircleComponent, + Controller, + Position, + RectangleComponent, + Velocity, +} from "./components/components"; +import { controlPlayer, draw, move, packetHandler } from "./systems/systems"; + +export const layer = new Layer(); + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ + tickRate: 60, + viewport: { width: 1920, height: 1080, fit: "contain" }, + }); + const graphics = new Graphics2DLibrary(); + const ecs = new EcsLibrary(); + const network = new NetworkClientLibrary(); + const input = new InputLibrary(); + + app.use(graphics); + app.use(ecs); + app.use(network); + app.use(input); + + await app.init(options); + + const registry = ecs.registry; + + graphics.stage.add(layer); + + const terrain = registry.spawnEntity(); + registry.addComponent(terrain, new Position(0, 0)); + registry.addComponent( + terrain, + new RectangleComponent(new Rect({ fill: "rgb(58,99,39)", width: 1920, height: 1080 })), + ); + const terrainLine = registry.spawnEntity(); + registry.addComponent(terrainLine, new Position(955, 0)); + registry.addComponent( + terrainLine, + new RectangleComponent(new Rect({ fill: "rgb(148,204,117)", width: 10, height: 1080 })), + ); + + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Velocity(0, 0)); + registry.addComponent(ball, new Position(0, 0)); + registry.addComponent( + ball, + new CircleComponent( + new Circle({ + radius: 30, + fill: "red", + }), + ), + ); + + const me = registry.spawnEntity(); + registry.addComponent(me, new Controller(InputEnum.ArrowUp, InputEnum.ArrowDown)); + + const paddle1 = registry.spawnEntity(); + registry.addComponent(paddle1, new Position(0, 0)); + registry.addComponent(paddle1, new Velocity(0, 0)); + registry.addComponent( + paddle1, + new RectangleComponent(new Rect({ fill: "blue", width: 30, height: 300 })), + ); + + const paddle2 = registry.spawnEntity(); + registry.addComponent(paddle2, new Position(0, 0)); + registry.addComponent(paddle2, new Velocity(0, 0)); + registry.addComponent( + paddle2, + new RectangleComponent(new Rect({ fill: "blue", width: 30, height: 300 })), + ); + + registry.addSystem(packetHandler); + registry.addSystem(move); + registry.addSystem(controlPlayer); + registry.addSystem(draw); + + async function waitForConnection(): Promise { + if (network.tcp?.isConnected()) return; + + return new Promise((resolve) => { + const check = () => { + if (network.tcp?.isConnected()) { + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + } + + await waitForConnection(); + network.tcp?.sendData(new TextEncoder().encode(JSON.stringify({ type: "play" }))); + + await app.run(); +}; diff --git a/src/routes/+page.ts b/packages/server-core/test/fixtures/pong-network/apps/client/src/scenes/.gitkeep similarity index 100% rename from src/routes/+page.ts rename to packages/server-core/test/fixtures/pong-network/apps/client/src/scenes/.gitkeep diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/src/systems/systems.ts b/packages/server-core/test/fixtures/pong-network/apps/client/src/systems/systems.ts new file mode 100644 index 00000000..d3b41fad --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/src/systems/systems.ts @@ -0,0 +1,91 @@ +import { type Context } from "@nanoforge-dev/common"; +import { type Registry } from "@nanoforge-dev/ecs/client"; + +import { + CircleComponent, + Controller, + NetworkId, + Position, + RectangleComponent, + Velocity, +} from "../components/components"; + +export function move(registry: Registry, ctx: Context) { + const entities = registry.getZipper([Position, Velocity]); + + entities.forEach(({ Position, Velocity }) => { + Position.x += Velocity.x * ctx.app.delta; + Position.y += Velocity.y * ctx.app.delta; + }); +} + +export const controlPlayer = (registry: Registry, ctx: Context) => { + const entities = registry.getZipper([Controller]); + const input = ctx.input; + const network = ctx.network; + + entities.forEach(({ Controller }) => { + const upPressed = input.isKeyPressed(Controller.up); + const downPressed = input.isKeyPressed(Controller.down); + if (upPressed == downPressed) { + if (Controller.lastPressedUp || Controller.lastPressedDown) { + network.tcp.sendData( + new TextEncoder().encode(JSON.stringify({ type: "input", key: "stop" })), + ); + Controller.lastPressedDown = false; + Controller.lastPressedUp = false; + } + } else if (upPressed && !Controller.lastPressedUp) { + network.tcp.sendData(new TextEncoder().encode(JSON.stringify({ type: "input", key: "up" }))); + Controller.lastPressedUp = true; + } else if (downPressed && !Controller.lastPressedDown) { + network.tcp?.sendData( + new TextEncoder().encode(JSON.stringify({ type: "input", key: "down" })), + ); + Controller.lastPressedDown = true; + } + }); +}; + +export function draw(registry: Registry) { + const circles = registry.getZipper([CircleComponent, Position]); + const rectangles = registry.getZipper([RectangleComponent, Position]); + + circles.forEach(({ CircleComponent, Position }) => { + CircleComponent.component.setPosition(Position); + }); + rectangles.forEach(({ RectangleComponent, Position }) => { + RectangleComponent.component.setPosition(Position); + }); +} + +export function packetHandler(registry: Registry, ctx: Context) { + const network = ctx.network; + const jsonPackets = network.tcp.getReceivedPackets().map((packet) => { + return JSON.parse(new TextDecoder().decode(packet)); + }); + + if (!jsonPackets || jsonPackets.length === 0) return; + jsonPackets.forEach((packet) => { + const type = packet.type; + if (type === "move") { + const zipper = registry.getZipper([NetworkId, Position, Velocity]); + const it = zipper.find((entity) => { + return entity.NetworkId.id === packet.id; + }); + if (!it) return; + it.Position.x = packet.position.x; + it.Position.y = packet.position.y; + it.Velocity.x = packet.velocity.x; + it.Velocity.y = packet.velocity.y; + } else if (type === "assignId") { + if (packet.assigned === "ball") { + registry.addComponent(registry.entityFromIndex(2), new NetworkId(packet.id)); + } else if (packet.assigned === "paddle1") { + registry.addComponent(registry.entityFromIndex(4), new NetworkId(packet.id)); + } else if (packet.assigned === "paddle2") { + registry.addComponent(registry.entityFromIndex(5), new NetworkId(packet.id)); + } + } + }); +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/client/tsconfig.json b/packages/server-core/test/fixtures/pong-network/apps/client/tsconfig.json new file mode 100644 index 00000000..f90a806b --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/client/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", ".nanoforge"] +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/.prettierignore b/packages/server-core/test/fixtures/pong-network/apps/server/.prettierignore new file mode 100644 index 00000000..64b127c9 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/.prettierignore @@ -0,0 +1,11 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/assets/.gitkeep b/packages/server-core/test/fixtures/pong-network/apps/server/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/eslint.config.js b/packages/server-core/test/fixtures/pong-network/apps/server/eslint.config.js new file mode 100644 index 00000000..62ec06dc --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import config from "@nanoforge-dev/utils-eslint-config"; + +export default config; diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/nanoforge.config.ts b/packages/server-core/test/fixtures/pong-network/apps/server/nanoforge.config.ts new file mode 100644 index 00000000..43a3fef2 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "server", +}); diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/package.json b/packages/server-core/test/fixtures/pong-network/apps/server/package.json new file mode 100644 index 00000000..039508dd --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "pong-network-server", + "version": "0.0.0", + "description": "", + "author": "", + "type": "module", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty \"src/**/*.ts\"", + "format": "prettier --write . && eslint --fix --format=pretty \"src/**/*.ts\"" + }, + "devDependencies": { + "@nanoforge-dev/asset": "workspace:*", + "@nanoforge-dev/common": "workspace:*", + "@nanoforge-dev/core": "workspace:*", + "@nanoforge-dev/env": "workspace:*", + "@nanoforge-dev/ecs": "workspace:*", + "@nanoforge-dev/network": "workspace:*", + "@nanoforge-dev/utils-eslint-config": "workspace:*", + "@nanoforge-dev/utils-prettier-config": "workspace:*", + "@trivago/prettier-plugin-sort-imports": "catalog:lint", + "eslint": "catalog:lint", + "lint-staged": "catalog:ci", + "prettier": "catalog:lint", + "typescript": "catalog:core" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "src/**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/prettier.config.js b/packages/server-core/test/fixtures/pong-network/apps/server/prettier.config.js new file mode 100644 index 00000000..27d0e269 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/prettier.config.js @@ -0,0 +1,3 @@ +import config from "@nanoforge-dev/utils-prettier-config"; + +export default config; diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/src/components/components.ts b/packages/server-core/test/fixtures/pong-network/apps/server/src/components/components.ts new file mode 100644 index 00000000..9bb8846e --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/src/components/components.ts @@ -0,0 +1,41 @@ +export class Velocity { + name = "Velocity"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +export class Position { + name = "Position"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +export class Circle { + name = "Circle"; + radius: number; + + constructor(radius: number) { + this.radius = radius; + } +} + +export class Rectangle { + name = "Rectangle"; + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } +} diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/src/main.ts b/packages/server-core/test/fixtures/pong-network/apps/server/src/main.ts new file mode 100644 index 00000000..f90b95a0 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/src/main.ts @@ -0,0 +1,38 @@ +import type { RunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/server"; +import { NetworkServerLibrary } from "@nanoforge-dev/network/server"; + +import { Position, Velocity } from "./components/components"; +import { bounce, move, packetHandler } from "./systems/systems"; + +export const main = async (options: RunOptions): Promise => { + const app = NanoforgeFactory.createServer({ tickRate: 60 }); + const ecs = new EcsLibrary(); + const network = new NetworkServerLibrary(); + + app.use(ecs); + app.use(network); + + await app.init(options); + + const registry = ecs.registry; + + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Position(960, 540)); + registry.addComponent(ball, new Velocity(0, 0)); + + const paddle1 = registry.spawnEntity(); + registry.addComponent(paddle1, new Position(20, 390)); + registry.addComponent(paddle1, new Velocity(0, 0)); + + const paddle2 = registry.spawnEntity(); + registry.addComponent(paddle2, new Position(1850, 390)); + registry.addComponent(paddle2, new Velocity(0, 0)); + + registry.addSystem(packetHandler); + registry.addSystem(bounce); + registry.addSystem(move); + + await app.run(); +}; diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/src/scenes/.gitkeep b/packages/server-core/test/fixtures/pong-network/apps/server/src/scenes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/src/systems/systems.ts b/packages/server-core/test/fixtures/pong-network/apps/server/src/systems/systems.ts new file mode 100644 index 00000000..c119dca8 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/src/systems/systems.ts @@ -0,0 +1,186 @@ +import { type Context } from "@nanoforge-dev/common"; +import { type Registry } from "@nanoforge-dev/ecs/server"; +import { type NetworkServerContextApi } from "@nanoforge-dev/network/server"; + +import { Position, Velocity } from "../components/components"; + +let cli1 = -1; +let cli2 = -1; +const paddleSpeed = 1; + +let roundStart = 0; + +export function move(registry: Registry, ctx: Context) { + const entities = registry.getZipper([Position, Velocity]); + + entities.forEach(({ Position, Velocity }) => { + Position.x += Velocity.x * ctx.app.delta; + Position.y += Velocity.y * ctx.app.delta; + }); +} + +function sendMoveAll(id: number, vel: Velocity, pos: Position, network: NetworkServerContextApi) { + network.tcp.sendToEverybody( + new TextEncoder().encode( + JSON.stringify({ + type: "move", + id: id, + position: { x: pos.x, y: pos.y }, + velocity: { x: vel.x, y: vel.y }, + }), + ), + ); +} + +function connectNewClient(newCli: number, network: NetworkServerContextApi, zip: any) { + network.tcp.sendToClient( + newCli, + new TextEncoder().encode(JSON.stringify({ type: "assignId", assigned: "ball", id: 0 })), + ); + network.tcp.sendToClient( + newCli, + new TextEncoder().encode(JSON.stringify({ type: "assignId", assigned: "paddle1", id: 1 })), + ); + network.tcp.sendToClient( + newCli, + new TextEncoder().encode(JSON.stringify({ type: "assignId", assigned: "paddle2", id: 2 })), + ); + sendMoveAll(0, zip[0].Velocity, zip[0].Position, network); + sendMoveAll(1, zip[1].Velocity, zip[1].Position, network); + sendMoveAll(2, zip[2].Velocity, zip[2].Position, network); +} + +function handleClientInput( + clientId: number, + key: string, + network: NetworkServerContextApi, + zip: any, +) { + let id; + + if (clientId === cli1) { + id = 1; + } else if (clientId === cli2) { + id = 2; + } else { + return; + } + const paddle = zip[id]; + + if (key === "up") { + paddle.Velocity.y = -paddleSpeed; + if (paddle.Position.y < 0) { + paddle.Position.y = 0; + paddle.Velocity.y = 0; + } + } + if (key === "down") { + paddle.Velocity.y = paddleSpeed; + if (paddle.Position.y > 780) { + paddle.Position.y = 780; + paddle.Velocity.y = 0; + } + } + if (key === "stop") { + paddle.Velocity.y = 0; + } + sendMoveAll(id, paddle.Velocity, paddle.Position, network); +} + +export function packetHandler(registry: Registry, ctx: Context) { + const zip = registry.getZipper([Position, Velocity]); + const network = ctx.network; + + if (network.tcp.getConnectedClients().indexOf(cli1) == -1) cli1 = -1; + if (network.tcp.getConnectedClients().indexOf(cli2) == -1) cli2 = -1; + const clientPackets: Map = network.tcp.getReceivedPackets(); + clientPackets.forEach((packets, client) => { + packets.forEach((packet) => { + const data = JSON.parse(new TextDecoder().decode(packet)); + if (data.type == "play") { + let newCli = -1; + if (client == cli1 || client == cli2) return; + if (cli1 == -1) { + cli1 = client; + newCli = client; + } else if (cli2 == -1) { + cli2 = client; + newCli = client; + } + connectNewClient(newCli, network, zip); + } else if (data.type == "input") { + handleClientInput(client, data.key, network, zip); + } + }); + }); +} + +function checkOutOfTerrain(id: number, paddle: any, network: NetworkServerContextApi) { + if (paddle.Position.y < 0) { + paddle.Position.y = 0; + paddle.Velocity.y = 0; + sendMoveAll(id, paddle.Velocity, paddle.Position, network); + } + if (paddle.Position.y > 780) { + paddle.Position.y = 780; + paddle.Velocity.y = 0; + sendMoveAll(id, paddle.Velocity, paddle.Position, network); + } +} + +export const bounce = (registry: Registry, ctx: Context) => { + const network = ctx.network; + const zip = registry.getZipper([Position, Velocity]); + checkOutOfTerrain(1, zip[1], network); + checkOutOfTerrain(2, zip[2], network); + if (roundStart < 3000 && roundStart != -1) { + roundStart += ctx.app.delta; + return; + } + if (roundStart >= 3000) { + roundStart = -1; + zip[0].Velocity.x = 1; + sendMoveAll(0, zip[0].Velocity, zip[0].Position, network); + return; + } + let bounced = false; + if (zip[0].Position.y <= 0 || zip[0].Position.y >= 1080) { + zip[0].Velocity.y *= -1; + bounced = true; + } + if ( + (zip[0].Position.x <= 65 && + zip[1].Position.y - 15 <= zip[0].Position.y && + zip[0].Position.y - 15 <= zip[1].Position.y + 300) || + (zip[0].Position.x >= 1835 && + zip[2].Position.y - 15 <= zip[0].Position.y && + zip[0].Position.y - 15 <= zip[2].Position.y + 300) + ) { + let paddleTouched = 1; + if (zip[0].Position.x >= 1835) { + paddleTouched = 2; + } + const prevSpeed = Math.sqrt(zip[0].Velocity.x ** 2 + zip[0].Velocity.y ** 2); + const newSpeed = prevSpeed * 1.1; + const posOnPaddle = + (zip[0].Position.y - + (zip[0].Position.x < 960 ? zip[paddleTouched].Position.y : zip[paddleTouched].Position.y) + + 15) / + 330; + const baseAngle = posOnPaddle * ((6 * Math.PI) / 8) + Math.PI / 8 - Math.PI / 2; + const outAngle = zip[0].Position.x < 960 ? baseAngle : -baseAngle + Math.PI; + zip[0].Velocity.x = Math.cos(outAngle) * newSpeed; + zip[0].Velocity.y = Math.sin(outAngle) * newSpeed; + bounced = true; + } else if (zip[0].Position.x <= 15 || zip[0].Position.x >= 1905) { + zip[0].Position.x = 960; + zip[0].Position.y = 540; + zip[0].Velocity.x = 0; + zip[0].Velocity.y = 0; + roundStart = 0; + bounced = true; + } + if (bounced) { + sendMoveAll(0, zip[0].Velocity, zip[0].Position, network); + } +}; diff --git a/packages/server-core/test/fixtures/pong-network/apps/server/tsconfig.json b/packages/server-core/test/fixtures/pong-network/apps/server/tsconfig.json new file mode 100644 index 00000000..f90a806b --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/apps/server/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", ".nanoforge"] +} diff --git a/packages/server-core/test/fixtures/pong-network/nanoforge.config.ts b/packages/server-core/test/fixtures/pong-network/nanoforge.config.ts new file mode 100644 index 00000000..99f24783 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "workspace", + packages: ["apps/*"], +}); diff --git a/packages/server-core/test/fixtures/pong-network/package.json b/packages/server-core/test/fixtures/pong-network/package.json new file mode 100644 index 00000000..28742f02 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/package.json @@ -0,0 +1,32 @@ +{ + "name": "pong-network", + "version": "0.0.0", + "description": "", + "author": "", + "type": "module", + "scripts": { + "start": "nf start", + "lint": "prettier --check .", + "format": "prettier --write .", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "devDependencies": { + "@nanoforge-dev/asset": "workspace:*", + "@nanoforge-dev/cli": "^2.3.0", + "@nanoforge-dev/common": "workspace:*", + "@nanoforge-dev/config": "catalog:config", + "@nanoforge-dev/core": "workspace:*", + "@nanoforge-dev/env": "workspace:*", + "@trivago/prettier-plugin-sort-imports": "catalog:lint", + "lint-staged": "catalog:ci", + "prettier": "catalog:lint", + "typescript": "catalog:core", + "vitest": "catalog:test" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ] + } +} diff --git a/packages/server-core/test/fixtures/pong-network/prettier.config.js b/packages/server-core/test/fixtures/pong-network/prettier.config.js new file mode 100644 index 00000000..27d0e269 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/prettier.config.js @@ -0,0 +1,3 @@ +import config from "@nanoforge-dev/utils-prettier-config"; + +export default config; diff --git a/packages/server-core/test/fixtures/pong-network/tsconfig.json b/packages/server-core/test/fixtures/pong-network/tsconfig.json new file mode 100644 index 00000000..b1528990 --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "include": ["test/**/*.ts"], + "exclude": ["node_modules", "**/dist", "**/.nanoforge"] +} diff --git a/packages/server-core/test/fixtures/pong-network/tsconfig.spec.json b/packages/server-core/test/fixtures/pong-network/tsconfig.spec.json new file mode 100644 index 00000000..8270caba --- /dev/null +++ b/packages/server-core/test/fixtures/pong-network/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true + }, + "include": ["test/**/*.spec.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/server-core/test/fixtures/runtime/bridge-game.js b/packages/server-core/test/fixtures/runtime/bridge-game.js new file mode 100644 index 00000000..e2ddfd5e --- /dev/null +++ b/packages/server-core/test/fixtures/runtime/bridge-game.js @@ -0,0 +1,36 @@ +// A game speaking the editor bridge protocol like the engine's `EditorLibrary` does (runtime +// tests): it says hello in its first tick, after `main` returned. +export const main = async ({ files, env, editor }) => { + console.log('port=' + env.PORT + ' files=' + [...files.keys()].join(',')); + if (env.CRASH) setTimeout(() => process.exit(3), 20); + let paused = false; + let stopped = false; + let reported; + let greeted = false; + editor.fromEditor.on('pause', () => (paused = true)); + editor.fromEditor.on('resume', () => (paused = false)); + editor.fromEditor.on('stop', () => (stopped = true)); + editor.fromEditor.on('ping', (value) => editor.toEditor.emit('pong', value)); + editor.fromEditor.on('step', () => editor.toEditor.emit('stepped')); + editor.fromEditor.on('welcome', ({ features }) => { + if (features.frameStats) { + editor.toEditor.emit('frame-stats', { + windowMs: 1000, + ticks: 60, + tps: 60, + tick: { avg: 1, max: 2 }, + libraries: {}, + }); + } + if (features.logs) editor.toEditor.emit('log', { level: 'warn', message: 'from the game' }); + }); + const tick = () => { + if (!greeted) editor.toEditor.emit('hello', { protocolVersion: 1 }); + greeted = true; + editor.fromEditor.runEvents(); + const state = stopped ? 'stopped' : paused ? 'paused' : 'running'; + if (state !== reported) editor.toEditor.emit('state', (reported = state)); + if (!stopped) setTimeout(tick, 5); + }; + setTimeout(tick); +}; diff --git a/packages/server-core/test/fixtures/runtime/legacy-game.js b/packages/server-core/test/fixtures/runtime/legacy-game.js new file mode 100644 index 00000000..14b73659 --- /dev/null +++ b/packages/server-core/test/fixtures/runtime/legacy-game.js @@ -0,0 +1,6 @@ +// A game built against an engine without the editor bridge: it never says hello or reports +// its state, and ignores editor commands (runtime tests). +export const main = async ({ env }) => { + console.log('legacy port=' + env.PORT); + setInterval(() => undefined, 1000).unref?.(); +}; diff --git a/packages/server-core/test/fixtures/runtime/nf.cjs b/packages/server-core/test/fixtures/runtime/nf.cjs new file mode 100644 index 00000000..115dd547 --- /dev/null +++ b/packages/server-core/test/fixtures/runtime/nf.cjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); +const { join, resolve } = require('node:path'); +const args = process.argv.slice(2); +const dir = resolve(args[args.indexOf('--directory') + 1]); +const source = readFileSync(join(dir, 'game.js'), 'utf8'); +if (source.includes('SYNTAX ERROR')) { + console.log('- Building'); + console.error('error: Unexpected ;'); + console.error(' at ' + join(dir, 'game.js') + ':3:11'); + console.error('Build failed!'); + process.exit(1); +} +rmSync(join(dir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(dir, 'dist'), { recursive: true }); +writeFileSync(join(dir, 'dist', 'main.js'), source); +writeFileSync(join(dir, 'dist', 'data.txt'), 'data'); +console.log('Build succeeded!'); diff --git a/packages/server-core/test/fs/fs-service.spec.ts b/packages/server-core/test/fs/fs-service.spec.ts new file mode 100644 index 00000000..622a7417 --- /dev/null +++ b/packages/server-core/test/fs/fs-service.spec.ts @@ -0,0 +1,160 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { FileChange } from '@nanoforge-dev/editor-protocol'; + +import { ProjectFileSystem } from '../../src/fs/fs-service'; +import { ProjectWatcher } from '../../src/fs/watcher'; +import { sha1 } from '../../src/util/hash'; + +const roots: string[] = []; +const project = () => { + const root = mkdtempSync(join(tmpdir(), 'nf-fs-')); + roots.push(root); + mkdirSync(join(root, 'src')); + writeFileSync(join(root, 'src/main.ts'), 'export {};\n'); + return { root, fs: new ProjectFileSystem(root) }; +}; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('ProjectFileSystem', () => { + it('reads, stats and lists', async () => { + const { fs } = project(); + const file = await fs.read('src/main.ts'); + expect(new TextDecoder().decode(file.content)).toBe('export {};\n'); + expect(file.hash).toBe(sha1('export {};\n')); + expect(await fs.stat('missing')).toBeNull(); + expect((await fs.list('', true)).map((e) => [e.path, e.kind])).toEqual([ + ['src', 'directory'], + ['src/main.ts', 'file'], + ]); + }); + + it('writes atomically with optimistic concurrency', async () => { + const { root, fs } = project(); + const { hash } = await fs.read('src/main.ts'); + const written = await fs.write('src/main.ts', 'export const a = 1;\n', { expectedHash: hash }); + expect(readFileSync(join(root, 'src/main.ts'), 'utf8')).toBe('export const a = 1;\n'); + await expect(fs.write('src/main.ts', 'x', { expectedHash: hash })).rejects.toMatchObject({ + code: 'CONFLICT', + data: { hash: written.hash }, + }); + await expect(fs.write('src/new.ts', 'x', { expectedHash: 'abc' })).rejects.toMatchObject({ + code: 'CONFLICT', + }); + await fs.write('deep/new/file.ts', 'x', { expectedHash: null }); + expect(readdirSync(join(root, 'src')).some((name) => name.includes('.nf-'))).toBe(false); + }); + + it('jails paths, including through symlinks', async () => { + const { root, fs } = project(); + const outside = mkdtempSync(join(tmpdir(), 'nf-outside-')); + roots.push(outside); + writeFileSync(join(outside, 'secret'), 'x'); + symlinkSync(outside, join(root, 'link')); + await expect(fs.read('../etc/passwd')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(fs.read('link/secret')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(fs.write('link/new', 'x')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('moves deleted entries to the trash', async () => { + const { root, fs } = project(); + await fs.delete('src/main.ts'); + expect(existsSync(join(root, 'src/main.ts'))).toBe(false); + const trash = readdirSync(join(root, '.nanoforge/editor/trash')); + expect(trash).toHaveLength(1); + expect(trash[0]).toMatch(/main\.ts$/); + expect(readFileSync(join(root, '.nanoforge/editor/.gitignore'), 'utf8')).toContain('trash/'); + await expect(fs.delete('')).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('renames and copies without overwriting by default', async () => { + const { fs } = project(); + await fs.copy('src/main.ts', 'src/copy.ts'); + await expect(fs.copy('src/main.ts', 'src/copy.ts')).rejects.toMatchObject({ code: 'CONFLICT' }); + await fs.rename('src/copy.ts', 'lib/moved.ts'); + expect(await fs.stat('lib/moved.ts')).toMatchObject({ kind: 'file' }); + await expect(fs.rename('missing', 'x')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); + +describe('ProjectFileSystem packages, ignore and trash', () => { + it('hides .nfignore entries from listings', async () => { + const { root, fs } = project(); + mkdirSync(join(root, 'generated')); + writeFileSync(join(root, 'generated/out.ts'), ''); + writeFileSync(join(root, 'src/secret.ts'), ''); + writeFileSync(join(root, '.nfignore'), '# hidden\ngenerated/\nsrc/secret.ts\n'); + const paths = (await fs.list('', true)).map((entry) => entry.path); + expect(paths).toContain('.nfignore'); + expect(paths).toContain('src/main.ts'); + expect(paths.filter((path) => path.includes('generated') || path.includes('secret'))).toEqual( + [], + ); + expect(fs.isIgnored('generated', true)).toBe(true); + expect(fs.isIgnored('src/main.ts', false)).toBe(false); + }); + + it('never changes installed packages (nf_modules)', async () => { + const { root, fs } = project(); + mkdirSync(join(root, 'nf_modules/physics'), { recursive: true }); + writeFileSync(join(root, 'nf_modules/physics/body.ts'), 'export {};\n'); + await expect(fs.write('nf_modules/physics/body.ts', 'x')).rejects.toThrow('cannot be changed'); + await expect(fs.delete('nf_modules/physics')).rejects.toThrow('cannot be changed'); + await expect(fs.rename('src/main.ts', 'nf_modules/main.ts')).rejects.toThrow( + 'cannot be changed', + ); + await fs.copy('nf_modules/physics/body.ts', 'src/body.ts'); + expect(readFileSync(join(root, 'src/body.ts'), 'utf8')).toBe('export {};\n'); + }); + + it('restores deleted entries from the trash', async () => { + const { root, fs } = project(); + const { trashPath } = await fs.delete('src/main.ts'); + expect(existsSync(join(root, 'src/main.ts'))).toBe(false); + await fs.restore(trashPath, 'src/main.ts'); + expect(readFileSync(join(root, 'src/main.ts'), 'utf8')).toBe('export {};\n'); + await expect(fs.restore('src/main.ts', 'src/other.ts')).rejects.toThrow('Not in the trash'); + }); +}); + +describe('ProjectWatcher', () => { + it('reports batched changes within 200 ms and ignores noise', async () => { + const { root, fs } = project(); + mkdirSync(join(root, 'node_modules')); + mkdirSync(join(root, 'dist')); + const watcher = new ProjectWatcher(fs.jail, { ignoredPaths: () => ['dist'] }); + const batches: FileChange[][] = []; + const subscription = watcher.onDidChange((changes) => batches.push(changes)); + await watcher.ready; + + const start = Date.now(); + writeFileSync(join(root, 'src/new.ts'), 'x'); + writeFileSync(join(root, 'node_modules/ignored.js'), 'x'); + writeFileSync(join(root, 'dist/main.js'), 'x'); + await fs.write('src/main.ts', 'changed'); + while (!batches.length && Date.now() - start < 1000) await new Promise((r) => setTimeout(r, 5)); + const elapsed = Date.now() - start; + await new Promise((r) => setTimeout(r, 100)); + subscription.dispose(); + + expect(elapsed).toBeLessThan(200); + expect(batches.flat().sort((a, b) => a.path.localeCompare(b.path))).toEqual([ + { type: 'changed', kind: 'file', path: 'src/main.ts' }, + { type: 'created', kind: 'file', path: 'src/new.ts' }, + ]); + }); +}); diff --git a/packages/server-core/test/git/git-service.spec.ts b/packages/server-core/test/git/git-service.spec.ts new file mode 100644 index 00000000..929a3552 --- /dev/null +++ b/packages/server-core/test/git/git-service.spec.ts @@ -0,0 +1,293 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { loadEnv } from '../../src/env/load-env'; +import { GitService } from '../../src/git/git-service'; +import { parseStatus } from '../../src/git/parse-status'; + +let workspace: string; +let git: GitService; + +const raw = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +const write = (root: string, path: string, text: string) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), text); +}; +/** A repository with an identity and one commit. */ +const repository = async (name: string) => { + const root = join(workspace, name); + mkdirSync(root, { recursive: true }); + await git.init(root); + raw(root, 'config', 'user.name', 'Test'); + raw(root, 'config', 'user.email', 'test@example.com'); + write(root, 'a.txt', 'one\n'); + write(root, 'src/b.txt', 'two\n'); + await git.commit(root, 'first commit', { all: true }); + return root; +}; +const files = async (root: string) => + (await git.projectStatus(root)).files.map( + (file) => `${file.staged ?? '-'}${file.conflicted ? 'C' : (file.unstaged ?? '-')} ${file.path}`, + ); + +beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'nf-git-')); + git = new GitService(loadEnv({ NODE_ENV: 'test', FS_ROOT: workspace }, workspace)); +}); +afterAll(() => rmSync(workspace, { recursive: true, force: true })); + +describe('parseStatus', () => { + it('reads the branch, the upstream counts and every kind of entry', () => { + const output = [ + '# branch.oid 1234567890abcdef', + '# branch.head main', + '# branch.upstream origin/main', + '# branch.ab +2 -1', + '1 .M N... 100644 100644 100644 aaa bbb with space.txt', + '1 A. N... 000000 100644 100644 aaa bbb new.txt', + '2 R. N... 100644 100644 100644 aaa bbb R100 moved.txt', + 'old.txt', + 'u UU N... 100644 100644 100644 100644 aaa bbb ccc both.txt', + '? untracked.txt', + '', + ].join('\0'); + expect(parseStatus(output)).toEqual({ + repository: true, + branch: 'main', + head: '1234567', + upstream: 'origin/main', + ahead: 2, + behind: 1, + files: [ + { path: 'both.txt', conflicted: true }, + { path: 'moved.txt', from: 'old.txt', staged: 'R', conflicted: false }, + { path: 'new.txt', staged: 'A', conflicted: false }, + { path: 'untracked.txt', unstaged: 'U', conflicted: false }, + { path: 'with space.txt', unstaged: 'M', conflicted: false }, + ], + }); + }); +}); + +describe('GitService on a project folder', () => { + it('ignores a repository above the folder, and initializes one', async () => { + const outer = await repository('outer'); + const inner = join(outer, 'project'); + mkdirSync(inner); + write(inner, 'file.txt', 'x\n'); + expect(await git.projectStatus(inner)).toMatchObject({ repository: false, files: [] }); + const before = raw(outer, 'status', '--porcelain'); + await expect(git.stage(inner, ['file.txt'])).rejects.toThrow(/not a git repository/); + await expect(git.commit(inner, 'oops', { all: true })).rejects.toThrow(/not a git repository/); + await expect(git.discard(inner, ['file.txt'])).rejects.toThrow(/not a git repository/); + await expect(git.stash(inner)).rejects.toThrow(/not a git repository/); + expect(existsSync(join(inner, 'file.txt'))).toBe(true); + expect(raw(outer, 'status', '--porcelain')).toBe(before); + expect(raw(outer, 'log', '--format=%s')).toBe('first commit\n'); + await git.init(inner); + const status = await git.projectStatus(inner); + expect(status).toMatchObject({ repository: true, branch: 'main', merging: false }); + expect(status.head).toBeUndefined(); + expect(status.files).toEqual([{ path: 'file.txt', unstaged: 'U', conflicted: false }]); + expect(await git.log(inner)).toEqual([]); + expect(await git.branches(inner)).toEqual([{ name: 'main', current: true, remote: false }]); + }); + + it('stages, unstages, discards and commits', async () => { + const root = await repository('changes'); + write(root, 'a.txt', 'one\nchanged\n'); + write(root, 'new.txt', 'new\n'); + rmSync(join(root, 'src/b.txt')); + expect(await files(root)).toEqual(['-M a.txt', '-U new.txt', '-D src/b.txt']); + write(root, 'deps/lib/index.js', 'x\n'); + write(root, 'deps/lib/other.js', 'y\n'); + expect(await files(root)).toEqual( + expect.arrayContaining(['-U deps/lib/index.js', '-U deps/lib/other.js']), + ); + await git.discard(root, ['deps/lib/index.js', 'deps/lib/other.js']); + expect(existsSync(join(root, 'deps/lib/index.js'))).toBe(false); + + await git.stage(root, ['a.txt', 'new.txt', 'src/b.txt']); + expect(await files(root)).toEqual(['M- a.txt', 'A- new.txt', 'D- src/b.txt']); + await git.unstage(root, ['new.txt', 'src/b.txt']); + expect(await files(root)).toEqual(['M- a.txt', '-U new.txt', '-D src/b.txt']); + + await git.discard(root, ['src/b.txt', 'new.txt']); + expect(readFileSync(join(root, 'src/b.txt'), 'utf8')).toBe('two\n'); + expect(existsSync(join(root, 'new.txt'))).toBe(false); + write(root, 'added.txt', 'added\n'); + await git.stage(root, ['added.txt']); + await git.discard(root, ['added.txt']); + expect(await files(root)).toEqual(['M- a.txt', '-U added.txt']); + await git.discard(root, ['added.txt']); + + expect(await git.show(root, 'a.txt', 'HEAD')).toBe('one\n'); + expect(await git.show(root, 'a.txt', 'INDEX')).toBe('one\nchanged\n'); + expect(await git.show(root, 'missing.txt', 'HEAD')).toBeNull(); + + write(root, 'src/b.txt', 'two\nmore\n'); + write(root, 'fresh.txt', 'fresh\n'); + await git.stage(root, ['src/b.txt']); + await git.commit(root, 'change a', { paths: ['a.txt'] }); + expect(await files(root)).toEqual(['-U fresh.txt', 'M- src/b.txt']); + await git.commit(root, 'b and a new file', { paths: ['src/b.txt', 'fresh.txt'] }); + expect(await files(root)).toEqual([]); + write(root, 'fresh.txt', 'fresher\n'); + await git.commit(root, 'b and a fresh file', { paths: ['fresh.txt'], amend: true }); + expect((await git.log(root)).map((commit) => commit.subject)).toEqual([ + 'b and a fresh file', + 'change a', + 'first commit', + ]); + raw(root, 'mv', 'fresh.txt', 'renamed.txt'); + expect((await git.projectStatus(root)).files).toEqual([ + { path: 'renamed.txt', from: 'fresh.txt', staged: 'R', conflicted: false }, + ]); + await git.discard(root, ['renamed.txt', 'fresh.txt']); + expect(await files(root)).toEqual(['-U renamed.txt']); + expect(existsSync(join(root, 'fresh.txt'))).toBe(true); + await git.discard(root, ['renamed.txt']); + raw(root, 'mv', 'fresh.txt', 'renamed.txt'); + await git.commit(root, 'rename', { paths: ['renamed.txt', 'fresh.txt'] }); + expect(await files(root)).toEqual([]); + raw(root, 'mv', 'renamed.txt', 'fresh.txt'); + await git.commit(root, 'rename back', { paths: ['fresh.txt', 'renamed.txt'] }); + expect(await files(root)).toEqual([]); + write(root, 'a.txt', 'again\n'); + await git.stage(root, ['a.txt']); + await git.discard(root, ['a.txt']); + expect(readFileSync(join(root, 'a.txt'), 'utf8')).toBe('one\nchanged\n'); + expect(await files(root)).toEqual([]); + const log = await git.log(root); + expect(log[0]).toMatchObject({ author: 'Test', refs: ['main'] }); + expect(log[0]!.hash).toMatch(/^[0-9a-f]{40}$/); + const changeA = log.find((commit) => commit.subject === 'change a')!; + expect(await git.commitFiles(root, changeA.hash)).toEqual([{ path: 'a.txt', change: 'M' }]); + expect(await git.commitFiles(root, log[1]!.hash)).toEqual([ + { path: 'renamed.txt', change: 'R' }, + ]); + expect(await git.show(root, 'a.txt', `${changeA.hash}^`)).toBe('one\n'); + expect((await git.log(root, 1, log.length - 1)).map((commit) => commit.subject)).toEqual([ + 'first commit', + ]); + await expect(git.commit(root, 'nothing')).rejects.toThrow(/nothing to commit/); + }); + + it('creates, switches and deletes branches', async () => { + const root = await repository('branches'); + await git.createBranch(root, 'feature'); + expect(await git.branches(root)).toEqual([ + { name: 'feature', current: true, remote: false }, + { name: 'main', current: false, remote: false }, + ]); + write(root, 'a.txt', 'feature\n'); + await git.commit(root, 'on feature', { all: true }); + await git.switchBranch(root, 'main'); + expect((await git.projectStatus(root)).branch).toBe('main'); + await git.createBranch(root, 'from-feature', 'feature'); + expect((await git.log(root, 1))[0]!.subject).toBe('on feature'); + await git.switchBranch(root, 'main'); + await git.deleteBranch(root, 'from-feature', true); + expect((await git.log(root, 50, 0, 'feature')).map((commit) => commit.subject)).toEqual([ + 'on feature', + 'first commit', + ]); + await expect(git.deleteBranch(root, 'feature')).rejects.toThrow(/not fully merged/); + await git.deleteBranch(root, 'feature', true); + expect((await git.branches(root)).map((branch) => branch.name)).toEqual(['main']); + }); + + it('stashes with untracked files, applies, pops and drops', async () => { + const root = await repository('stash'); + write(root, 'a.txt', 'stashed\n'); + write(root, 'extra.txt', 'extra\n'); + await git.stash(root, 'work in progress'); + expect(await files(root)).toEqual([]); + expect(await git.stashes(root)).toEqual([{ index: 0, message: 'On main: work in progress' }]); + await git.applyStash(root, 0); + expect(await files(root)).toEqual(['-M a.txt', '-U extra.txt']); + await git.discard(root, ['a.txt', 'extra.txt']); + await git.applyStash(root, 0, true); + expect(await git.stashes(root)).toEqual([]); + await git.stash(root); + await git.dropStash(root, 0); + expect(await git.stashes(root)).toEqual([]); + }); + + it('pushes and pulls with a remote, counts ahead and behind, and reports a conflict', async () => { + const root = await repository('local'); + const remote = join(workspace, 'remote.git'); + raw(workspace, 'init', '--bare', '-b', 'main', remote); + raw(root, 'remote', 'add', 'origin', remote); + expect((await git.outgoing(root)).map((commit) => commit.subject)).toEqual(['first commit']); + await git.push(root); + expect(await git.outgoing(root)).toEqual([]); + expect(await git.projectStatus(root)).toMatchObject({ + upstream: 'origin/main', + ahead: 0, + behind: 0, + }); + + const other = join(workspace, 'other'); + raw(workspace, 'clone', '--quiet', remote, other); + raw(other, 'config', 'user.name', 'Other'); + raw(other, 'config', 'user.email', 'other@example.com'); + write(other, 'a.txt', 'theirs\n'); + raw(other, 'commit', '--quiet', '-am', 'their change'); + raw(other, 'push', '--quiet'); + + write(root, 'a.txt', 'mine\n'); + await git.commit(root, 'my change', { all: true }); + await git.fetch(root); + expect(await git.projectStatus(root)).toMatchObject({ ahead: 1, behind: 1 }); + expect((await git.outgoing(root)).map((commit) => commit.subject)).toEqual(['my change']); + + expect(await git.mergePull(root)).toEqual({ conflicts: true }); + const status = await git.projectStatus(root); + expect(status.merging).toBe(true); + expect(status.files).toEqual([{ path: 'a.txt', conflicted: true }]); + + write(root, 'a.txt', 'both\n'); + await git.stage(root, ['a.txt']); + await git.commit(root, 'merge'); + expect(await git.projectStatus(root)).toMatchObject({ merging: false, ahead: 2, behind: 0 }); + await git.push(root); + expect(await git.projectStatus(root)).toMatchObject({ ahead: 0, behind: 0 }); + expect(await git.mergePull(root)).toEqual({ conflicts: false }); + + raw(other, 'switch', '--quiet', '-c', 'topic'); + raw(other, 'push', '--quiet', '-u', 'origin', 'topic'); + await git.fetch(root); + expect(await git.branches(root)).toEqual([ + { name: 'main', current: true, upstream: 'origin/main', remote: false }, + { name: 'origin/main', current: false, remote: true }, + { name: 'origin/topic', current: false, remote: true }, + ]); + await git.switchBranch(root, 'origin/topic'); + expect(await git.projectStatus(root)).toMatchObject({ + branch: 'topic', + upstream: 'origin/topic', + }); + }); + + it('stops a remote that never answers, and the repository stays usable', async () => { + const root = await repository('hang'); + const ssh = join(workspace, 'slow-ssh.sh'); + writeFileSync(ssh, '#!/bin/sh\nsleep 30\n', { mode: 0o755 }); + raw(root, 'remote', 'add', 'origin', 'ssh://git@example.invalid/repo.git'); + raw(root, 'config', 'core.sshCommand', ssh); + const impatient = new GitService( + loadEnv({ NODE_ENV: 'test', FS_ROOT: workspace }, workspace), + 300, + ); + const started = Date.now(); + await expect(impatient.push(root)).rejects.toMatchObject({ code: 'TIMEOUT' }); + await expect(impatient.fetch(root)).rejects.toMatchObject({ code: 'TIMEOUT' }); + expect(Date.now() - started).toBeLessThan(5000); + expect((await impatient.projectStatus(root)).branch).toBe('main'); + }); +}); diff --git a/packages/server-core/test/mocks/nanoforge-api.ts b/packages/server-core/test/mocks/nanoforge-api.ts new file mode 100644 index 00000000..b339f381 --- /dev/null +++ b/packages/server-core/test/mocks/nanoforge-api.ts @@ -0,0 +1,99 @@ +/** + * In-memory NanoForge API implementing what the editor calls (docs/api/README.md): the settings + * sync contract, token refresh and the project endpoints. Used as `fetch` by the ApiClient in + * tests, and as the default target of the contract tests (`test/contract`), which can run + * against a staging API instead. + */ +export interface MockApiOptions { + apiKey?: string; + /** Access token considered valid; others get 401. */ + validAccessToken?: string; + refreshToken?: string; +} + +/** The project of the mock's user. */ +export const PROJECT = { + id: 'project-1', + code: 'pong', + name: 'Pong', + description: 'A game', + gatewayProjectRegistryUrl: 'https://git.test/ada/pong.git', + gatewayProjectRegistryMetadata: { dir: null }, + token: 'git-token', +}; + +export const createMockApi = (options: MockApiOptions = {}) => { + const apiKey = options.apiKey ?? 'test-key'; + const validAccess = new Set([options.validAccessToken ?? 'access-1']); + const refresh = options.refreshToken ?? 'refresh-1'; + const documents = new Map }>(); + const calls: string[] = []; + + const json = (status: number, body?: unknown) => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL(String(input)); + const method = init?.method ?? 'GET'; + const headers = new Headers(init?.headers); + calls.push(`${method} ${url.pathname}`); + if (headers.get('api-key') !== apiKey) return json(403, { message: 'bad api key' }); + + if (url.pathname === '/auth/refresh-token' && method === 'POST') { + const body = JSON.parse(String(init?.body)) as { refreshToken: string }; + if (body.refreshToken !== refresh) return json(401, { message: 'bad refresh token' }); + const accessToken = `access-${calls.length}`; + validAccess.add(accessToken); + return json(200, { + accessToken, + refreshToken: refresh, + tokenExpiresAt: new Date().toISOString(), + }); + } + if (!validAccess.has((headers.get('authorization') ?? '').replace(/^Bearer /, ''))) + return json(401, { message: 'expired' }); + + if (url.pathname === '/users/me/editor-settings') { + const user = 'user-1'; + const current = documents.get(user); + if (method === 'GET') return current ? json(200, current) : json(404, { message: 'none' }); + if (method === 'PUT') { + let body: { baseRevision: string | null; values: Record }; + try { + body = JSON.parse(String(init?.body)) as typeof body; + } catch { + return json(400, { message: 'malformed body' }); + } + if (!body.values || typeof body.values !== 'object' || Array.isArray(body.values)) + return json(400, { message: 'values must be an object' }); + if ( + Object.keys(body.values).length > 2000 || + JSON.stringify(body.values).length > 256 * 1024 + ) + return json(413, { message: 'too large' }); + if ((current?.revision ?? null) !== body.baseRevision) { + return json(409, current ?? { revision: null, values: {} }); + } + const next = { revision: String(Number(current?.revision ?? 0) + 1), values: body.values }; + documents.set(user, next); + return json(200, { revision: next.revision }); + } + } + if (url.pathname === '/projects' && method === 'GET') return json(200, [PROJECT]); + if (url.pathname === `/editor/projects/${PROJECT.id}` && method === 'GET') + return json(200, PROJECT); + return json(404, { message: 'not found' }); + }; + + return { + fetch: fetch as typeof globalThis.fetch, + documents, + calls, + expireAccessToken: () => { + validAccess.clear(); + }, + }; +}; diff --git a/packages/server-core/test/project/discovery.spec.ts b/packages/server-core/test/project/discovery.spec.ts new file mode 100644 index 00000000..b2270a03 --- /dev/null +++ b/packages/server-core/test/project/discovery.spec.ts @@ -0,0 +1,322 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { executingConfigLoader, staticConfigLoader } from '../../src/project/config-loader'; +import { discoverProject, resolveLibraries } from '../../src/project/discovery'; +import { packageManagerOf } from '../../src/rpc/package-manager-of'; + +const FIXTURE = join(import.meta.dirname, '../fixtures', 'pong-network'); + +const temporary: string[] = []; +afterAll(() => { + for (const dir of temporary) rmSync(dir, { recursive: true, force: true }); +}); + +const copyFixture = () => { + const root = mkdtempSync(join(import.meta.dirname, '../.tmp-')); + temporary.push(root); + cpSync(FIXTURE, root, { recursive: true }); + return root; +}; + +describe.each([ + ['executing', executingConfigLoader], + ['static', staticConfigLoader], +])('discoverProject (%s config loader)', (_name, loader) => { + it('discovers the client and server apps of pong-network', async () => { + const model = await discoverProject({ + id: 'pong-1234', + root: FIXTURE, + location: FIXTURE, + loader, + }); + expect(model.diagnostics).toEqual([]); + expect(model.name).toBe('pong-network'); + expect(model.apps.map((app) => [app.id, app.type, app.name])).toEqual([ + ['apps/client', 'client', 'pong-network-client'], + ['apps/server', 'server', 'pong-network-server'], + ]); + const [client, server] = model.apps; + expect(client).toMatchObject({ + root: 'apps/client', + language: 'ts', + entryFile: 'apps/client/src/main.ts', + editorEntryFile: 'apps/client/src/main.ts', + outDir: 'apps/client/dist', + dirs: { + assets: 'apps/client/assets', + components: 'apps/client/src/components', + systems: 'apps/client/src/systems', + scenes: 'apps/client/src/scenes', + }, + }); + expect(Object.keys(client!.engineLibs)).toEqual( + expect.arrayContaining([ + '@nanoforge-dev/ecs', + '@nanoforge-dev/graphics-2d', + '@nanoforge-dev/input', + ]), + ); + expect(client!.engineLibs).not.toHaveProperty('@nanoforge-dev/utils-eslint-config'); + expect(server!.engineLibs).not.toHaveProperty('@nanoforge-dev/graphics-2d'); + }); + + it("reads the shared libraries of an app from its config's libs", async () => { + const root = copyFixture(); + writeFileSync( + join(root, 'nanoforge.config.ts'), + 'import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({ type: "workspace", packages: ["apps/*", "libs/*"] });\n', + ); + mkdirSync(join(root, 'libs/shared'), { recursive: true }); + writeFileSync( + join(root, 'libs/shared/nanoforge.config.ts'), + 'export default { type: "lib" };\n', + ); + writeFileSync(join(root, 'libs/shared/package.json'), '{ "name": "@pong/shared" }\n'); + const config = join(root, 'apps/client/nanoforge.config.ts'); + writeFileSync( + config, + readFileSync(config, 'utf8').replace( + 'type: "client",', + 'type: "client",\n libs: ["../../libs/shared"],', + ), + ); + const pkg = join(root, 'apps/client/package.json'); + const json = JSON.parse(readFileSync(pkg, 'utf8')) as Record>; + writeFileSync( + pkg, + JSON.stringify({ + ...json, + dependencies: { ...json.dependencies, '@pong/shared': 'workspace:*' }, + }), + ); + const model = await discoverProject({ id: 'pong-libs', root, location: root, loader }); + expect(model.diagnostics).toEqual([]); + expect(model.apps.map((app) => [app.id, app.libraries])).toEqual([ + ['apps/client', ['@pong/shared']], + ['apps/server', []], + ['libs/shared', []], + ]); + expect(model.apps[0]!.engineLibs).not.toHaveProperty('../../libs/shared'); + }); +}); + +describe('discoverProject diagnostics', () => { + it('reports broken app configs without failing', async () => { + const root = copyFixture(); + writeFileSync( + join(root, 'apps/server/nanoforge.config.ts'), + 'export default { type: "nope" };', + ); + const model = await discoverProject({ + id: 'pong-1234', + root, + location: root, + loader: staticConfigLoader, + }); + expect(model.apps.map((app) => app.id)).toEqual(['apps/client']); + expect(model.diagnostics).toEqual([ + { + path: 'apps/server/nanoforge.config.ts', + message: 'Unknown config type "nope"', + severity: 'error', + }, + ]); + }); + + it('refuses non literal configs in static mode', async () => { + const root = copyFixture(); + writeFileSync( + join(root, 'nanoforge.config.ts'), + 'import { defineConfig } from "@nanoforge-dev/config";\nexport default defineConfig({ type: "workspace", packages: [process.env.X] });', + ); + const model = await discoverProject({ + id: 'pong-1234', + root, + location: root, + loader: staticConfigLoader, + }); + expect(model.apps).toEqual([]); + expect(model.diagnostics[0]!.message).toMatch(/Only literal values.*process\.env\.X/); + }); + + it('loads configs of projects without installed dependencies', async () => { + const root = mkdtempSync(join(tmpdir(), 'nf-fresh-')); + temporary.push(root); + writeFileSync( + join(root, 'nanoforge.config.ts'), + 'import { defineConfig } from "@nanoforge-dev/config";\nconst lang: "ts" = "ts";\nexport default defineConfig({ type: "server", language: lang });', + ); + const model = await discoverProject({ + id: 'fresh-1234', + root, + location: root, + loader: executingConfigLoader, + }); + expect(model.diagnostics).toEqual([]); + expect(model.apps).toMatchObject([{ type: 'server', language: 'ts' }]); + }); + + it('supports single app projects', async () => { + const root = mkdtempSync(join(tmpdir(), 'nf-single-')); + temporary.push(root); + writeFileSync( + join(root, 'nanoforge.config.ts'), + 'export default { type: "client", language: "js" };', + ); + writeFileSync( + join(root, 'package.json'), + '{"name":"solo","dependencies":{"@nanoforge-dev/ecs":"^1.4.0"}}', + ); + const model = await discoverProject({ + id: 'solo-1234', + root, + location: root, + loader: staticConfigLoader, + }); + expect(model.apps).toMatchObject([ + { + id: '', + root: '', + language: 'js', + entryFile: 'src/main.ts', + engineLibs: { '@nanoforge-dev/ecs': '^1.4.0' }, + }, + ]); + }); +}); + +describe('resolveLibraries', () => { + const app = (root: string, name: string, type: 'client' | 'server' | 'lib') => + ({ id: root, root, name, type, libraries: [] }) as never as Parameters< + typeof resolveLibraries + >[0][number]; + const uses = ( + root: string, + packages: string[], + libs?: string[], + ): [ + string, + Parameters[1] extends ReadonlyMap ? U : never, + ] => [ + root, + { + configFile: `${root}/nanoforge.config.ts`, + packages, + ...(libs && { + libs: libs.map((path) => ({ path, root: path.replace(/^(\.\.\/)+/, '') })), + }), + }, + ]; + const libraries = (apps: { name: string; libraries: string[]; unclaimed?: boolean }[]) => + apps.map((entry) => [entry.name, entry.libraries, entry.unclaimed]); + + it("gives each app the shared libraries its config's libs lists", () => { + const apps = [ + app('apps/client', 'client', 'client'), + app('apps/server', 'server', 'server'), + app('libs/shared', '@pong/shared', 'lib'), + app('libs/ui', '@pong/ui', 'lib'), + ]; + const diagnostics = resolveLibraries( + apps, + new Map([ + uses( + 'apps/client', + ['@pong/shared', '@pong/ui', 'typescript'], + ['../../libs/shared', '../../libs/ui'], + ), + uses('apps/server', ['@pong/shared', '@nanoforge-dev/core'], ['../../libs/shared']), + uses('libs/shared', []), + uses('libs/ui', ['@pong/shared']), + ]), + ); + expect(diagnostics).toEqual([]); + expect(libraries(apps)).toEqual([ + ['client', ['@pong/shared', '@pong/ui'], undefined], + ['server', ['@pong/shared'], undefined], + ['@pong/shared', [], undefined], + ['@pong/ui', ['@pong/shared'], undefined], + ]); + }); + + it('follows libs when package.json disagrees, and says so', () => { + const apps = [ + app('apps/client', 'client', 'client'), + app('apps/server', 'server', 'server'), + app('libs/shared', '@pong/shared', 'lib'), + ]; + const diagnostics = resolveLibraries( + apps, + new Map([ + uses('apps/client', [], ['../../libs/shared', '../../libs/gone']), + uses('apps/server', ['@pong/shared'], []), + uses('libs/shared', []), + ]), + ); + expect(libraries(apps)).toEqual([ + ['client', ['@pong/shared'], undefined], + ['server', [], undefined], + ['@pong/shared', [], undefined], + ]); + expect(diagnostics.map((diagnostic) => [diagnostic.path, diagnostic.message])).toEqual([ + [ + 'apps/client/nanoforge.config.ts', + 'libs lists "../../libs/gone", which is not a shared library of the project.', + ], + [ + 'apps/client/nanoforge.config.ts', + 'client uses @pong/shared (libs), but its package.json does not depend on it: add "@pong/shared": "workspace:*".', + ], + [ + 'apps/server/nanoforge.config.ts', + "server's package.json depends on @pong/shared, but its libs does not list libs/shared: the editor does not give it to the app.", + ], + ]); + }); + + it('gives a library nothing mentions to every app (projects made before the choice existed)', () => { + const apps = [ + app('apps/client', 'client', 'client'), + app('apps/server', 'server', 'server'), + app('libs/shared', '@pong/shared', 'lib'), + ]; + expect( + resolveLibraries( + apps, + new Map([ + uses('apps/client', ['typescript'], []), + uses('apps/server', [], []), + uses('libs/shared', []), + ]), + ), + ).toEqual([]); + expect(libraries(apps)).toEqual([ + ['client', ['@pong/shared'], undefined], + ['server', ['@pong/shared'], undefined], + ['@pong/shared', [], true], + ]); + }); +}); + +describe('packageManagerOf', () => { + it('finds the package manager of a project from its lockfile', () => { + const root = mkdtempSync(join(tmpdir(), 'nf-lock-')); + try { + expect(packageManagerOf(root)).toBeUndefined(); + for (const [lockfile, manager] of [ + ['package-lock.json', 'npm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['pnpm-lock.yaml', 'pnpm'], + ] as const) { + writeFileSync(join(root, lockfile), ''); + expect(packageManagerOf(root)).toBe(manager); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server-core/test/project/formatter.spec.ts b/packages/server-core/test/project/formatter.spec.ts new file mode 100644 index 00000000..cd6c6e4c --- /dev/null +++ b/packages/server-core/test/project/formatter.spec.ts @@ -0,0 +1,59 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { Formatter } from '../../src/project/formatter'; + +let root: string; +let local: string; +const SOURCE = 'const a = {b:"c"}\n'; + +beforeAll(() => { + root = mkdtempSync(join(import.meta.dirname, '../.tmp-format-')); + mkdirSync(join(root, 'src')); + writeFileSync(join(root, '.prettierrc.yaml'), 'semi: false\nplugins: ["./evil.js"]\n'); + writeFileSync(join(root, '.prettierignore'), 'src/ignored.ts\n'); + local = join(root, 'local'); + mkdirSync(join(local, 'node_modules'), { recursive: true }); + const prettier = dirname(createRequire(import.meta.url).resolve('prettier/package.json')); + symlinkSync(realpathSync(prettier), join(local, 'node_modules/prettier')); + writeFileSync(join(local, 'prettier.config.js'), 'export default { singleQuote: true };\n'); +}); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('Formatter', () => { + it("uses the project's Prettier and code config on local editors", async () => { + const result = await new Formatter('project').format(local, join(local, 'a.ts'), SOURCE); + expect(result).toEqual({ text: "const a = { b: 'c' };\n", formatter: 'project' }); + }); + + it('uses the bundled Prettier and only static configs, without plugins, on hosted editors', async () => { + const result = await new Formatter('static').format(root, join(root, 'src/a.ts'), SOURCE); + expect(result).toEqual({ text: 'const a = { b: "c" }\n', formatter: 'bundled' }); + }); + + it('falls back to the bundled Prettier when the project has none', async () => { + const bare = mkdtempSync(join(tmpdir(), 'nf-format-')); + const result = await new Formatter('project').format(bare, join(bare, 'a.ts'), SOURCE); + expect(result).toEqual({ text: 'const a = { b: "c" };\n', formatter: 'bundled' }); + rmSync(bare, { recursive: true, force: true }); + }); + + it('leaves ignored and unknown files untouched', async () => { + const formatter = new Formatter('static'); + expect(await formatter.format(root, join(root, 'src/ignored.ts'), SOURCE)).toEqual({ + text: SOURCE, + formatter: 'none', + }); + expect((await formatter.format(root, join(root, 'notes.unknown'), 'x')).formatter).toBe('none'); + }); + + it('reports syntax errors', async () => { + await expect( + new Formatter('static').format(root, join(root, 'src/a.ts'), 'const = ;'), + ).rejects.toThrow('Prettier could not format the file'); + }); +}); diff --git a/packages/server-core/test/rpc/registry.spec.ts b/packages/server-core/test/rpc/registry.spec.ts new file mode 100644 index 00000000..e5002faa --- /dev/null +++ b/packages/server-core/test/rpc/registry.spec.ts @@ -0,0 +1,274 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + PluginsContract, + ProjectsContract, + RegistryContract, + SessionContract, +} from '@nanoforge-dev/editor-protocol'; +import { type RpcClient, defineContract } from '@nanoforge-dev/editor-rpc'; + +import { loadEnv } from '../../src/env/load-env'; +import { createEditorServer } from '../../src/server/create-editor-server'; +import type { EditorServer } from '../../src/server/editor-server.type'; +import { createLoopbackClient } from '../../src/testing'; + +const FIXTURES = join(import.meta.dirname, '../fixtures'); + +let workspace: string; +let dataDir: string; +let registryDir: string; + +const publish = ( + name: string, + version: string, + manifest: object, + files: Record, +) => { + const folder = join(registryDir, name, version); + mkdirSync(folder, { recursive: true }); + writeFileSync( + join(folder, 'nanoforge.manifest.json'), + JSON.stringify({ name, version, ...manifest }), + ); + for (const [file, text] of Object.entries(files)) writeFileSync(join(folder, file), text); +}; + +const start = async (extra: Record = {}) => { + const env = loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: dataDir, + REGISTRY_DIR: registryDir, + ...extra, + }, + workspace, + ); + const editor = createEditorServer({ + env, + version: '1.0.0', + staticDir: join(workspace, 'static'), + }); + await editor.start(); + return { editor, client: createLoopbackClient(editor) }; +}; + +let editor: EditorServer; +let client: RpcClient; +let project: string; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../.tmp-registry-')); + cpSync(join(FIXTURES, 'pong-network'), join(workspace, 'pong'), { recursive: true }); + mkdirSync(join(workspace, 'static')); + writeFileSync(join(workspace, 'static/index.html'), 'editor'); + dataDir = join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'); + registryDir = join(workspace, 'registry'); + + const plugin = { type: 'plugin', description: 'Greets from the server' }; + publish( + '@acme/greeter', + '1.0.0', + { + ...plugin, + engines: { editor: '^1.0.0' }, + entry: { client: 'index.js', server: 'server.js' }, + }, + { + 'index.js': 'export const activate = () => {};', + 'server.js': `import { z } from 'zod'; +export const activate = (context) => { + context.implement( + { namespace: context.namespace, methods: { hi: { input: z.null(), output: z.string() } }, events: {} }, + { methods: { hi: () => 'hi from 1.0.0' } }, + ); +};`, + }, + ); + publish( + '@acme/greeter', + '2.0.0', + { ...plugin, engines: { editor: '^2.0.0' }, entry: { client: 'index.js' } }, + { 'index.js': 'export const activate = () => {};' }, + ); + publish('@acme/shapes', '1.0.0', { type: 'package', description: 'Shapes' }, { 'circle.ts': '' }); + publish( + '@acme/render', + '1.0.0', + { type: 'package', dependencies: { '@acme/shapes': '^1.0.0' } }, + { 'draw.ts': '' }, + ); + + ({ editor, client } = await start()); + project = (await client.api(ProjectsContract).open({ path: 'pong' })).id; +}); + +afterAll(() => { + client.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('registry', () => { + it('searches by type and describes an item with the version this editor runs', async () => { + const registry = client.api(RegistryContract); + const plugins = await registry.search({ type: 'plugin' }); + expect(plugins.items.map((item) => [item.name, item.version])).toEqual([ + ['@acme/greeter', '2.0.0'], + ]); + expect((await registry.search({ type: 'package', q: 'shapes' })).items).toHaveLength(1); + expect(await registry.details({ name: '@acme/greeter' })).toMatchObject({ + version: '2.0.0', + compatible: '1.0.0', + }); + await expect(registry.details({ name: '@acme/none' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('installs a plugin for the user, runs its server entry, and uninstalls it', async () => { + const registry = client.api(RegistryContract); + const names = async () => + (await client.api(PluginsContract).list(null)).map( + (listing) => `${listing.source}:${(listing.manifest as { name: string }).name}`, + ); + expect(await names()).toEqual([]); + expect(await registry.installPlugin({ name: '@acme/greeter', scope: 'user' })).toEqual({ + version: '1.0.0', + }); + expect(await names()).toEqual(['installed:@acme/greeter']); + expect(existsSync(join(dataDir, 'plugins/@acme/greeter/index.js'))).toBe(true); + const greeter = defineContract('plugin.@acme/greeter', { + methods: { hi: { input: z.null(), output: z.string() } }, + }); + await expect(client.api(greeter).hi(null)).resolves.toBe('hi from 1.0.0'); + + await expect( + registry.installPlugin({ name: '@acme/shapes', scope: 'user' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + registry.installPlugin({ name: '@acme/greeter', scope: 'user', version: '9.0.0' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + + await registry.uninstallPlugin({ name: '@acme/greeter', scope: 'user' }); + expect(await names()).toEqual([]); + expect(editor.serverPlugins.active).toEqual([]); + await expect( + registry.uninstallPlugin({ name: '@acme/greeter', scope: 'user' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('installs a plugin in a project', async () => { + const registry = client.api(RegistryContract); + await expect( + registry.installPlugin({ name: '@acme/greeter', scope: 'project' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await registry.installPlugin({ name: '@acme/greeter', scope: 'project', project }); + const folder = join(workspace, 'pong/.nanoforge/plugins/@acme/greeter'); + expect(existsSync(join(folder, 'nanoforge.manifest.json'))).toBe(true); + const listed = await client.api(PluginsContract).list({ project }); + expect(listed.map((listing) => listing.source)).toEqual(['project']); + await registry.uninstallPlugin({ name: '@acme/greeter', scope: 'project', project }); + expect(existsSync(folder)).toBe(false); + }); + + it('installs, lists and removes the packages of a project', async () => { + const registry = client.api(RegistryContract); + expect(await registry.packages({ project })).toEqual([]); + const installed = await registry.installPackage({ project, name: '@acme/render' }); + expect(installed.map((entry) => [entry.name, entry.version, entry.range])).toEqual([ + ['@acme/render', '1.0.0', '^1.0.0'], + ['@acme/shapes', '1.0.0', undefined], + ]); + const root = join(workspace, 'pong'); + expect(existsSync(join(root, 'nf_modules/@acme/shapes/circle.ts'))).toBe(true); + expect(readFileSync(join(root, 'tsconfig.json'), 'utf8')).toContain( + '"@acme/render/*": ["./nf_modules/@acme/render/*"]', + ); + await expect(registry.installPackage({ project, name: '@acme/greeter' })).rejects.toMatchObject( + { code: 'BAD_REQUEST', message: expect.stringContaining('is a plugin') as string }, + ); + await expect( + registry.uninstallPackage({ project, name: '@acme/shapes' }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + + rmSync(join(root, 'nf_modules'), { recursive: true }); + expect((await registry.packages({ project })).map((entry) => entry.present)).toEqual([ + false, + false, + ]); + expect((await registry.restorePackages({ project })).map((entry) => entry.present)).toEqual([ + true, + true, + ]); + expect(await registry.uninstallPackage({ project, name: '@acme/render' })).toEqual([]); + expect(existsSync(join(root, 'nf_modules/@acme'))).toBe(false); + }); + + it('says the registry is unavailable, and still lists what is installed', async () => { + const offline = await start({ REGISTRY_DIR: '', REGISTRY_URL: 'http://127.0.0.1:9' }); + expect((await offline.client.api(SessionContract).info(null)).features).toEqual(['registry']); + try { + const registry = offline.client.api(RegistryContract); + await expect(registry.search({ type: 'plugin' })).rejects.toMatchObject({ + code: 'UNAVAILABLE', + }); + const id = (await offline.client.api(ProjectsContract).open({ path: 'pong' })).id; + expect(await registry.packages({ project: id })).toEqual([]); + } finally { + offline.client.dispose(); + offline.editor.dispose(); + } + }); + + it('asks no registry until the API has one (API_FEATURES)', async () => { + expect((await client.api(SessionContract).info(null)).features).toEqual(['registry']); + const none = await start({ REGISTRY_DIR: '' }); + try { + expect((await none.client.api(SessionContract).info(null)).features).toEqual([]); + await expect(none.client.api(RegistryContract).search({ type: 'plugin' })).rejects.toThrow( + 'The registry is not available yet', + ); + } finally { + none.client.dispose(); + none.editor.dispose(); + } + const env = (features: string) => loadEnv({ API_FEATURES: features }, workspace); + expect(env('settings, registry')).toMatchObject({ + apiFeatures: ['settings', 'registry'], + registryUrl: 'https://api.nanoforge.eu', + }); + expect(env('settings').registryUrl).toBeUndefined(); + expect(() => env('settings,teleport')).toThrow(/unknown feature "teleport"/); + }); + + it('never installs for a visitor of a hosted editor', async () => { + const hosted = await start({ + PUBLIC_MODE: 'ONLINE', + API_KEY: 'key', + SESSION_SECRET: 'x'.repeat(32), + }); + try { + const registry = hosted.client.api(RegistryContract); + await expect( + registry.installPlugin({ name: '@acme/greeter', scope: 'user' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + } finally { + hosted.client.dispose(); + hosted.editor.dispose(); + } + }); +}); diff --git a/packages/server-core/test/runtime/project-runtime.spec.ts b/packages/server-core/test/runtime/project-runtime.spec.ts new file mode 100644 index 00000000..37227daa --- /dev/null +++ b/packages/server-core/test/runtime/project-runtime.spec.ts @@ -0,0 +1,260 @@ +import { chmodSync, cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + ProjectsContract, + RuntimeContract, + type RuntimeEvent, + runtimeOutputPath, +} from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import { loadEnv } from '../../src/env/load-env'; +import { parseBuildOutput } from '../../src/runtime/build-orchestrator'; +import { parseDotenv, splitGameEnv } from '../../src/runtime/game-env'; +import { createEditorServer } from '../../src/server/create-editor-server'; +import type { EditorServer } from '../../src/server/editor-server.type'; +import { TEST_ORIGIN, createLoopbackClient } from '../../src/testing'; + +const RUNTIME_FIXTURES = join(import.meta.dirname, '../fixtures/runtime'); +const SERVER_GAME = readFileSync(join(RUNTIME_FIXTURES, 'bridge-game.js'), 'utf8'); + +let workspace: string; +let editor: EditorServer; +let client: RpcClient; +let project: string; +const events: RuntimeEvent[] = []; + +const waitFor = async (find: () => T | undefined, timeout = 10_000): Promise => { + const start = Date.now(); + for (;;) { + const found = find(); + if (found !== undefined && found !== false) return found; + if (Date.now() - start > timeout) throw new Error('Timed out'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../.tmp-runtime-')); + const root = join(workspace, 'pong'); + cpSync(join(import.meta.dirname, '../fixtures/pong-network'), root, { recursive: true }); + writeFileSync(join(root, 'apps/client/game.js'), 'export const main = async () => {};\n'); + writeFileSync(join(root, 'apps/server/game.js'), SERVER_GAME); + writeFileSync( + join(root, '.env'), + 'NANOFORGE_SERVER_PORT=4444\nNANOFORGE_CLIENT_SERVER_PORT=4444\nNANOFORGE_SHARED="both"\n', + ); + const cli = join(workspace, 'nf.cjs'); + cpSync(join(RUNTIME_FIXTURES, 'nf.cjs'), cli); + chmodSync(cli, 0o755); + + editor = createEditorServer({ + env: loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'), + NF_CLI_PATH: cli, + }, + workspace, + ), + version: '0.0.0-test', + }); + client = createLoopbackClient(editor); + project = (await client.api(ProjectsContract).open({ path: 'pong' })).id; + client.subscribe(RuntimeContract, 'events', { project }, (event) => events.push(event)); +}); + +afterAll(() => { + client.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('game env', () => { + it('parses .env files', () => { + expect( + parseDotenv('# comment\nA=1\nexport B = "two words"\nC=\'x#y\'\nD=3 # trailing\nbad line'), + ).toEqual({ A: '1', B: 'two words', C: 'x#y', D: '3' }); + }); + + it('splits client and server variables like nf start', () => { + expect( + splitGameEnv({ + NANOFORGE_CLIENT_PORT: '1', + NANOFORGE_SERVER_PORT: '2', + NANOFORGE_NAME: 'pong', + NANOFORGE_EMPTY: '', + PATH: '/bin', + }), + ).toEqual({ client: { PORT: '1', NAME: 'pong' }, server: { PORT: '2', NAME: 'pong' } }); + }); +}); + +describe('build output parsing', () => { + it('reads bun errors with their location', () => { + const output = + '\x1b[31merror: Unexpected ;\x1b[39m\n at /game/apps/client/src/main.ts:110:11\n'; + expect(parseBuildOutput(output, '/game')).toEqual([ + { + message: 'Unexpected ;', + severity: 'error', + path: 'apps/client/src/main.ts', + line: 110, + column: 11, + }, + ]); + }); +}); + +describe('runtime', () => { + const runtime = () => client.api(RuntimeContract); + + it('builds every app and serves their output with content hashes', async () => { + const statuses = await runtime().build({ project }); + expect(statuses.map((status) => [status.app, status.state])).toEqual([ + ['apps/client', 'ok'], + ['apps/server', 'ok'], + ]); + expect(statuses[0]!.version).toMatch(/^[0-9a-f]{16}$/); + + const manifest = await runtime().manifest({ project, app: 'apps/client' }); + expect(manifest).toMatchObject({ + entry: 'main.js', + baseUrl: runtimeOutputPath(project, 'apps/client'), + version: statuses[0]!.version, + }); + expect(manifest.files.map((file) => file.path)).toEqual(['data.txt', 'main.js']); + + const response = (await editor.fetch( + new Request(`${TEST_ORIGIN}${manifest.baseUrl}main.js?v=${manifest.files[1]!.hash}`), + ))!; + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('immutable'); + expect(await response.text()).toContain('export const main'); + const stale = (await editor.fetch( + new Request(`${TEST_ORIGIN}${manifest.baseUrl}main.js?v=0000`), + ))!; + expect(stale.headers.get('cache-control')).toBe('no-store'); + const escape = await editor.fetch( + new Request(`${TEST_ORIGIN}${manifest.baseUrl}..%2F..%2Fpackage.json`), + ); + expect(escape?.status).toBe(404); + }); + + it('streams other CLI runs of the project as tasks, and never builds', async () => { + const before = events.length; + const run = editor.cli.run(['build', '--editor', '--directory', 'apps/client'], { + cwd: join(workspace, 'pong'), + }); + await run.done; + const lines = await waitFor(() => { + const logs = events.slice(before).filter((event) => event.type === 'log'); + return logs.some((log) => log.text === 'Build succeeded!') ? logs : undefined; + }); + expect(lines.map((log) => [log.source, log.app, log.text])).toEqual([ + [ + 'cli', + 'nf build --editor --directory apps/client', + '$ nf build --editor --directory apps/client', + ], + ['cli', 'nf build --editor --directory apps/client', 'Build succeeded!'], + ]); + const count = events.length; + await editor.cli.run(['build', '--editor', '--directory', 'pong/apps/client'], { + cwd: workspace, + }).done; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events.slice(count).filter((event) => event.type === 'log')).toEqual([]); + }); + + it('reports build errors as diagnostics', async () => { + const file = join(workspace, 'pong/apps/client/game.js'); + writeFileSync(file, 'export const main = 1;\n// SYNTAX ERROR\n'); + const [status] = await runtime().build({ project, apps: ['apps/client'] }); + expect(status).toMatchObject({ state: 'error' }); + expect(status!.diagnostics[0]).toMatchObject({ + path: 'apps/client/game.js', + line: 3, + message: 'Unexpected ;', + }); + expect(events.some((event) => event.type === 'log' && event.text === 'Build failed!')).toBe( + true, + ); + writeFileSync(file, 'export const main = async () => {};\n'); + }); + + it('rebuilds only the changed app while subscribed', async () => { + await runtime().build({ project }); + for (let count = -1; count !== events.length;) { + count = events.length; + await new Promise((resolve) => setTimeout(resolve, 400)); + } + events.length = 0; + writeFileSync(join(workspace, 'pong/apps/server/game.js'), `${SERVER_GAME}// changed\n`); + await waitFor(() => + events.find((event) => event.type === 'build' && event.status.state === 'ok'), + ); + const built = events.flatMap((event) => (event.type === 'build' ? [event.status.app] : [])); + expect(new Set(built)).toEqual(new Set(['apps/server'])); + }); + + it('splits the env of each side, with overrides', async () => { + expect(await runtime().env({ project, app: 'apps/client' })).toEqual({ + SERVER_PORT: '4444', + SHARED: 'both', + }); + expect( + await runtime().env({ + project, + app: 'apps/server', + overrides: { NANOFORGE_SERVER_PORT: '5555' }, + }), + ).toEqual({ PORT: '5555', SHARED: 'both' }); + }); + + it('runs, pauses and stops the game server, three times in a row', async () => { + for (let run = 0; run < 3; run++) { + events.length = 0; + await runtime().startServer({ project, app: 'apps/server', overrides: {} }); + await waitFor(() => + events.find((event) => event.type === 'server' && event.state === 'running'), + ); + expect( + events.find((event) => event.type === 'log' && event.text.startsWith('port=')), + ).toMatchObject({ source: 'server', text: 'port=4444 files=/data.txt,/main.js' }); + + await runtime().sendServer({ project, app: 'apps/server', event: 'pause', args: [] }); + await waitFor(() => + events.find((event) => event.type === 'server' && event.state === 'paused'), + ); + await runtime().sendServer({ project, app: 'apps/server', event: 'ping', args: [run] }); + await waitFor(() => + events.find((event) => event.type === 'bridge' && event.event === 'pong'), + ); + + await runtime().stopServer({ project, app: 'apps/server' }); + expect(events.at(-1)).toMatchObject({ type: 'server', state: 'exited', exitCode: 0 }); + expect((await runtime().status({ project })).servers).toEqual([]); + } + }); + + it('kills a game server that ignores stop', async () => { + writeFileSync( + join(workspace, 'pong/apps/server/game.js'), + 'export const main = async () => { setInterval(() => {}, 1000); };\n', + ); + await runtime().build({ project, apps: ['apps/server'] }); + const server = editor.runtime.get(editor.projects.get({ mode: 'OFFLINE' } as never, project)); + events.length = 0; + await server.startServer('apps/server'); + await waitFor(() => events.find((event) => event.type === 'log' || event.type === 'server')); + const started = Date.now(); + await server.stopServer('apps/server'); + expect(Date.now() - started).toBeLessThan(3000); + expect(events.at(-1)).toMatchObject({ type: 'server', state: 'exited' }); + }); +}); diff --git a/packages/server-core/test/server/create-editor-server.spec.ts b/packages/server-core/test/server/create-editor-server.spec.ts new file mode 100644 index 00000000..559f3dec --- /dev/null +++ b/packages/server-core/test/server/create-editor-server.spec.ts @@ -0,0 +1,228 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + FsContract, + PluginsContract, + ProjectsContract, + SessionContract, +} from '@nanoforge-dev/editor-protocol'; +import { type RpcClient, defineContract } from '@nanoforge-dev/editor-rpc'; + +import { loadEnv } from '../../src/env/load-env'; +import { createEditorServer } from '../../src/server/create-editor-server'; +import type { EditorServer } from '../../src/server/editor-server.type'; +import { TEST_ORIGIN as ORIGIN, createLoopbackClient } from '../../src/testing'; + +const FIXTURES = join(import.meta.dirname, '../fixtures'); + +let workspace: string; +let editor: EditorServer; +let client: RpcClient; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../.tmp-server-')); + cpSync(join(FIXTURES, 'pong-network'), join(workspace, 'pong'), { recursive: true }); + const staticDir = join(workspace, 'static'); + mkdirSync(join(staticDir, '_app'), { recursive: true }); + writeFileSync(join(staticDir, 'index.html'), 'editor'); + writeFileSync(join(staticDir, '_app/app.js'), 'console.log(1)'); + + const env = loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'), + BUNDLED_PLUGINS_DIR: join(FIXTURES, 'plugins'), + }, + workspace, + ); + editor = createEditorServer({ env, version: '0.0.0-test', staticDir }); + await editor.start(); + + client = createLoopbackClient(editor); +}); + +afterAll(() => { + client.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('editor server', () => { + it('opens pong-network and exposes its model', async () => { + const session = await client.api(SessionContract).info(null); + expect(session).toMatchObject({ mode: 'OFFLINE', user: { id: 'local' }, loginUrl: null }); + + const projects = client.api(ProjectsContract); + const { id } = await projects.open({ path: 'pong' }); + const model = await projects.model({ id }); + expect(model.apps.map((app) => [app.id, app.type])).toEqual([ + ['apps/client', 'client'], + ['apps/server', 'server'], + ]); + expect(model.apps[0]!.engineLibs).toHaveProperty('@nanoforge-dev/ecs'); + expect((await projects.recent(null)).map((project) => project.id)).toEqual([id]); + expect((await projects.open({ path: 'pong' })).id).toBe(id); + }); + + it('streams disk changes to clients within 200 ms', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + const received: string[] = []; + const subscription = client.subscribe(FsContract, 'changes', { project: id }, ({ changes }) => + received.push(...changes.map((change) => `${change.type}:${change.path}`)), + ); + await new Promise((resolve) => setTimeout(resolve, 300)); // watcher initial scan + const start = Date.now(); + writeFileSync(join(workspace, 'pong/apps/client/src/new.ts'), 'export {};'); + while (!received.length && Date.now() - start < 2000) + await new Promise((r) => setTimeout(r, 5)); + subscription.dispose(); + expect(received).toEqual(['created:apps/client/src/new.ts']); + expect(Date.now() - start).toBeLessThan(200); + }); + + it('reads and writes files through rpc', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + const fs = client.api(FsContract); + const file = await fs.read({ project: id, path: 'apps/client/src/main.ts' }); + expect(new TextDecoder().decode(file.content)).toContain('NanoforgeFactory'); + await fs.write({ project: id, path: 'notes.md', content: '# hi', expectedHash: null }); + await expect(fs.read({ project: id, path: '../outside' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('runs plugin server entries in their namespace', async () => { + const echo = defineContract('plugin.@acme/echo', { + methods: { echo: { input: z.object({ text: z.string() }), output: z.string() } }, + }); + await expect(client.api(echo).echo({ text: 'hello' })).resolves.toMatch(/^hello from /); + expect(editor.serverPlugins.active).toEqual(['@acme/echo']); + }); + + it('lists the plugins of the open project and serves them to it', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + const dir = join(workspace, 'pong/.nanoforge/plugins/@acme/local'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'nanoforge.manifest.json'), + JSON.stringify({ + type: 'plugin', + name: '@acme/local', + version: '1.0.0', + engines: { editor: '*' }, + entry: { client: 'index.js' }, + }), + ); + writeFileSync(join(dir, 'index.js'), 'export const activate = () => {};'); + try { + const plugins = client.api(PluginsContract); + const names = (listings: { manifest: unknown }[]) => + listings.map((listing) => (listing.manifest as { name: string }).name); + expect(names(await plugins.list(null))).toEqual(['@acme/echo']); + const listings = await plugins.list({ project: id }); + expect(names(listings)).toEqual(['@acme/echo', '@acme/local']); + expect(listings[1]).toMatchObject({ + source: 'project', + baseUrl: `/plugins/project/${id}/@acme/local/1.0.0/`, + }); + const get = (path: string) => + editor.fetch(new Request(`${ORIGIN}${path}`)) as Promise; + expect((await get(`/plugins/project/${id}/@acme/local/1.0.0/index.js`)).status).toBe(200); + expect((await get('/plugins/project/unknown/@acme/local/1.0.0/index.js')).status).toBe(404); + await expect(plugins.list({ project: 'unknown' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + } finally { + rmSync(join(workspace, 'pong/.nanoforge/plugins'), { recursive: true, force: true }); + } + }); + + it('collects the plugins that installed packages suggest', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + const write = (path: string, manifest: object) => { + mkdirSync(join(workspace, 'pong/nf_modules', path), { recursive: true }); + writeFileSync( + join(workspace, 'pong/nf_modules', path, 'nanoforge.manifest.json'), + JSON.stringify({ type: 'package', version: '1.0.0', ...manifest }), + ); + }; + write('@nanoforge/motion', { name: '@nanoforge/motion', suggestedPlugins: ['@nanoforge/ecs'] }); + write('@nanoforge/render-2d', { + name: '@nanoforge/render-2d', + include: ['shapes'], + suggestedPlugins: { '@nanoforge/ecs': '^1.0.0' }, + }); + write('@nanoforge/render-2d/shapes', { + name: '@nanoforge/shapes', + suggestedPlugins: ['@nanoforge/graphics-gizmos'], + }); + write('@evil/escape', { name: '@evil/escape', include: ['../../..'] }); + try { + expect(await client.api(PluginsContract).suggestions({ project: id })).toEqual([ + { + name: '@nanoforge/ecs', + suggestedBy: [ + { package: '@nanoforge/motion', range: '*' }, + { package: '@nanoforge/render-2d', range: '^1.0.0' }, + ], + }, + { + name: '@nanoforge/graphics-gizmos', + suggestedBy: [{ package: '@nanoforge/shapes', range: '*' }], + }, + ]); + } finally { + rmSync(join(workspace, 'pong/nf_modules'), { recursive: true, force: true }); + } + }); + + it('serves plugin files, the SPA and refuses foreign origins', async () => { + const get = (path: string, headers: Record = {}) => + editor.fetch(new Request(`${ORIGIN}${path}`, { headers })) as Promise; + const plugin = await get('/plugins/bundled/@acme/echo/1.0.0/index.js'); + expect(plugin.status).toBe(200); + expect(plugin.headers.get('content-type')).toContain('javascript'); + expect( + (await get('/plugins/bundled/@acme/echo/1.0.0/../../../../../package.json')).status, + ).toBe(404); + expect(await (await get('/project/abc')).text()).toBe('editor'); + expect((await get('/_app/missing.js')).status).toBe(404); + + const forged = await editor.fetch( + new Request(`${ORIGIN}/rpc/session.info`, { + method: 'POST', + body: 'null', + headers: { origin: 'https://evil.example' }, + }), + ); + expect(forged!.status).toBe(403); + }); + + it('serves project files and downloads folders as zip', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + const fetchFile = async (path: string) => { + const response = await editor.fetch( + new Request(`${ORIGIN}/files/${id}/${path}`, { headers: { cookie: '' } }), + ); + return response!; + }; + const file = await fetchFile('apps/client/package.json'); + expect(file.status).toBe(200); + expect(await file.json()).toMatchObject({ name: expect.any(String) }); + + const download = await fetchFile('apps/client/package.json?download'); + expect(download.headers.get('content-disposition')).toBe('attachment; filename="package.json"'); + + const archive = await fetchFile('apps?download'); + expect(archive.headers.get('content-type')).toBe('application/zip'); + const bytes = new Uint8Array(await archive.arrayBuffer()); + expect(String.fromCharCode(bytes[0]!, bytes[1]!)).toBe('PK'); + + expect((await fetchFile('..%2Foutside')).status).toBe(404); + }); +}); diff --git a/packages/server-core/test/settings/account-backends.spec.ts b/packages/server-core/test/settings/account-backends.spec.ts new file mode 100644 index 00000000..befe8ec8 --- /dev/null +++ b/packages/server-core/test/settings/account-backends.spec.ts @@ -0,0 +1,82 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { ApiClient } from '../../src/api/api-client'; +import { loadEnv } from '../../src/env/load-env'; +import type { Session } from '../../src/session/session-store'; +import { ApiAccountBackend, FileAccountBackend } from '../../src/settings/account-backends'; +import { createMockApi } from '../mocks/nanoforge-api'; + +const session = (tokens: Session['tokens'] = null): Session => ({ + id: 's', + mode: tokens ? 'ONLINE' : 'OFFLINE', + user: { id: 'user-1', name: 'Ada' }, + tokens, + projects: new Set(), + lastSeen: 0, +}); + +describe('FileAccountBackend', () => { + const backend = () => + new FileAccountBackend(loadEnv({ DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-settings-')) })); + + it('stores revisioned documents and rejects stale writes', async () => { + const files = backend(); + const changes: string[] = []; + files.onDidChange(({ revision }) => changes.push(revision)); + const user = session(); + expect(await files.get(user)).toEqual({ revision: null, values: {} }); + const { revision } = await files.put(user, null, { 'editor.fontSize': 14 }); + expect(await files.get(user)).toEqual({ revision, values: { 'editor.fontSize': 14 } }); + await expect(files.put(user, null, {})).rejects.toMatchObject({ + code: 'CONFLICT', + data: { revision, values: { 'editor.fontSize': 14 } }, + }); + expect(changes).toEqual([revision]); + }); + + it('enforces size limits', async () => { + const values = Object.fromEntries(Array.from({ length: 2001 }, (_, i) => [`k${i}`, i])); + await expect(backend().put(session(), null, values)).rejects.toMatchObject({ + code: 'PAYLOAD_TOO_LARGE', + }); + }); +}); + +describe('ApiAccountBackend (mock NanoForge API)', () => { + const setup = () => { + const api = createMockApi(); + const env = loadEnv({ + PUBLIC_MODE: 'ONLINE', + API_KEY: 'test-key', + API_URL: 'https://api.test', + }); + return { api, backend: new ApiAccountBackend(new ApiClient(env, api.fetch)) }; + }; + + it('reads, writes and maps conflicts with the current document', async () => { + const { backend } = setup(); + const user = session({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + expect(await backend.get(user)).toEqual({ revision: null, values: {} }); + const first = await backend.put(user, null, { a: 1 }); + await expect(backend.put(user, null, { a: 2 })).rejects.toMatchObject({ + code: 'CONFLICT', + data: { revision: first.revision, values: { a: 1 } }, + }); + }); + + it('refreshes expired access tokens once', async () => { + const { api, backend } = setup(); + const user = session({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + api.expireAccessToken(); + await backend.put(user, null, { a: 1 }); + expect(user.tokens!.accessToken).not.toBe('access-1'); + expect(api.calls).toEqual([ + 'PUT /users/me/editor-settings', + 'POST /auth/refresh-token', + 'PUT /users/me/editor-settings', + ]); + }); +}); diff --git a/packages/server-core/tsconfig.json b/packages/server-core/tsconfig.json new file mode 100644 index 00000000..e9c37cbb --- /dev/null +++ b/packages/server-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist", "test/fixtures"] +} diff --git a/packages/settings/.prettierignore b/packages/settings/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/settings/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/settings/LICENSE b/packages/settings/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/settings/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/settings/README.md b/packages/settings/README.md new file mode 100644 index 00000000..f7ed2c87 --- /dev/null +++ b/packages/settings/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-settings` is an internal package of the NanoForge Editor. + +Layered settings engine (default, account, machine, project, project-local). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/settings +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/settings/package.json b/packages/settings/package.json new file mode 100644 index 00000000..ca4ddf65 --- /dev/null +++ b/packages/settings/package.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-settings", + "version": "0.0.0", + "description": "NanoForge Editor - Layered settings engine (default, account, machine, project, project-local)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/settings" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/settings/src/account-sync/account-sync-store.ts b/packages/settings/src/account-sync/account-sync-store.ts new file mode 100644 index 00000000..c495bc2c --- /dev/null +++ b/packages/settings/src/account-sync/account-sync-store.ts @@ -0,0 +1,199 @@ +import { + DisposableStore, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; + +import type { KeyValueStore } from '../key-value/key-value-store.type'; +import { deepEqual } from '../merge/deep-merge'; +import { type MergeConflict, merge3 } from '../merge/merge3'; +import type { ScopeStore, SettingValues } from '../store/scope-store.type'; +import { applyPatch } from '../store/setting-values'; +import type { + AccountDocument, + AccountRemote, + AccountSyncOptions, + SyncStatus, +} from './account-sync.type'; + +interface PersistedState { + base: AccountDocument; + local: SettingValues; + dirty: boolean; +} + +const errorCode = (error: unknown) => (error as { code?: string } | null)?.code; +const OFFLINE_CODES = new Set(['UNAVAILABLE', 'TIMEOUT']); + +/** + * Account scope: edits apply locally at once, are persisted (offline queue) and pushed to the + * remote in the background. Concurrent edits from other devices are merged per key against the + * last synced base; keys changed on both sides keep the local value and are reported as + * conflicts for the user to settle. + */ +export class AccountSyncStore implements ScopeStore { + private readonly _values = new ObservableValue({}); + private readonly _status = new ObservableValue('idle'); + private readonly _conflicts = new ObservableValue([]); + private readonly _store = new DisposableStore(); + private _state: PersistedState = { + base: { revision: null, values: {} }, + local: {}, + dirty: false, + }; + private _running: Promise | undefined; + private _rerun = false; + private _timer: ReturnType | undefined; + private _attempt = 0; + private _disposed = false; + + constructor( + private readonly _remote: AccountRemote, + private readonly _kv: KeyValueStore, + private readonly _key: string, + private readonly _options: AccountSyncOptions = {}, + ) {} + + get values(): Observable { + return this._values.readonly(); + } + + get status(): Observable { + return this._status.readonly(); + } + + get conflicts(): Observable { + return this._conflicts.readonly(); + } + + /** True while local edits have not reached the remote. */ + get dirty(): boolean { + return this._state.dirty; + } + + async initialize(): Promise { + const persisted = await this._kv.get(this._key); + if (persisted) this._state = persisted; + this._values.set(this._state.local); + if (this._remote.onDidReconnect) { + this._store.add(this._remote.onDidReconnect(() => void this.sync())); + } + await this.sync(); + } + + async write(patch: SettingValues): Promise { + this._state = { ...this._state, local: applyPatch(this._state.local, patch), dirty: true }; + this._values.set(this._state.local); + await this._persist(); + this._schedule(this._options.debounceMs ?? 500); + } + + /** Settles a conflict with the chosen value (pushed like any edit). */ + async resolveConflict(key: string, value: unknown): Promise { + this._conflicts.set(this._conflicts.get().filter((conflict) => conflict.key !== key)); + await this.write({ [key]: value }); + } + + /** Pulls or pushes now (serialized; a call during a run triggers one more run). */ + sync(): Promise { + if (this._running) { + this._rerun = true; + return this._running; + } + clearTimeout(this._timer); + this._running = this._run().finally(() => { + this._running = undefined; + if (this._rerun && !this._disposed) { + this._rerun = false; + void this.sync(); + } + }); + return this._running; + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._timer); + this._store.dispose(); + } + + private async _run(): Promise { + this._status.set('syncing'); + try { + if (this._state.dirty) await this._push(); + else await this._pull(); + this._attempt = 0; + this._status.set( + this._conflicts.get().length ? 'conflict' : this._state.dirty ? 'pending' : 'idle', + ); + } catch (error) { + if (OFFLINE_CODES.has(errorCode(error) ?? '') || error instanceof TypeError) { + this._status.set('offline'); + const delays = this._options.retryDelays ?? [1000, 2000, 5000, 15_000, 60_000]; + this._schedule(delays[Math.min(this._attempt++, delays.length - 1)]!); + } else { + this._status.set('error'); + } + } + } + + private async _pull(): Promise { + const remote = await this._remote.get(); + if (remote.revision === this._state.base.revision) return; + this._state = { + base: remote, + local: { ...remote.values, ...this._localOnly(this._state.local) }, + dirty: false, + }; + this._values.set(this._state.local); + await this._persist(); + } + + private async _push(attempt = 0): Promise { + const sent = this._synced(this._state.local); + try { + const { revision } = await this._remote.put(this._state.base.revision, sent); + const dirty = !deepEqual(this._synced(this._state.local), sent); + this._state = { ...this._state, base: { revision, values: sent }, dirty }; + await this._persist(); + if (dirty) this._rerun = true; + } catch (error) { + if (errorCode(error) !== 'CONFLICT' || attempt >= 3) throw error; + const remote = (error as { data?: AccountDocument }).data ?? (await this._remote.get()); + const { values, conflicts } = merge3(this._state.base.values, sent, remote.values); + this._state = { + base: remote, + local: { ...values, ...this._localOnly(this._state.local) }, + dirty: true, + }; + this._values.set(this._state.local); + if (conflicts.length) this._conflicts.set([...this._conflicts.get(), ...conflicts]); + await this._persist(); + await this._push(attempt + 1); + } + } + + private _synced(values: SettingValues): Record { + const isSynced = this._options.isSynced ?? (() => true); + return Object.fromEntries(Object.entries(values).filter(([key]) => isSynced(key))); + } + + private _localOnly(values: SettingValues): Record { + const isSynced = this._options.isSynced ?? (() => true); + return Object.fromEntries(Object.entries(values).filter(([key]) => !isSynced(key))); + } + + private _persist(): Promise { + return this._kv.set(this._key, this._state); + } + + private _schedule(delay: number): void { + if (this._disposed) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => void this.sync(), delay); + } +} + +/** The account scope's store: sync status and conflicts (settings UI, status bar). */ +export const AccountSyncToken = createToken('settings.accountSync'); diff --git a/packages/settings/src/account-sync/account-sync.type.ts b/packages/settings/src/account-sync/account-sync.type.ts new file mode 100644 index 00000000..649c07f9 --- /dev/null +++ b/packages/settings/src/account-sync/account-sync.type.ts @@ -0,0 +1,31 @@ +import type { Event } from '@nanoforge-dev/editor-kernel'; + +import type { SettingValues } from '../store/scope-store.type'; + +export interface AccountDocument { + /** Opaque revision (ETag); null before the first save. */ + readonly revision: string | null; + readonly values: SettingValues; +} + +/** Remote copy of the account settings (the editor server, which talks to the API). */ +export interface AccountRemote { + /** Fired when the connection came back: pending edits are flushed. */ + readonly onDidReconnect?: Event; + get(): Promise; + /** + * Saves when the remote revision is still `baseRevision`. Otherwise throws an error with + * `code: 'CONFLICT'` and the current document as `data`. + */ + put(baseRevision: string | null, values: SettingValues): Promise<{ revision: string }>; +} + +export type SyncStatus = 'idle' | 'syncing' | 'pending' | 'offline' | 'conflict' | 'error'; + +export interface AccountSyncOptions { + /** Keys that may leave the machine (settings with `sync: false` stay local). */ + isSynced?: (key: string) => boolean; + debounceMs?: number; + /** Retry delays while offline; the last one repeats. */ + retryDelays?: readonly number[]; +} diff --git a/packages/settings/src/account-sync/index.ts b/packages/settings/src/account-sync/index.ts new file mode 100644 index 00000000..34b84dd1 --- /dev/null +++ b/packages/settings/src/account-sync/index.ts @@ -0,0 +1,3 @@ +export * from './account-sync.type'; +export * from './account-sync-store'; +export * from './rpc-account-remote'; diff --git a/packages/settings/src/account-sync/rpc-account-remote.ts b/packages/settings/src/account-sync/rpc-account-remote.ts new file mode 100644 index 00000000..3bf4f828 --- /dev/null +++ b/packages/settings/src/account-sync/rpc-account-remote.ts @@ -0,0 +1,25 @@ +import { SettingsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { AccountRemote } from './account-sync.type'; + +/** + * Account settings through the editor server (which uses the NanoForge API online, or the local + * data dir offline). Saves from other tabs/devices are announced so the store pulls them. + */ +export const rpcAccountRemote = ( + rpc: RpcClient, +): AccountRemote & { watch(onChange: () => void): () => void } => { + const api = rpc.api(SettingsContract); + return { + get: () => api.accountGet(null), + put: (baseRevision, values) => api.accountPut({ baseRevision, values }), + onDidReconnect: rpc.onDidReconnect, + watch: (onChange) => { + const subscription = rpc.subscribe(SettingsContract, 'accountChanges', null, () => + onChange(), + ); + return () => subscription.dispose(); + }, + }; +}; diff --git a/packages/settings/src/definition/core-settings.ts b/packages/settings/src/definition/core-settings.ts new file mode 100644 index 00000000..eed3d9b9 --- /dev/null +++ b/packages/settings/src/definition/core-settings.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +import { defineSetting } from './define-setting'; + +/** Settings owned by the editor core (3.9). Features of later phases read them. */ +export const CoreSettings = { + locale: defineSetting({ + key: 'editor.locale', + schema: z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/), + default: 'en', + title: 'Language', + description: 'Language of the editor and plugins (en, fr…).', + category: 'Editor/Appearance', + scopes: ['account', 'machine'], + }), + theme: defineSetting({ + key: 'appearance.theme', + schema: z.string().min(1), + default: 'nanoforge-dark', + title: 'Theme', + description: 'Color theme of the editor.', + category: 'Editor/Appearance', + scopes: ['account', 'machine'], + }), + historyLimit: defineSetting({ + key: 'history.limit', + schema: z.number().int().min(1).max(10_000), + default: 100, + title: 'Undo limit', + description: 'Number of undo steps kept per document.', + category: 'Editor/History', + }), + historyMergeWindow: defineSetting({ + key: 'history.mergeWindowMs', + schema: z.number().int().min(0).max(10_000), + default: 500, + title: 'Undo merge window', + description: 'Consecutive changes of the same value within this delay (ms) are one undo step.', + category: 'Editor/History', + }), + layoutActive: defineSetting({ + key: 'layout.active', + schema: z.string(), + default: 'default', + title: 'Active layout', + category: 'Editor/Layout', + scopes: ['account', 'machine', 'projectLocal'], + }), + layoutSaved: defineSetting({ + key: 'layout.saved', + schema: z.record(z.string(), z.unknown()), + default: {}, + title: 'Saved layouts', + description: 'Named layouts (managed from the Window menu).', + category: 'Editor/Layout', + mergeStrategy: 'deep', + }), + keymapOverrides: defineSetting({ + key: 'keymap.overrides', + schema: z.array( + z.object({ + command: z.string(), + key: z.string(), + when: z.string().optional(), + args: z.array(z.unknown()).optional(), + remove: z.boolean().optional(), + }), + ), + default: [], + title: 'Keyboard shortcuts', + description: 'Shortcuts added or removed on top of the defaults and the preset.', + category: 'Editor/Keymap', + mergeStrategy: 'union', + scopes: ['account', 'machine'], + }), + keymapPreset: defineSetting({ + key: 'keymap.preset', + schema: z.string(), + default: 'default', + title: 'Keymap preset', + description: 'A set of shortcuts applied on top of the defaults.', + category: 'Editor/Keymap', + scopes: ['account', 'machine'], + }), + pluginsDisabled: defineSetting({ + key: 'plugins.disabled', + schema: z.array(z.string()), + default: [], + title: 'Disabled plugins', + description: 'Plugins not loaded (takes effect after a reload).', + category: 'Editor/Plugins', + mergeStrategy: 'union', + }), + pluginsAccount: defineSetting({ + key: 'plugins.account', + schema: z.record(z.string(), z.string()), + default: {}, + title: 'Plugins of my account', + description: + 'Plugins installed for me from the marketplace, with version ranges. On another machine the editor offers to install them.', + category: 'Editor/Plugins', + scopes: ['account'], + }), +} as const; diff --git a/packages/settings/src/definition/define-setting.ts b/packages/settings/src/definition/define-setting.ts new file mode 100644 index 00000000..0236c534 --- /dev/null +++ b/packages/settings/src/definition/define-setting.ts @@ -0,0 +1,29 @@ +import { WRITABLE_SCOPES } from '../scope/setting-scope.enum'; +import type { SettingDefinition, SettingInput } from './setting-definition.type'; + +const KEY_PATTERN = /^(@[a-z0-9][\w-]*\/[a-z0-9][\w-]*|[a-z0-9][\w-]*)(\.[a-zA-Z0-9][\w-]*)+$/; + +/** Declares a setting in code. The default value must satisfy the schema. */ +export const defineSetting = (input: SettingInput): SettingDefinition => { + if (!KEY_PATTERN.test(input.key)) throw new Error(`Invalid setting key "${input.key}"`); + const parsed = input.schema.safeParse(input.default); + if (!parsed.success) throw new Error(`Default of "${input.key}" does not match its schema`); + const sync = input.sync ?? true; + const scopes = (input.scopes ?? WRITABLE_SCOPES).filter((scope) => sync || scope !== 'account'); + return Object.freeze({ + key: input.key, + schema: input.schema, + default: parsed.data, + scopes, + sync, + mergeStrategy: input.mergeStrategy ?? 'replace', + title: input.title ?? input.key, + description: input.description ?? '', + category: input.category ?? 'Other', + order: input.order ?? 0, + tags: input.tags ?? [], + ...(input.deprecated && { deprecated: input.deprecated }), + renamedFrom: input.renamedFrom ?? [], + owner: input.owner ?? 'core', + }); +}; diff --git a/packages/settings/src/definition/index.ts b/packages/settings/src/definition/index.ts new file mode 100644 index 00000000..296be29a --- /dev/null +++ b/packages/settings/src/definition/index.ts @@ -0,0 +1,5 @@ +export * from './core-settings'; +export * from './setting-definition.type'; +export * from './define-setting'; +export * from './setting-declaration'; +export * from './json-schema-of'; diff --git a/packages/settings/src/definition/json-schema-of.ts b/packages/settings/src/definition/json-schema-of.ts new file mode 100644 index 00000000..b59993e6 --- /dev/null +++ b/packages/settings/src/definition/json-schema-of.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import type { SettingDefinition } from './setting-definition.type'; + +/** JSON Schema of a setting's value, for the settings UI. */ +export const jsonSchemaOf = (definition: SettingDefinition): unknown => + z.toJSONSchema(definition.schema, { unrepresentable: 'any' }); diff --git a/packages/settings/src/definition/setting-declaration.ts b/packages/settings/src/definition/setting-declaration.ts new file mode 100644 index 00000000..2de2059e --- /dev/null +++ b/packages/settings/src/definition/setting-declaration.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +import { defineSetting } from './define-setting'; +import type { SettingDefinition } from './setting-definition.type'; + +/** + * Settings declared in a plugin manifest (`contributes.settings`): JSON only, so the value type + * is described with a small JSON-schema-like vocabulary. + */ +const JsonType: z.ZodType = z.lazy(() => + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('string'), + enum: z.array(z.string()).optional(), + pattern: z.string().optional(), + }), + z.object({ + type: z.literal('number'), + integer: z.boolean().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + }), + z.object({ type: z.literal('boolean') }), + z.object({ type: z.literal('array'), items: JsonType }), + z.object({ type: z.literal('record'), values: JsonType }), + ]), +); + +type JsonTypeDeclaration = + | { type: 'string'; enum?: string[] | undefined; pattern?: string | undefined } + | { + type: 'number'; + integer?: boolean | undefined; + minimum?: number | undefined; + maximum?: number | undefined; + } + | { type: 'boolean' } + | { type: 'array'; items: JsonTypeDeclaration } + | { type: 'record'; values: JsonTypeDeclaration }; + +export const SettingDeclarationSchema = z.intersection( + JsonType, + z.object({ + key: z.string(), + default: z.unknown(), + title: z.string().optional(), + description: z.string().optional(), + category: z.string().optional(), + order: z.number().optional(), + tags: z.array(z.string()).optional(), + scopes: z.array(z.enum(['account', 'machine', 'project', 'projectLocal'])).optional(), + sync: z.boolean().optional(), + mergeStrategy: z.enum(['replace', 'deep', 'union']).optional(), + deprecated: z.string().optional(), + renamedFrom: z.array(z.string()).optional(), + }), +); +export type SettingDeclaration = z.output; + +const toSchema = (declaration: JsonTypeDeclaration): z.ZodType => { + switch (declaration.type) { + case 'string': { + if (declaration.enum?.length) return z.enum(declaration.enum as [string, ...string[]]); + return declaration.pattern ? z.string().regex(new RegExp(declaration.pattern)) : z.string(); + } + case 'number': { + let schema = declaration.integer ? z.number().int() : z.number(); + if (declaration.minimum !== undefined) schema = schema.min(declaration.minimum); + if (declaration.maximum !== undefined) schema = schema.max(declaration.maximum); + return schema; + } + case 'boolean': + return z.boolean(); + case 'array': + return z.array(toSchema(declaration.items)); + case 'record': + return z.record(z.string(), toSchema(declaration.values)); + } +}; + +/** Turns a manifest declaration into a definition; keys are prefixed with the plugin name. */ +export const fromDeclaration = ( + declaration: SettingDeclaration, + owner: string, +): SettingDefinition => { + const key = declaration.key.startsWith(`${owner}.`) + ? declaration.key + : `${owner}.${declaration.key}`; + return defineSetting({ + ...declaration, + key, + schema: toSchema(declaration), + owner, + ...(declaration.renamedFrom && { + renamedFrom: declaration.renamedFrom.map((old) => + old.includes('/') ? old : `${owner}.${old}`, + ), + }), + }); +}; diff --git a/packages/settings/src/definition/setting-definition.type.ts b/packages/settings/src/definition/setting-definition.type.ts new file mode 100644 index 00000000..69b8aa98 --- /dev/null +++ b/packages/settings/src/definition/setting-definition.type.ts @@ -0,0 +1,32 @@ +import { type z } from 'zod'; + +import type { WritableScope } from '../scope/setting-scope.enum'; + +export type MergeStrategy = 'replace' | 'deep' | 'union'; + +export interface SettingDefinition { + /** `.`, e.g. `appearance.theme` or `@nanoforge/ecs.gizmos.size`. */ + readonly key: string; + readonly schema: z.ZodType; + readonly default: T; + /** Scopes the setting may be written to (default: all). */ + readonly scopes: readonly WritableScope[]; + /** Whether account values leave the machine (default true). `false` forbids the account scope. */ + readonly sync: boolean; + readonly mergeStrategy: MergeStrategy; + readonly title: string; + readonly description: string; + /** Settings tree path, e.g. `Editor/Appearance`. */ + readonly category: string; + readonly order: number; + readonly tags: readonly string[]; + /** Deprecation notice shown when the setting is written. */ + readonly deprecated?: string; + /** Former keys whose stored values are read as this setting. */ + readonly renamedFrom: readonly string[]; + /** Plugin name or `core`. */ + readonly owner: string; +} + +export type SettingInput = Pick, 'key' | 'schema' | 'default'> & + Partial, 'key' | 'schema' | 'default'>>; diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts new file mode 100644 index 00000000..8964dcbd --- /dev/null +++ b/packages/settings/src/index.ts @@ -0,0 +1,9 @@ +export * from './account-sync'; +export * from './registry'; +export * from './definition'; +export * from './key-value'; +export * from './store'; +export * from './merge'; +export * from './scope'; +export * from './service'; +export * from './setup'; diff --git a/packages/settings/src/key-value/index.ts b/packages/settings/src/key-value/index.ts new file mode 100644 index 00000000..d7d470bc --- /dev/null +++ b/packages/settings/src/key-value/index.ts @@ -0,0 +1,3 @@ +export * from './key-value-store.type'; +export * from './memory.key-value-store'; +export * from './indexed-db.key-value-store'; diff --git a/packages/settings/src/key-value/indexed-db.key-value-store.ts b/packages/settings/src/key-value/indexed-db.key-value-store.ts new file mode 100644 index 00000000..915b571c --- /dev/null +++ b/packages/settings/src/key-value/indexed-db.key-value-store.ts @@ -0,0 +1,46 @@ +import type { KeyValueStore } from './key-value-store.type'; + +const DB_NAME = 'nanoforge-editor-settings'; +const STORE = 'kv'; + +export class IndexedDbKeyValueStore implements KeyValueStore { + private _db: Promise | undefined; + + static isSupported(): boolean { + return typeof indexedDB !== 'undefined'; + } + + get(key: string): Promise { + return this._request('readonly', (store) => store.get(key)); + } + + async set(key: string, value: unknown): Promise { + await this._request('readwrite', (store) => store.put(value, key)); + } + + async delete(key: string): Promise { + await this._request('readwrite', (store) => store.delete(key)); + } + + private _open(): Promise { + this._db ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB unavailable')); + }); + return this._db; + } + + private async _request( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const db = await this._open(); + return new Promise((resolve, reject) => { + const request = action(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } +} diff --git a/packages/settings/src/key-value/key-value-store.type.ts b/packages/settings/src/key-value/key-value-store.type.ts new file mode 100644 index 00000000..addd1ee3 --- /dev/null +++ b/packages/settings/src/key-value/key-value-store.type.ts @@ -0,0 +1,6 @@ +/** Small persistent key/value storage (IndexedDB in browsers). */ +export interface KeyValueStore { + get(key: string): Promise; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; +} diff --git a/packages/settings/src/key-value/memory.key-value-store.ts b/packages/settings/src/key-value/memory.key-value-store.ts new file mode 100644 index 00000000..7573765c --- /dev/null +++ b/packages/settings/src/key-value/memory.key-value-store.ts @@ -0,0 +1,19 @@ +import type { KeyValueStore } from './key-value-store.type'; + +export class MemoryKeyValueStore implements KeyValueStore { + readonly entries = new Map(); + + get(key: string) { + return Promise.resolve(structuredClone(this.entries.get(key)) as T | undefined); + } + + set(key: string, value: unknown) { + this.entries.set(key, structuredClone(value)); + return Promise.resolve(); + } + + delete(key: string) { + this.entries.delete(key); + return Promise.resolve(); + } +} diff --git a/packages/settings/src/merge/deep-merge.ts b/packages/settings/src/merge/deep-merge.ts new file mode 100644 index 00000000..202db57d --- /dev/null +++ b/packages/settings/src/merge/deep-merge.ts @@ -0,0 +1,46 @@ +import type { MergeStrategy } from '../definition/setting-definition.type'; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const deepMerge = (base: unknown, override: unknown): unknown => { + if (!isPlainObject(base) || !isPlainObject(override)) return override; + const result: Record = { ...base }; + for (const [key, value] of Object.entries(override)) result[key] = deepMerge(base[key], value); + return result; +}; + +export const deepEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => key in b && deepEqual(a[key], b[key])) + ); + } + return false; +}; + +/** Combines values from lowest to highest precedence according to the merge strategy. */ +export const combine = (strategy: MergeStrategy, values: readonly unknown[]): unknown => { + if (!values.length) return undefined; + switch (strategy) { + case 'replace': + return values.at(-1); + case 'deep': + return values.reduce((merged, value) => deepMerge(merged, value)); + case 'union': { + const result: unknown[] = []; + for (const value of values) { + if (!Array.isArray(value)) return values.at(-1); + for (const item of value) + if (!result.some((existing) => deepEqual(existing, item))) result.push(item); + } + return result; + } + } +}; diff --git a/packages/settings/src/merge/index.ts b/packages/settings/src/merge/index.ts new file mode 100644 index 00000000..2073aafe --- /dev/null +++ b/packages/settings/src/merge/index.ts @@ -0,0 +1,2 @@ +export * from './deep-merge'; +export * from './merge3'; diff --git a/packages/settings/src/merge/merge3.ts b/packages/settings/src/merge/merge3.ts new file mode 100644 index 00000000..c6a2aacf --- /dev/null +++ b/packages/settings/src/merge/merge3.ts @@ -0,0 +1,39 @@ +import { deepEqual } from './deep-merge'; + +export interface MergeConflict { + readonly key: string; + readonly base: unknown; + readonly local: unknown; + readonly remote: unknown; +} + +export interface ThreeWayMerge { + readonly values: Record; + /** Keys changed differently on both sides; `values` keeps the local side. */ + readonly conflicts: MergeConflict[]; +} + +/** Per key three-way merge of settings documents (missing key = unset). */ +export const merge3 = ( + base: Readonly>, + local: Readonly>, + remote: Readonly>, +): ThreeWayMerge => { + const values: Record = {}; + const conflicts: MergeConflict[] = []; + const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]); + for (const key of [...keys].sort()) { + const b = base[key]; + const l = local[key]; + const r = remote[key]; + let merged: unknown; + if (deepEqual(l, b)) merged = r; + else if (deepEqual(r, b) || deepEqual(l, r)) merged = l; + else { + conflicts.push({ key, base: b, local: l, remote: r }); + merged = l; + } + if (merged !== undefined) values[key] = merged; + } + return { values, conflicts }; +}; diff --git a/packages/settings/src/registry/index.ts b/packages/settings/src/registry/index.ts new file mode 100644 index 00000000..91ce20fa --- /dev/null +++ b/packages/settings/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from './settings-contribution'; +export * from './settings-registry'; diff --git a/packages/settings/src/registry/settings-contribution.ts b/packages/settings/src/registry/settings-contribution.ts new file mode 100644 index 00000000..467356a0 --- /dev/null +++ b/packages/settings/src/registry/settings-contribution.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import type { Disposable, StaticContributionHandler } from '@nanoforge-dev/editor-kernel'; + +import { SettingDeclarationSchema, fromDeclaration } from '../definition/setting-declaration'; +import type { SettingsRegistry } from './settings-registry'; + +/** Handles `contributes.settings` of plugin manifests. */ +export const settingsContributionHandler = ( + registry: SettingsRegistry, +): StaticContributionHandler[]> => ({ + key: 'settings', + validator: z.array(SettingDeclarationSchema), + apply: (declarations, plugin): Disposable => + registry.register( + ...declarations.map((declaration) => fromDeclaration(declaration, plugin.manifest.name)), + ), +}); diff --git a/packages/settings/src/registry/settings-registry.ts b/packages/settings/src/registry/settings-registry.ts new file mode 100644 index 00000000..1c7da321 --- /dev/null +++ b/packages/settings/src/registry/settings-registry.ts @@ -0,0 +1,59 @@ +import { + type Disposable, + Emitter, + type Event, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; + +import type { SettingDefinition } from '../definition/setting-definition.type'; + +/** Every known setting, from the core and plugins. */ +export class SettingsRegistry { + private readonly _definitions = new Map(); + private readonly _aliases = new Map(); + private readonly _onDidChange = new Emitter>(); + + /** Fires with the keys (un)registered. */ + readonly onDidChange: Event> = this._onDidChange.event; + + register(...definitions: SettingDefinition[]): Disposable { + for (const definition of definitions) { + const existing = this._definitions.get(definition.key); + if (existing) { + throw new Error(`Setting "${definition.key}" is already declared by ${existing.owner}`); + } + } + for (const definition of definitions) { + this._definitions.set(definition.key, definition); + for (const alias of definition.renamedFrom) this._aliases.set(alias, definition.key); + } + this._onDidChange.fire(new Set(definitions.map((definition) => definition.key))); + return toDisposable(() => { + for (const definition of definitions) { + if (this._definitions.get(definition.key) !== definition) continue; + this._definitions.delete(definition.key); + for (const alias of definition.renamedFrom) this._aliases.delete(alias); + } + this._onDidChange.fire(new Set(definitions.map((definition) => definition.key))); + }); + } + + get(key: string): SettingDefinition | undefined { + return this._definitions.get(key) as SettingDefinition | undefined; + } + + /** The current key for a stored key (itself, or the setting it was renamed to). */ + canonicalKey(key: string): string { + return this._aliases.get(key) ?? key; + } + + getAll(): SettingDefinition[] { + return [...this._definitions.values()].sort( + (a, b) => + a.category.localeCompare(b.category) || a.order - b.order || a.key.localeCompare(b.key), + ); + } +} + +export const SettingsRegistryToken = createToken('settings.registry'); diff --git a/packages/settings/src/scope/index.ts b/packages/settings/src/scope/index.ts new file mode 100644 index 00000000..fc8e2c8d --- /dev/null +++ b/packages/settings/src/scope/index.ts @@ -0,0 +1 @@ +export * from './setting-scope.enum'; diff --git a/packages/settings/src/scope/setting-scope.enum.ts b/packages/settings/src/scope/setting-scope.enum.ts new file mode 100644 index 00000000..fe72b00c --- /dev/null +++ b/packages/settings/src/scope/setting-scope.enum.ts @@ -0,0 +1,23 @@ +/** + * Where a setting value comes from, lowest to highest precedence: + * - `default`: the setting definition; + * - `account`: the user's account (synced; the local user's data dir when offline); + * - `machine`: this browser only; + * - `project`: committed with the project (`.nanoforge/editor/settings.json`); + * - `projectLocal`: this user on this project, never committed. + */ +export const SETTING_SCOPES = ['default', 'account', 'machine', 'project', 'projectLocal'] as const; + +export type SettingScope = (typeof SETTING_SCOPES)[number]; + +/** Scopes a user can write to. */ +export type WritableScope = Exclude; + +export const WRITABLE_SCOPES: readonly WritableScope[] = [ + 'account', + 'machine', + 'project', + 'projectLocal', +]; + +export const scopeRank = (scope: SettingScope): number => SETTING_SCOPES.indexOf(scope); diff --git a/packages/settings/src/service/index.ts b/packages/settings/src/service/index.ts new file mode 100644 index 00000000..1babdfda --- /dev/null +++ b/packages/settings/src/service/index.ts @@ -0,0 +1,3 @@ +export * from './settings-service.type'; +export * from './settings.exception'; +export * from './settings-service'; diff --git a/packages/settings/src/service/settings-service.ts b/packages/settings/src/service/settings-service.ts new file mode 100644 index 00000000..793d38cb --- /dev/null +++ b/packages/settings/src/service/settings-service.ts @@ -0,0 +1,276 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Logger, + type Observable, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; + +import type { SettingDefinition } from '../definition/setting-definition.type'; +import { combine, deepEqual } from '../merge/deep-merge'; +import type { SettingsRegistry } from '../registry/settings-registry'; +import { SETTING_SCOPES, type SettingScope, type WritableScope } from '../scope/setting-scope.enum'; +import type { ScopeStore, SettingValues } from '../store/scope-store.type'; +import type { ImportPreview, SettingInspection, SettingsChange } from './settings-service.type'; +import { SettingsError } from './settings.exception'; + +type KeyOrDefinition = string | SettingDefinition; + +/** + * Resolves settings across scopes (default < account < machine < project < projectLocal), + * validates writes and notifies changes per key. + */ +export class SettingsService implements Disposable { + private readonly _stores = new Map(); + private readonly _snapshots = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _observables = new Map>(); + private readonly _warned = new Set(); + private readonly _store = new DisposableStore(); + + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + readonly registry: SettingsRegistry, + private readonly _logger?: Logger, + ) { + this._store.add(registry.onDidChange((keys) => this._onDidChange.fire({ keys }))); + } + + /** Attaches the store of a scope (e.g. project stores when a project opens). */ + async setStore(scope: WritableScope, store: ScopeStore): Promise { + if (this._stores.has(scope)) throw new SettingsError(`A ${scope} store is already attached`); + await store.initialize(); + this._stores.set(scope, store); + this._snapshots.set(scope, {}); + const subscription = store.values.subscribe((values) => this._storeChanged(scope, values)); + return toDisposable(() => { + subscription(); + if (this._stores.get(scope) !== store) return; + this._stores.delete(scope); + this._storeChanged(scope, {}); + this._snapshots.delete(scope); + }); + } + + hasStore(scope: WritableScope): boolean { + return this._stores.has(scope); + } + + get(key: KeyOrDefinition): T { + return this.inspect(key).value; + } + + /** Reactive value of a setting (memoised per key). */ + observe(key: KeyOrDefinition): Observable { + const name = typeof key === 'string' ? key : key.key; + let observable = this._observables.get(name); + if (!observable) { + observable = { + get: () => this.get(name), + subscribe: (run) => { + let current = this.get(name); + run(current); + const subscription = this.onDidChange(({ keys }) => { + if (!keys.has(name)) return; + if (!this.registry.get(this.registry.canonicalKey(name))) return; + const next = this.get(name); + if (deepEqual(next, current)) return; + current = next; + run(next); + }); + return () => subscription.dispose(); + }, + }; + this._observables.set(name, observable); + } + return observable as Observable; + } + + inspect(key: KeyOrDefinition): SettingInspection { + const definition = this._definition(key); + const scopes: Partial> = { default: definition.default }; + const ignored: SettingInspection['ignored'][number][] = []; + const contributing: T[] = [definition.default]; + let effectiveScope: SettingScope = 'default'; + + for (const scope of SETTING_SCOPES) { + if (scope === 'default') continue; + const raw = this._raw(scope, definition); + if (raw === undefined) continue; + if (!definition.scopes.includes(scope)) { + ignored.push({ scope, value: raw, reason: 'scope' }); + this._warnOnce( + `${scope}:${definition.key}:scope`, + `"${definition.key}" cannot be set at ${scope} level: ignored`, + ); + continue; + } + const parsed = definition.schema.safeParse(raw); + if (!parsed.success) { + ignored.push({ scope, value: raw, reason: 'invalid' }); + this._warnOnce( + `${scope}:${definition.key}:invalid`, + `Invalid ${scope} value for "${definition.key}": ignored`, + ); + continue; + } + scopes[scope] = parsed.data; + contributing.push(parsed.data); + effectiveScope = scope; + } + return { + key: definition.key, + definition, + scopes, + ignored, + value: combine(definition.mergeStrategy, contributing) as T, + effectiveScope, + }; + } + + async set(key: KeyOrDefinition, value: T, scope: WritableScope): Promise { + const definition = this._definition(key); + if (!definition.scopes.includes(scope)) { + throw new SettingsError(`"${definition.key}" cannot be set at ${scope} level`); + } + const parsed = definition.schema.safeParse(value); + if (!parsed.success) { + throw new SettingsError( + `Invalid value for "${definition.key}": ${parsed.error.issues[0]?.message ?? 'invalid'}`, + ); + } + if (definition.deprecated) + this._logger?.warn(`"${definition.key}" is deprecated: ${definition.deprecated}`); + await this._storeOf(scope).write({ + ...this._aliasRemoval(definition), + [definition.key]: parsed.data, + }); + } + + /** Removes the value of a scope (falls back to lower scopes). */ + async reset(key: KeyOrDefinition, scope: WritableScope): Promise { + const definition = this._definition(key); + await this._storeOf(scope).write({ + ...this._aliasRemoval(definition), + [definition.key]: undefined, + }); + } + + /** The raw values of a scope as formatted JSON. */ + export(scope: WritableScope): string { + const values = this._stores.get(scope)?.values.get() ?? {}; + return `${JSON.stringify(values, null, 2)}\n`; + } + + /** Validates an exported JSON document against the registry before importing it. */ + previewImport(scope: WritableScope, json: string): ImportPreview { + let document: unknown; + try { + document = JSON.parse(json); + } catch (error) { + throw new SettingsError( + `Not a JSON document: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof document !== 'object' || document === null || Array.isArray(document)) { + throw new SettingsError('Expected a JSON object of settings'); + } + const current = this._stores.get(scope)?.values.get() ?? {}; + const preview: ImportPreview = { + scope, + added: [], + changed: [], + unknown: [], + rejected: [], + patch: {}, + }; + const patch = preview.patch as Record; + for (const [rawKey, value] of Object.entries(document)) { + const key = this.registry.canonicalKey(rawKey); + const definition = this.registry.get(key); + if (!definition) { + preview.unknown.push(rawKey); + patch[rawKey] = value; + continue; + } + if (!definition.scopes.includes(scope)) { + preview.rejected.push({ key, reason: `not allowed at ${scope} level` }); + continue; + } + if (!definition.schema.safeParse(value).success) { + preview.rejected.push({ key, reason: 'invalid value' }); + continue; + } + if (!(key in current)) preview.added.push(key); + else if (!deepEqual(current[key], value)) preview.changed.push(key); + else continue; + patch[key] = value; + } + return preview; + } + + async applyImport(preview: ImportPreview): Promise { + await this._storeOf(preview.scope).write(preview.patch); + } + + dispose(): void { + this._store.dispose(); + this._onDidChange.dispose(); + } + + private _definition(key: KeyOrDefinition): SettingDefinition { + if (typeof key !== 'string') return key; + const definition = this.registry.get(key); + if (!definition) throw new SettingsError(`Unknown setting "${key}"`); + return definition; + } + + private _storeOf(scope: WritableScope): ScopeStore { + const store = this._stores.get(scope); + if (!store) { + throw new SettingsError( + scope === 'project' || scope === 'projectLocal' + ? `Open a project to change ${scope} settings` + : `No ${scope} settings storage`, + ); + } + return store; + } + + /** Stored value for a setting in a scope, including values under a former key. */ + private _raw(scope: SettingScope, definition: SettingDefinition): unknown { + if (scope === 'default') return definition.default; + const values = this._snapshots.get(scope); + if (!values) return undefined; + if (values[definition.key] !== undefined) return values[definition.key]; + for (const alias of definition.renamedFrom) + if (values[alias] !== undefined) return values[alias]; + return undefined; + } + + private _aliasRemoval(definition: SettingDefinition): Record { + return Object.fromEntries(definition.renamedFrom.map((alias) => [alias, undefined])); + } + + private _storeChanged(scope: WritableScope, values: SettingValues): void { + const previous = this._snapshots.get(scope) ?? {}; + this._snapshots.set(scope, values); + const keys = new Set(); + for (const key of new Set([...Object.keys(previous), ...Object.keys(values)])) { + if (!deepEqual(previous[key], values[key])) keys.add(this.registry.canonicalKey(key)); + } + if (keys.size) this._onDidChange.fire({ keys }); + } + + private _warnOnce(id: string, message: string): void { + if (this._warned.has(id)) return; + this._warned.add(id); + this._logger?.warn(message); + } +} + +export const SettingsServiceToken = createToken('settings.service'); diff --git a/packages/settings/src/service/settings-service.type.ts b/packages/settings/src/service/settings-service.type.ts new file mode 100644 index 00000000..50d86dae --- /dev/null +++ b/packages/settings/src/service/settings-service.type.ts @@ -0,0 +1,32 @@ +import type { SettingDefinition } from '../definition/setting-definition.type'; +import type { SettingScope, WritableScope } from '../scope/setting-scope.enum'; +import type { SettingValues } from '../store/scope-store.type'; + +export interface SettingsChange { + readonly keys: ReadonlySet; +} + +export interface SettingInspection { + readonly key: string; + readonly definition: SettingDefinition; + /** Valid value per scope (undefined when unset, invalid or not allowed in that scope). */ + readonly scopes: Readonly>>; + /** Values present in scopes the setting does not allow, or failing its schema. */ + readonly ignored: readonly { + scope: WritableScope; + value: unknown; + reason: 'scope' | 'invalid'; + }[]; + readonly value: T; + /** Highest scope contributing to the value. */ + readonly effectiveScope: SettingScope; +} + +export interface ImportPreview { + readonly scope: WritableScope; + readonly added: string[]; + readonly changed: string[]; + readonly unknown: string[]; + readonly rejected: { key: string; reason: string }[]; + readonly patch: SettingValues; +} diff --git a/packages/settings/src/service/settings.exception.ts b/packages/settings/src/service/settings.exception.ts new file mode 100644 index 00000000..3453cff2 --- /dev/null +++ b/packages/settings/src/service/settings.exception.ts @@ -0,0 +1,6 @@ +export class SettingsError extends Error { + constructor(message: string) { + super(message); + this.name = 'SettingsError'; + } +} diff --git a/packages/settings/src/setup/follow-current-project.ts b/packages/settings/src/setup/follow-current-project.ts new file mode 100644 index 00000000..06914253 --- /dev/null +++ b/packages/settings/src/setup/follow-current-project.ts @@ -0,0 +1,38 @@ +import { + type Disposable, + MutableDisposable, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; +import type { ClientProject } from '@nanoforge-dev/editor-project'; + +import type { SettingsSetup } from './setup-settings'; + +/** Keeps the project scopes attached to the current project of a window. */ +export const followCurrentProject = ( + setup: SettingsSetup, + current: { subscribe(run: (project: ClientProject | undefined) => void): () => void }, +): Disposable & { readonly attached: Observable } => { + const attached = new MutableDisposable(); + const attachedId = new ObservableValue(undefined); + let generation = 0; + const unsubscribe = current.subscribe((project) => { + const token = ++generation; + attached.clear(); + attachedId.set(undefined); + if (!project) return; + void setup.attachProject(project).then((disposable) => { + if (token !== generation) return disposable.dispose(); + attached.value = disposable; + attachedId.set(project.id); + }); + }); + return { + /** Id of the project whose scopes are attached (layouts and project settings are ready). */ + attached: attachedId.readonly(), + dispose: () => { + unsubscribe(); + attached.dispose(); + }, + }; +}; diff --git a/packages/settings/src/setup/index.ts b/packages/settings/src/setup/index.ts new file mode 100644 index 00000000..89c1e42a --- /dev/null +++ b/packages/settings/src/setup/index.ts @@ -0,0 +1,2 @@ +export * from './setup-settings'; +export * from './follow-current-project'; diff --git a/packages/settings/src/setup/setup-settings.ts b/packages/settings/src/setup/setup-settings.ts new file mode 100644 index 00000000..459dfee1 --- /dev/null +++ b/packages/settings/src/setup/setup-settings.ts @@ -0,0 +1,99 @@ +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + observe, +} from '@nanoforge-dev/editor-kernel'; +import type { ClientProject } from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import { AccountSyncStore } from '../account-sync/account-sync-store'; +import { rpcAccountRemote } from '../account-sync/rpc-account-remote'; +import { CoreSettings } from '../definition/core-settings'; +import { IndexedDbKeyValueStore } from '../key-value/indexed-db.key-value-store'; +import type { KeyValueStore } from '../key-value/key-value-store.type'; +import { MemoryKeyValueStore } from '../key-value/memory.key-value-store'; +import { SettingsRegistry, SettingsRegistryToken } from '../registry/settings-registry'; +import { SettingsService, SettingsServiceToken } from '../service/settings-service'; +import { JsonFileScopeStore } from '../store/json-file.scope-store'; +import { KeyValueScopeStore } from '../store/key-value.scope-store'; +import { + PROJECT_LOCAL_SETTINGS_FILE, + PROJECT_SETTINGS_FILE, + projectFileAdapter, +} from '../store/project-file-adapter'; +import { RemoteProjectLocalStore } from '../store/remote-project-local.scope-store'; + +export interface SettingsSetupOptions { + services: Container; + rpc: RpcClient; + mode: 'OFFLINE' | 'ONLINE'; + /** Identifies whose account data the browser caches (a user id). */ + userId: string; + kv?: KeyValueStore; +} + +export interface SettingsSetup extends Disposable { + readonly settings: SettingsService; + readonly registry: SettingsRegistry; + readonly account: AccountSyncStore; + /** Attaches the project and projectLocal scopes of a project (detached on dispose). */ + attachProject(project: ClientProject): Promise; +} + +/** + * Creates the settings services of an editor window: machine scope in IndexedDB, account scope + * synced through the editor server, project scopes attached per project. + */ +export const setupSettings = async (options: SettingsSetupOptions): Promise => { + const { services, rpc } = options; + const store = new DisposableStore(); + const logger = services.get(CoreServices.Logger).getLogger('settings'); + const kv = + options.kv ?? + (IndexedDbKeyValueStore.isSupported() + ? new IndexedDbKeyValueStore() + : new MemoryKeyValueStore()); + + const registry = new SettingsRegistry(); + store.add(registry.register(...Object.values(CoreSettings))); + const settings = store.add(new SettingsService(registry, logger)); + services.provide(SettingsRegistryToken, registry); + services.provide(SettingsServiceToken, settings); + + store.add(await settings.setStore('machine', new KeyValueScopeStore(kv, 'scope:machine'))); + + const remote = rpcAccountRemote(rpc); + const account = store.add( + new AccountSyncStore(remote, kv, `scope:account:${options.userId}`, { + isSynced: (key) => registry.get(key)?.sync ?? true, + }), + ); + store.add(await settings.setStore('account', account)); + const unwatch = remote.watch(() => void account.sync()); + store.add({ dispose: unwatch }); + + const i18n = services.get(CoreServices.Localization); + store.add(observe(settings.observe(CoreSettings.locale), (locale) => i18n.setLocale(locale))); + + const attachProject = async (project: ClientProject): Promise => { + const attached = new DisposableStore(); + const projectStore = new JsonFileScopeStore( + projectFileAdapter(project.fs, PROJECT_SETTINGS_FILE), + ); + const localStore = + options.mode === 'ONLINE' + ? new RemoteProjectLocalStore(rpc, project.id) + : new JsonFileScopeStore( + projectFileAdapter(project.fs, PROJECT_LOCAL_SETTINGS_FILE, { gitignored: true }), + ); + attached.add(projectStore); + attached.add(localStore); + attached.add(await settings.setStore('project', projectStore)); + attached.add(await settings.setStore('projectLocal', localStore)); + return attached; + }; + + return { settings, registry, account, attachProject, dispose: () => store.dispose() }; +}; diff --git a/packages/settings/src/store/index.ts b/packages/settings/src/store/index.ts new file mode 100644 index 00000000..699542d1 --- /dev/null +++ b/packages/settings/src/store/index.ts @@ -0,0 +1,8 @@ +export * from './key-value.scope-store'; +export * from './project-file-adapter'; +export * from './scope-store.type'; +export * from './setting-values'; +export * from './memory.scope-store'; +export * from './settings-file.exception'; +export * from './json-file.scope-store'; +export * from './remote-project-local.scope-store'; diff --git a/packages/settings/src/store/json-file.scope-store.ts b/packages/settings/src/store/json-file.scope-store.ts new file mode 100644 index 00000000..890ef681 --- /dev/null +++ b/packages/settings/src/store/json-file.scope-store.ts @@ -0,0 +1,81 @@ +import { DisposableStore, type Observable, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import type { ScopeStore, SettingValues, TextFileAdapter } from './scope-store.type'; +import { applyPatch, serializeValues } from './setting-values'; +import { SettingsFileError } from './settings-file.exception'; + +const isConflict = (error: unknown) => (error as { code?: string } | null)?.code === 'CONFLICT'; + +/** + * Scope stored as a JSON object in a file. Edits made outside the editor are picked up; a + * file that is not valid JSON is never overwritten (writes fail until it is fixed). + */ +export class JsonFileScopeStore implements ScopeStore { + private readonly _values = new ObservableValue({}); + private readonly _store = new DisposableStore(); + private _hash: string | null = null; + private _invalid: string | undefined; + private _queue: Promise = Promise.resolve(); + + constructor(private readonly _file: TextFileAdapter) {} + + get values(): Observable { + return this._values.readonly(); + } + + /** Parse error of the file, when it is not a JSON object. */ + get invalid(): string | undefined { + return this._invalid; + } + + async initialize(): Promise { + this._store.add(this._file.onDidChange(() => void this._reload())); + await this._reload(); + } + + write(patch: SettingValues): Promise { + const task = this._queue.then(() => this._write(patch)); + this._queue = task.catch(() => undefined); + return task; + } + + dispose(): void { + this._store.dispose(); + } + + private async _write(patch: SettingValues, attempt = 0): Promise { + if (this._invalid) throw new SettingsFileError(`Settings file is invalid: ${this._invalid}`); + const next = applyPatch(this._values.get(), patch); + try { + const { hash } = await this._file.write(serializeValues(next), this._hash); + this._hash = hash; + this._values.set(next); + } catch (error) { + if (!isConflict(error) || attempt >= 3) throw error; + await this._reload(true); + await this._write(patch, attempt + 1); + } + } + + private async _reload(fresh = false): Promise { + const file = await this._file.read(fresh); + if (file?.hash === this._hash && this._hash !== null) return; + this._hash = file?.hash ?? null; + if (!file) { + this._invalid = undefined; + this._values.set({}); + return; + } + try { + const parsed: unknown = JSON.parse(file.text || '{}'); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('expected a JSON object'); + } + this._invalid = undefined; + this._values.set(parsed as SettingValues); + } catch (error) { + this._invalid = error instanceof Error ? error.message : String(error); + this._values.set({}); + } + } +} diff --git a/packages/settings/src/store/key-value.scope-store.ts b/packages/settings/src/store/key-value.scope-store.ts new file mode 100644 index 00000000..baf5fb3d --- /dev/null +++ b/packages/settings/src/store/key-value.scope-store.ts @@ -0,0 +1,31 @@ +import { type Observable, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import type { KeyValueStore } from '../key-value/key-value-store.type'; +import type { ScopeStore, SettingValues } from './scope-store.type'; +import { applyPatch } from './setting-values'; + +/** A scope persisted as one record of a key/value store (machine scope). */ +export class KeyValueScopeStore implements ScopeStore { + private readonly _values = new ObservableValue({}); + + constructor( + private readonly _kv: KeyValueStore, + private readonly _key: string, + ) {} + + get values(): Observable { + return this._values.readonly(); + } + + async initialize(): Promise { + this._values.set((await this._kv.get(this._key)) ?? {}); + } + + async write(patch: SettingValues): Promise { + const next = applyPatch(this._values.get(), patch); + await this._kv.set(this._key, next); + this._values.set(next); + } + + dispose(): void {} +} diff --git a/packages/settings/src/store/memory.scope-store.ts b/packages/settings/src/store/memory.scope-store.ts new file mode 100644 index 00000000..8e8f88ad --- /dev/null +++ b/packages/settings/src/store/memory.scope-store.ts @@ -0,0 +1,27 @@ +import { type Observable, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import type { ScopeStore, SettingValues } from './scope-store.type'; +import { applyPatch } from './setting-values'; + +export class MemoryScopeStore implements ScopeStore { + private readonly _values: ObservableValue; + + constructor(initial: SettingValues = {}) { + this._values = new ObservableValue(initial); + } + + get values(): Observable { + return this._values.readonly(); + } + + initialize(): Promise { + return Promise.resolve(); + } + + write(patch: SettingValues): Promise { + this._values.set(applyPatch(this._values.get(), patch)); + return Promise.resolve(); + } + + dispose(): void {} +} diff --git a/packages/settings/src/store/project-file-adapter.ts b/packages/settings/src/store/project-file-adapter.ts new file mode 100644 index 00000000..33ae07fe --- /dev/null +++ b/packages/settings/src/store/project-file-adapter.ts @@ -0,0 +1,45 @@ +import { Events } from '@nanoforge-dev/editor-kernel'; +import type { ProjectFs } from '@nanoforge-dev/editor-project'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { TextFileAdapter } from './scope-store.type'; + +export const PROJECT_SETTINGS_FILE = '.nanoforge/editor/settings.json'; +export const PROJECT_LOCAL_SETTINGS_FILE = '.nanoforge/editor/local.json'; +const EDITOR_GITIGNORE = '.nanoforge/editor/.gitignore'; + +/** A file of the open project as a settings file. */ +export const projectFileAdapter = ( + fs: ProjectFs, + path: string, + options: { gitignored?: boolean } = {}, +): TextFileAdapter => ({ + async read(fresh = false) { + if (!fresh && !fs.entry(path)) return undefined; + try { + const { text, hash } = await fs.readText(path, { fresh }); + return { text, hash }; + } catch (error) { + if (RpcError.is(error, 'NOT_FOUND')) return undefined; + throw error; + } + }, + async write(text, expectedHash) { + if (options.gitignored) await ensureIgnored(fs, path); + return fs.write(path, text, { expectedHash }); + }, + onDidChange: Events.map( + Events.filter(fs.onDidChange, (changes) => changes.some((change) => change.path === path)), + () => undefined, + ), +}); + +/** Adds the file to `.nanoforge/editor/.gitignore` (never committed). */ +const ensureIgnored = async (fs: ProjectFs, path: string) => { + const name = path.split('/').at(-1)!; + const current = fs.entry(EDITOR_GITIGNORE) ? await fs.readText(EDITOR_GITIGNORE) : undefined; + const lines = current?.text.split('\n').map((line) => line.trim()) ?? []; + if (lines.includes(name)) return; + const text = `${current?.text.trimEnd() ?? ''}${current?.text.trim() ? '\n' : ''}${name}\n`; + await fs.write(EDITOR_GITIGNORE, text, { expectedHash: current?.hash ?? null }); +}; diff --git a/packages/settings/src/store/remote-project-local.scope-store.ts b/packages/settings/src/store/remote-project-local.scope-store.ts new file mode 100644 index 00000000..3e3b13f1 --- /dev/null +++ b/packages/settings/src/store/remote-project-local.scope-store.ts @@ -0,0 +1,42 @@ +import { type Observable, ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { SettingsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { ScopeStore, SettingValues } from './scope-store.type'; +import { applyPatch } from './setting-values'; + +/** Project-local settings stored server-side (hosted editors, shared checkouts). */ +export class RemoteProjectLocalStore implements ScopeStore { + private readonly _values = new ObservableValue({}); + private _queue: Promise = Promise.resolve(); + + constructor( + private readonly _rpc: RpcClient, + private readonly _projectId: string, + ) {} + + get values(): Observable { + return this._values.readonly(); + } + + async initialize(): Promise { + this._values.set( + await this._rpc.api(SettingsContract).projectLocalGet({ project: this._projectId }), + ); + } + + write(patch: SettingValues): Promise { + const next = applyPatch(this._values.get(), patch); + this._values.set(next); + const task = this._queue.then(() => + this._rpc + .api(SettingsContract) + .projectLocalPut({ project: this._projectId, values: next }) + .then(() => undefined), + ); + this._queue = task.catch(() => undefined); + return task; + } + + dispose(): void {} +} diff --git a/packages/settings/src/store/scope-store.type.ts b/packages/settings/src/store/scope-store.type.ts new file mode 100644 index 00000000..c0e4b62e --- /dev/null +++ b/packages/settings/src/store/scope-store.type.ts @@ -0,0 +1,24 @@ +import type { Disposable, Event, Observable } from '@nanoforge-dev/editor-kernel'; + +export type SettingValues = Readonly>; + +/** Values of one scope. Keys unknown to the registry are preserved untouched. */ +export interface ScopeStore extends Disposable { + readonly values: Observable; + /** Loads initial values (called once before use). */ + initialize(): Promise; + /** Applies a patch; an `undefined` value removes the key. */ + write(patch: SettingValues): Promise; +} + +/** A text file with optimistic concurrency (implemented over `ProjectFs`). */ +export interface TextFileAdapter { + readonly onDidChange: Event; + /** + * `fresh`: what is on disk now, not a cached copy (after a write conflict, the cache may + * not know yet that the file changed). + */ + read(fresh?: boolean): Promise<{ text: string; hash: string } | undefined>; + /** `expectedHash: null` means the file must not exist; throws `{ code: 'CONFLICT' }` otherwise. */ + write(text: string, expectedHash: string | null): Promise<{ hash: string }>; +} diff --git a/packages/settings/src/store/setting-values.ts b/packages/settings/src/store/setting-values.ts new file mode 100644 index 00000000..c172e2ee --- /dev/null +++ b/packages/settings/src/store/setting-values.ts @@ -0,0 +1,25 @@ +import type { SettingValues } from './scope-store.type'; + +export const applyPatch = ( + values: SettingValues, + patch: SettingValues, +): Record => { + const next = new Map(Object.entries(values)); + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) next.delete(key); + else next.set(key, value); + } + return Object.fromEntries(next); +}; + +/** Sorted keys, 2-space JSON: stable diffs for files committed with the project. */ +export const serializeValues = (values: SettingValues): string => + `${JSON.stringify( + Object.fromEntries( + Object.keys(values) + .sort() + .map((key) => [key, values[key]]), + ), + null, + 2, + )}\n`; diff --git a/packages/settings/src/store/settings-file.exception.ts b/packages/settings/src/store/settings-file.exception.ts new file mode 100644 index 00000000..ef4ed29c --- /dev/null +++ b/packages/settings/src/store/settings-file.exception.ts @@ -0,0 +1,6 @@ +export class SettingsFileError extends Error { + constructor(message: string) { + super(message); + this.name = 'SettingsFileError'; + } +} diff --git a/packages/settings/test/account-sync/account-sync-store.spec.ts b/packages/settings/test/account-sync/account-sync-store.spec.ts new file mode 100644 index 00000000..ef2da779 --- /dev/null +++ b/packages/settings/test/account-sync/account-sync-store.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { Emitter } from '@nanoforge-dev/editor-kernel'; + +import { + type AccountDocument, + type AccountRemote, + AccountSyncStore, + MemoryKeyValueStore, + SettingsRegistry, + SettingsService, +} from '../../src'; +import { fontSize } from '../fixtures/settings'; + +/** In-memory editor server holding the account document. */ +class FakeRemote implements AccountRemote { + document: AccountDocument = { revision: null, values: {} }; + online = true; + puts = 0; + private readonly reconnect = new Emitter(); + readonly onDidReconnect = this.reconnect.event; + + get = async () => { + this.check(); + return structuredClone(this.document); + }; + + put = async (baseRevision: string | null, values: Record) => { + this.check(); + this.puts++; + if (baseRevision !== this.document.revision) { + throw Object.assign(new Error('conflict'), { + code: 'CONFLICT', + data: structuredClone(this.document), + }); + } + this.document = { + revision: String(Number(this.document.revision ?? 0) + 1), + values: structuredClone(values), + }; + return { revision: this.document.revision! }; + }; + + /** Another device saved. */ + remoteEdit(patch: Record) { + this.document = { + revision: String(Number(this.document.revision ?? 0) + 1), + values: { ...this.document.values, ...patch }, + }; + } + + goOnline() { + this.online = true; + this.reconnect.fire(); + } + + private check() { + if (!this.online) throw Object.assign(new Error('offline'), { code: 'UNAVAILABLE' }); + } +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('AccountSyncStore', () => { + const create = (remote: FakeRemote, kv = new MemoryKeyValueStore()) => + new AccountSyncStore(remote, kv, 'account:local', { + debounceMs: 0, + retryDelays: [60_000], + isSynced: (key) => key !== 'local.only', + }); + + it('pulls the account document', async () => { + const remote = new FakeRemote(); + remote.remoteEdit({ 'editor.fontSize': 16 }); + const store = create(remote); + await store.initialize(); + expect(store.values.get()).toEqual({ 'editor.fontSize': 16 }); + expect(store.status.get()).toBe('idle'); + }); + + it('keeps offline edits (persisted) and flushes them on reconnect', async () => { + const remote = new FakeRemote(); + const kv = new MemoryKeyValueStore(); + const store = create(remote, kv); + await store.initialize(); + remote.online = false; + await store.write({ 'editor.fontSize': 18, 'local.only': true }); + await store.sync(); + expect(store.status.get()).toBe('offline'); + expect(store.values.get()).toEqual({ 'editor.fontSize': 18, 'local.only': true }); + store.dispose(); + + const reloaded = create(remote, kv); + await reloaded.initialize(); + expect(reloaded.dirty).toBe(true); + remote.goOnline(); + await flush(); + await reloaded.sync(); + expect(remote.document.values).toEqual({ 'editor.fontSize': 18 }); // local.only never leaves + expect(reloaded.dirty).toBe(false); + expect(reloaded.status.get()).toBe('idle'); + reloaded.dispose(); + }); + + it('merges concurrent edits on 409 and reports true conflicts', async () => { + const remote = new FakeRemote(); + remote.remoteEdit({ a: 1, b: 1, c: 1 }); + const store = create(remote); + await store.initialize(); + + remote.remoteEdit({ b: 2, c: 2 }); // other device + await store.write({ a: 3, c: 3 }); // this device + await store.sync(); + + expect(remote.document.values).toEqual({ a: 3, b: 2, c: 3 }); + expect(store.values.get()).toEqual({ a: 3, b: 2, c: 3 }); + expect(store.conflicts.get()).toEqual([{ key: 'c', base: 1, local: 3, remote: 2 }]); + expect(store.status.get()).toBe('conflict'); + + await store.resolveConflict('c', 2); + await store.sync(); + expect(remote.document.values).toEqual({ a: 3, b: 2, c: 2 }); + expect(store.conflicts.get()).toEqual([]); + store.dispose(); + }); + + it('works as the account scope of the settings service', async () => { + const remote = new FakeRemote(); + const registry = new SettingsRegistry(); + registry.register(fontSize); + const service = new SettingsService(registry); + const store = create(remote); + await service.setStore('account', store); + await service.set(fontSize, 20, 'account'); + await store.sync(); + expect(remote.document.values).toEqual({ 'editor.fontSize': 20 }); + remote.remoteEdit({ 'editor.fontSize': 11 }); + await store.sync(); + expect(service.get(fontSize)).toBe(11); + store.dispose(); + }); +}); diff --git a/packages/settings/test/definition/setting-declaration.spec.ts b/packages/settings/test/definition/setting-declaration.spec.ts new file mode 100644 index 00000000..c90132e4 --- /dev/null +++ b/packages/settings/test/definition/setting-declaration.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { fromDeclaration } from '../../src'; + +describe('manifest declarations', () => { + it('builds prefixed definitions with json types', () => { + const definition = fromDeclaration( + { + key: 'gizmos.size', + type: 'number', + integer: true, + minimum: 1, + default: 8, + scopes: ['machine'], + }, + '@nanoforge/ecs', + ); + expect(definition.key).toBe('@nanoforge/ecs.gizmos.size'); + expect(definition.schema.safeParse(0).success).toBe(false); + expect(definition.scopes).toEqual(['machine']); + expect(() => fromDeclaration({ key: 'bad', type: 'boolean', default: 'yes' }, '@a/b')).toThrow( + /Default/, + ); + }); +}); diff --git a/packages/settings/test/end-to-end.spec.ts b/packages/settings/test/end-to-end.spec.ts new file mode 100644 index 00000000..e5761779 --- /dev/null +++ b/packages/settings/test/end-to-end.spec.ts @@ -0,0 +1,101 @@ +import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { MemoryContentCache, MemoryTreeCache, ProjectService } from '@nanoforge-dev/editor-project'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { + AccountSyncStore, + CoreSettings, + JsonFileScopeStore, + MemoryKeyValueStore, + PROJECT_LOCAL_SETTINGS_FILE, + PROJECT_SETTINGS_FILE, + SettingsRegistry, + SettingsService, + projectFileAdapter, + rpcAccountRemote, +} from '../src'; + +let workspace: string; +let editor: EditorServer; + +beforeAll(() => { + workspace = mkdtempSync(join(import.meta.dirname, '../../server-core/test/.tmp-settings-')); + cpSync( + join(import.meta.dirname, '../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { + recursive: true, + }, + ); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); +}); + +afterAll(() => { + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +const client = async () => { + const rpc = createLoopbackClient(editor); + const registry = new SettingsRegistry(); + registry.register(...Object.values(CoreSettings)); + const settings = new SettingsService(registry); + const remote = rpcAccountRemote(rpc); + const account = new AccountSyncStore(remote, new MemoryKeyValueStore(), 'account', { + debounceMs: 0, + }); + remote.watch(() => void account.sync()); + await settings.setStore('account', account); + return { rpc, settings, account }; +}; + +describe('settings end to end', () => { + it('syncs account settings between two clients', async () => { + const a = await client(); + const b = await client(); + await a.settings.set(CoreSettings.historyLimit, 250, 'account'); + await a.account.sync(); + await new Promise((resolve) => setTimeout(resolve, 20)); // change stream → b pulls + await b.account.sync(); + expect(b.settings.get(CoreSettings.historyLimit)).toBe(250); + a.rpc.dispose(); + b.rpc.dispose(); + }); + + it('stores project settings in the project and keeps local ones out of git', async () => { + const { rpc, settings } = await client(); + const project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ path: 'pong' }); + await settings.setStore( + 'project', + new JsonFileScopeStore(projectFileAdapter(project.fs, PROJECT_SETTINGS_FILE)), + ); + await settings.setStore( + 'projectLocal', + new JsonFileScopeStore( + projectFileAdapter(project.fs, PROJECT_LOCAL_SETTINGS_FILE, { gitignored: true }), + ), + ); + + await settings.set(CoreSettings.historyLimit, 50, 'project'); + await settings.set(CoreSettings.layoutActive, 'debug', 'projectLocal'); + const root = join(workspace, 'pong/.nanoforge/editor'); + expect(readFileSync(join(root, 'settings.json'), 'utf8')).toBe('{\n "history.limit": 50\n}\n'); + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toContain('local.json'); + expect(settings.inspect(CoreSettings.historyLimit).effectiveScope).toBe('project'); + rpc.dispose(); + }); +}); diff --git a/packages/settings/test/fixtures/settings.ts b/packages/settings/test/fixtures/settings.ts new file mode 100644 index 00000000..5d7f34c6 --- /dev/null +++ b/packages/settings/test/fixtures/settings.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { defineSetting } from '../../src'; + +export const fontSize = defineSetting({ + key: 'editor.fontSize', + schema: z.number().int().min(6), + default: 13, +}); +export const theme = defineSetting({ + key: 'appearance.theme', + schema: z.string(), + default: 'dark', + scopes: ['account', 'machine'], +}); +export const colors = defineSetting({ + key: 'appearance.colors', + schema: z.record(z.string(), z.string()), + default: { background: '#000', text: '#fff' }, + mergeStrategy: 'deep', +}); +export const disabled = defineSetting({ + key: 'plugins.disabled', + schema: z.array(z.string()), + default: [], + mergeStrategy: 'union', +}); +export const tabSize = defineSetting({ + key: 'code.tabSize', + schema: z.number(), + default: 2, + renamedFrom: ['editor.tabSize'], +}); diff --git a/packages/settings/test/merge/merge3.spec.ts b/packages/settings/test/merge/merge3.spec.ts new file mode 100644 index 00000000..b6ebb1d0 --- /dev/null +++ b/packages/settings/test/merge/merge3.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { merge3 } from '../../src'; + +describe('merge3', () => { + it('merges non conflicting edits and reports conflicts', () => { + const result = merge3( + { a: 1, b: 1, c: 1, d: 1 }, + { a: 2, b: 1, c: 5, e: 1 }, // local: a changed, c changed, d removed, e added + { a: 1, b: 3, c: 7, d: 1 }, // remote: b changed, c changed + ); + expect(result.values).toEqual({ a: 2, b: 3, c: 5, e: 1 }); + expect(result.conflicts).toEqual([{ key: 'c', base: 1, local: 5, remote: 7 }]); + }); +}); diff --git a/packages/settings/test/service/settings-service.spec.ts b/packages/settings/test/service/settings-service.spec.ts new file mode 100644 index 00000000..0efc4793 --- /dev/null +++ b/packages/settings/test/service/settings-service.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { MemoryScopeStore, SettingsRegistry, SettingsService, defineSetting } from '../../src'; +import { colors, disabled, fontSize, tabSize, theme } from '../fixtures/settings'; + +const setup = async () => { + const registry = new SettingsRegistry(); + registry.register(fontSize, theme, colors, disabled, tabSize); + const warn = vi.fn(); + const service = new SettingsService(registry, { warn } as never); + const stores = { + account: new MemoryScopeStore(), + machine: new MemoryScopeStore(), + project: new MemoryScopeStore(), + projectLocal: new MemoryScopeStore(), + }; + for (const [scope, store] of Object.entries(stores)) + await service.setStore(scope as keyof typeof stores, store); + return { registry, service, stores, warn }; +}; + +describe('SettingsService', () => { + it('applies scope precedence', async () => { + const { service } = await setup(); + expect(service.get(fontSize)).toBe(13); + await service.set(fontSize, 14, 'account'); + await service.set(fontSize, 15, 'project'); + expect(service.inspect(fontSize)).toMatchObject({ + value: 15, + effectiveScope: 'project', + scopes: { default: 13, account: 14, project: 15 }, + }); + await service.set(fontSize, 16, 'projectLocal'); + expect(service.get(fontSize)).toBe(16); + await service.reset(fontSize, 'projectLocal'); + await service.reset(fontSize, 'project'); + expect(service.get(fontSize)).toBe(14); + }); + + it('merges deep and union settings across scopes', async () => { + const { service } = await setup(); + await service.set(colors, { text: '#eee' }, 'account'); + await service.set(colors, { accent: '#f0f' }, 'project'); + expect(service.get(colors)).toEqual({ background: '#000', text: '#eee', accent: '#f0f' }); + await service.set(disabled, ['a/x', 'b/y'], 'account'); + await service.set(disabled, ['b/y', 'c/z'], 'project'); + expect(service.get(disabled)).toEqual(['a/x', 'b/y', 'c/z']); + }); + + it('restricts scopes on write and ignores values stored in forbidden scopes', async () => { + const { service, stores, warn } = await setup(); + await expect(service.set(theme, 'light', 'project')).rejects.toThrow( + /cannot be set at project level/, + ); + await stores.project.write({ 'appearance.theme': 'evil' }); + expect(service.get(theme)).toBe('dark'); + expect(service.inspect(theme).ignored).toEqual([ + { scope: 'project', value: 'evil', reason: 'scope' }, + ]); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('validates writes and ignores invalid stored values', async () => { + const { service, stores } = await setup(); + await expect(service.set(fontSize, 2, 'account')).rejects.toThrow(/Invalid value/); + await stores.machine.write({ 'editor.fontSize': 'huge' }); + await service.set(fontSize, 20, 'account'); + expect(service.get(fontSize)).toBe(20); + }); + + it('reads renamed settings and moves them on write', async () => { + const { service, stores } = await setup(); + await stores.account.write({ 'editor.tabSize': 4 }); + expect(service.get(tabSize)).toBe(4); + await service.set(tabSize, 8, 'account'); + expect(stores.account.values.get()).toEqual({ 'code.tabSize': 8 }); + }); + + it('notifies observers only for relevant changes', async () => { + const { service, stores } = await setup(); + const run = vi.fn(); + service.observe(fontSize).subscribe(run); + await service.set(theme, 'light', 'machine'); + await service.set(fontSize, 18, 'machine'); + await stores.project.write({ 'editor.fontSize': 18 }); // same effective value + expect(run.mock.calls).toEqual([[13], [18]]); + }); + + it('keeps the last value of an observed setting when its definition is removed', async () => { + const { registry, service } = await setup(); + const zoom = defineSetting({ key: '@acme/plugin.zoom', schema: z.number(), default: 1 }); + const registration = registry.register(zoom); + const run = vi.fn(); + service.observe(zoom).subscribe(run); + expect(() => registration.dispose()).not.toThrow(); + registry.register(zoom); + await service.set(zoom, 2, 'machine'); + expect(run.mock.calls).toEqual([[1], [2]]); + }); + + it('previews and applies imports', async () => { + const { service } = await setup(); + await service.set(fontSize, 14, 'account'); + const preview = service.previewImport( + 'account', + JSON.stringify({ + 'editor.fontSize': 16, + 'appearance.theme': 'light', + '@other/plugin.x': 1, + 'code.tabSize': 'no', + }), + ); + expect(preview).toMatchObject({ + changed: ['editor.fontSize'], + added: ['appearance.theme'], + unknown: ['@other/plugin.x'], + rejected: [{ key: 'code.tabSize', reason: 'invalid value' }], + }); + await service.applyImport(preview); + expect(service.get(fontSize)).toBe(16); + expect(JSON.parse(service.export('account'))).toEqual({ + 'editor.fontSize': 16, + 'appearance.theme': 'light', + '@other/plugin.x': 1, + }); + }); +}); diff --git a/packages/settings/test/store/json-file.scope-store.spec.ts b/packages/settings/test/store/json-file.scope-store.spec.ts new file mode 100644 index 00000000..26f5943a --- /dev/null +++ b/packages/settings/test/store/json-file.scope-store.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { Emitter } from '@nanoforge-dev/editor-kernel'; + +import { JsonFileScopeStore, type TextFileAdapter } from '../../src'; + +describe('JsonFileScopeStore', () => { + const file = (initial?: string) => { + let content = initial; + let version = 0; + const onDidChange = new Emitter(); + const adapter: TextFileAdapter & { + external(text: string): void; + content(): string | undefined; + } = { + read: async () => + content === undefined ? undefined : { text: content, hash: String(version) }, + write: async (text, expectedHash) => { + const current = content === undefined ? null : String(version); + if (current !== expectedHash) + throw Object.assign(new Error('conflict'), { code: 'CONFLICT' }); + content = text; + return { hash: String(++version) }; + }, + onDidChange: onDidChange.event, + external(text) { + content = text; + version++; + }, + content: () => content, + }; + return adapter; + }; + + it('writes sorted json and retries after external edits', async () => { + const adapter = file('{"b": 1}'); + const store = new JsonFileScopeStore(adapter); + await store.initialize(); + adapter.external('{"b": 2, "c": 3}'); // not yet reloaded: the write hits a conflict + await store.write({ a: true }); + expect(adapter.content()).toBe('{\n "a": true,\n "b": 2,\n "c": 3\n}\n'); + }); + + it('reads the file again after a conflict, past a cache that missed the change', async () => { + const adapter = file('{"b": 1}'); + const read = adapter.read.bind(adapter); + const store = new JsonFileScopeStore(adapter); + await store.initialize(); + const stale = await read(); + adapter.read = (fresh) => (fresh ? read() : Promise.resolve(stale)); + adapter.external('{"b": 2}'); + await store.write({ a: true }); + expect(adapter.content()).toBe('{\n "a": true,\n "b": 2\n}\n'); + }); + + it('never overwrites an invalid file', async () => { + const store = new JsonFileScopeStore(file('{ oops')); + await store.initialize(); + expect(store.invalid).toBeDefined(); + await expect(store.write({ a: 1 })).rejects.toThrow(/invalid/); + }); +}); diff --git a/packages/settings/tsconfig.json b/packages/settings/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/settings/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/ui/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/ui/.vitest-attachments/02b15f3f113918a38cd8e641b8a17d93aa33ebb2.png b/packages/ui/.vitest-attachments/02b15f3f113918a38cd8e641b8a17d93aa33ebb2.png new file mode 100644 index 0000000000000000000000000000000000000000..79272762510e72d9cec451a4afeafb26eacb8b12 GIT binary patch literal 6745 zcmeI1`Bzg{zs3U=sd$052v`|Xs8k}PY5^Ie zq5=Ym5rGg0ae|--ks(Y0A_RzmkN_c&kc8Zwwzt=J-QN2L+|?g8>#TDYhqL$oem~Fi zY_5B_IjgH}Q-eSt>L-pLI|YF#xk4by?>|=tcZx69--bZGfSfpX#5=i!JC-&&ATSUu z#(u96`1!-DB{{-h`hFJPIrpwm_5S=mtFB+8wtMLrS#@naaI5Af%Sg}2U|{OtgF6lZ zrwe!Ixmm;U0adbb3NA}Bh{s8IYGd4V7&c>Q+In=53=3xR*#a_tu680rE@3Jkk1kd2 zaAW_1?cNS6bwF8XNw?*blcqXu=IB|BCk&oJ(^EtfS#l{G7TY}EpcnnDCku)qo_%pS zM7}l{A{iOXrZECz$@ID~T5|b`+q=e+Svg(B7G{}m4_0#c5raX9F4JaiFYZipKrpg% zZ{ukC>Xf)`3m*d?B<#7+ncrq5FH7TDOJj89nzM7jK#1Tb7`55p^F`U(jKM;WjX03W6@ak?<+7xDU_kx~(H$Ufl#{CEB2A z8@KR6m5CH|Eoz;F#Vt*}VT#%#YPp6OCu}ng5cEfntbOQ zTRm5LIDHk)$<6#$$zx5&ED5hUw%jRaZDpQ8I8O~Gr?%akd{i8ht#xsWAkm7^kU!_n_O4@WoMpM|O zF0|`fqRnP49{mwA;UGpJ?lhwWt@I>QdjmIUKi+r87>m64{_YV%GLEx}u!$Mp=gN8= zm`~OlQ6a!EMK;uMF=y-~RfPt(&maCMg$@P@_Uh;v05e@(WIx z2KDO4?G2-q*+mUKA)0PAkrUSk!G~+Rb8gB&UFo{#LcglMQNLJW)A9I40b3GrMO8Dm z+#yOw?E`}TM7009?5GlQy!BgMT=|0kGxrk9eZC@6^CJmT(c%SYZD*6`^DxgqHd@y#S1Zc3uar^=?i4*H2!3vfW;E2l_ANk^ zXVUH?FIMNYJm{WrQo=fln>;5*5wyL&6{c*b97DUc9%rb5ZE_q3pKik=o>%g2AKl$S z`*f`OfAa?~KAkVW4rPb~uPjBaaniU1>eWqJGc+u-F3cSpoAox2k9_;WjfC<2;L@JWrjfVsul%tTE=cIA1-e;1g|Ly7XS1vM=fa- z?p*UtS@Ps{<;w4JB!c+{hUr>R{X|4a|7hjJt1QL-t9TGbaIYL^(|Aqn_xBt+DNE=( zdfGRsHf5=^Rf>n!@*WIoOZA*luH}7hc>>HLV(7^^Bc~h0Ib5^aS=atMLB|YTc>XBe zL)CRno-PHZ^8>XEP>ZImqREr%b6-BsejAddXO|jZlFy1Kj|{@rK9-+vzcCz&-WytL zh!&t^Yi&?+kwvWG;KXEX6nt;3Pqi=H2schGh3~@JB=M#TLJTcH<8I+N2>>*6MJ)CW zC~Unb5_i2CPO!wZ9UaBl3!GO75AmqJp?1J%xoPIxJ9;YO!cT_SaI>Pm+&=|3*a)z~ zZ*J{W`NdI8Bb)#D{sX`c>EnHLHodA&99L1AIFiVSZUi0OeP^E$ijcoIP{9?c?I>Wb zP2@lgp!C4f!E^J-&LsyYDwh>TLo&}ShT96$YZcEEi2i%bf?GslHr7n?;}|Uq%{p4)R{F@bc%?c-_879qH;_%( zh0$74Qq}iZ&r7g}nreJTPFuC-5m_Vkk$w>EYEZ7WZi8n?@m1k&Uf>NTR^qAKSsDi0 zsukNWo^s07Z<4JroTnUXXQS+cYF4VLu=a6OmO>TZnQoCdMmS)WNy}3_sXqOQ&i>gqe(Qp`*p|h1pi2xQoE4fd8x`aIruU(X zrq$Fhf^Hl9^7fRwUrpP@j*$|mxdt>#){PO$`%`}qV$N1$hhV15B=e9bi_PE@S-Ho_ zmEz{e&iuX2(xpy*6T57-;}!TBSX1BRd1a%z_40cM){|3^$omblln3kmUhe9KwnWei zwiXeiL^xbD6ibn*omHm~q?DWqNt>@P$JmpkCUbEy-dvT*J%_;_sGaSi52YM4G@11e z7+k&z8d=-qvL(&JpZCTX8wDW7A180VyMOw28lSux1EvkIl6PSR6Z`>*wL_alo{(=RASul;jdy1Z%dljlA=ho45p0nR7@biCT#{j^B z4!usEy5)kStgk6oz6+ae0YsaB+G%Rvy8-J~*qtVvrGqjDmFUYvyi&J_=ME7(%biT@ zYk6!yLbpO$3eb9*4ZmeJ_69U@=v-I{y#3@&-2{7!h!yTI!IwW!8wf;%nxhA*)Fi{e zG;wUKISJEm^s?hA@IM!VPiG;@k5W*w9ZSd40g|jHe>U>98C9?~_k0A|BPhf* z4o;6&T8t+fox&Ku)`S8;??fx#ef=qy71#7{{|6X=A_byLGo*{u#<9dePz9#M50@3j zs07hH40g#mXPZx-K)%vQ$%~P4+%v2d){0b@NHt80y5b%`HxT{M8BdyhEHdb}o~vQR zQplNWVS7=$`g59o3&ErXBNy9>oKBKK?UGZ4d(!jf8O-$+U;|Hm z3q6gP_(`{QWhAmX9Ut)Mr25zU|Dj=uv0XZDJCV6TV<+B`1ug!s-&@~=c*{m~~I#&x{!W1YhCX0%} z*kFMQm`9Ji78tvi*#b!4cC+T>2gXz_q^thN)-O_YJ1HA7K3r0@Ul}^D(tjXf(Jo&h zc7iU8jG4l)$^LD|;v9={%kD}aAD^BTRVea3ka18u?N&%&IVsX6aEe zxH;w{mGNOELwMkTq8o;H#ZXIOGLE|+65Z=vCE64soAqE0AjIgIQmCy~{j zbAH0(u$`A~X~Dq!1wSspHD|I7Z6Yojxo%vR3{XT_?sWnFIzjp@?@`zC+#o*w{KAJ9 z&ie3UuY*=L&l2MC;oqtM!#9cPlB0D|iU9grPphnY1D0a&oqdK-{9gG~{D*SPO zl^2&3SRbK1NM{9%Jm%NWdRJu`rtUWFr)t^)xn-T1d@6tW(`MFEaEqss@SS{UmBz|e zWCJ#Z35lHQJY?~B7lD}@$%T88L2vKKIwXYkr4*Q+3GEpNkwe+vVaDkQs+Fqz-$w1< zgmktINd)s&lrhlE043q6b#WXhg4MC4kiJK*#;!%)hsnQY*azvKCMC|3+H?Z9c!W%2 zHC6lZ;y4eO;%e&mp{pz22uiJsA{+>WpEKi$<2S2`m>2Bzr@}rNY|OjA(uIPInEp(q z4W>;~w?rx_ltsMwaS7eS`LXvnlnU+CT7iWRC9Ybu1deL zVjDgk|I3z$*-n5HQ_Fqsl(pGvJaIA`;JNPizd~6T;^D|}08uF{db~v}pus4BJGvg& zDXgA0J{wGgc@zU}1)$LNp!K7hJQqy~SGL+CMW6{OlhRF<#^e=e;dS=%FML~yn%W6(ebla)E|9UcnZ0Pjwt5=vvhLCsdmj{3kuLfp=6nHa$^Ec;gR+Q>ff;gHJfDh~0nM z0jm@w5du+DR-6Siu5~-->JOh)0o7+>x1A?m#AHa(A<{`znCOKf)FHo&C_JF#y}T+| zmr1a|;~uh01uX}XC{H&i?ge+aF0e7i?%d0(PnnCsq#j&jbA6*KNYC{byivyJa^+26^FmTN5A9Zl$`3Y$@5F%D`}mzn0ID&L zpxFUe6Ugbgu4QZ<(O(&#*5_(bYMXLV=m5XQ2iO44gGB`vGWX^pIA3T0tI#fBbpu^m znWaYpK#)1W5CcU?p17iEPaF(Ri*B0EZ(G9O^uvZ(e9KnAIdB;8q?hke zbl^*ql`^}uTs;dM#CJhn%P^|7e1TiOVcyx6}y1L00^|b z3-&lBHSFCTPZ9-qf}0?t@(r>uK<%H#vtyd)br=#$vF;(X!HXBFP+-!`@XOlEBkb=E z+xgjFYsYTg|NU`csb16*Mbr;T7t%rHG^}kRkYa|PPCG->y;Hm26&_%PREK;ODICgo zFWkS=m%I3KgNEf<`f9);(c?r3eAq1b;(YyJKI&2js?9kP6p7GRjGIACR+i_I0OoZ} zgZxRb{MUpWd3RE)m+Y#)vOWk{0Oz~&&B`3~H?K3@L&8ddw(U#oX9JhOslXcgaePzD zhlUiI{E!jbD*hq-z89D;E7`OEY=5Qaq7FQTvb@<=@a}+Pla-dr!u4x#1Dt>BXijPf z$aj`LPPXu}8kAFz_S`r*ZBz@r@D#BP*n+2~XGl_T!h}&z3jgtfpD9i>t9x%XJ!jUT*wVxLS zHZx%uSd&jkHU<&J?PXm?E>?N(*V5S?Igg7yKWCT*BH8*XVMHNmOrR&)NuOzFzsyqC z)7dN{uKPhEyGu58(1d&b%7p!XEbwn?^JnA#MV|ZfAmQ)d`0Z@+@3s1?)c<#t{M~B5 zpWXl6AOCOqV>JW(Uq~*G*>bNHTx?Xh!3~ga>FqrAfdf~;9moksw_~`YXD|OJsJcYC2?#&ksCK}`HsWoVz0yA->qI>oVKUtXAzUfY&*s)FJ*qE?fJQ_ z=I5tocK*2ib+!Nh{k^?Czy8~so6CG>+x`7=dA51}y4c-i@9ykOK0Cv{=EsMVlhy10 z{d_LbcXFzB__o~J-|m**zq_k6`}@1Q@pV6+s!ad;_}>AM%Bw%S#r2o@*Ow;RSATmm z$FleszjfK0A5W*po8{av==%G1`+bnPzrMT#s*|%UO0hYAex7ahwKb9Te?A`X64n0p z_V)MR@AuEQsVo8-@7^zGTl(rs%CxeulaOx$DetY#c4in+mnrQZ*BRw|G|%skLTBZySdbRy4&7l zi~M_gj&_UN*Z-^ebo$@6|36*}AGk7OWBU1dg)?Q}>c;LWxw$E|oljQka-sF#e?aF% ze!S=|e|KN)?|XZz^P^<#{{47d*j^9xJHOqJ2WOug=C_xLf4|pkW@`R>dq$s`;oDz_ zuaEota{2s!^;YYS{r~iOhkAYW!5yFH|JhW3U;an_Sqb+1c7FM~FE1``Og^4>XNRHR z#W0|bzu)ioTmLlIes_Precj(ztJnXVWmEAX!DrvUU#|;qe7ioY{9fhr+4=isKH0SW z`+SCFXTAVq1L&Ck#|b(A{`_1Ww)WPh)YBQI7Zzu)bi9RF?mU-bq}Je{P}w^9#$1UtG2v z6&?+f(F8G?8%B$Q(c*EmZWvA#aEP23uW%oNKL6&m`?mf3zZ>bF?q|Q<`Ydi&N#^x+vFrE! zdX@65^84NL=VxYq{{4Rc`7`zW|1{Iq#_j#J{eE5b>$Tg}j4y3WJ|0*7cB_2dk3^d+f`@aAGe$TJ__42rU{hfWa zznj_l-`(H;|L61h{`0b9Y{WjO|37_hu66$1U0;tI&oK`BLeR_Jjea(-8r>CZ-pPMuDkG)RhrYG0q>t(lByx;r%*!BGVf6Hvb z($1UhY?z+(>s+4;-r>b}3b%PVabv$H7m)BGj-|NVNMp1(2u{JfP{j(_{n zQSbnXY5;ddbi+Zg6g)h1u$le+-QC-> zuC7`>xBvK&>G5?tOJ83zye+TZFJt-X*X#B6)zc^b`t|ko_qVsziz-$wMYh8XUIQiAD^tG|O%b510 zTrU52(OrJ7U2Rp?J-e9wb$?%7T`gb#=i_DWXTazw{gB?@Y?^(oN3oXa48vvp+P|Mp z>nC5c-C9)l``g=%eC0nrJWSXB{pGU%@dh3h% zXte|PvwnU2v-|x%>G{bMbI$D2kK3~Y=*iU6(?HJJuP1%FDEUVGhJ=GNX0DIem~?GT zWbpIn=jZP)e0S?K8 z&-&KBKgnZukM&-~<6h1WO#klwY-DDir?>XJ#`-;i8 zNo7{;c5|KQpIhkv{KE3$7netcfjMt9OhyyLXl@uS3Py{9(Yj%1R*d)e16#rW|4(u) h3v_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX +
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+ + +## About + +`@nanoforge-dev/editor-ui` is an internal package of the NanoForge Editor. + +Svelte UI kit, theme tokens, style injection and layout renderer. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/ui +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..430c1343 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-ui", + "version": "0.0.0", + "description": "NanoForge Editor - UI kit, theme, style injection, widget host and layout renderer", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": { + "svelte": "./src/index.ts", + "default": "./src/index.ts" + }, + "./fonts.css": "./src/theme/fonts.css", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/ui" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@fontsource/instrument-sans": "catalog:components", + "@fontsource/jetbrains-mono": "catalog:components", + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-layout": "workspace:*", + "@nanoforge-dev/editor-settings": "workspace:*", + "bits-ui": "catalog:components", + "zod": "catalog:libs" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "catalog:core", + "@vitest/browser-playwright": "catalog:test", + "playwright": "catalog:test", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test", + "vitest-browser-svelte": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/ui/src/components/MenuEntries.svelte b/packages/ui/src/components/MenuEntries.svelte new file mode 100644 index 00000000..54e4fc81 --- /dev/null +++ b/packages/ui/src/components/MenuEntries.svelte @@ -0,0 +1,41 @@ + + +{#each items as entry, index (index)} + {#if entry.kind === 'separator'} + + {:else if entry.kind === 'submenu'} + + + + {entry.label} + + + + + + + {:else} + + + {#if entry.checked}{:else if entry.icon}{/if} + + {entry.label} + {#if entry.shortcut}{entry.shortcut}{/if} + + {/if} +{/each} diff --git a/packages/ui/src/components/button.svelte b/packages/ui/src/components/button.svelte new file mode 100644 index 00000000..c4fda580 --- /dev/null +++ b/packages/ui/src/components/button.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/ui/src/components/checkbox.svelte b/packages/ui/src/components/checkbox.svelte new file mode 100644 index 00000000..2684511a --- /dev/null +++ b/packages/ui/src/components/checkbox.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/ui/src/components/color-picker.svelte b/packages/ui/src/components/color-picker.svelte new file mode 100644 index 00000000..8617cd13 --- /dev/null +++ b/packages/ui/src/components/color-picker.svelte @@ -0,0 +1,95 @@ + + +
+ + (editing = true)} + onblur={commitText} + onkeydown={(event) => event.key === 'Enter' && event.currentTarget.blur()} + /> +
+ + diff --git a/packages/ui/src/components/combobox.svelte b/packages/ui/src/components/combobox.svelte new file mode 100644 index 00000000..7813def4 --- /dev/null +++ b/packages/ui/src/components/combobox.svelte @@ -0,0 +1,53 @@ + + + ({ value: item.value, label: item.label }))} + onValueChange={(next) => onchange?.(next)} + onOpenChange={(open) => !open && (search = '')} +> +
+ (search = event.currentTarget.value)} + /> + + + +
+ + + {#each filtered as item (item.value)} + + {item.label} + {#if item.detail}{item.detail}{/if} + + {:else} +

No match

+ {/each} +
+
+
diff --git a/packages/ui/src/components/context-menu.svelte b/packages/ui/src/components/context-menu.svelte new file mode 100644 index 00000000..d91da8f3 --- /dev/null +++ b/packages/ui/src/components/context-menu.svelte @@ -0,0 +1,70 @@ + + + open && (entries = items())}> + + {@render children()} + + + + {#each entries as entry, index (index)} + {#if entry.kind === 'separator'} + + {:else if entry.kind === 'submenu'} + + + + {entry.label} + + + + {#each entry.items as child, childIndex (childIndex)} + {#if child.kind === 'item'} + + + {child.label} + + {:else if child.kind === 'separator'} + + {/if} + {/each} + + + {:else} + + + {#if entry.checked}{:else if entry.icon}{/if} + + {entry.label} + {#if entry.shortcut}{entry.shortcut}{/if} + + {/if} + {/each} + + + diff --git a/packages/ui/src/components/dialog.svelte b/packages/ui/src/components/dialog.svelte new file mode 100644 index 00000000..fe023555 --- /dev/null +++ b/packages/ui/src/components/dialog.svelte @@ -0,0 +1,52 @@ + + + { + if (!next) onclose?.(); + }} +> + + + +
+ {title} + + {#snippet child({ props })} + + {/snippet} + +
+ {#if description}{description}{/if} + {#if children}
{@render children()}
{/if} + {#if footer}
{@render footer()}
{/if} +
+
+
diff --git a/packages/ui/src/components/empty-state.svelte b/packages/ui/src/components/empty-state.svelte new file mode 100644 index 00000000..10ceee59 --- /dev/null +++ b/packages/ui/src/components/empty-state.svelte @@ -0,0 +1,50 @@ + + +
+ {#if icon}{/if} +

{title}

+ {#if description}

{description}

{/if} + {#if actions}
{@render actions()}
{/if} +
+ + diff --git a/packages/ui/src/components/icon-button.svelte b/packages/ui/src/components/icon-button.svelte new file mode 100644 index 00000000..1c819ebe --- /dev/null +++ b/packages/ui/src/components/icon-button.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/ui/src/components/icon.svelte b/packages/ui/src/components/icon.svelte new file mode 100644 index 00000000..2e3dc36e --- /dev/null +++ b/packages/ui/src/components/icon.svelte @@ -0,0 +1,25 @@ + + +{#if Resolved} + +{/if} diff --git a/packages/ui/src/components/icons.ts b/packages/ui/src/components/icons.ts new file mode 100644 index 00000000..2af2910b --- /dev/null +++ b/packages/ui/src/components/icons.ts @@ -0,0 +1,130 @@ +import Activity from '@lucide/svelte/icons/activity'; +import AppWindow from '@lucide/svelte/icons/app-window'; +import Blocks from '@lucide/svelte/icons/blocks'; +import Box from '@lucide/svelte/icons/box'; +import Check from '@lucide/svelte/icons/check'; +import ChevronDown from '@lucide/svelte/icons/chevron-down'; +import ChevronRight from '@lucide/svelte/icons/chevron-right'; +import CircleAlert from '@lucide/svelte/icons/circle-alert'; +import CircleCheck from '@lucide/svelte/icons/circle-check'; +import Code from '@lucide/svelte/icons/code'; +import Copy from '@lucide/svelte/icons/copy'; +import Ellipsis from '@lucide/svelte/icons/ellipsis'; +import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical'; +import File from '@lucide/svelte/icons/file'; +import FileCode from '@lucide/svelte/icons/file-code'; +import Folder from '@lucide/svelte/icons/folder'; +import FolderOpen from '@lucide/svelte/icons/folder-open'; +import FolderTree from '@lucide/svelte/icons/folder-tree'; +import Gamepad2 from '@lucide/svelte/icons/gamepad-2'; +import GitBranch from '@lucide/svelte/icons/git-branch'; +import Globe from '@lucide/svelte/icons/globe'; +import GripVertical from '@lucide/svelte/icons/grip-vertical'; +import History from '@lucide/svelte/icons/history'; +import Info from '@lucide/svelte/icons/info'; +import Layers from '@lucide/svelte/icons/layers'; +import LayoutPanelLeft from '@lucide/svelte/icons/layout-panel-left'; +import ListTree from '@lucide/svelte/icons/list-tree'; +import LoaderCircle from '@lucide/svelte/icons/loader-circle'; +import Lock from '@lucide/svelte/icons/lock'; +import Maximize2 from '@lucide/svelte/icons/maximize-2'; +import Minimize2 from '@lucide/svelte/icons/minimize-2'; +import Minus from '@lucide/svelte/icons/minus'; +import Network from '@lucide/svelte/icons/network'; +import PanelBottom from '@lucide/svelte/icons/panel-bottom'; +import PanelLeft from '@lucide/svelte/icons/panel-left'; +import PanelRight from '@lucide/svelte/icons/panel-right'; +import Pause from '@lucide/svelte/icons/pause'; +import Pencil from '@lucide/svelte/icons/pencil'; +import Play from '@lucide/svelte/icons/play'; +import Plug from '@lucide/svelte/icons/plug'; +import Plus from '@lucide/svelte/icons/plus'; +import RotateCcw from '@lucide/svelte/icons/rotate-ccw'; +import Search from '@lucide/svelte/icons/search'; +import Server from '@lucide/svelte/icons/server'; +import Settings from '@lucide/svelte/icons/settings'; +import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; +import Square from '@lucide/svelte/icons/square'; +import SquareTerminal from '@lucide/svelte/icons/square-terminal'; +import StepForward from '@lucide/svelte/icons/step-forward'; +import Trash2 from '@lucide/svelte/icons/trash-2'; +import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; +import Variable from '@lucide/svelte/icons/variable'; +import X from '@lucide/svelte/icons/x'; +import type { Component } from 'svelte'; +import { SvelteMap } from 'svelte/reactivity'; + +import type { Disposable } from '@nanoforge-dev/editor-kernel'; + +export type IconComponent = Component; + +const registry = new SvelteMap( + Object.entries({ + activity: Activity, + 'app-window': AppWindow, + blocks: Blocks, + box: Box, + check: Check, + 'chevron-down': ChevronDown, + 'chevron-right': ChevronRight, + 'circle-alert': CircleAlert, + 'circle-check': CircleCheck, + code: Code, + copy: Copy, + ellipsis: Ellipsis, + 'ellipsis-vertical': EllipsisVertical, + file: File, + 'file-code': FileCode, + folder: Folder, + 'folder-open': FolderOpen, + 'folder-tree': FolderTree, + 'gamepad-2': Gamepad2, + 'git-branch': GitBranch, + globe: Globe, + 'grip-vertical': GripVertical, + history: History, + info: Info, + layers: Layers, + 'layout-panel-left': LayoutPanelLeft, + 'list-tree': ListTree, + 'loader-circle': LoaderCircle, + lock: Lock, + 'maximize-2': Maximize2, + 'minimize-2': Minimize2, + minus: Minus, + network: Network, + 'panel-bottom': PanelBottom, + 'panel-left': PanelLeft, + 'panel-right': PanelRight, + pause: Pause, + pencil: Pencil, + play: Play, + plug: Plug, + plus: Plus, + 'rotate-ccw': RotateCcw, + search: Search, + server: Server, + settings: Settings, + 'sliders-horizontal': SlidersHorizontal, + square: Square, + 'square-terminal': SquareTerminal, + 'step-forward': StepForward, + 'trash-2': Trash2, + 'triangle-alert': TriangleAlert, + variable: Variable, + x: X, + }), +); + +/** + * Registers icons by name (manifests reference icons by name). Plugins register the Lucide + * icons they use: `registerIcons({ music: Music })` with `@lucide/svelte/icons/music`. + */ +export const registerIcons = (icons: Record): Disposable => { + const added = Object.entries(icons).filter(([name]) => !registry.has(name)); + for (const [name, icon] of added) registry.set(name, icon); + return { dispose: () => added.forEach(([name]) => registry.delete(name)) }; +}; + +export const resolveIcon = (name: string | undefined): IconComponent | undefined => + name ? registry.get(name) : undefined; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts new file mode 100644 index 00000000..2aecb995 --- /dev/null +++ b/packages/ui/src/components/index.ts @@ -0,0 +1,25 @@ +export * from './icons'; +export type { MenuEntry } from './menu-entry.type'; +export type { PropertyField } from './property-field.type'; +export * from './tree-model'; +export { default as Button } from './button.svelte'; +export { default as Checkbox } from './checkbox.svelte'; +export { default as ColorPicker } from './color-picker.svelte'; +export { default as Combobox } from './combobox.svelte'; +export { default as ContextMenu } from './context-menu.svelte'; +export { default as Dialog } from './dialog.svelte'; +export { default as EmptyState } from './empty-state.svelte'; +export { default as Icon } from './icon.svelte'; +export { default as IconButton } from './icon-button.svelte'; +export { default as Input } from './input.svelte'; +export { default as Menu } from './menu.svelte'; +export { default as NumberInput } from './number-input.svelte'; +export { default as Select } from './select.svelte'; +export { default as Splitter } from './splitter.svelte'; +export { default as Switch } from './switch.svelte'; +export { default as Tabs } from './tabs.svelte'; +export { default as Toasts } from './toasts.svelte'; +export { default as Tooltip } from './tooltip.svelte'; +export { default as Vector2Input } from './vector2-input.svelte'; +export { default as PropertyGrid } from './property-grid.svelte'; +export { default as Tree } from './tree.svelte'; diff --git a/packages/ui/src/components/input.svelte b/packages/ui/src/components/input.svelte new file mode 100644 index 00000000..25a47a60 --- /dev/null +++ b/packages/ui/src/components/input.svelte @@ -0,0 +1,45 @@ + + + + + diff --git a/packages/ui/src/components/menu-entry.type.ts b/packages/ui/src/components/menu-entry.type.ts new file mode 100644 index 00000000..dcfe6e03 --- /dev/null +++ b/packages/ui/src/components/menu-entry.type.ts @@ -0,0 +1,19 @@ +/** Entries of dropdown and context menus. */ +export type MenuEntry = + | { + readonly kind: 'item'; + readonly id: string; + readonly label: string; + readonly shortcut?: string; + readonly icon?: string; + readonly disabled?: boolean; + readonly checked?: boolean; + readonly onSelect: () => void; + } + | { readonly kind: 'separator' } + | { + readonly kind: 'submenu'; + readonly label: string; + readonly items: readonly MenuEntry[]; + readonly disabled?: boolean; + }; diff --git a/packages/ui/src/components/menu.svelte b/packages/ui/src/components/menu.svelte new file mode 100644 index 00000000..c69a299f --- /dev/null +++ b/packages/ui/src/components/menu.svelte @@ -0,0 +1,30 @@ + + + + + {#snippet child({ props })} + {@render trigger({ props })} + {/snippet} + + + + + + + diff --git a/packages/ui/src/components/number-input.svelte b/packages/ui/src/components/number-input.svelte new file mode 100644 index 00000000..35dcab88 --- /dev/null +++ b/packages/ui/src/components/number-input.svelte @@ -0,0 +1,164 @@ + + +
+ {#if prefix} + + {/if} + (editing = true)} + onblur={commit} + {onkeydown} + /> +
+ + diff --git a/packages/ui/src/components/property-field.type.ts b/packages/ui/src/components/property-field.type.ts new file mode 100644 index 00000000..e4dc508b --- /dev/null +++ b/packages/ui/src/components/property-field.type.ts @@ -0,0 +1,20 @@ +export type PropertyField = { + readonly key: string; + readonly label: string; + readonly description?: string; + readonly readonly?: boolean; +} & ( + | { readonly type: 'string'; readonly placeholder?: string } + | { + readonly type: 'number'; + readonly step?: number; + readonly min?: number; + readonly max?: number; + readonly precision?: number; + } + | { readonly type: 'boolean' } + | { readonly type: 'color' } + | { readonly type: 'vector2'; readonly step?: number } + | { readonly type: 'enum'; readonly options: readonly { value: string; label: string }[] } + | { readonly type: 'json' } +); diff --git a/packages/ui/src/components/property-grid.svelte b/packages/ui/src/components/property-grid.svelte new file mode 100644 index 00000000..68bcfffc --- /dev/null +++ b/packages/ui/src/components/property-grid.svelte @@ -0,0 +1,115 @@ + + +
+ {#each fields as field (field.key)} + +
+ {#if field.type === 'number'} + onchange(field.key, value, final)} + /> + {:else if field.type === 'boolean'} + onchange(field.key, checked, true)} + /> + {:else if field.type === 'color'} + onchange(field.key, value, final)} + /> + {:else if field.type === 'vector2'} + onchange(field.key, value, final)} + /> + {:else if field.type === 'enum'} + { + try { + onchange(field.key, JSON.parse(event.currentTarget.value), true); + } catch { + event.currentTarget.value = JSON.stringify(values[field.key] ?? null); + } + }} + /> + {:else} + onchange(field.key, event.currentTarget.value, true)} + /> + {/if} +
+ {/each} +
+ + diff --git a/packages/ui/src/components/select.svelte b/packages/ui/src/components/select.svelte new file mode 100644 index 00000000..9cca2c46 --- /dev/null +++ b/packages/ui/src/components/select.svelte @@ -0,0 +1,54 @@ + + + ({ + value: item.value, + label: item.label, + disabled: item.disabled ?? false, + }))} + onValueChange={(next) => onchange?.(next as T)} +> + + {selected?.label ?? ''} + + + + + + {#each items as item (item.value)} + + {#snippet children({ selected: isSelected })} + {#if isSelected}{/if} + {item.label} + {/snippet} + + {/each} + + + + diff --git a/packages/ui/src/components/splitter.svelte b/packages/ui/src/components/splitter.svelte new file mode 100644 index 00000000..181d3533 --- /dev/null +++ b/packages/ui/src/components/splitter.svelte @@ -0,0 +1,85 @@ + + + + + + diff --git a/packages/ui/src/components/switch.svelte b/packages/ui/src/components/switch.svelte new file mode 100644 index 00000000..03187cff --- /dev/null +++ b/packages/ui/src/components/switch.svelte @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/ui/src/components/tabs.svelte b/packages/ui/src/components/tabs.svelte new file mode 100644 index 00000000..a9c0bf25 --- /dev/null +++ b/packages/ui/src/components/tabs.svelte @@ -0,0 +1,68 @@ + + +
+ {#each items as item (item.id)} + + {/each} +
+ + diff --git a/packages/ui/src/components/toasts.svelte b/packages/ui/src/components/toasts.svelte new file mode 100644 index 00000000..a5b801e2 --- /dev/null +++ b/packages/ui/src/components/toasts.svelte @@ -0,0 +1,107 @@ + + +
+ {#each $active as notification (notification.id)} +
+ +
+

{notification.message}

+ {#if notification.detail}

{notification.detail}

{/if} + {#if notification.actions.length} +
+ {#each notification.actions as action (action.title)} + + {/each} +
+ {/if} +
+ notifications.dismiss(notification.id)} + /> +
+ {/each} +
+ + diff --git a/packages/ui/src/components/tooltip.svelte b/packages/ui/src/components/tooltip.svelte new file mode 100644 index 00000000..2bdfa588 --- /dev/null +++ b/packages/ui/src/components/tooltip.svelte @@ -0,0 +1,25 @@ + + + + + + {#snippet child({ props })} + {@render children({ props })} + {/snippet} + + + {text} + + + diff --git a/packages/ui/src/components/tree-model.ts b/packages/ui/src/components/tree-model.ts new file mode 100644 index 00000000..0f1ad2a7 --- /dev/null +++ b/packages/ui/src/components/tree-model.ts @@ -0,0 +1,44 @@ +export interface TreeNode { + readonly id: string; + readonly label: string; + readonly icon?: string; + readonly children?: readonly TreeNode[]; + /** Muted secondary text after the label. */ + readonly detail?: string; + /** Colors the detail: a state of the item (a changed file…). */ + readonly tone?: 'modified' | 'added' | 'deleted' | 'conflict'; + /** Tooltip of the detail. */ + readonly detailTitle?: string; + /** Whether rows can be dragged (default true when the tree handles drops). */ + readonly draggable?: boolean; + /** Whether other rows can be dropped inside it (default: when it has children). */ + readonly droppable?: boolean; +} + +export interface TreeRow { + readonly node: TreeNode; + readonly depth: number; + readonly parentId: string | null; + readonly expandable: boolean; +} + +export type DropPosition = 'before' | 'inside' | 'after'; + +export const TREE_DRAG_TYPE = 'application/x-nanoforge-tree'; + +/** Visible rows of a tree for a set of expanded nodes. */ +export const flattenTree = ( + nodes: readonly TreeNode[], + expanded: ReadonlySet, +): TreeRow[] => { + const rows: TreeRow[] = []; + const walk = (list: readonly TreeNode[], depth: number, parentId: string | null) => { + for (const node of list) { + const expandable = !!node.children?.length; + rows.push({ node, depth, parentId, expandable }); + if (expandable && expanded.has(node.id)) walk(node.children!, depth + 1, node.id); + } + }; + walk(nodes, 0, null); + return rows; +}; diff --git a/packages/ui/src/components/tree.svelte b/packages/ui/src/components/tree.svelte new file mode 100644 index 00000000..4ade1465 --- /dev/null +++ b/packages/ui/src/components/tree.svelte @@ -0,0 +1,411 @@ + + +
(scrollTop = container!.scrollTop)} + {onkeydown} + onfocus={() => { + if (!focused && rows[0]) focused = rows[0].node.id; + }} +> +
+ {#each visible as row (row.node.id)} + {@const node = row.node} + {@const i = index.get(node.id) ?? 0} + +
click(event, node.id)} + ondblclick={() => onactivate?.(node.id)} + oncontextmenu={(event) => { + if (!selected.has(node.id)) click(event, node.id); + oncontextmenu?.(node.id, event); + }} + ondragstart={(event) => dragStart(event, node.id)} + ondragover={(event) => dragOver(event, node, row.expandable)} + ondragleave={() => drop?.id === node.id && (drop = undefined)} + ondrop={dropOn} + > + + {#if node.icon}{/if} + {#if renaming === node.id} + + event.stopPropagation()} + onkeydown={(event) => { + event.stopPropagation(); + if (event.key === 'Enter') void finishRename(true); + else if (event.key === 'Escape') void finishRename(false); + }} + onblur={() => void finishRename(true)} + /> + {:else} + {node.label} + {#if node.detail}{node.detail}{/if} + {/if} +
+ {/each} +
+
+ + diff --git a/packages/ui/src/components/vector2-input.svelte b/packages/ui/src/components/vector2-input.svelte new file mode 100644 index 00000000..c2082298 --- /dev/null +++ b/packages/ui/src/components/vector2-input.svelte @@ -0,0 +1,43 @@ + + +
+ onchange?.({ ...value, x }, final)} + /> + onchange?.({ ...value, y }, final)} + /> +
+ + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 00000000..b1363f83 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,4 @@ +export * from './components'; +export * from './style'; +export * from './theme'; +export * from './workbench'; diff --git a/packages/ui/src/style/index.ts b/packages/ui/src/style/index.ts new file mode 100644 index 00000000..b3000c5f --- /dev/null +++ b/packages/ui/src/style/index.ts @@ -0,0 +1 @@ +export * from './style-service'; diff --git a/packages/ui/src/style/style-service.ts b/packages/ui/src/style/style-service.ts new file mode 100644 index 00000000..9e1ee018 --- /dev/null +++ b/packages/ui/src/style/style-service.ts @@ -0,0 +1,64 @@ +import { type Disposable, createToken, toDisposable } from '@nanoforge-dev/editor-kernel'; + +/** + * Cascade layers, lowest to highest priority. Svelte component styles are scoped by hashed + * classes and stay unlayered; layers order the global and injected rules. + */ +export const STYLE_LAYERS = ['reset', 'ui', 'plugins', 'user'] as const; +export type StyleLayer = (typeof STYLE_LAYERS)[number]; + +export interface InjectOptions { + /** Scopes the rules under `[data-nf-widget=""]`. */ + readonly widgetId?: string; + readonly layer?: StyleLayer; +} + +const identifier = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '_'); + +/** Escapes a value for a double-quoted CSS attribute selector. */ +const attributeValue = (value: string) => value.replace(/["\\]/g, '\\$&'); + +/** + * Injects constructable stylesheets into a document. Plugin styles live in + * `@layer plugins.` and are removed when disposed. + */ +export class StyleService { + private readonly _sheets = new Set(); + + constructor(private readonly _document: Document = document) { + const order = new CSSStyleSheet(); + order.replaceSync(`@layer ${STYLE_LAYERS.join(', ')};`); + this._add(order); + } + + inject(owner: string, css: string, options: InjectOptions = {}): Disposable { + const layer = options.layer ?? (owner === 'core' ? 'ui' : 'plugins'); + const scoped = options.widgetId + ? `[data-nf-widget="${attributeValue(options.widgetId)}"] {\n${css}\n}` + : css; + const body = layer === 'plugins' ? `@layer ${identifier(owner)} {\n${scoped}\n}` : scoped; + const sheet = new CSSStyleSheet(); + sheet.replaceSync(`@layer ${layer} {\n${body}\n}`); + this._add(sheet); + return toDisposable(() => this._remove(sheet)); + } + + /** Number of sheets currently injected (tests, diagnostics). */ + get size(): number { + return this._sheets.size; + } + + private _add(sheet: CSSStyleSheet): void { + this._sheets.add(sheet); + this._document.adoptedStyleSheets = [...this._document.adoptedStyleSheets, sheet]; + } + + private _remove(sheet: CSSStyleSheet): void { + if (!this._sheets.delete(sheet)) return; + this._document.adoptedStyleSheets = this._document.adoptedStyleSheets.filter( + (s) => s !== sheet, + ); + } +} + +export const StyleServiceToken = createToken('ui.styles'); diff --git a/packages/ui/src/theme/base-css.ts b/packages/ui/src/theme/base-css.ts new file mode 100644 index 00000000..1190f3c9 --- /dev/null +++ b/packages/ui/src/theme/base-css.ts @@ -0,0 +1,22 @@ +/** Global base rules of the editor (injected in the `reset` layer). */ +export const BASE_CSS = ` +*, *::before, *::after { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + background: var(--nf-color-bg); + color: var(--nf-color-text); + font: var(--nf-font-size-md) / var(--nf-line-height) var(--nf-font-ui); + font-feature-settings: 'tnum' 1; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} +:focus-visible { outline: 2px solid var(--nf-color-focus); outline-offset: -2px; } +::selection { background: var(--nf-color-selection); color: var(--nf-color-selection-text); } +button, input, select, textarea { font: inherit; color: inherit; } +code, kbd, pre { font-family: var(--nf-font-code); } +* { scrollbar-width: thin; scrollbar-color: var(--nf-color-border-strong) transparent; } +[hidden] { display: none !important; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; } +} +`; diff --git a/packages/ui/src/theme/fonts.css b/packages/ui/src/theme/fonts.css new file mode 100644 index 00000000..59d302ef --- /dev/null +++ b/packages/ui/src/theme/fonts.css @@ -0,0 +1,5 @@ +/* Self-hosted fonts: the editor must work offline. */ +@import '@fontsource/instrument-sans/400.css'; +@import '@fontsource/instrument-sans/500.css'; +@import '@fontsource/instrument-sans/600.css'; +@import '@fontsource/jetbrains-mono/400.css'; diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts new file mode 100644 index 00000000..ca43cf1d --- /dev/null +++ b/packages/ui/src/theme/index.ts @@ -0,0 +1,5 @@ +export * from './base-css'; +export * from './kit-css'; +export * from './theme-service'; +export * from './tokens'; +export * from './uno'; diff --git a/packages/ui/src/theme/kit-css.ts b/packages/ui/src/theme/kit-css.ts new file mode 100644 index 00000000..b8c882b1 --- /dev/null +++ b/packages/ui/src/theme/kit-css.ts @@ -0,0 +1,83 @@ +/** + * Styles of portaled UI kit parts (menus, popovers, dialogs, tooltips): they render outside of + * their component, so they cannot use scoped styles. Injected in the `ui` layer. + */ +export const KIT_CSS = ` +.nf-popover { + z-index: var(--nf-z-menu); + min-width: 180px; + max-height: min(420px, var(--bits-select-content-available-height, 420px)); + overflow: auto; + padding: var(--nf-space-1); + border: 1px solid var(--nf-color-border-strong); + border-radius: var(--nf-radius-float); + background: var(--nf-color-raised); + box-shadow: var(--nf-shadow-float); + outline: none; +} +.nf-menu-item { + display: flex; + align-items: center; + gap: var(--nf-space-2); + height: var(--nf-row-height); + padding: 0 var(--nf-space-2); + border-radius: 2px; + cursor: default; + user-select: none; + outline: none; +} +.nf-menu-item[data-highlighted] { background: var(--nf-color-selection); color: var(--nf-color-selection-text); } +.nf-menu-item[data-disabled] { opacity: 0.45; } +.nf-menu-check { display: inline-grid; place-items: center; width: 14px; flex: none; color: var(--nf-color-text-muted); } +.nf-menu-label { flex: 1; white-space: nowrap; } +.nf-menu-shortcut, .nf-menu-detail { color: var(--nf-color-text-faint); font: inherit; font-size: var(--nf-font-size-sm); } +.nf-menu-separator { height: 1px; margin: var(--nf-space-1) calc(-1 * var(--nf-space-1)); background: var(--nf-color-border); } +.nf-menu-empty { margin: 0; padding: var(--nf-space-2); color: var(--nf-color-text-muted); } +.nf-select-trigger, .nf-combobox { + display: flex; + align-items: center; + width: 100%; + height: var(--nf-control-height); + border: 1px solid var(--nf-color-border); + border-radius: var(--nf-radius-control); + background: var(--nf-color-sunken); +} +.nf-select-trigger { justify-content: space-between; gap: var(--nf-space-2); padding: 0 var(--nf-space-2); cursor: pointer; } +.nf-select-trigger:hover, .nf-combobox:hover { border-color: var(--nf-color-border-strong); } +.nf-select-trigger:focus-visible, .nf-combobox:focus-within { border-color: var(--nf-color-focus); outline: none; } +.nf-combobox-input { flex: 1; min-width: 0; height: 100%; padding: 0 var(--nf-space-2); border: 0; background: transparent; outline: none; } +.nf-combobox-trigger { display: grid; place-items: center; width: var(--nf-control-height); height: 100%; border: 0; background: transparent; color: var(--nf-color-text-muted); cursor: pointer; } +.nf-tooltip { + z-index: var(--nf-z-menu); + max-width: 280px; + padding: 3px var(--nf-space-2); + border-radius: var(--nf-radius-control); + background: var(--nf-color-text); + color: var(--nf-color-bg); + font-size: var(--nf-font-size-sm); +} +.nf-dialog-overlay { position: fixed; inset: 0; z-index: var(--nf-z-dialog); background: var(--nf-color-scrim); } +.nf-dialog { + position: fixed; + top: 18vh; + left: 50%; + z-index: var(--nf-z-dialog); + translate: -50% 0; + display: flex; + flex-direction: column; + gap: var(--nf-space-3); + max-height: 70vh; + padding: var(--nf-space-4); + border: 1px solid var(--nf-color-border-strong); + border-radius: var(--nf-radius-float); + background: var(--nf-color-raised); + box-shadow: var(--nf-shadow-float); + outline: none; +} +.nf-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--nf-space-3); } +.nf-dialog-title { margin: 0; font-size: var(--nf-font-size-lg); font-weight: 600; } +.nf-dialog-description { margin: 0; color: var(--nf-color-text-muted); } +.nf-dialog-body { flex: 1 1 auto; min-height: 0; overflow: auto; } +.nf-dialog-footer { display: flex; justify-content: flex-end; gap: var(--nf-space-2); } +.nf-context-area { display: contents; } +`; diff --git a/packages/ui/src/theme/theme-service.ts b/packages/ui/src/theme/theme-service.ts new file mode 100644 index 00000000..bcb61140 --- /dev/null +++ b/packages/ui/src/theme/theme-service.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +import { + type Disposable, + DisposableStore, + type ExtensionRegistry, + MutableDisposable, + type Observable, + ObservableValue, + createToken, + defineExtensionPoint, + observe, +} from '@nanoforge-dev/editor-kernel'; + +import type { StyleService } from '../style/style-service'; +import { + COLOR_TOKENS, + type ThemeDefinition, + nanoforgeDark, + nanoforgeLight, + themeDeclarations, +} from './tokens'; + +const ThemeSchema = z.object({ + id: z.string().regex(/^[\w-]+$/), + label: z.string().min(1), + kind: z.enum(['dark', 'light']), + colors: z.object(Object.fromEntries(COLOR_TOKENS.map((token) => [token, z.string().min(1)]))), +}) as unknown as z.ZodType; + +/** Color themes (built-in ones and `contributes.themes` of plugins). */ +export const THEMES = defineExtensionPoint('ui.themes', { + validator: ThemeSchema, +}); + +/** Applies the selected theme to the document root. */ +export class ThemeService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _applied = new MutableDisposable(); + private readonly _current = new ObservableValue(nanoforgeDark); + + constructor( + private readonly _extensions: ExtensionRegistry, + private readonly _styles: StyleService, + selected: Observable, + private readonly _root: HTMLElement = document.documentElement, + ) { + this._store.add(_extensions.contribute(THEMES, nanoforgeDark, { owner: 'core' })); + this._store.add(_extensions.contribute(THEMES, nanoforgeLight, { owner: 'core' })); + const apply = () => this._apply(selected.get()); + this._store.add(observe(selected, apply)); + this._store.add(_extensions.onDidChange((point) => point.id === THEMES.id && apply())); + } + + get current(): Observable { + return this._current.readonly(); + } + + get themes(): ThemeDefinition[] { + return this._extensions.getValues(THEMES); + } + + dispose(): void { + this._applied.dispose(); + this._store.dispose(); + } + + private _apply(id: string): void { + const theme = this.themes.find((candidate) => candidate.id === id) ?? nanoforgeDark; + this._root.dataset.nfTheme = theme.id; + this._applied.value = this._styles.inject('core', `:root {\n ${themeDeclarations(theme)}\n}`, { + layer: 'reset', + }); + this._current.set(theme); + } +} + +export const ThemeServiceToken = createToken('ui.theme'); diff --git a/packages/ui/src/theme/tokens.ts b/packages/ui/src/theme/tokens.ts new file mode 100644 index 00000000..1a0cd30f --- /dev/null +++ b/packages/ui/src/theme/tokens.ts @@ -0,0 +1,154 @@ +/** + * Design tokens of the editor. Colors come from themes; everything else is shared. + * All tokens are CSS custom properties named `--nf--`. + */ +export const COLOR_TOKENS = [ + /** App chrome behind panels. */ + 'bg', + /** Panels and docks. */ + 'surface', + /** Floating surfaces: menus, dialogs, floating docks. */ + 'raised', + /** Inputs and wells. */ + 'sunken', + 'hover', + 'pressed', + 'border', + 'border-strong', + 'text', + 'text-muted', + 'text-faint', + /** Primary actions and active markers: the NanoForge brand violet. */ + 'accent', + 'accent-hover', + /** Text drawn on the accent color. */ + 'on-accent', + /** Accent used for text (lighter in dark themes for contrast). */ + 'accent-text', + /** Keyboard focus rings and selection. */ + 'focus', + 'selection', + 'selection-text', + 'danger', + 'warning', + 'success', + /** Backdrop of modal dialogs. */ + 'scrim', +] as const; + +export type ColorToken = (typeof COLOR_TOKENS)[number]; + +export interface ThemeDefinition { + readonly id: string; + readonly label: string; + readonly kind: 'dark' | 'light'; + readonly colors: Readonly>; +} + +/** + * Blue-grey steel surfaces with the NanoForge brand violet (`oklch(0.4493 0.1953 294.69)`, + * shared with the landing page and web workspace) as accent; focus and selection stay blue. + * The dark theme lifts the violet's lightness so markers stay visible on dark panels. + */ +export const nanoforgeDark: ThemeDefinition = { + id: 'nanoforge-dark', + label: 'NanoForge Dark', + kind: 'dark', + colors: { + bg: '#12161d', + surface: '#1a1f28', + raised: '#222834', + sunken: '#141821', + hover: '#262d3a', + pressed: '#2d3545', + border: '#2a3140', + 'border-strong': '#3a4356', + text: '#d8dde7', + 'text-muted': '#949cae', + 'text-faint': '#626a7d', + accent: '#855bdd', + 'accent-hover': '#9067ea', + 'on-accent': '#ffffff', + 'accent-text': '#af87ff', + focus: '#6d91ea', + selection: '#2c3f68', + 'selection-text': '#eef2ff', + danger: '#e26b62', + warning: '#e4924a', + success: '#62b58c', + scrim: 'rgba(8, 10, 14, 0.62)', + }, +}; + +export const nanoforgeLight: ThemeDefinition = { + id: 'nanoforge-light', + label: 'NanoForge Light', + kind: 'light', + colors: { + bg: '#dfe3ea', + surface: '#eef1f5', + raised: '#f9fafc', + sunken: '#e6e9ef', + hover: '#dde2ea', + pressed: '#d2d8e2', + border: '#c7cedb', + 'border-strong': '#aab3c4', + text: '#1c2230', + 'text-muted': '#546076', + 'text-faint': '#7e889b', + accent: '#612eb3', + 'accent-hover': '#541ea0', + 'on-accent': '#ffffff', + 'accent-text': '#612eb3', + focus: '#3c63c7', + selection: '#c9d6f5', + 'selection-text': '#101a33', + danger: '#bf3f36', + warning: '#b3611b', + success: '#2d8a5e', + scrim: 'rgba(28, 34, 48, 0.4)', + }, +}; + +/** Theme-independent tokens. */ +export const SHARED_TOKENS: Readonly> = { + 'font-ui': "'Instrument Sans', system-ui, -apple-system, 'Segoe UI', sans-serif", + 'font-code': "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace", + 'font-size-xs': '11px', + 'font-size-sm': '12px', + 'font-size-md': '13px', + 'font-size-lg': '15px', + 'font-size-xl': '19px', + 'line-height': '1.45', + 'space-1': '4px', + 'space-2': '8px', + 'space-3': '12px', + 'space-4': '16px', + 'space-5': '24px', + 'space-6': '32px', + 'radius-control': '3px', + 'radius-float': '6px', + 'control-height': '26px', + 'row-height': '24px', + 'shadow-float': '0 10px 30px -8px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.25)', + 'z-dock': '10', + 'z-float': '100', + 'z-dialog': '2000', + 'z-menu': '2500', + 'z-toast': '3000', + 'motion-fast': '90ms', + motion: '160ms', + /** The only gradient of the UI (brand violet to focus blue): active screen marker, startup progress. */ + temper: 'linear-gradient(90deg, #855bdd 0%, #6d62e0 50%, #5a80dc 100%)', +}; + +export const cssVar = (group: 'color' | 'token', name: string) => + group === 'color' ? `var(--nf-color-${name})` : `var(--nf-${name})`; + +/** CSS declarations of a theme (colors) plus the shared tokens. */ +export const themeDeclarations = (theme: ThemeDefinition): string => + [ + `color-scheme: ${theme.kind};`, + ...Object.entries(theme.colors).map(([name, value]) => `--nf-color-${name}: ${value};`), + ...Object.entries(SHARED_TOKENS).map(([name, value]) => `--nf-${name}: ${value};`), + ].join('\n '); diff --git a/packages/ui/src/theme/uno.ts b/packages/ui/src/theme/uno.ts new file mode 100644 index 00000000..33e8a90a --- /dev/null +++ b/packages/ui/src/theme/uno.ts @@ -0,0 +1,9 @@ +import { COLOR_TOKENS } from './tokens'; + +/** + * Theme colors for plugin authors using UnoCSS/Tailwind-like tools: + * `theme: { colors: { nf: nanoforgeColors } }` gives `bg-nf-surface`, `text-nf-muted`… + */ +export const nanoforgeColors: Readonly> = Object.fromEntries( + COLOR_TOKENS.map((token) => [token, `var(--nf-color-${token})`]), +); diff --git a/packages/ui/src/workbench/action/index.ts b/packages/ui/src/workbench/action/index.ts new file mode 100644 index 00000000..ba1a83fb --- /dev/null +++ b/packages/ui/src/workbench/action/index.ts @@ -0,0 +1 @@ +export * from './list-actions'; diff --git a/packages/ui/src/workbench/action/list-actions.ts b/packages/ui/src/workbench/action/list-actions.ts new file mode 100644 index 00000000..24d507b6 --- /dev/null +++ b/packages/ui/src/workbench/action/list-actions.ts @@ -0,0 +1,108 @@ +import { + COMMAND_METADATA, + type CommandMetadata, + type CommandRegistry, + type ContextKeyService, + type ExtensionRegistry, +} from '@nanoforge-dev/editor-kernel'; + +import { MENU_BAR, MENU_ITEMS, type MenuItem } from '../extension-point/menu.extension-point'; +import { actionId } from '../keybinding/resolve-keymap'; + +/** Something the user can run from the palette or bind a shortcut to. */ +export interface EditorAction { + /** `actionId(command, args)`. */ + readonly id: string; + readonly command: string; + readonly args: readonly unknown[]; + readonly title: string; + /** `File`, `Run`, or the menu path of a menu entry: `View › Panels`. */ + readonly category?: string; + /** Condition the action is available in (the command's, and the menu entry's). */ + readonly when: readonly string[]; +} + +export interface ActionSources { + readonly commands: CommandRegistry; + readonly extensions: ExtensionRegistry; +} + +/** `Category: Title`, as the palette and the keymap editor name an action. */ +export const actionLabel = (action: Pick): string => + action.category ? `${action.category}: ${action.title}` : action.title; + +/** + * Every action of the editor: + * - commands with a title, registered or declared by the manifest of a plugin not active yet, + * unless they opt out (`palette: false`: they need arguments); + * - menu entries that run a command with arguments (_View › Panels: Console_). + */ +export const listActions = ({ commands, extensions }: ActionSources): EditorAction[] => { + const metadata = new Map(); + for (const declared of extensions.getValues(COMMAND_METADATA)) { + metadata.set(declared.id, declared); + } + for (const command of commands.getAll()) { + const declared = metadata.get(command.id); + metadata.set(command.id, { + ...declared, + ...(command.title !== undefined && { title: command.title }), + ...(command.category !== undefined && { category: command.category }), + ...(command.when !== undefined && { when: command.when }), + ...(command.palette !== undefined && { palette: command.palette }), + }); + } + + const actions = new Map(); + for (const [command, { title, category, when, palette }] of metadata) { + if (!title || palette === false) continue; + actions.set(command, { + id: command, + command, + args: [], + title, + ...(category && { category }), + when: when ? [when] : [], + }); + } + + const items = extensions.getValues(MENU_ITEMS); + const bar = new Map(extensions.getValues(MENU_BAR).map((entry) => [entry.id, entry.title])); + const parents = new Map(); + for (const item of items) if (item.submenu) parents.set(item.submenu, item); + /** `View › Panels` for the menu `view.panels`; undefined for context menus. */ + const pathOf = (menu: string): string | undefined => { + const path: string[] = []; + for (let current = menu, depth = 0; depth < 6; depth++) { + const root = bar.get(current); + if (root) return [root, ...path].join(' › '); + const parent = parents.get(current); + if (!parent) return undefined; + path.unshift(parent.title ?? current); + current = parent.menu; + } + return undefined; + }; + for (const item of items) { + if (!item.command || !item.args.length) continue; + const category = pathOf(item.menu); + const title = item.title ?? metadata.get(item.command)?.title; + if (!category || !title) continue; + const id = actionId(item.command, item.args); + if (actions.has(id)) continue; + const commandWhen = metadata.get(item.command)?.when; + actions.set(id, { + id, + command: item.command, + args: item.args, + title, + category, + when: [commandWhen, item.when].filter((clause): clause is string => !!clause), + }); + } + return [...actions.values()].sort((a, b) => actionLabel(a).localeCompare(actionLabel(b))); +}; + +/** Whether an action can run in the current context. */ +export const isActionEnabled = (action: EditorAction, context: ContextKeyService): boolean => + action.when.every((clause) => context.evaluate(clause)); diff --git a/packages/ui/src/workbench/extension-point/code-editor.extension-point.ts b/packages/ui/src/workbench/extension-point/code-editor.extension-point.ts new file mode 100644 index 00000000..f69d0a2f --- /dev/null +++ b/packages/ui/src/workbench/extension-point/code-editor.extension-point.ts @@ -0,0 +1,32 @@ +import type { Component } from 'svelte'; + +import { + type ExtensionPoint, + type Observable, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; + +/** + * A panel beside the code editor, for some files (e.g. the ECS plugin's Component panel next to a + * component's source). The code editor shows the panels that apply to the active file, with a + * toggle in its toolbar. + */ +export interface CodeEditorSidePanel { + readonly id: string; + readonly title: string; + readonly icon?: string; + /** Fires when `appliesTo` may give another answer (the catalog changed…). */ + readonly changes?: Observable; + readonly component: Component<{ path: string }>; + /** Whether the panel applies to a file (project path). */ + appliesTo(path: string): boolean; +} + +export const CODE_EDITOR_SIDE_PANELS: ExtensionPoint = + defineExtensionPoint('codeEditor.sidePanels'); + +/** + * Context key holding the project path of the file in the focused code editor group; unset when + * no file is open (or the code editor never opened one). + */ +export const CODE_EDITOR_ACTIVE_FILE = 'codeEditor.activeFile'; diff --git a/packages/ui/src/workbench/extension-point/file.extension-point.ts b/packages/ui/src/workbench/extension-point/file.extension-point.ts new file mode 100644 index 00000000..d40d89b5 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/file.extension-point.ts @@ -0,0 +1,79 @@ +import { + type ExtensionPoint, + type Observable, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; + +/** A file created from a template: path relative to the target folder. */ +export interface TemplateFile { + readonly path: string; + readonly content: string | Uint8Array; +} + +/** "New …" entries of the file manager (and other creation UIs). */ +export interface FileTemplate { + readonly id: string; + readonly title: string; + readonly icon?: string; + /** Groups templates in the New menu (`Code`, `ECS`…). */ + readonly category?: string; + /** Offered only when true (context keys: `fileFolder`, `appType`…). */ + readonly when?: string; + /** Name proposed to the user, extension included (`NewComponent.ts`). */ + readonly defaultName: string; + /** Files to create for the chosen `name` in `folder` (the first one is opened). */ + create(context: { + folder: string; + name: string; + }): readonly TemplateFile[] | Promise; +} + +export const FILE_TEMPLATES: ExtensionPoint = + defineExtensionPoint('ui.fileTemplates'); + +/** + * Context menu entries of files and folders. The command receives the selected paths. `when` + * sees `fileKind` (`file`/`directory`), `fileExtname` (`.ts`), `fileReadOnly` and + * `fileSelectionCount`. + */ +export interface FileAction { + readonly id: string; + readonly title: string; + readonly command: string; + readonly icon?: string; + /** Actions are grouped (separated) by group, then sorted by order. */ + readonly group?: string; + readonly order?: number; + readonly when?: string; +} + +export const FILE_ACTIONS: ExtensionPoint = + defineExtensionPoint('ui.fileActions'); + +/** Icon of files matching a glob (`**\/*.png`); the highest priority wins. */ +export interface FileIcon { + readonly pattern: string; + readonly icon: string; + readonly priority?: number; +} + +export const FILE_ICONS: ExtensionPoint = defineExtensionPoint('ui.fileIcons'); + +/** A mark on a file or folder of the Files panel: a short badge after its name, colored. */ +export interface FileDecoration { + /** One or two characters (`M`, `U`…). */ + readonly badge: string; + readonly tone?: 'modified' | 'added' | 'deleted' | 'conflict'; + readonly tooltip?: string; +} + +/** Marks files (their version control status…). The first provider with an answer wins. */ +export interface FileDecorationProvider { + readonly id: string; + /** Fires when `decorate` may give other answers. */ + readonly changes: Observable; + decorate(path: string, kind: 'file' | 'directory'): FileDecoration | undefined; +} + +export const FILE_DECORATIONS: ExtensionPoint = + defineExtensionPoint('ui.fileDecorations'); diff --git a/packages/ui/src/workbench/extension-point/index.ts b/packages/ui/src/workbench/extension-point/index.ts new file mode 100644 index 00000000..0d8de008 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/index.ts @@ -0,0 +1,9 @@ +export * from './widget.extension-point'; +export * from './menu.extension-point'; +export * from './status-bar.extension-point'; +export * from './keybinding.extension-point'; +export * from './toolbar.extension-point'; +export * from './file.extension-point'; +export * from './code-editor.extension-point'; +export * from './viewport.extension-point'; +export * from './settings-page.extension-point'; diff --git a/packages/ui/src/workbench/extension-point/keybinding.extension-point.ts b/packages/ui/src/workbench/extension-point/keybinding.extension-point.ts new file mode 100644 index 00000000..2d7a8548 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/keybinding.extension-point.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +/** `Ctrl+Shift+P`, `Mod+Z` (Cmd on macOS, Ctrl elsewhere), chords: `Ctrl+K Ctrl+S`. */ +export const KeybindingSchema = z.object({ + key: z.string().min(1), + command: z.string().min(1), + when: z.string().optional(), + args: z.array(z.unknown()).default([]), +}); +export type Keybinding = z.output; +export const KEYBINDINGS: ExtensionPoint = defineExtensionPoint('ui.keybindings', { + validator: KeybindingSchema, +}); diff --git a/packages/ui/src/workbench/extension-point/menu.extension-point.ts b/packages/ui/src/workbench/extension-point/menu.extension-point.ts new file mode 100644 index 00000000..30ebf4d1 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/menu.extension-point.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +/** Top level menus of the menu bar. */ +export const MenuBarEntrySchema = z.object({ + id: z.string(), + title: z.string(), + order: z.number().default(0), +}); +export type MenuBarEntry = z.output; +export const MENU_BAR: ExtensionPoint = defineExtensionPoint('ui.menuBar', { + validator: MenuBarEntrySchema, +}); + +/** An item of a menu: runs a command (its title/icon by default) or opens a submenu. */ +export const MenuItemSchema = z.object({ + /** Menu id: a menu bar entry (`file`, `edit`, `view`…), a submenu or a context menu id. */ + menu: z.string(), + command: z.string().optional(), + submenu: z.string().optional(), + title: z.string().optional(), + /** An icon before the label, e.g. `check` on the current choice of a list. */ + icon: z.string().optional(), + /** Items are grouped (separated) by group, then sorted by order. */ + group: z.string().default('main'), + order: z.number().default(0), + when: z.string().optional(), + args: z.array(z.unknown()).default([]), +}); +export type MenuItem = z.output; +export const MENU_ITEMS: ExtensionPoint = defineExtensionPoint('ui.menuItems', { + validator: MenuItemSchema, +}); diff --git a/packages/ui/src/workbench/extension-point/settings-page.extension-point.ts b/packages/ui/src/workbench/extension-point/settings-page.extension-point.ts new file mode 100644 index 00000000..95d071f4 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/settings-page.extension-point.ts @@ -0,0 +1,43 @@ +import type { Component } from 'svelte'; + +import { + type ExtensionPoint, + type Observable, + type ServiceAccessor, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; +import type { WritableScope } from '@nanoforge-dev/editor-settings'; + +/** What a page of the Settings dialog gets: the dialog's pending changes, written on Apply. */ +export interface SettingsPageApi { + /** Bumped when pending changes or stored settings change. */ + readonly revision: Observable; + /** + * The value of a setting with its pending change. For a setting merged across scopes + * (`union`, `deep`), read each scope with `scopeValue` instead. + */ + value(key: string): unknown; + /** What one scope holds for a setting (pending change included); undefined when not set there. */ + scopeValue(key: string, scope: WritableScope): unknown; + /** Sets the value of a setting in a scope. A setting has one pending change at a time. */ + set(key: string, value: unknown, scope: WritableScope): void; + /** Whether a setting has a pending change. */ + isPending(key: string): boolean; +} + +/** + * A page of the Settings dialog with its own editor (keyboard shortcuts…), listed after the + * setting categories. + */ +export interface SettingsPage { + readonly id: string; + readonly title: string; + readonly icon?: string; + readonly order?: number; + /** Settings the page edits: they leave the category tree and the search. */ + readonly settings?: readonly string[]; + readonly component: Component<{ services: ServiceAccessor; page: SettingsPageApi }>; +} + +export const SETTINGS_PAGES: ExtensionPoint = + defineExtensionPoint('ui.settingsPages'); diff --git a/packages/ui/src/workbench/extension-point/status-bar.extension-point.ts b/packages/ui/src/workbench/extension-point/status-bar.extension-point.ts new file mode 100644 index 00000000..0d2683dd --- /dev/null +++ b/packages/ui/src/workbench/extension-point/status-bar.extension-point.ts @@ -0,0 +1,16 @@ +import { + type ExtensionPoint, + type Observable, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; + +export interface StatusBarItem { + readonly id: string; + readonly alignment: 'left' | 'right'; + readonly order?: number; + readonly text: Observable; + readonly tooltip?: string; + readonly command?: string; + readonly when?: string; +} +export const STATUS_BAR_ITEMS: ExtensionPoint = defineExtensionPoint('ui.statusBar'); diff --git a/packages/ui/src/workbench/extension-point/toolbar.extension-point.ts b/packages/ui/src/workbench/extension-point/toolbar.extension-point.ts new file mode 100644 index 00000000..bbb011f4 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/toolbar.extension-point.ts @@ -0,0 +1,15 @@ +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +/** Buttons of the top bar toolbar (play controls…). */ +export interface ToolbarItem { + readonly id: string; + readonly icon: string; + readonly title: string; + readonly command: string; + readonly args?: readonly unknown[]; + readonly order?: number; + readonly when?: string; + /** Highlighted (pressed) while this clause is true. */ + readonly toggled?: string; +} +export const TOOLBAR_ITEMS: ExtensionPoint = defineExtensionPoint('ui.toolbar'); diff --git a/packages/ui/src/workbench/extension-point/viewport.extension-point.ts b/packages/ui/src/workbench/extension-point/viewport.extension-point.ts new file mode 100644 index 00000000..e4d2f3b0 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/viewport.extension-point.ts @@ -0,0 +1,52 @@ +import type { Component } from 'svelte'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +import type { WidgetInstance } from '../widget/widget-instance.type'; +import type { MountedWidget } from './widget.extension-point'; + +/** The screens of the viewport. */ +export type ViewportScreen = 'game' | 'scene'; + +/** + * Content of the Scene screen for code generation targets: the scene editor of the ECS plugin + * for its entry-file target, a scene plugin's own editor for its target… + */ +export interface SceneEditor { + readonly id: string; + readonly title: string; + /** Ids of `code.codegenTargets` providers it edits (`*`: any). */ + readonly targets: readonly string[]; + /** Gets the Scene screen's widget instance, e.g. to point undo at the file it edits. */ + readonly component?: Component<{ instance?: WidgetInstance }>; + readonly mount?: (element: HTMLElement) => MountedWidget; +} + +export const SCENE_EDITORS: ExtensionPoint = + defineExtensionPoint('ui.sceneEditors'); + +/** A button of the Game or Scene screen toolbar. */ +export interface ViewportTool { + readonly id: string; + readonly screen: ViewportScreen; + readonly icon: string; + readonly title: string; + readonly command: string; + readonly order?: number; + readonly when?: string; + /** Pressed while this clause is true. */ + readonly toggled?: string; +} + +export const VIEWPORT_TOOLS: ExtensionPoint = + defineExtensionPoint('ui.viewportTools'); + +/** Drawn over the running game or the scene (e.g. gizmos, debug views). */ +export interface ViewportOverlay { + readonly id: string; + readonly screen: ViewportScreen; + readonly component: Component>; +} + +export const VIEWPORT_OVERLAYS: ExtensionPoint = + defineExtensionPoint('ui.viewportOverlays'); diff --git a/packages/ui/src/workbench/extension-point/widget.extension-point.ts b/packages/ui/src/workbench/extension-point/widget.extension-point.ts new file mode 100644 index 00000000..91b52702 --- /dev/null +++ b/packages/ui/src/workbench/extension-point/widget.extension-point.ts @@ -0,0 +1,56 @@ +import type { Component } from 'svelte'; +import { z } from 'zod'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; +import { SLOT_IDS, type SlotId } from '@nanoforge-dev/editor-layout'; + +import type { WidgetInstance } from '../widget/widget-instance.type'; + +const SLOT = z.enum(SLOT_IDS); + +/** + * What a widget is (metadata only): declared in manifests (`contributes.widgets`) or in code, + * so layouts, menus and the widget list know it before its plugin activates. + */ +export const WidgetDescriptorSchema = z.object({ + id: z.string().regex(/^[a-z0-9][\w/.-]*$/), + title: z.string().min(1), + /** Lucide icon name, e.g. `folder-tree`. */ + icon: z.string().optional(), + /** `screen`: main screen in the center; `dock`: panel in a dock slot or floating window. */ + kind: z.enum(['dock', 'screen']).default('dock'), + defaultSlot: SLOT.optional(), + order: z.number().default(0), + openByDefault: z.boolean().default(true), + /** Only one instance at a time (default). */ + singleton: z.boolean().default(true), + /** Offered in menus only when true. */ + when: z.string().optional(), + /** History context of the widget (undo/redo target while focused). */ + historyContext: z.string().optional(), +}); +export type WidgetDescriptor = z.output & { + readonly defaultSlot?: SlotId; +}; + +export const WIDGETS: ExtensionPoint = defineExtensionPoint('ui.widgets', { + validator: WidgetDescriptorSchema as unknown as z.ZodType, +}); + +/** What a framework-agnostic widget returns when mounted. */ +export interface MountedWidget { + dispose(): void; +} + +/** How a widget renders (registered by its plugin on activation). */ +export interface WidgetView { + readonly id: string; + /** Svelte component receiving `{ instance }`. */ + readonly component?: Component<{ instance: WidgetInstance }>; + /** Framework-agnostic alternative. */ + readonly mount?: (element: HTMLElement, instance: WidgetInstance) => MountedWidget; + /** CSS scoped to the widget (`[data-nf-widget=""]`), in the plugin's cascade layer. */ + readonly styles?: string; +} + +export const WIDGET_VIEWS: ExtensionPoint = defineExtensionPoint('ui.widgetViews'); diff --git a/packages/ui/src/workbench/index.ts b/packages/ui/src/workbench/index.ts new file mode 100644 index 00000000..67217375 --- /dev/null +++ b/packages/ui/src/workbench/index.ts @@ -0,0 +1,12 @@ +export * from './extension-point'; +export * from './keybinding'; +export * from './action'; +export * from './layout'; +export * from './notification'; +export * from './widget'; +export * from './service'; +export * from './workbench-context'; +export * from './menu'; +export * from './prompt'; +export * from './setup'; +export { default as Workbench } from './workbench.svelte'; diff --git a/packages/ui/src/workbench/keybinding/index.ts b/packages/ui/src/workbench/keybinding/index.ts new file mode 100644 index 00000000..ff844656 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/index.ts @@ -0,0 +1,13 @@ +export { + isMac, + normalizeStroke, + parseKeybinding, + strokeFromEvent, + formatKeybinding, + portableStroke, +} from './keystroke'; +export * from './keymap.type'; +export * from './keymap-presets'; +export * from './resolve-keymap'; +export * from './keybinding-service'; +export * from './keymap-edit'; diff --git a/packages/ui/src/workbench/keybinding/keybinding-service.ts b/packages/ui/src/workbench/keybinding/keybinding-service.ts new file mode 100644 index 00000000..ac074598 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/keybinding-service.ts @@ -0,0 +1,127 @@ +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, + type ExtensionRegistry, + type Logger, + type Observable, + constant, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; + +import { KEYBINDINGS } from '../extension-point/keybinding.extension-point'; +import type { KeybindingConflict, KeymapOverride, ResolvedKeybinding } from './keymap.type'; +import { startsWith, strokeFromEvent } from './keystroke'; +import { actionId, findConflicts, resolveKeymap } from './resolve-keymap'; + +const CHORD_TIMEOUT_MS = 1500; + +/** Dispatches keyboard shortcuts to commands, honoring `when` clauses, the preset and user overrides. */ +export class KeybindingService implements Disposable { + private readonly _store = new DisposableStore(); + private _pending: { strokes: string[]; timer: ReturnType } | undefined; + private _suspended = 0; + + constructor( + private readonly _extensions: ExtensionRegistry, + private readonly _commands: CommandRegistry, + private readonly _context: ContextKeyService, + private readonly _overrides: Observable = constant([]), + private readonly _logger?: Logger, + /** Entries of the chosen preset (`keymap.preset`). */ + private readonly _preset: Observable = constant([]), + ) {} + + /** Effective bindings, see {@link resolveKeymap}. */ + get bindings(): ResolvedKeybinding[] { + return resolveKeymap( + this._extensions.getValues(KEYBINDINGS), + this._preset.get(), + this._overrides.get(), + (binding, error) => + this._logger?.warn(`Ignoring keybinding ${binding.key} → ${binding.command}`, error), + ); + } + + /** Keybinding shown next to a command (run with these arguments) in menus. */ + keyFor(command: string, args: readonly unknown[] = []): string | undefined { + const action = actionId(command, args); + const forAction = this.bindings.filter( + (candidate) => actionId(candidate.command, candidate.args) === action, + ); + const binding = + forAction.find((candidate) => this._context.evaluate(candidate.when)) ?? forAction[0]; + return binding?.strokes.join(' '); + } + + /** Shortcuts that get in each other's way, see {@link findConflicts}. */ + conflicts(): KeybindingConflict[] { + return findConflicts(this.bindings); + } + + /** + * Stops dispatching until disposed: keys reach the focused element (a shortcut recorder). + */ + suspend(): Disposable { + this._suspended++; + this._clearPending(); + let disposed = false; + return toDisposable(() => { + if (disposed) return; + disposed = true; + this._suspended--; + }); + } + + /** Listens to keydown on a target (the editor window). */ + attach(target: Pick): Disposable { + const listener = (event: Event) => this.handle(event as KeyboardEvent); + target.addEventListener('keydown', listener, { capture: true }); + return this._store.add( + toDisposable(() => target.removeEventListener('keydown', listener, { capture: true })), + ); + } + + /** Returns true when the event ran (or started) a keybinding; its default is then prevented. */ + handle(event: KeyboardEvent): boolean { + if (this._suspended) return false; + const stroke = strokeFromEvent(event); + if (!stroke) return false; + const strokes = [...(this._pending?.strokes ?? []), stroke]; + this._clearPending(); + const candidates = this.bindings.filter( + (binding) => startsWith(binding.strokes, strokes) && this._context.evaluate(binding.when), + ); + const exact = candidates.find((binding) => binding.strokes.length === strokes.length); + if (exact) { + event.preventDefault(); + event.stopPropagation(); + this._commands + .execute(exact.command, ...exact.args) + .catch((error: unknown) => + this._logger?.error(`Keybinding ${exact.key} → ${exact.command} failed`, error), + ); + return true; + } + if (candidates.length) { + event.preventDefault(); + this._pending = { strokes, timer: setTimeout(() => this._clearPending(), CHORD_TIMEOUT_MS) }; + return true; + } + return false; + } + + dispose(): void { + this._clearPending(); + this._store.dispose(); + } + + private _clearPending(): void { + if (this._pending) clearTimeout(this._pending.timer); + this._pending = undefined; + } +} + +export const KeybindingServiceToken = createToken('ui.keybindings'); diff --git a/packages/ui/src/workbench/keybinding/keymap-edit.ts b/packages/ui/src/workbench/keybinding/keymap-edit.ts new file mode 100644 index 00000000..3adac681 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/keymap-edit.ts @@ -0,0 +1,91 @@ +import type { Keybinding } from '../extension-point/keybinding.extension-point'; +import type { KeymapOverride, ResolvedKeybinding } from './keymap.type'; +import { parseKeybinding } from './keystroke'; +import { actionId, resolveKeymap } from './resolve-keymap'; + +/** + * The layers of a keymap while one scope's overrides are edited: `own` is that scope's list, + * `before` and `after` the lists of the scopes applied before and after it (the account's + * overrides apply before this machine's). + */ +export interface KeymapLayers { + readonly defaults: readonly Keybinding[]; + readonly preset: readonly KeymapOverride[]; + readonly before: readonly KeymapOverride[]; + readonly own: readonly KeymapOverride[]; + readonly after: readonly KeymapOverride[]; +} + +/** A shortcut of an action: the command with its arguments, and the keys. */ +export interface ShortcutRef { + readonly command: string; + readonly args?: readonly unknown[]; + readonly key: string; +} + +export const resolveLayers = (layers: KeymapLayers): ResolvedKeybinding[] => + resolveKeymap(layers.defaults, layers.preset, [...layers.before, ...layers.own, ...layers.after]); + +const strokesOf = (key: string): string | undefined => { + try { + return parseKeybinding(key).join(' '); + } catch { + return undefined; + } +}; + +const sameShortcut = (entry: KeymapOverride, ref: ShortcutRef): boolean => + actionId(entry.command, entry.args) === actionId(ref.command, ref.args) && + strokesOf(entry.key) === strokesOf(ref.key); + +/** + * The edited scope's list after setting a shortcut of an action: present with a condition + * (`{ when }`), or absent (`undefined`). Entries of the list about that shortcut are replaced by + * the fewest that give the wanted result on top of the layers below: none when the layers below + * already give it, a removal, an addition, or both (a default whose condition changes). + */ +export const setShortcut = ( + layers: KeymapLayers, + ref: ShortcutRef, + state: { readonly when?: string | undefined } | undefined, +): KeymapOverride[] => { + const own = layers.own.filter((entry) => !sameShortcut(entry, ref)); + const action = actionId(ref.command, ref.args); + const strokes = strokesOf(ref.key); + const below = resolveKeymap(layers.defaults, layers.preset, [...layers.before, ...own]).filter( + (binding) => + actionId(binding.command, binding.args) === action && binding.strokes.join(' ') === strokes, + ); + const entry = { + command: ref.command, + key: ref.key, + ...(ref.args?.length && { args: [...ref.args] }), + }; + const when = state?.when?.trim(); + const already = + state !== undefined && below.length === 1 && (below[0]!.when ?? '') === (when ?? ''); + if (already) return own; + if (below.length) own.push({ ...entry, remove: true }); + if (state !== undefined) own.push({ ...entry, ...(when && { when }) }); + return own; +}; + +/** The edited scope's list without anything about an action: back to the layers below. */ +export const resetAction = ( + own: readonly KeymapOverride[], + command: string, + args: readonly unknown[] = [], +): KeymapOverride[] => { + const action = actionId(command, args); + return own.filter((entry) => actionId(entry.command, entry.args) !== action); +}; + +/** Whether the user's overrides say anything about an action. */ +export const isActionChanged = ( + overrides: readonly KeymapOverride[], + command: string, + args: readonly unknown[] = [], +): boolean => { + const action = actionId(command, args); + return overrides.some((entry) => actionId(entry.command, entry.args) === action); +}; diff --git a/packages/ui/src/workbench/keybinding/keymap-presets.ts b/packages/ui/src/workbench/keybinding/keymap-presets.ts new file mode 100644 index 00000000..def73177 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/keymap-presets.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +/** A named set of shortcuts applied on top of the defaults (it adds and removes). */ +export const KeymapPresetSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().optional(), + bindings: z.array( + z.object({ + command: z.string().min(1), + key: z.string().min(1), + when: z.string().optional(), + args: z.array(z.unknown()).optional(), + remove: z.boolean().optional(), + }), + ), +}); +export type KeymapPreset = z.output; +export const KEYMAP_PRESETS: ExtensionPoint = defineExtensionPoint( + 'ui.keymapPresets', + { validator: KeymapPresetSchema }, +); + +/** The preset every editor has: the defaults, unchanged. */ +export const DEFAULT_KEYMAP_PRESET = 'default'; diff --git a/packages/ui/src/workbench/keybinding/keymap.type.ts b/packages/ui/src/workbench/keybinding/keymap.type.ts new file mode 100644 index 00000000..2038736d --- /dev/null +++ b/packages/ui/src/workbench/keybinding/keymap.type.ts @@ -0,0 +1,21 @@ +import type { Keybinding } from '../extension-point/keybinding.extension-point'; + +export interface KeymapOverride { + readonly command: string; + readonly key: string; + readonly when?: string | undefined; + /** Arguments the command runs with (a shortcut for a menu entry such as _View: Game_). */ + readonly args?: readonly unknown[] | undefined; + /** Removes this shortcut of the command, whatever layer below added it. */ + readonly remove?: boolean | undefined; +} + +export interface ResolvedKeybinding extends Keybinding { + readonly strokes: readonly string[]; + readonly source: 'default' | 'preset' | 'user'; +} + +export interface KeybindingConflict { + readonly key: string; + readonly bindings: readonly ResolvedKeybinding[]; +} diff --git a/packages/ui/src/workbench/keybinding/keystroke.ts b/packages/ui/src/workbench/keybinding/keystroke.ts new file mode 100644 index 00000000..b7531338 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/keystroke.ts @@ -0,0 +1,102 @@ +const MODIFIERS = ['Ctrl', 'Alt', 'Shift', 'Meta'] as const; + +export const isMac = (): boolean => + typeof navigator !== 'undefined' && + /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent); + +const KEY_ALIASES: Record = { + esc: 'Escape', + escape: 'Escape', + del: 'Delete', + delete: 'Delete', + enter: 'Enter', + return: 'Enter', + space: 'Space', + ' ': 'Space', + tab: 'Tab', + backspace: 'Backspace', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + plus: '+', +}; + +const normalizeKey = (key: string): string => { + const alias = KEY_ALIASES[key.toLowerCase()]; + if (alias) return alias; + if (/^f\d{1,2}$/i.test(key)) return key.toUpperCase(); + if (key.length === 1) return key.toUpperCase(); + return key[0]!.toUpperCase() + key.slice(1); +}; + +/** Canonical form of one keystroke: `Ctrl+Shift+P` (modifier order Ctrl, Alt, Shift, Meta). */ +export const normalizeStroke = (stroke: string, mac = isMac()): string => { + const parts = stroke.split('+').map((part) => part.trim()); + if (parts.at(-1) === '' && parts.at(-2) === '') parts.splice(-2, 2, '+'); + const key = parts.pop(); + if (!key) throw new Error(`Invalid keybinding "${stroke}"`); + const modifiers = new Set(); + for (const part of parts) { + const name = part.toLowerCase(); + if (name === 'mod') modifiers.add(mac ? 'Meta' : 'Ctrl'); + else if (name === 'ctrl' || name === 'control') modifiers.add('Ctrl'); + else if (name === 'alt' || name === 'option') modifiers.add('Alt'); + else if (name === 'shift') modifiers.add('Shift'); + else if (name === 'meta' || name === 'cmd' || name === 'command' || name === 'super') + modifiers.add('Meta'); + else throw new Error(`Unknown modifier "${part}" in "${stroke}"`); + } + return [...MODIFIERS.filter((modifier) => modifiers.has(modifier)), normalizeKey(key)].join('+'); +}; + +/** `Ctrl+K Ctrl+S` → ['Ctrl+K', 'Ctrl+S']. */ +export const parseKeybinding = (key: string, mac = isMac()): string[] => + key + .trim() + .split(/\s+/) + .map((stroke) => normalizeStroke(stroke, mac)); + +/** Keystroke of a keyboard event, using the produced character (keyboard layout aware). */ +export const strokeFromEvent = (event: KeyboardEvent): string | undefined => { + if ( + ['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Unidentified'].includes( + event.key, + ) + ) { + return undefined; + } + const modifiers = [ + event.ctrlKey && 'Ctrl', + event.altKey && 'Alt', + event.shiftKey && 'Shift', + event.metaKey && 'Meta', + ].filter(Boolean); + return [...modifiers, normalizeKey(event.key)].join('+'); +}; + +/** Human readable form for menus: `⌘⇧P` on macOS, `Ctrl+Shift+P` elsewhere. */ +export const formatKeybinding = (key: string, mac = isMac()): string => + parseKeybinding(key, mac) + .map((stroke) => + mac + ? stroke + .replace('Ctrl+', '⌃') + .replace('Alt+', '⌥') + .replace('Shift+', '⇧') + .replace('Meta+', '⌘') + : stroke, + ) + .join(' '); + +/** + * A keystroke as saved in a keymap: the platform's main modifier becomes `Mod` (Ctrl, or Cmd on + * macOS), so a shortcut saved to an account works on both. + */ +export const portableStroke = (stroke: string, mac = isMac()): string => { + const main = new RegExp(`(^|\\+)${mac ? 'Meta' : 'Ctrl'}\\+`); + return main.test(stroke) ? `Mod+${stroke.replace(main, '$1')}` : stroke; +}; + +export const startsWith = (strokes: readonly string[], prefix: readonly string[]) => + prefix.length <= strokes.length && prefix.every((stroke, index) => strokes[index] === stroke); diff --git a/packages/ui/src/workbench/keybinding/resolve-keymap.ts b/packages/ui/src/workbench/keybinding/resolve-keymap.ts new file mode 100644 index 00000000..51071f58 --- /dev/null +++ b/packages/ui/src/workbench/keybinding/resolve-keymap.ts @@ -0,0 +1,110 @@ +import { parseWhen } from '@nanoforge-dev/editor-kernel'; + +import type { Keybinding } from '../extension-point/keybinding.extension-point'; +import type { KeybindingConflict, KeymapOverride, ResolvedKeybinding } from './keymap.type'; +import { parseKeybinding, startsWith } from './keystroke'; + +/** Identity of what a shortcut runs: a command with its arguments. */ +export const actionId = (command: string, args: readonly unknown[] = []): string => + args.length ? `${command} ${JSON.stringify(args)}` : command; + +const SOURCE_ORDER = { user: 0, preset: 1, default: 2 } as const; + +/** + * The effective shortcuts: the defaults, then the preset's entries, then the user's overrides + * (account first, then this machine), applied in order. A `remove` entry cancels the shortcut + * whatever added it, and a later entry can add it back. The user's shortcuts come first (they + * win when two shortcuts match), then the preset's, then the defaults. + */ +export const resolveKeymap = ( + defaults: readonly Keybinding[], + preset: readonly KeymapOverride[], + overrides: readonly KeymapOverride[], + onInvalid?: (binding: Keybinding, error: unknown) => void, +): ResolvedKeybinding[] => { + const resolve = ( + binding: Keybinding, + source: ResolvedKeybinding['source'], + ): ResolvedKeybinding | undefined => { + try { + if (binding.when) parseWhen(binding.when); + return { ...binding, strokes: parseKeybinding(binding.key), source }; + } catch (error) { + onInvalid?.(binding, error); + return undefined; + } + }; + let bindings = defaults.flatMap((binding) => resolve(binding, 'default') ?? []); + const apply = (entries: readonly KeymapOverride[], source: 'preset' | 'user') => { + for (const entry of entries) { + const resolved = resolve( + { + key: entry.key, + command: entry.command, + args: [...(entry.args ?? [])], + ...(entry.when && { when: entry.when }), + }, + source, + ); + if (!resolved) continue; + const action = actionId(resolved.command, resolved.args); + const strokes = resolved.strokes.join(' '); + if (entry.remove) { + bindings = bindings.filter( + (binding) => + actionId(binding.command, binding.args) !== action || + binding.strokes.join(' ') !== strokes, + ); + } else if ( + !bindings.some( + (binding) => + actionId(binding.command, binding.args) === action && + binding.strokes.join(' ') === strokes && + (binding.when ?? '') === (resolved.when ?? ''), + ) + ) { + bindings.push(resolved); + } + } + }; + apply(preset, 'preset'); + apply(overrides, 'user'); + return bindings + .map((binding, index) => ({ binding, index })) + .sort( + (a, b) => + SOURCE_ORDER[a.binding.source] - SOURCE_ORDER[b.binding.source] || a.index - b.index, + ) + .map(({ binding }) => binding); +}; + +/** Two conditions can be true together as far as the editor can tell: equal, or one is absent. */ +const whenOverlaps = (a: string | undefined, b: string | undefined): boolean => + !a?.trim() || !b?.trim() || a.trim() === b.trim(); + +/** + * Whether two shortcuts get in each other's way: different actions, conditions that overlap, and + * the same keys or one hiding the other's chord (`Ctrl+K` against `Ctrl+K S`). + */ +export const bindingsConflict = (a: ResolvedKeybinding, b: ResolvedKeybinding): boolean => + actionId(a.command, a.args) !== actionId(b.command, b.args) && + whenOverlaps(a.when, b.when) && + (startsWith(a.strokes, b.strokes) || startsWith(b.strokes, a.strokes)); + +/** Shortcuts that conflict, grouped by the keys they share (the shortest of each group). */ +export const findConflicts = (bindings: readonly ResolvedKeybinding[]): KeybindingConflict[] => { + const groups = new Map>(); + for (let i = 0; i < bindings.length; i++) { + for (let j = i + 1; j < bindings.length; j++) { + const a = bindings[i]!; + const b = bindings[j]!; + if (!bindingsConflict(a, b)) continue; + const shortest = a.strokes.length <= b.strokes.length ? a : b; + const key = shortest.strokes.join(' '); + const group = groups.get(key) ?? new Set(); + group.add(a).add(b); + groups.set(key, group); + } + } + return [...groups].map(([key, group]) => ({ key, bindings: [...group] })); +}; diff --git a/packages/ui/src/workbench/layout/drag-controller.ts b/packages/ui/src/workbench/layout/drag-controller.ts new file mode 100644 index 00000000..df285851 --- /dev/null +++ b/packages/ui/src/workbench/layout/drag-controller.ts @@ -0,0 +1,96 @@ +import { type Observable, ObservableValue } from '@nanoforge-dev/editor-kernel'; +import type { Location, SlotId } from '@nanoforge-dev/editor-layout'; + +export type DropTarget = + | { readonly kind: 'location'; readonly location: Location; readonly rect: DOMRect } + /** Center of the workbench: the widget becomes a floating window at the pointer. */ + | { readonly kind: 'float'; readonly rect: DOMRect }; + +export interface DragState { + readonly instanceId: string; + readonly title: string; + readonly x: number; + readonly y: number; + readonly target: DropTarget | undefined; +} + +const THRESHOLD = 5; + +/** + * Pointer based dragging of dock tabs. Drop zones are elements with `data-nf-drop`: + * - `tab` (`data-slot`|`data-float`, `data-index`): insert before that tab (or after it, past + * its middle: horizontal, or vertical with `data-axis="y"`); + * - `stack` (`data-slot`|`data-float`, `data-count`): append to the stack; + * - `center`: float the widget. + */ +export class DragController { + private readonly _state = new ObservableValue(undefined); + + get state(): Observable { + return this._state.readonly(); + } + + /** Starts tracking a pointer on a tab; `drop` runs when released over a target. */ + begin( + event: PointerEvent, + instanceId: string, + title: string, + drop: (target: DropTarget, x: number, y: number) => void, + ): void { + if (event.button !== 0) return; + const startX = event.clientX; + const startY = event.clientY; + let started = false; + const move = (e: PointerEvent) => { + if (!started && Math.hypot(e.clientX - startX, e.clientY - startY) < THRESHOLD) return; + started = true; + this._state.set({ + instanceId, + title, + x: e.clientX, + y: e.clientY, + target: this._targetAt(e.clientX, e.clientY, instanceId), + }); + }; + const end = (e: PointerEvent) => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', cancel); + const state = this._state.get(); + this._state.set(undefined); + if (started && state?.target) drop(state.target, e.clientX, e.clientY); + }; + const cancel = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', cancel); + this._state.set(undefined); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', cancel); + } + + private _targetAt(x: number, y: number, instanceId: string): DropTarget | undefined { + for (const element of document.elementsFromPoint(x, y)) { + const zone = (element as HTMLElement).closest('[data-nf-drop]'); + if (!zone) continue; + const rect = zone.getBoundingClientRect(); + const kind = zone.dataset.nfDrop; + if (kind === 'center') return { kind: 'float', rect }; + const slot = zone.dataset.slot as SlotId | undefined; + const floatId = zone.dataset.float; + let index = Number(kind === 'tab' ? zone.dataset.index : zone.dataset.count); + const after = + zone.dataset.axis === 'y' ? y > rect.top + rect.height / 2 : x > rect.left + rect.width / 2; + if (kind === 'tab' && after) index++; + if (zone.dataset.contains?.split(' ').includes(instanceId)) { + const current = zone.dataset.contains.split(' ').indexOf(instanceId); + if (current < index) index--; + } + if (slot) return { kind: 'location', location: { kind: 'slot', slot, index }, rect }; + if (floatId) return { kind: 'location', location: { kind: 'float', floatId, index }, rect }; + } + return undefined; + } +} diff --git a/packages/ui/src/workbench/layout/index.ts b/packages/ui/src/workbench/layout/index.ts new file mode 100644 index 00000000..3ac0d90e --- /dev/null +++ b/packages/ui/src/workbench/layout/index.ts @@ -0,0 +1,3 @@ +export * from './layout-controller.type'; +export * from './layout-controller'; +export * from './drag-controller'; diff --git a/packages/ui/src/workbench/layout/layout-controller.ts b/packages/ui/src/workbench/layout/layout-controller.ts new file mode 100644 index 00000000..008e8846 --- /dev/null +++ b/packages/ui/src/workbench/layout/layout-controller.ts @@ -0,0 +1,309 @@ +import type { HistoryContext } from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type Layout, + LayoutError, + type LayoutOp, + type Location, + type SlotId, + allWidgets, + composeDefaultLayout, + deserializeLayout, + findWidget, + isSlotVisible, + reconcileLayout, + reduce, + serializeLayout, + showSlotOp, +} from '@nanoforge-dev/editor-layout'; +import { CoreSettings, type WritableScope } from '@nanoforge-dev/editor-settings'; + +import { WIDGETS, type WidgetDescriptor } from '../extension-point/widget.extension-point'; +import type { LayoutControllerOptions } from './layout-controller.type'; + +export const LAYOUT_HISTORY_CONTEXT = 'layout'; +const SAVE_DELAY_MS = 500; + +const mergeKeyOf = (op: LayoutOp): string | undefined => { + switch (op.type) { + case 'resizeSlot': + return `resize:${op.slot}`; + case 'setSplit': + return `split:${op.side}`; + case 'setFloatRect': + return `float:${op.floatId}`; + default: + return undefined; + } +}; + +/** + * Owns the workbench layout: applies operations as undoable steps (history context `layout`), + * persists the named layouts in settings and adds widgets of newly installed plugins. + */ +export class LayoutController implements Disposable { + private readonly _layout: ObservableValue; + private readonly _store = new DisposableStore(); + private readonly _history: HistoryContext; + private _saveTimer: ReturnType | undefined; + private _floatCounter = 0; + private readonly _docksHidden = new ObservableValue(false); + + constructor(private readonly _options: LayoutControllerOptions) { + this._layout = new ObservableValue(this._load()); + this._history = this._store.add( + _options.history.registerContext({ id: LAYOUT_HISTORY_CONTEXT, label: 'Layout' }), + ); + this._store.add( + _options.extensions.onDidChange((point) => { + if (point.id !== WIDGETS.id) return; + const current = this._layout.get(); + const known = new Set([ + ...current.screens, + ...allWidgets(current).map((ref) => ref.widgetId), + ]); + this._layout.set(reconcileLayout(current, this._widgets(), known)); + }), + ); + } + + get layout(): Observable { + return this._layout.readonly(); + } + + get current(): Layout { + return this._layout.get(); + } + + /** Docks hidden for a while (e.g. maximize on play): not saved, not an undo step. */ + get docksHidden(): Observable { + return this._docksHidden.readonly(); + } + + setDocksHidden(hidden: boolean): void { + this._docksHidden.set(hidden); + } + + /** Applies an operation as one undoable step (continuous resizes merge). */ + async apply(op: LayoutOp, label = 'Change layout'): Promise { + let inverse: LayoutOp | undefined; + const mergeKey = mergeKeyOf(op); + await this._history.stack.push({ + label, + ...(mergeKey && { mergeKey }), + do: () => { + const result = reduce(this._layout.get(), op); + inverse = result.inverse; + this._set(result.layout); + }, + undo: () => { + if (inverse) this._set(reduce(this._layout.get(), inverse).layout); + }, + redo: () => { + const result = reduce(this._layout.get(), op); + inverse = result.inverse; + this._set(result.layout); + }, + }); + } + + /** Shows a tab of its stack, and its slot if hidden (navigation: not an undo step). */ + focusTab(instanceId: string): void { + this._navigate({ type: 'activate', instanceId }); + const found = findWidget(this._layout.get(), instanceId); + if (found?.location.kind === 'slot') this.showSlot(found.location.slot, true); + } + + /** Shows or hides a dock slot on the active screen (navigation: not an undo step). */ + showSlot(slot: SlotId, visible: boolean): void { + const layout = this._layout.get(); + if (isSlotVisible(layout, slot) !== visible) this._navigate(showSlotOp(layout, slot, visible)); + } + + /** A click on a tool window's icon: shows its panel, or hides the slot if already shown. */ + toggleTool(instanceId: string): void { + const found = findWidget(this._layout.get(), instanceId); + if (found?.location.kind !== 'slot') return this.focusTab(instanceId); + const { slot } = found.location; + if (found.active && isSlotVisible(this._layout.get(), slot)) this.showSlot(slot, false); + else this.focusTab(instanceId); + } + + /** Switches the main screen (navigation: not an undo step). */ + showScreen(screen: string): void { + this._navigate({ type: 'switchScreen', screen }); + } + + /** Brings a floating window to the front (cosmetic: not an undo step). */ + raise(floatId: string): void { + const layout = this._layout.get(); + const float = layout.floats.find((candidate) => candidate.id === floatId); + const top = Math.max(0, ...layout.floats.map((candidate) => candidate.z)); + if (!float || float.z === top) return; + this._set(reduce(layout, { type: 'focusFloat', floatId, z: top + 1 }).layout); + } + + /** Opens a widget (or focuses its existing singleton instance). Returns the instance id. */ + async open(widgetId: string, location?: Location): Promise { + const descriptor = this._descriptor(widgetId); + const layout = this._layout.get(); + if (descriptor?.kind === 'screen') { + this.showScreen(widgetId); + return widgetId; + } + const singleton = descriptor?.singleton ?? true; + const existing = singleton + ? allWidgets(layout).find((ref) => ref.widgetId === widgetId) + : undefined; + if (existing) { + this.focusTab(existing.instanceId); + return existing.instanceId; + } + const instanceId = singleton ? widgetId : `${widgetId}#${Date.now().toString(36)}`; + const slot: SlotId = descriptor?.defaultSlot ?? 'leftTop'; + await this.apply( + { + type: 'add', + ref: { instanceId, widgetId }, + location: location ?? { kind: 'slot', slot, index: layout.slots[slot].stack.tabs.length }, + }, + `Open ${descriptor?.title ?? widgetId}`, + ); + if (!location) this.showSlot(slot, true); + return instanceId; + } + + async close(instanceId: string): Promise { + const found = findWidget(this._layout.get(), instanceId); + if (!found) return; + await this.apply( + { type: 'remove', instanceId }, + `Close ${this._descriptor(found.ref.widgetId)?.title ?? instanceId}`, + ); + } + + /** Moves a widget into a new floating window at `rect`. */ + async float( + instanceId: string, + rect: { x: number; y: number; width: number; height: number }, + ): Promise { + await this.apply( + { + type: 'move', + instanceId, + location: { kind: 'float', floatId: this._newFloatId(), index: 0, rect }, + }, + 'Float panel', + ); + } + + /** Stores the current layout under a name and makes it the active one. */ + async saveAs(name: string): Promise { + await this._persist(name); + await this._options.settings.set(CoreSettings.layoutActive, name, this._scope()); + } + + /** Switches to a saved layout. */ + async switchTo(name: string): Promise { + const saved = this._saved()[name]; + const layout = saved === undefined ? undefined : deserializeLayout(saved); + if (!layout) throw new LayoutError(`No saved layout "${name}"`); + await this._options.settings.set(CoreSettings.layoutActive, name, this._scope()); + this._set(this._reconcile(layout), { save: false }); + await this._history.stack.clear(); + } + + /** Back to the default layout of the installed widgets. */ + async reset(): Promise { + this._set(composeDefaultLayout(this._widgets())); + await this._history.stack.clear(); + } + + get savedNames(): string[] { + return Object.keys(this._saved()).sort(); + } + + dispose(): void { + if (this._saveTimer) { + clearTimeout(this._saveTimer); + void this._persist(this._activeName()); + } + this._store.dispose(); + } + + private _navigate(op: LayoutOp): void { + try { + this._set(reduce(this._layout.get(), op).layout); + } catch (error) { + this._options.logger?.warn('Ignored layout navigation', error); + } + } + + private _set(layout: Layout, options: { save?: boolean } = {}): void { + this._layout.set(layout); + if (options.save === false) return; + clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this._saveTimer = undefined; + this._persist(this._activeName()).catch((error: unknown) => + this._options.logger?.error('Saving the layout failed', error), + ); + }, SAVE_DELAY_MS); + } + + private async _persist(name: string): Promise { + const saved = { + ...this._saved(), + [name]: JSON.parse(serializeLayout(this._layout.get())) as unknown, + }; + await this._options.settings.set(CoreSettings.layoutSaved, saved, this._scope()); + } + + private _load(): Layout { + const saved = this._saved()[this._activeName()]; + const layout = saved === undefined ? undefined : deserializeLayout(saved); + return layout ? this._reconcile(layout) : composeDefaultLayout(this._widgets()); + } + + private _reconcile(layout: Layout): Layout { + const known = new Set([...layout.screens, ...allWidgets(layout).map((ref) => ref.widgetId)]); + return reconcileLayout(layout, this._widgets(), known); + } + + /** Layouts follow the project when one is open, the user otherwise. */ + private _scope(): WritableScope { + return this._options.settings.hasStore('projectLocal') ? 'projectLocal' : 'account'; + } + + private _saved(): Record { + return this._options.settings.get(CoreSettings.layoutSaved) as Record; + } + + private _activeName(): string { + return this._options.settings.get(CoreSettings.layoutActive); + } + + private _widgets(): WidgetDescriptor[] { + return this._options.extensions.getValues(WIDGETS); + } + + private _descriptor(widgetId: string): WidgetDescriptor | undefined { + return this._widgets().find((widget) => widget.id === widgetId); + } + + private _newFloatId(): string { + const ids = new Set(this._layout.get().floats.map((float) => float.id)); + let id: string; + do id = `float-${Date.now().toString(36)}-${this._floatCounter++}`; + while (ids.has(id)); + return id; + } +} + +export const LayoutControllerToken = createToken('ui.layout'); diff --git a/packages/ui/src/workbench/layout/layout-controller.type.ts b/packages/ui/src/workbench/layout/layout-controller.type.ts new file mode 100644 index 00000000..da6e30db --- /dev/null +++ b/packages/ui/src/workbench/layout/layout-controller.type.ts @@ -0,0 +1,10 @@ +import type { HistoryService } from '@nanoforge-dev/editor-history'; +import type { ExtensionRegistry, Logger } from '@nanoforge-dev/editor-kernel'; +import type { SettingsService } from '@nanoforge-dev/editor-settings'; + +export interface LayoutControllerOptions { + readonly extensions: ExtensionRegistry; + readonly history: HistoryService; + readonly settings: SettingsService; + readonly logger?: Logger; +} diff --git a/packages/ui/src/workbench/menu/index.ts b/packages/ui/src/workbench/menu/index.ts new file mode 100644 index 00000000..74e4d6cb --- /dev/null +++ b/packages/ui/src/workbench/menu/index.ts @@ -0,0 +1 @@ +export * from './resolve-menu'; diff --git a/packages/ui/src/workbench/menu/resolve-menu.ts b/packages/ui/src/workbench/menu/resolve-menu.ts new file mode 100644 index 00000000..edcd4b9f --- /dev/null +++ b/packages/ui/src/workbench/menu/resolve-menu.ts @@ -0,0 +1,76 @@ +import { + COMMAND_METADATA, + type CommandRegistry, + type ContextKeyService, + type ExtensionRegistry, + type Logger, +} from '@nanoforge-dev/editor-kernel'; + +import type { MenuEntry } from '../../components/menu-entry.type'; +import { MENU_ITEMS, type MenuItem } from '../extension-point/menu.extension-point'; +import type { KeybindingService } from '../keybinding/keybinding-service'; +import { formatKeybinding } from '../keybinding/keystroke'; + +export interface MenuContext { + readonly extensions: ExtensionRegistry; + readonly commands: CommandRegistry; + readonly contextKeys: ContextKeyService; + readonly keybindings?: KeybindingService; + readonly logger?: Logger; +} + +/** Title of a command: its runtime registration, else its manifest metadata, else its id. */ +export const commandTitle = (context: MenuContext, command: string): string => + context.commands.get(command)?.title ?? + context.extensions.getValues(COMMAND_METADATA).find((metadata) => metadata.id === command) + ?.title ?? + command; + +/** + * Menu entries of a menu id: contributed items passing their `when`, grouped (separators + * between groups) and sorted by order. + */ +export const resolveMenu = (context: MenuContext, menu: string, depth = 0): MenuEntry[] => { + if (depth > 5) return []; + const items = context.extensions + .getValues(MENU_ITEMS) + .filter((item) => item.menu === menu && context.contextKeys.evaluate(item.when)); + const groups = new Map(); + for (const item of items) groups.set(item.group, [...(groups.get(item.group) ?? []), item]); + const entries: MenuEntry[] = []; + const sortedGroups = [...groups.entries()].sort(([a], [b]) => + a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b), + ); + for (const [, group] of sortedGroups) { + if (entries.length) entries.push({ kind: 'separator' }); + for (const item of group.sort((a, b) => a.order - b.order)) { + if (item.submenu) { + entries.push({ + kind: 'submenu', + label: item.title ?? item.submenu, + items: resolveMenu(context, item.submenu, depth + 1), + }); + continue; + } + if (!item.command) continue; + const command = item.command; + const key = context.keybindings?.keyFor(command, item.args); + entries.push({ + kind: 'item', + id: `${menu}:${command}:${entries.length}`, + label: item.title ?? commandTitle(context, command), + ...(item.icon && { icon: item.icon }), + ...(key && { shortcut: formatKeybinding(key) }), + disabled: context.commands.has(command) && !context.commands.isEnabled(command), + onSelect: () => { + context.commands + .execute(command, ...item.args) + .catch((error: unknown) => + context.logger?.error(`Menu command ${command} failed`, error), + ); + }, + }); + } + } + return entries; +}; diff --git a/packages/ui/src/workbench/notification/index.ts b/packages/ui/src/workbench/notification/index.ts new file mode 100644 index 00000000..5ce85c27 --- /dev/null +++ b/packages/ui/src/workbench/notification/index.ts @@ -0,0 +1 @@ +export * from './notification-service'; diff --git a/packages/ui/src/workbench/notification/notification-service.ts b/packages/ui/src/workbench/notification/notification-service.ts new file mode 100644 index 00000000..cc21ddfc --- /dev/null +++ b/packages/ui/src/workbench/notification/notification-service.ts @@ -0,0 +1,71 @@ +import { type Observable, ObservableValue, createToken } from '@nanoforge-dev/editor-kernel'; + +export type Severity = 'info' | 'success' | 'warning' | 'error'; + +export interface NotificationAction { + readonly title: string; + readonly run: () => void | Promise; +} + +export interface Notification { + readonly id: number; + readonly severity: Severity; + readonly message: string; + readonly detail?: string; + readonly actions: readonly NotificationAction[]; + readonly source?: string; + readonly time: number; +} + +export interface NotifyOptions { + readonly detail?: string; + readonly actions?: readonly NotificationAction[]; + readonly source?: string; + /** Auto dismiss delay in ms; errors stay until dismissed. Default 5000 (0 = sticky). */ + readonly timeout?: number; +} + +/** Toasts and the notification center history. */ +export class NotificationService { + private readonly _active = new ObservableValue([]); + private readonly _history = new ObservableValue([]); + private _nextId = 1; + + /** Notifications currently shown as toasts. */ + get active(): Observable { + return this._active.readonly(); + } + + /** Every notification of the session (notification center). */ + get history(): Observable { + return this._history.readonly(); + } + + notify(severity: Severity, message: string, options: NotifyOptions = {}): { dismiss(): void } { + const notification: Notification = { + id: this._nextId++, + severity, + message, + actions: options.actions ?? [], + time: Date.now(), + ...(options.detail && { detail: options.detail }), + ...(options.source && { source: options.source }), + }; + this._active.update((list) => [...list, notification]); + this._history.update((list) => [notification, ...list].slice(0, 200)); + const timeout = options.timeout ?? (severity === 'error' ? 0 : 5000); + const dismiss = () => this.dismiss(notification.id); + if (timeout > 0) setTimeout(dismiss, timeout); + return { dismiss }; + } + + dismiss(id: number): void { + this._active.update((list) => list.filter((notification) => notification.id !== id)); + } + + clearHistory(): void { + this._history.set([]); + } +} + +export const NotificationServiceToken = createToken('ui.notifications'); diff --git a/packages/ui/src/workbench/parts/DockStack.svelte b/packages/ui/src/workbench/parts/DockStack.svelte new file mode 100644 index 00000000..2ed23945 --- /dev/null +++ b/packages/ui/src/workbench/parts/DockStack.svelte @@ -0,0 +1,350 @@ + + +
+ {#if slot} +
+ {#if active} + {@const activeTab = active} + menu(activeTab.instanceId)}> + + + + {#snippet trigger({ props })} + + {/snippet} + + layout.showSlot(slot, false)} + /> + {/if} +
+ {:else} +
+
{ + if (!overflowing || event.deltaX || !strip) return; + strip.scrollLeft += event.deltaY; + }} + data-nf-drop="stack" + data-slot={slot} + data-float={floatId} + data-count={stack.tabs.length} + data-contains={contains} + > + {@render leading?.()} + {#each stack.tabs as tab, index (tab.instanceId)} + menu(tab.instanceId)}> + + + {/each} +
+ {#if overflowing} + ({ + kind: 'item' as const, + id: tab.instanceId, + label: title(tab.widgetId), + ...(icon(tab.widgetId) && { icon: icon(tab.widgetId) }), + onSelect: () => layout.focusTab(tab.instanceId), + }))} + > + {#snippet trigger({ props })} + + {/snippet} + + {/if} +
+ {/if} +
+ {#each stack.tabs as tab (tab.instanceId)} + {#if visited.has(tab.instanceId)} + run(layout.close(tab.instanceId))} + /> + {/if} + {/each} +
+
+ + diff --git a/packages/ui/src/workbench/parts/DragOverlay.svelte b/packages/ui/src/workbench/parts/DragOverlay.svelte new file mode 100644 index 00000000..f24c479b --- /dev/null +++ b/packages/ui/src/workbench/parts/DragOverlay.svelte @@ -0,0 +1,46 @@ + + +{#if $dragging} + {#if $dragging.target} + {@const rect = $dragging.target.rect} +
+ {/if} +
+ {$dragging.title} +
+{/if} + + diff --git a/packages/ui/src/workbench/parts/FloatWindow.svelte b/packages/ui/src/workbench/parts/FloatWindow.svelte new file mode 100644 index 00000000..86f4e502 --- /dev/null +++ b/packages/ui/src/workbench/parts/FloatWindow.svelte @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/ui/src/workbench/parts/MenuBarMenu.svelte b/packages/ui/src/workbench/parts/MenuBarMenu.svelte new file mode 100644 index 00000000..9df3f182 --- /dev/null +++ b/packages/ui/src/workbench/parts/MenuBarMenu.svelte @@ -0,0 +1,38 @@ + + + + {#snippet trigger({ props })} + + {/snippet} + + + diff --git a/packages/ui/src/workbench/parts/PromptHost.svelte b/packages/ui/src/workbench/parts/PromptHost.svelte new file mode 100644 index 00000000..2f21905e --- /dev/null +++ b/packages/ui/src/workbench/parts/PromptHost.svelte @@ -0,0 +1,69 @@ + + +{#if $current} + $current?.resolve(undefined)}> +
{ + event.preventDefault(); + submit(); + }} + > + + {#if error}{/if} +
+ + +
+
+
+{/if} + + diff --git a/packages/ui/src/workbench/parts/StatusBar.svelte b/packages/ui/src/workbench/parts/StatusBar.svelte new file mode 100644 index 00000000..a1a070cd --- /dev/null +++ b/packages/ui/src/workbench/parts/StatusBar.svelte @@ -0,0 +1,155 @@ + + +
+
+ {#each left as item (item.id)} + run(item)} /> + {/each} +
+
+ {#each right as item (item.id)} + run(item)} /> + {/each} + + + + {#if $history.length}{$history.length}{/if} + + + +
+ Notifications + +
+ {#each $history as notification (notification.id)} +

+ + {notification.message} +

+ {:else} +

No notifications

+ {/each} +
+
+
+
+
+ + diff --git a/packages/ui/src/workbench/parts/StatusText.svelte b/packages/ui/src/workbench/parts/StatusText.svelte new file mode 100644 index 00000000..a752edcf --- /dev/null +++ b/packages/ui/src/workbench/parts/StatusText.svelte @@ -0,0 +1,18 @@ + + +{#if item.command} + +{:else} + {$text} +{/if} + + diff --git a/packages/ui/src/workbench/parts/ToolStripe.svelte b/packages/ui/src/workbench/parts/ToolStripe.svelte new file mode 100644 index 00000000..6935b521 --- /dev/null +++ b/packages/ui/src/workbench/parts/ToolStripe.svelte @@ -0,0 +1,173 @@ + + +{#snippet group(slot: SlotId)} + {@const stack = current.slots[slot].stack} + {@const shown = isSlotVisible(current, slot)} + {@const contains = stack.tabs.map((tab) => tab.instanceId).join(' ')} +
+ {#each stack.tabs as tab, index (tab.instanceId)} + {@const selected = shown && tab.instanceId === stack.active} + tabMenu(layout, tab.instanceId, title(tab.widgetId), run)}> + + {#snippet children({ props })} + + {/snippet} + + + {/each} +
+{/snippet} + + + + diff --git a/packages/ui/src/workbench/parts/TopBar.svelte b/packages/ui/src/workbench/parts/TopBar.svelte new file mode 100644 index 00000000..bf8e97e1 --- /dev/null +++ b/packages/ui/src/workbench/parts/TopBar.svelte @@ -0,0 +1,156 @@ + + +
+
+ {@render brand?.()} + +
+ +
+ {#each current.screens as screen (screen)} + + {/each} +
+ + +
+ + diff --git a/packages/ui/src/workbench/parts/WidgetHost.svelte b/packages/ui/src/workbench/parts/WidgetHost.svelte new file mode 100644 index 00000000..419911c2 --- /dev/null +++ b/packages/ui/src/workbench/parts/WidgetHost.svelte @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/ui/src/workbench/parts/tab-menu.ts b/packages/ui/src/workbench/parts/tab-menu.ts new file mode 100644 index 00000000..aedaf095 --- /dev/null +++ b/packages/ui/src/workbench/parts/tab-menu.ts @@ -0,0 +1,103 @@ +import { SLOT_IDS, type SlotId, findWidget } from '@nanoforge-dev/editor-layout'; + +import type { MenuEntry } from '../../components/menu-entry.type'; +import type { LayoutController } from '../layout/layout-controller'; + +export const SLOT_LABELS: Record = { + leftTop: 'Left top', + leftBottom: 'Left bottom', + rightTop: 'Right top', + rightBottom: 'Right bottom', + bottom: 'Bottom left', + bottomRight: 'Bottom right', +}; + +/** The menu of a dock tab (stripe icon, panel header, floating window tab). */ +export const tabMenu = ( + layout: LayoutController, + instanceId: string, + title: string, + run: (promise: Promise) => void, +): MenuEntry[] => { + const found = findWidget(layout.current, instanceId); + if (!found) return []; + const { location } = found; + const stack = + location.kind === 'slot' + ? layout.current.slots[location.slot].stack + : layout.current.floats.find((float) => float.id === location.floatId)!.stack; + const slot = location.kind === 'slot' ? location.slot : undefined; + const maximized = layout.current.maximized === instanceId; + return [ + ...(slot + ? [ + { + kind: 'item' as const, + id: 'hide', + label: 'Hide', + onSelect: () => layout.showSlot(slot, false), + }, + ] + : []), + { kind: 'item', id: 'close', label: 'Close', onSelect: () => run(layout.close(instanceId)) }, + { + kind: 'item', + id: 'close-others', + label: 'Close others', + disabled: stack.tabs.length < 2, + onSelect: () => + run( + layout.apply( + { + type: 'batch', + ops: stack.tabs + .filter((tab) => tab.instanceId !== instanceId) + .map((tab) => ({ type: 'remove' as const, instanceId: tab.instanceId })), + }, + 'Close other panels', + ), + ), + }, + { kind: 'separator' }, + { + kind: 'item', + id: 'float', + label: 'Float', + disabled: location.kind === 'float' && stack.tabs.length === 1, + onSelect: () => run(layout.float(instanceId, { x: 160, y: 120, width: 420, height: 320 })), + }, + { + kind: 'submenu', + label: 'Move to', + items: SLOT_IDS.filter((target) => target !== slot).map((target) => ({ + kind: 'item' as const, + id: target, + label: SLOT_LABELS[target], + onSelect: () => + run( + layout + .apply( + { + type: 'move', + instanceId, + location: { + kind: 'slot', + slot: target, + index: layout.current.slots[target].stack.tabs.length, + }, + }, + `Move ${title}`, + ) + .then(() => layout.focusTab(instanceId)), + ), + })), + }, + { + kind: 'item', + id: 'maximize', + label: maximized ? 'Restore' : 'Maximize', + onSelect: () => + run(layout.apply({ type: 'maximize', instanceId: maximized ? null : instanceId })), + }, + ]; +}; diff --git a/packages/ui/src/workbench/prompt/index.ts b/packages/ui/src/workbench/prompt/index.ts new file mode 100644 index 00000000..aa5b469e --- /dev/null +++ b/packages/ui/src/workbench/prompt/index.ts @@ -0,0 +1 @@ +export * from './prompt-service'; diff --git a/packages/ui/src/workbench/prompt/prompt-service.ts b/packages/ui/src/workbench/prompt/prompt-service.ts new file mode 100644 index 00000000..e18d0a3f --- /dev/null +++ b/packages/ui/src/workbench/prompt/prompt-service.ts @@ -0,0 +1,40 @@ +import { type Observable, ObservableValue, createToken } from '@nanoforge-dev/editor-kernel'; + +export interface PromptOptions { + readonly title: string; + readonly label: string; + readonly value?: string; + readonly confirm?: string; + /** Returns an error message for invalid values. */ + readonly validate?: (value: string) => string | undefined; + /** An empty value can be confirmed (an optional field): it resolves to `''`. */ + readonly optional?: boolean; +} + +export interface PendingPrompt extends PromptOptions { + readonly resolve: (value: string | undefined) => void; +} + +/** Asks the user for a short text in a dialog (no blocking browser prompts). */ +export class PromptService { + private readonly _current = new ObservableValue(undefined); + + get current(): Observable { + return this._current.readonly(); + } + + ask(options: PromptOptions): Promise { + this._current.get()?.resolve(undefined); + return new Promise((resolve) => { + this._current.set({ + ...options, + resolve: (value) => { + this._current.set(undefined); + resolve(value); + }, + }); + }); + } +} + +export const PromptServiceToken = createToken('ui.prompt'); diff --git a/packages/ui/src/workbench/service/index.ts b/packages/ui/src/workbench/service/index.ts new file mode 100644 index 00000000..798ed6c9 --- /dev/null +++ b/packages/ui/src/workbench/service/index.ts @@ -0,0 +1,2 @@ +export * from './workbench-service.type'; +export * from './workbench-service'; diff --git a/packages/ui/src/workbench/service/workbench-service.ts b/packages/ui/src/workbench/service/workbench-service.ts new file mode 100644 index 00000000..761b6840 --- /dev/null +++ b/packages/ui/src/workbench/service/workbench-service.ts @@ -0,0 +1,300 @@ +import { z } from 'zod'; + +import { HISTORY_CONTEXT_KEY, TEXT_INPUT_FOCUS_KEY } from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + Emitter, + type Observable, + ObservableValue, + createToken, + derived, + observe, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import { + type Layout, + type WidgetRef, + findWidget, + isSlotVisible, +} from '@nanoforge-dev/editor-layout'; +import { defineSetting } from '@nanoforge-dev/editor-settings'; + +import { + WIDGETS, + WIDGET_VIEWS, + type WidgetDescriptor, + type WidgetView, +} from '../extension-point/widget.extension-point'; +import type { WidgetInstance } from '../widget/widget-instance.type'; +import type { ViewStatus, WorkbenchServiceOptions } from './workbench-service.type'; + +/** Small per instance states of widgets (scroll, expanded nodes…). */ +export const WidgetStateSetting = defineSetting({ + key: 'layout.widgetState', + schema: z.record(z.string(), z.unknown()), + default: {}, + title: 'Panel states', + category: 'Editor/Layout', + scopes: ['machine', 'projectLocal'], +}); + +/** Focus context keys. */ +export const FOCUSED_WIDGET_KEY = 'focusedWidget'; + +const isTextInput = (element: Element | null): boolean => + !!element && + (element instanceof HTMLTextAreaElement || + (element instanceof HTMLInputElement && + !['checkbox', 'radio', 'button', 'range', 'color'].includes(element.type)) || + (element as HTMLElement).isContentEditable || + !!element.closest('.monaco-editor')); + +interface InstanceRecord { + readonly instance: WidgetInstance; + historyContext: string | undefined; + readonly store: DisposableStore; +} + +/** Resolves widget views, instances and focus for the layout renderer. */ +export class WorkbenchService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _activations = new Map>(); + private readonly _failed = new Map(); + private readonly _instances = new Map(); + private readonly _styleRefs = new Map(); + private readonly _state: ObservableValue>; + private _stateTimer: ReturnType | undefined; + private readonly _onDidFail = new Emitter(); + + constructor(private readonly _options: WorkbenchServiceOptions) { + this._store.add(_options.settings.registry.register(WidgetStateSetting)); + this._state = new ObservableValue({ + ...(_options.settings.get(WidgetStateSetting) as Record), + }); + } + + descriptor(widgetId: string): WidgetDescriptor | undefined { + return this._options.extensions.getValues(WIDGETS).find((widget) => widget.id === widgetId); + } + + get descriptors(): Observable { + return derived([this._options.extensions.observe(WIDGETS)], (list) => + list.map((contribution) => contribution.value), + ); + } + + /** How to render a widget; triggers its plugin activation when needed. */ + view(widgetId: string): Observable { + const compute = (): ViewStatus => { + const descriptor = this.descriptor(widgetId); + if (!descriptor) return { kind: 'missing', widgetId }; + const contribution = this._options.extensions + .getContributions(WIDGET_VIEWS) + .find((candidate) => candidate.value.id === widgetId); + if (contribution) { + return { kind: 'ready', view: contribution.value, descriptor, owner: contribution.owner }; + } + const failure = this._failed.get(widgetId); + if (failure) return { kind: 'unavailable', descriptor, reason: failure }; + this._activate(widgetId); + return { kind: 'loading', descriptor }; + }; + return { + get: compute, + subscribe: (run) => { + let current = compute(); + run(current); + const refresh = () => { + const next = compute(); + if ( + next.kind === current.kind && + (next as { view?: unknown }).view === (current as { view?: unknown }).view + ) + return; + current = next; + run(next); + }; + const subscription = this._options.extensions.onDidChange(refresh); + const failures = this._onDidFail.event(refresh); + return () => { + subscription.dispose(); + failures.dispose(); + }; + }, + }; + } + + /** The instance object handed to a widget view (created once per instance id). */ + instance(ref: WidgetRef): WidgetInstance { + const existing = this._instances.get(ref.instanceId); + if (existing) return existing.instance; + const store = new DisposableStore(); + const onShow = store.add(new Emitter()); + const onHide = store.add(new Emitter()); + const visible = derived([this._options.layout.layout], (layout) => isVisible(layout, ref)); + store.add(observe(visible, (shown) => (shown ? onShow : onHide).fire(), { immediate: false })); + const descriptor = this.descriptor(ref.widgetId) ?? { + id: ref.widgetId, + title: ref.widgetId, + kind: 'dock' as const, + order: 0, + openByDefault: false, + singleton: true, + }; + const record: InstanceRecord = { + historyContext: descriptor.historyContext, + store, + instance: undefined as never, + }; + const instance: WidgetInstance = { + instanceId: ref.instanceId, + descriptor, + services: this._options.services, + visible, + onShow: onShow.event, + onHide: onHide.event, + getState: () => this._state.get()[ref.instanceId] as T | undefined, + setState: (state) => { + this._state.update((all) => ({ ...all, [ref.instanceId]: state })); + this._scheduleStateSave(); + }, + setHistoryContext: (contextId) => { + record.historyContext = contextId; + if (this._options.contextKeys.get(FOCUSED_WIDGET_KEY) === ref.instanceId) { + this._options.contextKeys.set(HISTORY_CONTEXT_KEY, contextId); + } + }, + }; + (record as { instance: WidgetInstance }).instance = instance; + this._instances.set(ref.instanceId, record); + return instance; + } + + /** Forgets an instance whose widget left the layout. */ + release(instanceId: string): void { + const record = this._instances.get(instanceId); + if (!record) return; + this._instances.delete(instanceId); + record.store.dispose(); + } + + /** Injects a view's styles while at least one of its instances is mounted. */ + useStyles(view: WidgetView, owner: string): Disposable { + if (!view.styles) return toDisposable(() => undefined); + let ref = this._styleRefs.get(view); + if (!ref) { + ref = { + count: 0, + sheet: this._options.styles.inject(owner, view.styles, { widgetId: view.id }), + }; + this._styleRefs.set(view, ref); + } + ref.count++; + return toDisposable(() => { + if (--ref.count > 0) return; + ref.sheet.dispose(); + this._styleRefs.delete(view); + }); + } + + /** + * Tracks focus inside `root`: `focusedWidget`, `historyContext` (undo target) and + * `textInputFocus` (native undo) context keys. + */ + trackFocus(root: HTMLElement): Disposable { + const context = this._options.contextKeys; + const update = () => { + const active = root.ownerDocument.activeElement; + const host = active?.closest('[data-nf-instance]'); + const instanceId = host?.dataset.nfInstance; + const record = instanceId ? this._instances.get(instanceId) : undefined; + context.set(FOCUSED_WIDGET_KEY, instanceId); + context.set(HISTORY_CONTEXT_KEY, record?.historyContext ?? host?.dataset.nfHistory); + context.set(TEXT_INPUT_FOCUS_KEY, isTextInput(active)); + }; + let queued = false; + const schedule = () => { + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + update(); + }); + }; + root.addEventListener('focusin', schedule); + root.addEventListener('focusout', schedule); + return this._store.add( + toDisposable(() => { + root.removeEventListener('focusin', schedule); + root.removeEventListener('focusout', schedule); + }), + ); + } + + dispose(): void { + if (this._stateTimer) { + clearTimeout(this._stateTimer); + void this._saveState(); + } + for (const record of this._instances.values()) record.store.dispose(); + this._instances.clear(); + this._store.dispose(); + this._onDidFail.dispose(); + } + + private _activate(widgetId: string): void { + if (this._activations.has(widgetId)) return; + const activation = this._options + .activate(`onWidget:${widgetId}`) + .then(() => { + const registered = this._options.extensions + .getValues(WIDGET_VIEWS) + .some((view) => view.id === widgetId); + if (!registered) this._fail(widgetId, 'Its plugin did not register a view for this panel.'); + }) + .catch((error: unknown) => + this._fail(widgetId, error instanceof Error ? error.message : String(error)), + ); + this._activations.set(widgetId, activation); + } + + private _fail(widgetId: string, reason: string): void { + this._options.logger?.warn(`Panel ${widgetId} unavailable: ${reason}`); + this._failed.set(widgetId, reason); + this._onDidFail.fire(widgetId); + } + + private _scheduleStateSave(): void { + clearTimeout(this._stateTimer); + this._stateTimer = setTimeout(() => { + this._stateTimer = undefined; + void this._saveState(); + }, 1000); + } + + private async _saveState(): Promise { + const settings = this._options.settings; + const scope = settings.hasStore('projectLocal') ? 'projectLocal' : 'machine'; + try { + await settings.set(WidgetStateSetting, this._state.get(), scope); + } catch (error) { + this._options.logger?.warn('Saving panel states failed', error); + } + } +} + +/** Whether an instance is on screen: active in its stack, in a shown slot or a float. */ +export const isVisible = (layout: Layout, ref: WidgetRef): boolean => { + if (layout.screens.includes(ref.instanceId)) return layout.activeScreen === ref.instanceId; + if (layout.maximized && layout.maximized !== ref.instanceId) return false; + const found = findWidget(layout, ref.instanceId); + if (!found || !found.active) return false; + return ( + found.location.kind === 'float' || + layout.maximized === ref.instanceId || + isSlotVisible(layout, found.location.slot) + ); +}; + +export const WorkbenchServiceToken = createToken('ui.workbench'); diff --git a/packages/ui/src/workbench/service/workbench-service.type.ts b/packages/ui/src/workbench/service/workbench-service.type.ts new file mode 100644 index 00000000..ea58850e --- /dev/null +++ b/packages/ui/src/workbench/service/workbench-service.type.ts @@ -0,0 +1,37 @@ +import type { + ContextKeyService, + ExtensionRegistry, + Logger, + ServiceAccessor, +} from '@nanoforge-dev/editor-kernel'; +import type { SettingsService } from '@nanoforge-dev/editor-settings'; + +import type { StyleService } from '../../style/style-service'; +import type { WidgetDescriptor, WidgetView } from '../extension-point/widget.extension-point'; +import type { LayoutController } from '../layout/layout-controller'; + +export type ViewStatus = + | { + readonly kind: 'ready'; + readonly view: WidgetView; + readonly descriptor: WidgetDescriptor; + /** Plugin that registered the view (its styles go in its cascade layer). */ + readonly owner: string; + } + | { readonly kind: 'loading'; readonly descriptor: WidgetDescriptor } + /** Descriptor known but the plugin registered no view (activation failed…). */ + | { readonly kind: 'unavailable'; readonly descriptor: WidgetDescriptor; readonly reason: string } + /** Widget of a plugin that is not installed (kept in the layout as a placeholder). */ + | { readonly kind: 'missing'; readonly widgetId: string }; + +export interface WorkbenchServiceOptions { + readonly services: ServiceAccessor; + readonly extensions: ExtensionRegistry; + readonly styles: StyleService; + readonly layout: LayoutController; + readonly contextKeys: ContextKeyService; + readonly settings: SettingsService; + /** Activates plugins listening to an activation event (`onWidget:`). */ + readonly activate: (event: string) => Promise; + readonly logger?: Logger; +} diff --git a/packages/ui/src/workbench/setup/index.ts b/packages/ui/src/workbench/setup/index.ts new file mode 100644 index 00000000..0fb35ceb --- /dev/null +++ b/packages/ui/src/workbench/setup/index.ts @@ -0,0 +1,2 @@ +export * from './setup-shell'; +export * from './setup-workbench'; diff --git a/packages/ui/src/workbench/setup/setup-shell.ts b/packages/ui/src/workbench/setup/setup-shell.ts new file mode 100644 index 00000000..458b33bf --- /dev/null +++ b/packages/ui/src/workbench/setup/setup-shell.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; + +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, + type ExtensionPoint, + type ExtensionRegistry, + type Logger, + type Observable, + type PluginHost, + type StaticContributionHandler, + derived, +} from '@nanoforge-dev/editor-kernel'; +import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; + +import { StyleService } from '../../style/style-service'; +import { BASE_CSS } from '../../theme/base-css'; +import { KIT_CSS } from '../../theme/kit-css'; +import { THEMES, ThemeService } from '../../theme/theme-service'; +import type { ThemeDefinition } from '../../theme/tokens'; +import { KEYBINDINGS, KeybindingSchema } from '../extension-point/keybinding.extension-point'; +import { MENU_BAR, MENU_ITEMS, MenuItemSchema } from '../extension-point/menu.extension-point'; +import { + WIDGETS, + type WidgetDescriptor, + WidgetDescriptorSchema, +} from '../extension-point/widget.extension-point'; +import { KeybindingService } from '../keybinding/keybinding-service'; +import { KEYMAP_PRESETS, KeymapPresetSchema } from '../keybinding/keymap-presets'; +import type { KeymapOverride } from '../keybinding/keymap.type'; +import { NotificationService } from '../notification/notification-service'; +import { PromptService } from '../prompt/prompt-service'; + +export interface ShellOptions { + readonly extensions: ExtensionRegistry; + readonly commands: CommandRegistry; + readonly contextKeys: ContextKeyService; + readonly settings: SettingsService; + readonly logger: Logger; + readonly plugins?: PluginHost; + readonly target?: Window; +} + +export interface Shell extends Disposable { + readonly styles: StyleService; + readonly themes: ThemeService; + readonly notifications: NotificationService; + readonly prompts: PromptService; + readonly keybindings: KeybindingService; +} + +/** Manifest handler registering each declared value on an extension point. */ +const listHandler = ( + key: string, + schema: z.ZodType, + register: (value: T, owner: string) => Disposable, +): StaticContributionHandler => ({ + key, + validator: z.array(schema) as unknown as z.ZodType, + apply: (values, plugin) => { + const store = new DisposableStore(); + for (const value of values) store.add(register(value, plugin.manifest.name)); + return store; + }, +}); + +/** App-wide UI services: styles, theme, notifications, prompts, keybindings, menu bar. */ +export const setupShell = (options: ShellOptions): Shell => { + const { extensions, commands, contextKeys, settings, logger } = options; + const store = new DisposableStore(); + const styles = new StyleService(); + store.add(styles.inject('core', BASE_CSS, { layer: 'reset' })); + store.add(styles.inject('core', KIT_CSS, { layer: 'ui' })); + const themes = store.add( + new ThemeService(extensions, styles, settings.observe(CoreSettings.theme)), + ); + const notifications = new NotificationService(); + const prompts = new PromptService(); + const keybindings = store.add( + new KeybindingService( + extensions, + commands, + contextKeys, + settings.observe(CoreSettings.keymapOverrides) as Observable, + logger, + derived( + [settings.observe(CoreSettings.keymapPreset), extensions.observe(KEYMAP_PRESETS)], + (id, presets) => presets.find((preset) => preset.value.id === id)?.value.bindings ?? [], + ), + ), + ); + store.add(keybindings.attach(options.target ?? window)); + + const contribute = (point: ExtensionPoint, values: T[]) => { + for (const value of values) store.add(extensions.contribute(point, value, { owner: 'core' })); + }; + contribute(MENU_BAR, [ + { id: 'file', title: 'File', order: 0 }, + { id: 'edit', title: 'Edit', order: 1 }, + { id: 'view', title: 'View', order: 2 }, + { id: 'help', title: 'Help', order: 9 }, + ]); + contribute(KEYBINDINGS, [ + { key: 'Mod+Z', command: 'history.undo', when: '!textInputFocus', args: [] }, + { key: 'Mod+Shift+Z', command: 'history.redo', when: '!textInputFocus', args: [] }, + { key: 'Mod+Y', command: 'history.redo', when: '!textInputFocus', args: [] }, + { key: 'F6', command: 'workbench.focusNextPart', args: [] }, + { key: 'Shift+F6', command: 'workbench.focusPreviousPart', args: [] }, + ]); + contribute(MENU_ITEMS, [ + { menu: 'edit', command: 'history.undo', group: 'history', order: 0, args: [] }, + { menu: 'edit', command: 'history.redo', group: 'history', order: 1, args: [] }, + { menu: 'view', command: 'appearance.toggleTheme', group: 'z-appearance', order: 0, args: [] }, + ]); + + store.add( + commands.register({ + id: 'appearance.toggleTheme', + title: 'Toggle light/dark theme', + category: 'View', + handler: async () => { + const current = themes.current.get(); + const next = themes.themes.find((theme) => theme.kind !== current.kind) ?? current; + const scope = settings.hasStore('account') ? 'account' : 'machine'; + await settings.set(CoreSettings.theme, next.id, scope); + }, + }), + ); + + if (options.plugins) { + const host = options.plugins; + store.add( + host.registerContributionHandler( + listHandler( + 'widgets', + WidgetDescriptorSchema as unknown as z.ZodType, + (value, owner) => extensions.contribute(WIDGETS, value, { owner }), + ), + ), + ); + store.add( + host.registerContributionHandler( + listHandler('menus', MenuItemSchema, (value, owner) => + extensions.contribute(MENU_ITEMS, value, { owner }), + ), + ), + ); + store.add( + host.registerContributionHandler( + listHandler('keybindings', KeybindingSchema, (value, owner) => + extensions.contribute(KEYBINDINGS, value, { owner }), + ), + ), + ); + store.add( + host.registerContributionHandler( + listHandler('keymapPresets', KeymapPresetSchema, (value, owner) => + extensions.contribute(KEYMAP_PRESETS, value, { owner }), + ), + ), + ); + store.add( + host.registerContributionHandler( + listHandler('themes', z.unknown() as z.ZodType, (value, owner) => + extensions.contribute(THEMES, value, { owner }), + ), + ), + ); + } + + return { styles, themes, notifications, prompts, keybindings, dispose: () => store.dispose() }; +}; diff --git a/packages/ui/src/workbench/setup/setup-workbench.ts b/packages/ui/src/workbench/setup/setup-workbench.ts new file mode 100644 index 00000000..a0114d3b --- /dev/null +++ b/packages/ui/src/workbench/setup/setup-workbench.ts @@ -0,0 +1,166 @@ +import { type z } from 'zod'; + +import type { HistoryService } from '@nanoforge-dev/editor-history'; +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, + type ExtensionRegistry, + type Logger, + type ServiceAccessor, + observe, +} from '@nanoforge-dev/editor-kernel'; +import { SLOT_IDS, type SlotId, isSlotVisible } from '@nanoforge-dev/editor-layout'; +import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; + +import { MENU_ITEMS, MenuItemSchema } from '../extension-point/menu.extension-point'; +import { WIDGETS } from '../extension-point/widget.extension-point'; +import { DragController } from '../layout/drag-controller'; +import { LayoutController } from '../layout/layout-controller'; +import { SLOT_LABELS } from '../parts/tab-menu'; +import { WorkbenchService } from '../service/workbench-service'; +import type { WorkbenchContext } from '../workbench-context'; +import type { Shell } from './setup-shell'; + +export interface WorkbenchSetupOptions { + readonly shell: Shell; + readonly services: ServiceAccessor; + readonly extensions: ExtensionRegistry; + readonly commands: CommandRegistry; + readonly contextKeys: ContextKeyService; + readonly settings: SettingsService; + readonly history: HistoryService; + readonly logger: Logger; + /** Activates plugins (`onWidget:`). */ + readonly activate: (event: string) => Promise; +} + +/** The workbench of an open project: layout, widget host and their commands and menus. */ +export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext & Disposable => { + const { extensions, commands, settings, logger, shell } = options; + const store = new DisposableStore(); + const layout = store.add( + new LayoutController({ extensions, history: options.history, settings, logger }), + ); + const workbench = store.add( + new WorkbenchService({ + services: options.services, + extensions, + styles: shell.styles, + layout, + contextKeys: options.contextKeys, + settings, + activate: options.activate, + logger, + }), + ); + const drag = new DragController(); + + /** `needsArgs`: run from menu entries that give the arguments, never from the palette. */ + const register = ( + id: string, + title: string, + handler: (...args: never[]) => unknown, + needsArgs = false, + ) => + store.add( + commands.register({ + id, + title, + category: 'View', + ...(needsArgs && { palette: false }), + handler: (_services, ...args) => handler(...(args as never[])), + }), + ); + + register('workbench.openWidget', 'Open panel', (widgetId: string) => layout.open(widgetId), true); + register('workbench.closeWidget', 'Close panel', (instanceId?: string) => { + const target = instanceId ?? options.contextKeys.get('focusedWidget'); + return target ? layout.close(target) : undefined; + }); + register( + 'workbench.toggleSlot', + 'Toggle dock', + (slot: SlotId) => layout.showSlot(slot, !isSlotVisible(layout.current, slot)), + true, + ); + register('layout.saveAs', 'Save layout as…', async () => { + const name = await shell.prompts.ask({ + title: 'Save layout', + label: 'Layout name', + value: settings.get(CoreSettings.layoutActive), + confirm: 'Save layout', + validate: (value) => (value.length > 40 ? 'Use at most 40 characters' : undefined), + }); + if (!name) return; + await layout.saveAs(name); + shell.notifications.notify('success', `Layout "${name}" saved`); + }); + register('layout.switch', 'Switch layout', (name: string) => layout.switchTo(name), true); + register('layout.reset', 'Reset layout', async () => { + await layout.reset(); + shell.notifications.notify('info', 'Layout reset to default'); + }); + + const dynamic = store.add(new DisposableStore()); + const refreshMenus = () => { + dynamic.clear(); + const add = (item: z.input) => + dynamic.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); + add({ menu: 'view', submenu: 'view.panels', title: 'Panels', group: 'layout', order: 0 }); + add({ menu: 'view', submenu: 'view.screens', title: 'Screens', group: 'layout', order: 1 }); + add({ menu: 'view', submenu: 'view.docks', title: 'Docks', group: 'layout', order: 2 }); + add({ menu: 'view', submenu: 'view.layouts', title: 'Layouts', group: 'layout', order: 3 }); + const widgets = extensions + .getValues(WIDGETS) + .filter((widget) => options.contextKeys.evaluate(widget.when)); + for (const widget of widgets.sort((a, b) => a.title.localeCompare(b.title))) { + add({ + menu: widget.kind === 'screen' ? 'view.screens' : 'view.panels', + command: 'workbench.openWidget', + args: [widget.id], + title: widget.title, + order: widget.order, + }); + } + for (const slot of SLOT_IDS) { + add({ + menu: 'view.docks', + command: 'workbench.toggleSlot', + args: [slot], + title: `${SLOT_LABELS[slot]} dock`, + }); + } + add({ menu: 'view.layouts', command: 'layout.saveAs', group: 'manage', order: 0 }); + add({ menu: 'view.layouts', command: 'layout.reset', group: 'manage', order: 1 }); + for (const name of layout.savedNames) { + add({ + menu: 'view.layouts', + command: 'layout.switch', + args: [name], + title: name, + group: 'saved', + }); + } + }; + refreshMenus(); + store.add(extensions.onDidChange((point) => point.id === WIDGETS.id && refreshMenus())); + store.add( + observe(settings.observe(CoreSettings.layoutSaved), refreshMenus, { immediate: false }), + ); + + return { + workbench, + layout, + drag, + notifications: shell.notifications, + prompts: shell.prompts, + keybindings: shell.keybindings, + extensions, + commands, + contextKeys: options.contextKeys, + logger, + dispose: () => store.dispose(), + }; +}; diff --git a/packages/ui/src/workbench/widget/index.ts b/packages/ui/src/workbench/widget/index.ts new file mode 100644 index 00000000..8b29fa82 --- /dev/null +++ b/packages/ui/src/workbench/widget/index.ts @@ -0,0 +1 @@ +export * from './widget-instance.type'; diff --git a/packages/ui/src/workbench/widget/widget-instance.type.ts b/packages/ui/src/workbench/widget/widget-instance.type.ts new file mode 100644 index 00000000..1ff67de5 --- /dev/null +++ b/packages/ui/src/workbench/widget/widget-instance.type.ts @@ -0,0 +1,22 @@ +import type { Event, Observable, ServiceAccessor } from '@nanoforge-dev/editor-kernel'; + +import type { WidgetDescriptor } from '../extension-point/widget.extension-point'; + +/** What a widget receives: its identity, services and lifecycle. */ +export interface WidgetInstance { + readonly instanceId: string; + readonly descriptor: WidgetDescriptor; + readonly services: ServiceAccessor; + /** True while the widget is on screen (its tab active and its slot visible). */ + readonly visible: Observable; + readonly onShow: Event; + readonly onHide: Event; + /** + * Saves a small, JSON-serializable state (scroll, expanded nodes…) kept across reloads of the + * editor and hot reloads of the plugin. + */ + setState(state: unknown): void; + getState(): T | undefined; + /** Sets the undo/redo target while the widget has focus. */ + setHistoryContext(contextId: string | undefined): void; +} diff --git a/packages/ui/src/workbench/workbench-context.ts b/packages/ui/src/workbench/workbench-context.ts new file mode 100644 index 00000000..51f01812 --- /dev/null +++ b/packages/ui/src/workbench/workbench-context.ts @@ -0,0 +1,41 @@ +import { getContext, setContext } from 'svelte'; + +import type { + CommandRegistry, + ContextKeyService, + ExtensionRegistry, + Logger, +} from '@nanoforge-dev/editor-kernel'; + +import type { KeybindingService } from './keybinding/keybinding-service'; +import type { DragController } from './layout/drag-controller'; +import type { LayoutController } from './layout/layout-controller'; +import type { MenuContext } from './menu/resolve-menu'; +import type { NotificationService } from './notification/notification-service'; +import type { PromptService } from './prompt/prompt-service'; +import type { WorkbenchService } from './service/workbench-service'; + +export interface WorkbenchContext extends MenuContext { + readonly workbench: WorkbenchService; + readonly layout: LayoutController; + readonly drag: DragController; + readonly notifications: NotificationService; + readonly prompts: PromptService; + readonly extensions: ExtensionRegistry; + readonly commands: CommandRegistry; + readonly contextKeys: ContextKeyService; + readonly keybindings: KeybindingService; + readonly logger: Logger; +} + +const KEY = Symbol('nanoforge-workbench'); + +export const setWorkbenchContext = (context: WorkbenchContext): void => { + setContext(KEY, context); +}; + +export const getWorkbenchContext = (): WorkbenchContext => { + const context = getContext(KEY); + if (!context) throw new Error('Workbench parts must be rendered inside '); + return context; +}; diff --git a/packages/ui/src/workbench/workbench.svelte b/packages/ui/src/workbench/workbench.svelte new file mode 100644 index 00000000..cd1faef0 --- /dev/null +++ b/packages/ui/src/workbench/workbench.svelte @@ -0,0 +1,385 @@ + + +
+ + +
+ {#if !$docksHidden} + + {/if} + {#if leftTop || leftBottom} +
+ {#if leftTop} +
+ +
+ {/if} + {#if leftTop && leftBottom} + resizeSplit('left', delta, final)} + /> + {/if} + {#if leftBottom} +
+ +
+ {/if} +
+ resizeColumn('left', delta, final)} + /> + {/if} + +
+
+ {#if layout.screens.length === 0} + + {/if} + {#each visitedScreens.filter( (screen) => layout.screens.includes(screen) ) as screen (screen)} + + {/each} +
+ {#if bottom || bottomRight} + +
+ {#if bottom} +
+ +
+ {/if} + {#if bottom && bottomRight} + resizeSplit('bottom', delta, final)} + /> + {/if} + {#if bottomRight} +
+ +
+ {/if} +
+ {/if} +
+ + {#if rightTop || rightBottom} + resizeColumn('right', delta, final)} + /> +
+ {#if rightTop} +
+ +
+ {/if} + {#if rightTop && rightBottom} + resizeSplit('right', delta, final)} + /> + {/if} + {#if rightBottom} +
+ +
+ {/if} +
+ {/if} + {#if !$docksHidden} + + {/if} + + {#each layout.floats as float (float.id)} +
+ {/each} + + {#if maximized} +
+
+ {workbench.descriptor(maximized.widgetId)?.title ?? maximized.widgetId} + +
+
+
+ {/if} +
+ + + + + +
+ + diff --git a/packages/ui/test/__screenshots__/kit.svelte.spec.ts/Tree-only-renders-visible-rows-of-large-trees-1.png b/packages/ui/test/__screenshots__/kit.svelte.spec.ts/Tree-only-renders-visible-rows-of-large-trees-1.png new file mode 100644 index 0000000000000000000000000000000000000000..79272762510e72d9cec451a4afeafb26eacb8b12 GIT binary patch literal 6745 zcmeI1`Bzg{zs3U=sd$052v`|Xs8k}PY5^Ie zq5=Ym5rGg0ae|--ks(Y0A_RzmkN_c&kc8Zwwzt=J-QN2L+|?g8>#TDYhqL$oem~Fi zY_5B_IjgH}Q-eSt>L-pLI|YF#xk4by?>|=tcZx69--bZGfSfpX#5=i!JC-&&ATSUu z#(u96`1!-DB{{-h`hFJPIrpwm_5S=mtFB+8wtMLrS#@naaI5Af%Sg}2U|{OtgF6lZ zrwe!Ixmm;U0adbb3NA}Bh{s8IYGd4V7&c>Q+In=53=3xR*#a_tu680rE@3Jkk1kd2 zaAW_1?cNS6bwF8XNw?*blcqXu=IB|BCk&oJ(^EtfS#l{G7TY}EpcnnDCku)qo_%pS zM7}l{A{iOXrZECz$@ID~T5|b`+q=e+Svg(B7G{}m4_0#c5raX9F4JaiFYZipKrpg% zZ{ukC>Xf)`3m*d?B<#7+ncrq5FH7TDOJj89nzM7jK#1Tb7`55p^F`U(jKM;WjX03W6@ak?<+7xDU_kx~(H$Ufl#{CEB2A z8@KR6m5CH|Eoz;F#Vt*}VT#%#YPp6OCu}ng5cEfntbOQ zTRm5LIDHk)$<6#$$zx5&ED5hUw%jRaZDpQ8I8O~Gr?%akd{i8ht#xsWAkm7^kU!_n_O4@WoMpM|O zF0|`fqRnP49{mwA;UGpJ?lhwWt@I>QdjmIUKi+r87>m64{_YV%GLEx}u!$Mp=gN8= zm`~OlQ6a!EMK;uMF=y-~RfPt(&maCMg$@P@_Uh;v05e@(WIx z2KDO4?G2-q*+mUKA)0PAkrUSk!G~+Rb8gB&UFo{#LcglMQNLJW)A9I40b3GrMO8Dm z+#yOw?E`}TM7009?5GlQy!BgMT=|0kGxrk9eZC@6^CJmT(c%SYZD*6`^DxgqHd@y#S1Zc3uar^=?i4*H2!3vfW;E2l_ANk^ zXVUH?FIMNYJm{WrQo=fln>;5*5wyL&6{c*b97DUc9%rb5ZE_q3pKik=o>%g2AKl$S z`*f`OfAa?~KAkVW4rPb~uPjBaaniU1>eWqJGc+u-F3cSpoAox2k9_;WjfC<2;L@JWrjfVsul%tTE=cIA1-e;1g|Ly7XS1vM=fa- z?p*UtS@Ps{<;w4JB!c+{hUr>R{X|4a|7hjJt1QL-t9TGbaIYL^(|Aqn_xBt+DNE=( zdfGRsHf5=^Rf>n!@*WIoOZA*luH}7hc>>HLV(7^^Bc~h0Ib5^aS=atMLB|YTc>XBe zL)CRno-PHZ^8>XEP>ZImqREr%b6-BsejAddXO|jZlFy1Kj|{@rK9-+vzcCz&-WytL zh!&t^Yi&?+kwvWG;KXEX6nt;3Pqi=H2schGh3~@JB=M#TLJTcH<8I+N2>>*6MJ)CW zC~Unb5_i2CPO!wZ9UaBl3!GO75AmqJp?1J%xoPIxJ9;YO!cT_SaI>Pm+&=|3*a)z~ zZ*J{W`NdI8Bb)#D{sX`c>EnHLHodA&99L1AIFiVSZUi0OeP^E$ijcoIP{9?c?I>Wb zP2@lgp!C4f!E^J-&LsyYDwh>TLo&}ShT96$YZcEEi2i%bf?GslHr7n?;}|Uq%{p4)R{F@bc%?c-_879qH;_%( zh0$74Qq}iZ&r7g}nreJTPFuC-5m_Vkk$w>EYEZ7WZi8n?@m1k&Uf>NTR^qAKSsDi0 zsukNWo^s07Z<4JroTnUXXQS+cYF4VLu=a6OmO>TZnQoCdMmS)WNy}3_sXqOQ&i>gqe(Qp`*p|h1pi2xQoE4fd8x`aIruU(X zrq$Fhf^Hl9^7fRwUrpP@j*$|mxdt>#){PO$`%`}qV$N1$hhV15B=e9bi_PE@S-Ho_ zmEz{e&iuX2(xpy*6T57-;}!TBSX1BRd1a%z_40cM){|3^$omblln3kmUhe9KwnWei zwiXeiL^xbD6ibn*omHm~q?DWqNt>@P$JmpkCUbEy-dvT*J%_;_sGaSi52YM4G@11e z7+k&z8d=-qvL(&JpZCTX8wDW7A180VyMOw28lSux1EvkIl6PSR6Z`>*wL_alo{(=RASul;jdy1Z%dljlA=ho45p0nR7@biCT#{j^B z4!usEy5)kStgk6oz6+ae0YsaB+G%Rvy8-J~*qtVvrGqjDmFUYvyi&J_=ME7(%biT@ zYk6!yLbpO$3eb9*4ZmeJ_69U@=v-I{y#3@&-2{7!h!yTI!IwW!8wf;%nxhA*)Fi{e zG;wUKISJEm^s?hA@IM!VPiG;@k5W*w9ZSd40g|jHe>U>98C9?~_k0A|BPhf* z4o;6&T8t+fox&Ku)`S8;??fx#ef=qy71#7{{|6X=A_byLGo*{u#<9dePz9#M50@3j zs07hH40g#mXPZx-K)%vQ$%~P4+%v2d){0b@NHt80y5b%`HxT{M8BdyhEHdb}o~vQR zQplNWVS7=$`g59o3&ErXBNy9>oKBKK?UGZ4d(!jf8O-$+U;|Hm z3q6gP_(`{QWhAmX9Ut)Mr25zU|Dj=uv0XZDJCV6TV<+B`1ug!s-&@~=c*{m~~I#&x{!W1YhCX0%} z*kFMQm`9Ji78tvi*#b!4cC+T>2gXz_q^thN)-O_YJ1HA7K3r0@Ul}^D(tjXf(Jo&h zc7iU8jG4l)$^LD|;v9={%kD}aAD^BTRVea3ka18u?N&%&IVsX6aEe zxH;w{mGNOELwMkTq8o;H#ZXIOGLE|+65Z=vCE64soAqE0AjIgIQmCy~{j zbAH0(u$`A~X~Dq!1wSspHD|I7Z6Yojxo%vR3{XT_?sWnFIzjp@?@`zC+#o*w{KAJ9 z&ie3UuY*=L&l2MC;oqtM!#9cPlB0D|iU9grPphnY1D0a&oqdK-{9gG~{D*SPO zl^2&3SRbK1NM{9%Jm%NWdRJu`rtUWFr)t^)xn-T1d@6tW(`MFEaEqss@SS{UmBz|e zWCJ#Z35lHQJY?~B7lD}@$%T88L2vKKIwXYkr4*Q+3GEpNkwe+vVaDkQs+Fqz-$w1< zgmktINd)s&lrhlE043q6b#WXhg4MC4kiJK*#;!%)hsnQY*azvKCMC|3+H?Z9c!W%2 zHC6lZ;y4eO;%e&mp{pz22uiJsA{+>WpEKi$<2S2`m>2Bzr@}rNY|OjA(uIPInEp(q z4W>;~w?rx_ltsMwaS7eS`LXvnlnU+CT7iWRC9Ybu1deL zVjDgk|I3z$*-n5HQ_Fqsl(pGvJaIA`;JNPizd~6T;^D|}08uF{db~v}pus4BJGvg& zDXgA0J{wGgc@zU}1)$LNp!K7hJQqy~SGL+CMW6{OlhRF<#^e=e;dS=%FML~yn%W6(ebla)E|9UcnZ0Pjwt5=vvhLCsdmj{3kuLfp=6nHa$^Ec;gR+Q>ff;gHJfDh~0nM z0jm@w5du+DR-6Siu5~-->JOh)0o7+>x1A?m#AHa(A<{`znCOKf)FHo&C_JF#y}T+| zmr1a|;~uh01uX}XC{H&i?ge+aF0e7i?%d0(PnnCsq#j&jbA6*KNYC{byivyJa^+26^FmTN5A9Zl$`3Y$@5F%D`}mzn0ID&L zpxFUe6Ugbgu4QZ<(O(&#*5_(bYMXLV=m5XQ2iO44gGB`vGWX^pIA3T0tI#fBbpu^m znWaYpK#)1W5CcU?p17iEPaF(Ri*B0EZ(G9O^uvZ(e9KnAIdB;8q?hke zbl^*ql`^}uTs;dM#CJhn%P^|7e1TiOVcyx6}y1L00^|b z3-&lBHSFCTPZ9-qf}0?t@(r>uK<%H#vtyd)br=#$vF;(X!HXBFP+-!`@XOlEBkb=E z+xgjFYsYTg|NU`csb16*Mbr;T7t%rHG^}kRkYa|PPCG->y;Hm26&_%PREK;ODICgo zFWkS=m%I3KgNEf<`f9);(c?r3eAq1b;(YyJKI&2js?9kP6p7GRjGIACR+i_I0OoZ} zgZxRb{MUpWd3RE)m+Y#)vOWk{0Oz~&&B`3~H?K3@L&8ddw(U#oX9JhOslXcgaePzD zhlUiI{E!jbD*hq-z89D;E7`OEY=5Qaq7FQTvb@<=@a}+Pla-dr!u4x#1Dt>BXijPf z$aj`LPPXu}8kAFz_S`r*ZBz@r@D#BP*n+2~XGl_T!h}&z3jgtfpD9i>t9x%XJ!jUT*wVxLS zHZx%uSd&jkHU<&J?PXm?E>?N(*V5S?Igg7yKWCT*BH8*XVMHNmOrR&)NuOzFzsyqC z)7dN{uKPhEyGu58(1d&b%7p!XEbwn?^JnA#MV|ZfAmQ)d`0Z@+@3s1?)c<#t{M~B5 zpWXl6AOCOqV>JW(Uq~*G*>bNHTx?Xh!3~ga>FqrAfdf~;9moksw_~`YXD|OJsJcYC2?#&ksCK}`HsWoVz0yA->qI>oVKUtXAzUfY&*s)FJ*qE?fJQ_ z=I5tocK*2ib+!Nh{k^?Czy8~so6CG>+x`7=dA51}y4c-i@9ykOK0Cv{=EsMVlhy10 z{d_LbcXFzB__o~J-|m**zq_k6`}@1Q@pV6+s!ad;_}>AM%Bw%S#r2o@*Ow;RSATmm z$FleszjfK0A5W*po8{av==%G1`+bnPzrMT#s*|%UO0hYAex7ahwKb9Te?A`X64n0p z_V)MR@AuEQsVo8-@7^zGTl(rs%CxeulaOx$DetY#c4in+mnrQZ*BRw|G|%skLTBZySdbRy4&7l zi~M_gj&_UN*Z-^ebo$@6|36*}AGk7OWBU1dg)?Q}>c;LWxw$E|oljQka-sF#e?aF% ze!S=|e|KN)?|XZz^P^<#{{47d*j^9xJHOqJ2WOug=C_xLf4|pkW@`R>dq$s`;oDz_ zuaEota{2s!^;YYS{r~iOhkAYW!5yFH|JhW3U;an_Sqb+1c7FM~FE1``Og^4>XNRHR z#W0|bzu)ioTmLlIes_Precj(ztJnXVWmEAX!DrvUU#|;qe7ioY{9fhr+4=isKH0SW z`+SCFXTAVq1L&Ck#|b(A{`_1Ww)WPh)YBQI7Zzu)bi9RF?mU-bq}Je{P}w^9#$1UtG2v z6&?+f(F8G?8%B$Q(c*EmZWvA#aEP23uW%oNKL6&m`?mf3zZ>bF?q|Q<`Ydi&N#^x+vFrE! zdX@65^84NL=VxYq{{4Rc`7`zW|1{Iq#_j#J{eE5b>$Tg}j4y3WJ|0*7cB_2dk3^d+f`@aAGe$TJ__42rU{hfWa zznj_l-`(H;|L61h{`0b9Y{WjO|37_hu66$1U0;tI&oK`BLeR_Jjea(-8r>CZ-pPMuDkG)RhrYG0q>t(lByx;r%*!BGVf6Hvb z($1UhY?z+(>s+4;-r>b}3b%PVabv$H7m)BGj-|NVNMp1(2u{JfP{j(_{n zQSbnXY5;ddbi+Zg6g)h1u$le+-QC-> zuC7`>xBvK&>G5?tOJ83zye+TZFJt-X*X#B6)zc^b`t|ko_qVsziz-$wMYh8XUIQiAD^tG|O%b510 zTrU52(OrJ7U2Rp?J-e9wb$?%7T`gb#=i_DWXTazw{gB?@Y?^(oN3oXa48vvp+P|Mp z>nC5c-C9)l``g=%eC0nrJWSXB{pGU%@dh3h% zXte|PvwnU2v-|x%>G{bMbI$D2kK3~Y=*iU6(?HJJuP1%FDEUVGhJ=GNX0DIem~?GT zWbpIn=jZP)e0S?K8 z&-&KBKgnZukM&-~<6h1WO#klwY-DDir?>XJ#`-;i8 zNo7{;c5|KQpIhkv{KE3$7netcfjMt9OhyyLXl@uS3Py{9(Yj%1R*d)e16#rW|4(u) h3v_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX + import Button from '../../src/components/button.svelte'; + import Menu from '../../src/components/menu.svelte'; + import type { MenuEntry } from '../../src/components/menu-entry.type'; + + const { items }: { items: MenuEntry[] } = $props(); + + + + {#snippet trigger({ props })} + + {/snippet} + diff --git a/packages/ui/test/fixtures/TreeHarness.svelte b/packages/ui/test/fixtures/TreeHarness.svelte new file mode 100644 index 00000000..e0a274f0 --- /dev/null +++ b/packages/ui/test/fixtures/TreeHarness.svelte @@ -0,0 +1,21 @@ + + +
+ +
+{[...selected].join(',')} +{[...expanded].join(',')} diff --git a/packages/ui/test/kit.svelte.spec.ts b/packages/ui/test/kit.svelte.spec.ts new file mode 100644 index 00000000..91bc479d --- /dev/null +++ b/packages/ui/test/kit.svelte.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +import ColorPicker from '../src/components/color-picker.svelte'; +import Dialog from '../src/components/dialog.svelte'; +import NumberInput from '../src/components/number-input.svelte'; +import Select from '../src/components/select.svelte'; +import Splitter from '../src/components/splitter.svelte'; +import Switch from '../src/components/switch.svelte'; +import Tabs from '../src/components/tabs.svelte'; +import type { TreeNode } from '../src/components/tree-model'; +import MenuHarness from './fixtures/MenuHarness.svelte'; +import TreeHarness from './fixtures/TreeHarness.svelte'; + +describe('NumberInput', () => { + it('commits typed values, arrows and scrubbing', async () => { + const onchange = vi.fn(); + const screen = render(NumberInput, { value: 10, onchange, label: 'X', prefix: 'X', step: 1 }); + const input = screen.getByRole('textbox', { name: 'X' }); + await input.fill('12,5'); + await userEvent.keyboard('{Enter}'); + expect(onchange).toHaveBeenLastCalledWith(12.5, true); + await input.click(); + await userEvent.keyboard('{ArrowUp}'); + expect(onchange).toHaveBeenLastCalledWith(11, true); + + const handle = screen.container.querySelector('.prefix')!; + const rect = handle.getBoundingClientRect(); + const pointer = (type: string, x: number) => + handle.dispatchEvent( + new PointerEvent(type, { + clientX: x, + clientY: rect.top + 2, + button: 0, + pointerId: 1, + bubbles: true, + }), + ); + pointer('pointerdown', rect.left); + pointer('pointermove', rect.left + 10); + pointer('pointermove', rect.left + 50); + pointer('pointerup', rect.left + 50); + expect(onchange).toHaveBeenCalledWith(20, false); + expect(onchange).toHaveBeenLastCalledWith(20, true); + }); +}); + +const bigTree = (): TreeNode[] => + Array.from({ length: 50 }, (_, i) => ({ + id: `group-${i}`, + label: `Group ${i}`, + children: Array.from({ length: 100 }, (_, j) => ({ + id: `n-${i}-${j}`, + label: `Entity ${i}.${j}`, + })), + })); + +describe('Tree', () => { + it('only renders visible rows of large trees', async () => { + const screen = render(TreeHarness, { nodes: bigTree() }); + (screen.getByRole('tree').element() as HTMLElement).focus(); // focuses the first row + await userEvent.keyboard('{ArrowRight}'); // expand it + await expect.element(screen.getByTestId('expanded')).toHaveTextContent('group-0'); + const rows = screen.container.querySelectorAll('[role="treeitem"]'); + expect(rows.length).toBeGreaterThan(5); + expect(rows.length).toBeLessThan(40); + }); + + it('navigates, selects ranges and renames with the keyboard', async () => { + const onrename = vi.fn(); + const onactivate = vi.fn(); + const nodes: TreeNode[] = [ + { id: 'a', label: 'Player', children: [{ id: 'a1', label: 'Sprite' }] }, + { id: 'b', label: 'Ball' }, + { id: 'c', label: 'Wall' }, + ]; + const screen = render(TreeHarness, { nodes, onrename, onactivate }); + await screen.getByText('Ball').click(); + await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}'); + await expect.element(screen.getByTestId('selected')).toHaveTextContent('b,c'); + await userEvent.keyboard('{ArrowUp}{ArrowUp}{ArrowRight}{ArrowDown}'); + await expect.element(screen.getByTestId('selected')).toHaveTextContent('a1'); + await userEvent.keyboard('{Enter}'); + expect(onactivate).toHaveBeenCalledWith('a1'); + await userEvent.keyboard('{F2}'); + const input = screen.getByRole('textbox', { name: 'New name' }); + await input.fill('Hero sprite'); + await userEvent.keyboard('{Enter}'); + expect(onrename).toHaveBeenCalledWith('a1', 'Hero sprite'); + }); +}); + +describe('controls', () => { + it('Switch toggles', async () => { + const onchange = vi.fn(); + const screen = render(Switch, { label: 'Snap', onchange }); + await screen.getByRole('switch', { name: 'Snap' }).click(); + expect(onchange).toHaveBeenCalledWith(true); + }); + + it('Tabs move with arrow keys', async () => { + const onchange = vi.fn(); + const screen = render(Tabs, { + label: 'Mode', + active: 'game', + onchange, + items: [ + { id: 'game', label: 'Game' }, + { id: 'scene', label: 'Scene' }, + ], + }); + await screen.getByRole('tab', { name: 'Game' }).click(); + await userEvent.keyboard('{ArrowRight}'); + expect(onchange).toHaveBeenLastCalledWith('scene'); + }); + + it('Splitter resizes with the keyboard', async () => { + const onresize = vi.fn(); + const screen = render(Splitter, { + orientation: 'vertical', + label: 'Resize left panel', + onresize, + }); + (screen.getByRole('separator').element() as HTMLElement).focus(); + await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowLeft}{/Shift}'); + expect(onresize.mock.calls.filter(([, final]) => final).map(([delta]) => delta)).toEqual( + expect.arrayContaining([10, -40]), + ); + }); + + it('ColorPicker normalizes hex input', async () => { + const onchange = vi.fn(); + const screen = render(ColorPicker, { value: '#000000', label: 'Tint', onchange }); + await screen.getByRole('textbox', { name: 'Tint', exact: true }).fill('#F80'); + await userEvent.keyboard('{Enter}'); + expect(onchange).toHaveBeenLastCalledWith('#ff8800', true); + }); + + it('Select picks an option', async () => { + const onchange = vi.fn(); + const screen = render(Select, { + label: 'Shape', + value: 'circle', + onchange, + items: [ + { value: 'circle', label: 'Circle' }, + { value: 'rect', label: 'Rectangle' }, + ], + }); + await screen.getByLabelText('Shape').click(); + await page.getByRole('option', { name: 'Rectangle' }).click(); + expect(onchange).toHaveBeenCalledWith('rect'); + }); +}); + +describe('overlays', () => { + it('Menu runs the selected item', async () => { + const onSelect = vi.fn(); + const screen = render(MenuHarness, { + items: [ + { kind: 'item', id: 'save', label: 'Save', shortcut: 'Ctrl+S', onSelect }, + { kind: 'separator' }, + { kind: 'item', id: 'quit', label: 'Quit', disabled: true, onSelect: vi.fn() }, + ], + }); + await screen.getByRole('button', { name: 'File' }).click(); + await page.getByRole('menuitem', { name: /Save/ }).click(); + expect(onSelect).toHaveBeenCalledOnce(); + }); + + it('Dialog closes with Escape', async () => { + const onclose = vi.fn(); + render(Dialog, { open: true, title: 'New scene', description: 'Name the scene.', onclose }); + await expect.element(page.getByRole('dialog', { name: 'New scene' })).toBeVisible(); + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + expect(onclose).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/test/style.svelte.spec.ts b/packages/ui/test/style.svelte.spec.ts new file mode 100644 index 00000000..e4372e37 --- /dev/null +++ b/packages/ui/test/style.svelte.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ExtensionRegistry, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import { StyleService } from '../src/style/style-service'; +import { ThemeService } from '../src/theme/theme-service'; + +const element = (html: string) => { + const host = document.createElement('div'); + host.innerHTML = html; + document.body.append(host); + return host; +}; + +afterEach(() => { + document.body.innerHTML = ''; + document.adoptedStyleSheets = []; +}); + +describe('StyleService', () => { + it('scopes widget styles and removes them on dispose', () => { + const styles = new StyleService(); + const host = element(` +

in

+

out

`); + const [inside, outside] = host.querySelectorAll('p'); + const sheet = styles.inject('@acme/tools', '.label { color: rgb(255, 0, 0); }', { + widgetId: 'acme.hierarchy', + }); + expect(getComputedStyle(inside!).color).toBe('rgb(255, 0, 0)'); + expect(getComputedStyle(outside!).color).not.toBe('rgb(255, 0, 0)'); + sheet.dispose(); + expect(getComputedStyle(inside!).color).not.toBe('rgb(255, 0, 0)'); + }); + + it('orders layers: user overrides beat plugins, plugins beat ui', () => { + const styles = new StyleService(); + const host = element('

x

'); + const p = host.querySelector('p')!; + styles.inject('user', '.x { color: rgb(0, 0, 255); }', { layer: 'user' }); + styles.inject('@acme/tools', 'p.x { color: rgb(0, 255, 0); }'); + styles.inject('core', 'body p.x { color: rgb(255, 0, 0); }'); + expect(getComputedStyle(p).color).toBe('rgb(0, 0, 255)'); + }); +}); + +describe('ThemeService', () => { + it('applies the selected theme and follows the setting', () => { + const styles = new StyleService(); + const selected = new ObservableValue('nanoforge-dark'); + const themes = new ThemeService(new ExtensionRegistry(), styles, selected); + const root = document.documentElement; + expect(root.dataset.nfTheme).toBe('nanoforge-dark'); + expect(getComputedStyle(root).getPropertyValue('--nf-color-surface').trim()).toBe('#1a1f28'); + selected.set('nanoforge-light'); + expect(getComputedStyle(root).getPropertyValue('--nf-color-surface').trim()).toBe('#eef1f5'); + selected.set('missing-theme'); + expect(root.dataset.nfTheme).toBe('nanoforge-dark'); + themes.dispose(); + }); +}); diff --git a/packages/ui/test/workbench/action/list-actions.spec.ts b/packages/ui/test/workbench/action/list-actions.spec.ts new file mode 100644 index 00000000..47935d99 --- /dev/null +++ b/packages/ui/test/workbench/action/list-actions.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + COMMAND_METADATA, + CommandRegistry, + Container, + ContextKeyService, + ExtensionRegistry, +} from '@nanoforge-dev/editor-kernel'; + +import { + actionLabel, + isActionEnabled, + listActions, +} from '../../../src/workbench/action/list-actions'; +import { + MENU_BAR, + MENU_ITEMS, + MenuItemSchema, +} from '../../../src/workbench/extension-point/menu.extension-point'; + +const setup = () => { + const extensions = new ExtensionRegistry(); + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + const noop = () => undefined; + commands.register({ + id: 'runtime.stop', + title: 'Stop', + category: 'Run', + when: 'playing', + handler: noop, + }); + commands.register({ id: 'open.widget', title: 'Open panel', palette: false, handler: noop }); + commands.register({ id: 'internal', handler: noop }); + commands.register({ id: 'hello.greet', handler: noop }); + const declare = (id: string, title: string, extra = {}) => + extensions.contribute( + COMMAND_METADATA, + { id, title, owner: '@acme/hello', ...extra }, + { owner: 'x' }, + ); + declare('hello.greet', 'Say hello', { category: 'Hello' }); + declare('hello.lazy', 'Lazy one'); + extensions.contribute(MENU_BAR, { id: 'view', title: 'View', order: 0 }, { owner: 'core' }); + const item = (value: Record) => + extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(value), { owner: 'core' }); + item({ menu: 'view', submenu: 'view.panels', title: 'Panels' }); + item({ menu: 'view.panels', command: 'open.widget', args: ['console'], title: 'Console' }); + item({ + menu: 'view.panels', + command: 'open.widget', + args: ['files'], + title: 'Files', + when: 'project', + }); + item({ menu: 'view', command: 'runtime.stop' }); + item({ menu: 'tab/context', command: 'open.widget', args: ['x'], title: 'Context only' }); + return { actions: listActions({ commands, extensions }), context }; +}; + +describe('listActions', () => { + it('lists titled commands and menu entries with arguments', () => { + const { actions } = setup(); + expect(actions.map((action) => [action.id, actionLabel(action)])).toEqual([ + ['hello.greet', 'Hello: Say hello'], + ['hello.lazy', 'Lazy one'], + ['runtime.stop', 'Run: Stop'], + ['open.widget ["console"]', 'View › Panels: Console'], + ['open.widget ["files"]', 'View › Panels: Files'], + ]); + }); + + it('tells which actions can run now', () => { + const { actions, context } = setup(); + const enabled = () => + actions.filter((action) => isActionEnabled(action, context)).map((action) => action.id); + expect(enabled()).toEqual(['hello.greet', 'hello.lazy', 'open.widget ["console"]']); + context.set('playing', true); + context.set('project', true); + expect(enabled()).toHaveLength(5); + }); +}); diff --git a/packages/ui/test/workbench/keybinding/keybindings.spec.ts b/packages/ui/test/workbench/keybinding/keybindings.spec.ts new file mode 100644 index 00000000..8269ae30 --- /dev/null +++ b/packages/ui/test/workbench/keybinding/keybindings.spec.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + CommandRegistry, + Container, + ContextKeyService, + ExtensionRegistry, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; + +import { KEYBINDINGS } from '../../../src/workbench/extension-point/keybinding.extension-point'; +import { KeybindingService } from '../../../src/workbench/keybinding/keybinding-service'; +import type { KeymapOverride } from '../../../src/workbench/keybinding/keymap.type'; +import { + formatKeybinding, + normalizeStroke, + parseKeybinding, + portableStroke, +} from '../../../src/workbench/keybinding/keystroke'; +import { + bindingsConflict, + findConflicts, + resolveKeymap, +} from '../../../src/workbench/keybinding/resolve-keymap'; + +const key = (init: Partial & { key: string }) => + ({ + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...init, + }) as unknown as KeyboardEvent; + +const setup = (overrides: KeymapOverride[] = []) => { + const extensions = new ExtensionRegistry(); + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + const ran: string[] = []; + for (const id of ['undo', 'palette', 'save', 'quickOpen', 'other']) { + commands.register({ id, handler: () => void ran.push(id) }); + } + const bind = (binding: { key: string; command: string; when?: string }) => + extensions.contribute(KEYBINDINGS, { args: [], ...binding }, { owner: 'core' }); + bind({ key: 'Mod+Z', command: 'undo', when: '!textInputFocus' }); + bind({ key: 'Ctrl+Shift+P', command: 'palette' }); + bind({ key: 'Ctrl+K Ctrl+S', command: 'save' }); + bind({ key: 'Ctrl+P', command: 'quickOpen' }); + const service = new KeybindingService( + extensions, + commands, + context, + new ObservableValue(overrides), + ); + return { service, context, ran, bind }; +}; + +describe('keybinding parsing', () => { + it('normalizes strokes', () => { + expect(normalizeStroke('shift+ctrl+p', false)).toBe('Ctrl+Shift+P'); + expect(normalizeStroke('Mod+Z', true)).toBe('Meta+Z'); + expect(normalizeStroke('Mod+Z', false)).toBe('Ctrl+Z'); + expect(normalizeStroke('Ctrl++', false)).toBe('Ctrl++'); + expect(normalizeStroke('esc', false)).toBe('Escape'); + expect(parseKeybinding('ctrl+k ctrl+s', false)).toEqual(['Ctrl+K', 'Ctrl+S']); + expect(formatKeybinding('Mod+Shift+P', true)).toBe('⇧⌘P'); + expect(() => normalizeStroke('Hyper+X', false)).toThrow(/modifier/); + }); +}); + +describe('KeybindingService', () => { + it('runs commands and lets native undo work in text inputs', async () => { + const { service, context, ran } = setup(); + const event = key({ key: 'z', ctrlKey: true }); + expect(service.handle(event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + context.set('textInputFocus', true); + const native = key({ key: 'z', ctrlKey: true }); + expect(service.handle(native)).toBe(false); + expect(native.preventDefault).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(ran).toEqual(['undo']); + }); + + it('uses the typed character (AZERTY friendly) and supports chords', async () => { + const { service, ran } = setup(); + service.handle(key({ key: 'P', ctrlKey: true, shiftKey: true })); + expect(service.handle(key({ key: 'k', ctrlKey: true }))).toBe(true); // chord start + service.handle(key({ key: 's', ctrlKey: true })); + service.handle(key({ key: 'k', ctrlKey: true })); + service.handle(key({ key: 'x', ctrlKey: true })); // broken chord + await Promise.resolve(); + expect(ran).toEqual(['palette', 'save']); + }); + + it('applies user overrides and reports conflicts', () => { + const { service, bind } = setup([ + { command: 'quickOpen', key: 'Ctrl+P', remove: true }, + { command: 'other', key: 'Ctrl+P' }, + ]); + expect(service.bindings.find((binding) => binding.strokes.join() === 'Ctrl+P')?.command).toBe( + 'other', + ); + expect(service.keyFor('other')).toBe('Ctrl+P'); + expect(service.conflicts()).toEqual([]); + bind({ key: 'Ctrl+Shift+P', command: 'other' }); + expect(service.conflicts().map((conflict) => conflict.key)).toEqual(['Ctrl+Shift+P']); + }); +}); + +describe('resolveKeymap', () => { + const defaults = [ + { key: 'Mod+S', command: 'save', args: [] }, + { key: 'F5', command: 'play', args: [], when: '!playing' }, + { key: 'Ctrl+K S', command: 'saveAll', args: [] }, + ]; + const keys = (bindings: ReturnType) => + bindings.map((binding) => `${binding.source}:${binding.command}:${binding.strokes.join(' ')}`); + + it('applies the preset, then the overrides, in order', () => { + const bindings = resolveKeymap( + defaults, + [ + { command: 'play', key: 'F5', remove: true }, + { command: 'play', key: 'F9' }, + ], + [ + { command: 'play', key: 'F9', remove: true }, + { command: 'play', key: 'F9' }, + { command: 'open', key: 'Ctrl+F1', args: ['game'] }, + { command: 'save', key: 'Mod+S', remove: true }, + ], + undefined, + ); + expect(keys(bindings)).toEqual([ + 'user:play:F9', + 'user:open:Ctrl+F1', + 'default:saveAll:Ctrl+K S', + ]); + expect(bindings[1]!.args).toEqual(['game']); + }); + + it('removes by action: the command and its arguments', () => { + const withArgs = [ + { key: 'Ctrl+F1', command: 'open', args: ['scene'] }, + { key: 'Ctrl+F1', command: 'open', args: ['game'] }, + ]; + const bindings = resolveKeymap( + withArgs, + [], + [{ command: 'open', key: 'Ctrl+F1', args: ['game'], remove: true }], + ); + expect(bindings.map((binding) => binding.args)).toEqual([['scene']]); + }); + + it('skips invalid entries and does not add a shortcut twice', () => { + const invalid: unknown[] = []; + const bindings = resolveKeymap( + defaults, + [], + [ + { command: 'save', key: 'Mod+S' }, + { command: 'save', key: 'Nope+S' }, + { command: 'save', key: 'F2', when: '((' }, + ], + (binding) => invalid.push(binding.key), + ); + expect(bindings).toHaveLength(3); + expect(invalid).toEqual(['Nope+S', 'F2']); + }); +}); + +describe('conflicts', () => { + const [save, saveAs, guarded, other, chord, sameAction] = resolveKeymap( + [ + { key: 'Ctrl+K', command: 'save', args: [] }, + { key: 'Ctrl+K', command: 'saveAs', args: [] }, + { key: 'Ctrl+K', command: 'guarded', args: [], when: 'a' }, + { key: 'Ctrl+K', command: 'other', args: [], when: 'b' }, + { key: 'Ctrl+K S', command: 'chord', args: [], when: 'b' }, + { key: 'Ctrl+K', command: 'save', args: [], when: 'c' }, + ], + [], + [], + ) as [never, never, never, never, never, never]; + + it('needs different actions and conditions that overlap', () => { + expect(bindingsConflict(save, saveAs)).toBe(true); + expect(bindingsConflict(save, guarded)).toBe(true); + expect(bindingsConflict(guarded, other)).toBe(false); + expect(bindingsConflict(save, sameAction)).toBe(false); + }); + + it('sees a shortcut hiding a chord', () => { + expect(bindingsConflict(other, chord)).toBe(true); + expect(bindingsConflict(guarded, chord)).toBe(false); + }); + + it('tells actions with different arguments apart', () => { + const [scene, game] = resolveKeymap( + [ + { key: 'F1', command: 'open', args: ['scene'] }, + { key: 'F1', command: 'open', args: ['game'] }, + ], + [], + [], + ); + expect(bindingsConflict(scene!, game!)).toBe(true); + }); + + it('groups conflicts by the keys they share', () => { + const groups = findConflicts([save, saveAs, guarded, other, chord, sameAction]); + expect(groups.map((group) => group.key)).toEqual(['Ctrl+K']); + expect(groups[0]!.bindings).toHaveLength(6); + }); +}); + +describe('recording', () => { + it('saves the main modifier as Mod', () => { + expect(portableStroke('Ctrl+Shift+P', false)).toBe('Mod+Shift+P'); + expect(portableStroke('Shift+Meta+P', true)).toBe('Mod+Shift+P'); + expect(portableStroke('Ctrl+P', true)).toBe('Ctrl+P'); + expect(portableStroke('Ctrl++', false)).toBe('Mod++'); + expect(portableStroke('F5', false)).toBe('F5'); + expect(normalizeStroke(portableStroke('Ctrl+Alt+K', false), false)).toBe('Ctrl+Alt+K'); + }); + + it('lets keys through while suspended', () => { + const extensions = new ExtensionRegistry(); + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + let ran = 0; + commands.register({ id: 'go', handler: () => void ran++ }); + extensions.contribute(KEYBINDINGS, { key: 'F5', command: 'go', args: [] }, { owner: 'core' }); + const service = new KeybindingService(extensions, commands, context); + const suspended = service.suspend(); + expect(service.handle(key({ key: 'F5' }))).toBe(false); + suspended.dispose(); + suspended.dispose(); + expect(service.handle(key({ key: 'F5' }))).toBe(true); + expect(ran).toBe(1); + }); +}); diff --git a/packages/ui/test/workbench/keybinding/keymap-edit.spec.ts b/packages/ui/test/workbench/keybinding/keymap-edit.spec.ts new file mode 100644 index 00000000..fd3a619a --- /dev/null +++ b/packages/ui/test/workbench/keybinding/keymap-edit.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { + type KeymapLayers, + isActionChanged, + resetAction, + resolveLayers, + setShortcut, +} from '../../../src/workbench/keybinding/keymap-edit'; +import type { KeymapOverride } from '../../../src/workbench/keybinding/keymap.type'; + +const layers = (patch: Partial = {}): KeymapLayers => ({ + defaults: [ + { key: 'Mod+S', command: 'save', args: [] }, + { key: 'F5', command: 'play', args: [], when: '!playing' }, + ], + preset: [], + before: [], + own: [], + after: [], + ...patch, +}); + +const keys = (value: KeymapLayers) => + resolveLayers(value).map( + (binding) => + `${binding.source}:${binding.command}:${binding.strokes.join(' ')}${binding.when ? `:${binding.when}` : ''}`, + ); + +/** Applies an edit to the edited scope and returns the layers with it. */ +const edit = (value: KeymapLayers, own: KeymapOverride[]): KeymapLayers => ({ ...value, own }); + +describe('setShortcut', () => { + it('adds a shortcut, a chord too, and several per action', () => { + let value = layers(); + value = edit(value, setShortcut(value, { command: 'save', key: 'Mod+K Mod+S' }, {})); + value = edit(value, setShortcut(value, { command: 'save', key: 'F2' }, { when: 'editing' })); + expect(value.own).toEqual([ + { command: 'save', key: 'Mod+K Mod+S' }, + { command: 'save', key: 'F2', when: 'editing' }, + ]); + expect(keys(value)).toContain('user:save:Ctrl+K Ctrl+S'); + expect(keys(value)).toContain('default:save:Ctrl+S'); + }); + + it('removes a default with one entry, and puts it back with none', () => { + let value = layers(); + value = edit(value, setShortcut(value, { command: 'save', key: 'Mod+S' }, undefined)); + expect(value.own).toEqual([{ command: 'save', key: 'Mod+S', remove: true }]); + expect(keys(value)).toEqual(['default:play:F5:!playing']); + value = edit(value, setShortcut(value, { command: 'save', key: 'Ctrl+S' }, {})); + expect(value.own).toEqual([]); + }); + + it('removes a shortcut the user added by dropping its entry', () => { + let value = layers({ own: [{ command: 'save', key: 'F2' }] }); + value = edit(value, setShortcut(value, { command: 'save', key: 'F2' }, undefined)); + expect(value.own).toEqual([]); + }); + + it('changes the condition of a default: a removal, then the new shortcut', () => { + let value = layers(); + value = edit(value, setShortcut(value, { command: 'play', key: 'F5' }, { when: 'ready' })); + expect(value.own).toEqual([ + { command: 'play', key: 'F5', remove: true }, + { command: 'play', key: 'F5', when: 'ready' }, + ]); + expect(keys(value)).toEqual(['user:play:F5:ready', 'default:save:Ctrl+S']); + value = edit(value, setShortcut(value, { command: 'play', key: 'F5' }, { when: '!playing' })); + expect(value.own).toEqual([]); + }); + + it('keeps arguments, and tells actions with different arguments apart', () => { + let value = layers(); + const game = { command: 'open', args: ['game'], key: 'Ctrl+F2' }; + value = edit(value, setShortcut(value, game, {})); + value = edit( + value, + setShortcut(value, { command: 'open', args: ['scene'], key: 'Ctrl+F2' }, {}), + ); + expect(value.own).toHaveLength(2); + value = edit(value, setShortcut(value, game, undefined)); + expect(value.own).toEqual([{ command: 'open', key: 'Ctrl+F2', args: ['scene'] }]); + }); + + it('edits one scope on top of the other', () => { + const account: KeymapOverride[] = [ + { command: 'save', key: 'F2' }, + { command: 'save', key: 'Mod+S', remove: true }, + ]; + let machine = layers({ before: account }); + machine = edit(machine, setShortcut(machine, { command: 'save', key: 'F2' }, undefined)); + machine = edit(machine, setShortcut(machine, { command: 'save', key: 'Mod+S' }, {})); + expect(machine.own).toEqual([ + { command: 'save', key: 'F2', remove: true }, + { command: 'save', key: 'Mod+S' }, + ]); + expect(keys(machine)).toEqual(['user:save:Ctrl+S', 'default:play:F5:!playing']); + const edited = layers({ own: account, after: machine.own }); + expect(setShortcut(edited, { command: 'play', key: 'F9' }, {})).toEqual([ + ...account, + { command: 'play', key: 'F9' }, + ]); + }); + + it('works on top of a preset', () => { + let value = layers({ preset: [{ command: 'play', key: 'F6' }] }); + value = edit(value, setShortcut(value, { command: 'play', key: 'F6' }, undefined)); + expect(value.own).toEqual([{ command: 'play', key: 'F6', remove: true }]); + expect(keys(value)).not.toContain('preset:play:F6'); + }); +}); + +describe('resetAction', () => { + it('drops every entry about the action and nothing else', () => { + const own: KeymapOverride[] = [ + { command: 'save', key: 'F2' }, + { command: 'save', key: 'Mod+S', remove: true }, + { command: 'play', key: 'F9' }, + { command: 'open', key: 'F1', args: ['game'] }, + ]; + expect(resetAction(own, 'save')).toEqual([own[2], own[3]]); + expect(resetAction(own, 'open', ['scene'])).toEqual(own); + expect(isActionChanged(own, 'save')).toBe(true); + expect(isActionChanged(own, 'open')).toBe(false); + expect(isActionChanged(own, 'open', ['game'])).toBe(true); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000..d3ed72c6 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["@vitest/browser-playwright"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "test/**/*.ts", "test/**/*.svelte"] +} diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..36a9745e --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,40 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +/** + * Node tests (`*.spec.ts`) and real browser tests (`*.svelte.spec.ts`, Chromium through + * Playwright). Set CHROME to use a locally installed browser instead of Playwright's. + */ +export default defineConfig({ + plugins: [svelte({ compilerOptions: { runes: true } })], + test: { + passWithNoTests: true, + projects: [ + { + extends: true, + test: { + name: 'node', + environment: 'node', + include: ['test/**/*.spec.ts'], + exclude: ['**/*.svelte.spec.ts'], + }, + }, + { + extends: true, + test: { + name: 'browser', + include: ['test/**/*.svelte.spec.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: process.env.CHROME ? { executablePath: process.env.CHROME } : {}, + }), + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +}); diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index e1a161a2..00000000 --- a/playwright.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - webServer: { command: 'npm run build && npm run preview', port: 4173 }, - testDir: 'e2e', -}); diff --git a/plugins/code-editor/.prettierignore b/plugins/code-editor/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/code-editor/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/code-editor/LICENSE b/plugins/code-editor/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/code-editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/code-editor/README.md b/plugins/code-editor/README.md new file mode 100644 index 00000000..2005b15f --- /dev/null +++ b/plugins/code-editor/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-code-editor` is an internal package of the NanoForge Editor. + +Built-in code editor (Monaco). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/code-editor +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/code-editor/nanoforge.manifest.json b/plugins/code-editor/nanoforge.manifest.json new file mode 100644 index 00000000..32e8a24c --- /dev/null +++ b/plugins/code-editor/nanoforge.manifest.json @@ -0,0 +1,175 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/code-editor", + "version": "0.1.0", + "displayName": "Code editor", + "description": "Edit the game's code with Monaco: tabs, split view, types, diagnostics and Prettier.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": [ + "onCommand:codeEditor.open", + "onCommand:codeEditor.compare", + "onWidget:code-editor.script", + "onScreen:code-editor.script" + ], + "contributes": { + "widgets": [ + { + "id": "code-editor.script", + "title": "Script", + "icon": "code", + "kind": "screen", + "order": 200 + } + ], + "documentEditors": [ + { + "pattern": "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc,md,mdx,css,scss,less,html,svelte,vue,yaml,yml,toml,txt,xml,svg,glsl,wgsl,sh,env,lock,log}", + "widget": "code-editor.script", + "command": "codeEditor.open", + "title": "Code editor" + }, + { + "pattern": "**/{.*,*rc,Dockerfile,LICENSE,Makefile}", + "widget": "code-editor.script", + "command": "codeEditor.open", + "title": "Code editor", + "priority": -10 + } + ], + "commands": [ + { + "id": "codeEditor.open", + "title": "Open in code editor", + "category": "File", + "palette": false + }, + { + "id": "codeEditor.compare", + "title": "Compare with another version", + "category": "Code", + "palette": false + }, + { "id": "codeEditor.save", "title": "Save", "category": "File" }, + { "id": "codeEditor.saveAll", "title": "Save all", "category": "File" }, + { "id": "codeEditor.revert", "title": "Revert file", "category": "File" }, + { "id": "codeEditor.format", "title": "Format file", "category": "Code" }, + { "id": "codeEditor.organizeImports", "title": "Organize imports", "category": "Code" }, + { "id": "codeEditor.splitRight", "title": "Split editor right", "category": "View" }, + { "id": "codeEditor.closeTab", "title": "Close tab", "category": "View" } + ], + "keybindings": [ + { + "key": "Mod+S", + "command": "codeEditor.save", + "when": "focusedWidget == 'code-editor.script'" + }, + { "key": "Mod+K S", "command": "codeEditor.saveAll" }, + { + "key": "Shift+Alt+F", + "command": "codeEditor.format", + "when": "focusedWidget == 'code-editor.script'" + }, + { + "key": "Shift+Alt+O", + "command": "codeEditor.organizeImports", + "when": "focusedWidget == 'code-editor.script'" + }, + { + "key": "Mod+\\", + "command": "codeEditor.splitRight", + "when": "focusedWidget == 'code-editor.script'" + }, + { + "key": "Alt+W", + "command": "codeEditor.closeTab", + "when": "focusedWidget == 'code-editor.script'" + } + ], + "menus": [ + { "menu": "file", "command": "codeEditor.save", "group": "save", "order": 0 }, + { "menu": "file", "command": "codeEditor.saveAll", "group": "save", "order": 1 }, + { "menu": "file", "command": "codeEditor.revert", "group": "save", "order": 2 }, + { "menu": "edit", "command": "codeEditor.format", "group": "code", "order": 0 }, + { "menu": "edit", "command": "codeEditor.organizeImports", "group": "code", "order": 1 }, + { "menu": "view", "command": "codeEditor.splitRight", "group": "editor" } + ], + "settings": [ + { + "key": "autoSave.afterDelay", + "type": "boolean", + "default": false, + "title": "Save after a delay", + "description": "Save files automatically when you stop typing.", + "category": "Editor/Code editor/Saving", + "order": 0 + }, + { + "key": "autoSave.delayMs", + "type": "number", + "integer": true, + "minimum": 100, + "maximum": 60000, + "default": 1000, + "title": "Autosave delay (ms)", + "category": "Editor/Code editor/Saving", + "order": 1 + }, + { + "key": "autoSave.onFocusLoss", + "type": "boolean", + "default": false, + "title": "Save when the editor loses focus", + "description": "Save when you leave the editor, switch tabs or leave the window.", + "category": "Editor/Code editor/Saving", + "order": 2 + }, + { + "key": "onSave.format", + "type": "boolean", + "default": false, + "title": "Reformat with Prettier on save", + "description": "Uses the project's Prettier and its config.", + "category": "Editor/Code editor/Actions on save", + "order": 0 + }, + { + "key": "onSave.organizeImports", + "type": "boolean", + "default": false, + "title": "Organize imports on save", + "description": "Sorts imports and removes unused ones in TypeScript and JavaScript files.", + "category": "Editor/Code editor/Actions on save", + "order": 1 + }, + { + "key": "fontSize", + "type": "number", + "integer": true, + "minimum": 8, + "maximum": 32, + "default": 13, + "title": "Font size", + "category": "Editor/Code editor/Display", + "scopes": ["account", "machine"] + }, + { + "key": "minimap", + "type": "boolean", + "default": true, + "title": "Show the minimap", + "category": "Editor/Code editor/Display", + "scopes": ["account", "machine"] + }, + { + "key": "wordWrap", + "type": "boolean", + "default": false, + "title": "Wrap long lines", + "category": "Editor/Code editor/Display", + "scopes": ["account", "machine"] + } + ] + } +} diff --git a/plugins/code-editor/package.json b/plugins/code-editor/package.json new file mode 100644 index 00000000..c7ef7ee9 --- /dev/null +++ b/plugins/code-editor/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-code-editor", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in code editor (Monaco)", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/code-editor" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "dev": "vite build --watch", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "monaco-editor": "catalog:components", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/code-editor/src/document/editor-document.ts b/plugins/code-editor/src/document/editor-document.ts new file mode 100644 index 00000000..7aecc5dd --- /dev/null +++ b/plugins/code-editor/src/document/editor-document.ts @@ -0,0 +1,245 @@ +import { + type CommandOrigin, + type DocumentChange, + Emitter, + type Event, + type Observable, + ObservableValue, + type OpenDocument, + type TextEdit, +} from '@nanoforge-dev/editor-sdk'; + +import { type Monaco, type monaco, uriOf } from '../monaco/monaco'; +import type { Conflict, DocumentHost } from './editor-document.type'; + +const PERSIST_DELAY_MS = 400; + +/** The model of a file (Monaco may have created it already, e.g. to peek a definition). */ +const modelFor = (monaco: Monaco, path: string, text: string): monaco.editor.ITextModel => { + const existing = monaco.editor.getModel(uriOf(path)); + if (!existing) return monaco.editor.createModel(text, undefined, uriOf(path)); + if (existing.getValue() !== text) existing.setValue(text); + return existing; +}; + +const isConflict = (error: unknown): boolean => + typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'CONFLICT'; + +/** + * A file open in the code editor: a Monaco model, registered with the DocumentService so edits + * of other plugins (codegen) go to it. Unsaved edits are kept locally until saved or reverted. + */ +export class EditorDocument implements OpenDocument { + readonly kind = 'text' as const; + private readonly _dirty = new ObservableValue(false); + private readonly _conflict = new ObservableValue(undefined); + private readonly _onDidChange = new Emitter(); + private readonly _listener: monaco.IDisposable; + private _savedVersion: number; + private _hash: string | null; + private _origin: CommandOrigin | undefined; + private _persistTimer: ReturnType | undefined; + private _restoring = false; + + readonly onDidChange: Event = this._onDidChange.event; + + /** Opens a project file, restoring unsaved edits kept from a previous session. */ + static async open(host: DocumentHost, path: string): Promise { + const disk = await host.backend.read(path); + const model = modelFor(host.monaco, path, disk.text); + const document = new EditorDocument(host, path, model, disk.hash, false); + const unsaved = await host.unsaved.get(path).catch(() => undefined); + if (unsaved && unsaved.text !== disk.text) { + document._restoring = true; + document._replaceAll(unsaved.text); + document._restoring = false; + if (unsaved.baseHash !== disk.hash) { + document._conflict.set({ diskText: disk.text, diskHash: disk.hash }); + } + } + return document; + } + + /** Opens a type declaration file (not part of the project, never saved). */ + static library(host: DocumentHost, path: string, text: string): EditorDocument { + return new EditorDocument(host, path, modelFor(host.monaco, path, text), null, true); + } + + private constructor( + private readonly _host: DocumentHost, + readonly uri: string, + readonly model: monaco.editor.ITextModel, + hash: string | null, + /** Library declarations (go to definition): never saved. */ + readonly readOnly: boolean, + ) { + this._hash = hash; + this._savedVersion = model.getAlternativeVersionId(); + this._listener = model.onDidChangeContent((event) => { + this._dirty.set(!this.readOnly && model.getAlternativeVersionId() !== this._savedVersion); + this._onDidChange.fire({ + uri, + edits: event.changes.map((change) => ({ + start: change.rangeOffset, + end: change.rangeOffset + change.rangeLength, + text: change.text, + })), + origin: this._origin, + }); + if (!this._restoring) this._schedulePersist(); + }); + } + + get dirty(): Observable { + return this._dirty.readonly(); + } + + get conflict(): Observable { + return this._conflict.readonly(); + } + + getText(): string { + return this.model.getValue(); + } + + applyEdits(edits: readonly TextEdit[], origin: CommandOrigin | undefined): TextEdit[] { + const model = this.model; + const operations = edits.map((edit) => ({ + range: this._host.monaco.Range.fromPositions( + model.getPositionAt(edit.start), + model.getPositionAt(edit.end), + ), + text: edit.text, + })); + let inverse: monaco.editor.IValidEditOperation[] = []; + this._origin = origin; + try { + model.pushStackElement(); + model.pushEditOperations(null, operations, (operations) => { + inverse = operations; + return null; + }); + model.pushStackElement(); + } finally { + this._origin = undefined; + } + return inverse.map((operation) => ({ + start: model.getOffsetAt(operation.range.getStartPosition()), + end: model.getOffsetAt(operation.range.getEndPosition()), + text: operation.text ?? '', + })); + } + + async save(): Promise { + if (this.readOnly) return; + if (this._conflict.get()) throw new Error(`${this.uri} changed on disk: resolve it first`); + await this._host.beforeSave(this); + const text = this.getText(); + const version = this.model.getAlternativeVersionId(); + try { + const { hash } = await this._host.backend.write(this.uri, text, this._hash); + this._markSaved(hash, version); + } catch (error) { + if (isConflict(error)) await this._detectConflict(); + throw error; + } + } + + async revert(): Promise { + if (this.readOnly) return; + const disk = await this._host.backend.read(this.uri); + this._replaceAll(disk.text); + this._conflict.set(undefined); + this._markSaved(disk.hash, this.model.getAlternativeVersionId()); + } + + /** The file changed on disk: reload it, or report a conflict when there are unsaved edits. */ + async onDiskChange(deleted: boolean): Promise { + if (this.readOnly) return; + if (deleted) { + this._conflict.set({ diskText: null, diskHash: null }); + return; + } + const disk = await this._host.backend.read(this.uri).catch(() => undefined); + if (!disk || disk.hash === this._hash) return; + if (!this._dirty.get()) { + this._replaceAll(disk.text); + this._markSaved(disk.hash, this.model.getAlternativeVersionId()); + } else this._conflict.set({ diskText: disk.text, diskHash: disk.hash }); + } + + /** Conflict resolution: keep the unsaved edits (the next save overwrites the disk). */ + keepMine(): void { + const conflict = this._conflict.get(); + if (!conflict) return; + this._hash = conflict.diskHash; + this._conflict.set(undefined); + } + + /** Conflict resolution: take the disk version (undo brings the edits back). */ + loadDisk(): void { + const conflict = this._conflict.get(); + if (!conflict || conflict.diskText === null) return; + this._replaceAll(conflict.diskText); + this._conflict.set(undefined); + this._markSaved(conflict.diskHash, this.model.getAlternativeVersionId()); + } + + /** Replaces the whole text as one undoable edit. */ + replaceAll(text: string, origin?: CommandOrigin): void { + if (text === this.getText()) return; + this._origin = origin; + try { + this._replaceAll(text); + } finally { + this._origin = undefined; + } + } + + dispose(): void { + clearTimeout(this._persistTimer); + this._listener.dispose(); + this._onDidChange.dispose(); + this.model.dispose(); + } + + private _replaceAll(text: string): void { + this.model.pushStackElement(); + this.model.pushEditOperations( + null, + [{ range: this.model.getFullModelRange(), text }], + () => null, + ); + this.model.pushStackElement(); + } + + private _markSaved(hash: string | null, version: number): void { + this._hash = hash; + this._savedVersion = version; + this._dirty.set(this.model.getAlternativeVersionId() !== version); + if (!this._dirty.get()) { + clearTimeout(this._persistTimer); + void this._host.unsaved.delete(this.uri).catch(() => undefined); + } + } + + private async _detectConflict(): Promise { + const disk = await this._host.backend.read(this.uri).catch(() => undefined); + this._conflict.set({ diskText: disk?.text ?? null, diskHash: disk?.hash ?? null }); + } + + private _schedulePersist(): void { + if (this.readOnly) return; + clearTimeout(this._persistTimer); + this._persistTimer = setTimeout(() => { + const operation = this._dirty.get() + ? this._host.unsaved.set(this.uri, { + text: this.getText(), + baseHash: this._hash, + time: Date.now(), + }) + : this._host.unsaved.delete(this.uri); + void operation.catch(() => undefined); + }, PERSIST_DELAY_MS); + } +} diff --git a/plugins/code-editor/src/document/editor-document.type.ts b/plugins/code-editor/src/document/editor-document.type.ts new file mode 100644 index 00000000..99771d30 --- /dev/null +++ b/plugins/code-editor/src/document/editor-document.type.ts @@ -0,0 +1,19 @@ +import type { DocumentBackend } from '@nanoforge-dev/editor-sdk'; + +import type { Monaco } from '../monaco/monaco'; +import { type EditorDocument } from './editor-document'; +import type { UnsavedStore } from './unsaved-store'; + +/** The file changed on disk while the document had unsaved edits (`diskText` null: deleted). */ +export interface Conflict { + readonly diskText: string | null; + readonly diskHash: string | null; +} + +export interface DocumentHost { + readonly monaco: Monaco; + readonly backend: DocumentBackend; + readonly unsaved: UnsavedStore; + /** Actions on save (format, organize imports), run before writing. */ + beforeSave(document: EditorDocument): Promise; +} diff --git a/plugins/code-editor/src/document/unsaved-store.ts b/plugins/code-editor/src/document/unsaved-store.ts new file mode 100644 index 00000000..5e226c20 --- /dev/null +++ b/plugins/code-editor/src/document/unsaved-store.ts @@ -0,0 +1,60 @@ +/** Unsaved text of a file, and the disk version it was based on. */ +export interface UnsavedText { + readonly text: string; + /** Hash of the file on disk when editing started (detects disk changes meanwhile). */ + readonly baseHash: string | null; + readonly time: number; +} + +export interface UnsavedStore { + get(path: string): Promise; + set(path: string, value: UnsavedText): Promise; + delete(path: string): Promise; +} + +const DATABASE = 'nanoforge-code-editor'; +const STORE = 'unsaved'; + +const request = (operation: IDBRequest): Promise => + new Promise((resolve, reject) => { + operation.onsuccess = () => resolve(operation.result); + operation.onerror = () => reject(operation.error); + }); + +/** + * Keeps unsaved edits in the browser (IndexedDB) per project, so they survive a reload until + * saved or reverted. Falls back to memory when IndexedDB is unavailable. + */ +export const createUnsavedStore = (projectId: string): UnsavedStore => { + const memory = new Map(); + const key = (path: string) => `${projectId}:${path}`; + let database: Promise | undefined; + const open = () => + (database ??= new Promise((resolve) => { + if (typeof indexedDB === 'undefined') return resolve(undefined); + const opening = indexedDB.open(DATABASE, 1); + opening.onupgradeneeded = () => opening.result.createObjectStore(STORE); + opening.onsuccess = () => resolve(opening.result); + opening.onerror = () => resolve(undefined); + })); + const store = async (mode: IDBTransactionMode) => + (await open())?.transaction(STORE, mode).objectStore(STORE); + + return { + async get(path) { + const objects = await store('readonly'); + if (!objects) return memory.get(key(path)); + return (await request(objects.get(key(path)))) as UnsavedText | undefined; + }, + async set(path, value) { + const objects = await store('readwrite'); + if (!objects) memory.set(key(path), value); + else await request(objects.put(value, key(path))); + }, + async delete(path) { + const objects = await store('readwrite'); + if (!objects) memory.delete(key(path)); + else await request(objects.delete(key(path))); + }, + }; +}; diff --git a/plugins/code-editor/src/index.ts b/plugins/code-editor/src/index.ts new file mode 100644 index 00000000..2c318bc1 --- /dev/null +++ b/plugins/code-editor/src/index.ts @@ -0,0 +1,149 @@ +import type { Component } from 'svelte'; + +import { + CodeServiceToken, + DiagnosticsServiceToken, + DocumentServiceToken, + EditorServices, + JSON_SCHEMAS, + type JsonSchemaContribution, + ProjectServiceToken, + SettingsServiceToken, + definePlugin, + observe, +} from '@nanoforge-dev/editor-sdk'; +import { + CODE_EDITOR_ACTIVE_FILE, + NotificationServiceToken, + StyleServiceToken, + ThemeServiceToken, + WIDGET_VIEWS, + type WidgetInstance, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { setupMonaco } from './monaco/monaco'; +import { CodeEditorService } from './service/code-editor-service'; +import type { Comparison, OpenOptions } from './service/code-editor.type'; +import { current } from './session/code-editor-session'; +import ScriptScreen from './widgets/script/ScriptScreen.svelte'; + +export const SCRIPT_SCREEN = 'code-editor.script'; + +export default definePlugin({ + async activate(context) { + const monaco = setupMonaco(); + const { services } = context; + + const css = await fetch(context.resolveAsset('code-editor.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id: SCRIPT_SCREEN, + component: ScriptScreen as Component<{ instance: WidgetInstance }>, + }), + ); + + const schemas = context.contributions(JSON_SCHEMAS); + const applySchemas = (list: readonly { value: JsonSchemaContribution }[]) => + monaco.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + allowComments: true, + enableSchemaRequest: false, + schemas: list.map(({ value }, index) => ({ + uri: `nanoforge://schemas/${index}.json`, + fileMatch: value.fileMatch.map((glob) => `file:///${glob}`), + schema: typeof value.schema === 'function' ? value.schema() : value.schema, + })), + }); + context.subscriptions.add({ dispose: schemas.subscribe(applySchemas) }); + + const themes = services.get(ThemeServiceToken); + const projects = services.get(ProjectServiceToken); + const contextKeys = services.get(EditorServices.ContextKeys); + let stopFollowing = () => {}; + const unsubscribe = projects.current.subscribe((project) => { + stopFollowing(); + contextKeys.delete(CODE_EDITOR_ACTIVE_FILE); + current.get()?.service.dispose(); + current.set(undefined); + if (!project) return; + const service = new CodeEditorService({ + monaco, + project, + documents: services.get(DocumentServiceToken), + code: services.tryGet(CodeServiceToken), + diagnostics: services.tryGet(DiagnosticsServiceToken), + settings: services.get(SettingsServiceToken), + notifications: services.tryGet(NotificationServiceToken), + logger: context.logger, + }); + service.applyTheme(themes.current.get()); + current.set({ service, monaco }); + stopFollowing = service.layout.subscribe(() => { + const path = service.activePath(); + if (path) contextKeys.set(CODE_EDITOR_ACTIVE_FILE, path); + else contextKeys.delete(CODE_EDITOR_ACTIVE_FILE); + }); + }); + context.subscriptions.add({ + dispose: () => { + unsubscribe(); + stopFollowing(); + contextKeys.delete(CODE_EDITOR_ACTIVE_FILE); + current.get()?.service.dispose(); + current.set(undefined); + }, + }); + context.subscriptions.add( + observe(themes.current, (theme) => current.get()?.service.applyTheme(theme)), + ); + + const withService = + (run: (service: CodeEditorService, ...args: A) => unknown) => + (...args: A) => { + const active = current.get()?.service; + return active ? run(active, ...args) : undefined; + }; + + const commands: [string, (...args: never[]) => unknown][] = [ + [ + 'codeEditor.open', + withService(async (service, path: string, options: OpenOptions = {}) => { + await context.executeCommand('workbench.openWidget', SCRIPT_SCREEN); + await service.open(path, options); + }), + ], + [ + 'codeEditor.compare', + withService(async (service, path: string, comparison: Comparison) => { + await context.executeCommand('workbench.openWidget', SCRIPT_SCREEN); + await service.compare(path, comparison); + }), + ], + ['codeEditor.save', withService((service) => service.save())], + ['codeEditor.saveAll', withService((service) => service.saveAll())], + ['codeEditor.revert', withService((service) => service.revert())], + ['codeEditor.format', withService((service) => service.format())], + ['codeEditor.organizeImports', withService((service) => service.organizeImports())], + ['codeEditor.splitRight', withService((service) => service.splitRight())], + [ + 'codeEditor.closeTab', + withService((service) => { + const layout = service.layout.get(); + const active = layout.groups[layout.focused]?.active; + if (active) service.close(layout.focused, active); + }), + ], + ]; + for (const [id, handler] of commands) { + context.subscriptions.add( + context.registerCommand(id, (_services, ...args: unknown[]) => + (handler as (...args: unknown[]) => unknown)(...args), + ), + ); + } + }, +}); diff --git a/plugins/code-editor/src/monaco/monaco-theme.ts b/plugins/code-editor/src/monaco/monaco-theme.ts new file mode 100644 index 00000000..1ca2e591 --- /dev/null +++ b/plugins/code-editor/src/monaco/monaco-theme.ts @@ -0,0 +1,29 @@ +import type { ThemeDefinition } from '@nanoforge-dev/editor-sdk/ui'; + +import type { monaco } from './monaco'; + +/** The editor's theme as a Monaco theme: the colors Monaco takes, from the theme tokens. */ +export const monacoThemeOf = (theme: ThemeDefinition): monaco.editor.IStandaloneThemeData => { + const { colors } = theme; + const hex = (value: string) => (/^#[0-9a-f]{3,8}$/i.test(value) ? value : undefined); + const entries: [string, string | undefined][] = [ + ['editor.background', hex(colors.surface)], + ['editor.foreground', hex(colors.text)], + ['editorLineNumber.foreground', hex(colors['text-faint'])], + ['editorLineNumber.activeForeground', hex(colors['text-muted'])], + ['editorCursor.foreground', hex(colors.accent)], + ['editor.selectionBackground', hex(colors.selection)], + ['editorWidget.background', hex(colors.raised)], + ['editorWidget.border', hex(colors.border)], + ['editorSuggestWidget.background', hex(colors.raised)], + ['editorSuggestWidget.selectedBackground', hex(colors.hover)], + ['focusBorder', hex(colors.focus)], + ['scrollbarSlider.background', hex(`${colors['border-strong']}`)], + ]; + return { + base: theme.kind === 'dark' ? 'vs-dark' : 'vs', + inherit: true, + rules: [], + colors: Object.fromEntries(entries.filter((entry): entry is [string, string] => !!entry[1])), + }; +}; diff --git a/plugins/code-editor/src/monaco/monaco.ts b/plugins/code-editor/src/monaco/monaco.ts new file mode 100644 index 00000000..56e0f9d8 --- /dev/null +++ b/plugins/code-editor/src/monaco/monaco.ts @@ -0,0 +1,72 @@ +import * as monaco from 'monaco-editor'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'; +import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker.js?worker'; +import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker.js?worker'; +import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'; +import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker.js?worker'; + +export { monaco }; +export type Monaco = typeof monaco; + +let configured = false; + +/** + * One-time Monaco setup: workers, TypeScript defaults. Diagnostics come from the editor's code + * worker (a single TypeScript for the whole editor) and formatting from Prettier, so Monaco's + * own TypeScript diagnostics and formatters are turned off. + */ +export const setupMonaco = (): Monaco => { + if (configured) return monaco; + configured = true; + (self as unknown as { MonacoEnvironment: monaco.Environment }).MonacoEnvironment = { + getWorker: (_id, label) => { + if (label === 'typescript' || label === 'javascript') return new TsWorker(); + if (label === 'json') return new JsonWorker(); + if (label === 'css' || label === 'scss' || label === 'less') return new CssWorker(); + if (label === 'html') return new HtmlWorker(); + return new EditorWorker(); + }, + }; + const ts = monaco.typescript; + const compilerOptions: Parameters[0] = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: 100 as never, + lib: ['esnext', 'dom', 'dom.iterable'], + strict: true, + allowJs: true, + checkJs: false, + esModuleInterop: true, + skipLibCheck: true, + allowNonTsExtensions: true, + experimentalDecorators: true, + resolveJsonModule: true, + }; + const modes: Parameters[0] = { + completionItems: true, + hovers: true, + documentSymbols: true, + definitions: true, + references: true, + documentHighlights: true, + rename: true, + diagnostics: false, + documentRangeFormattingEdits: false, + signatureHelp: true, + onTypeFormattingEdits: false, + codeActions: true, + inlayHints: true, + }; + for (const defaults of [ts.typescriptDefaults, ts.javascriptDefaults]) { + defaults.setCompilerOptions(compilerOptions); + defaults.setModeConfiguration(modes); + defaults.setEagerModelSync(true); + } + return monaco; +}; + +/** Monaco model URI of a project path (`apps/client/src/main.ts`). */ +export const uriOf = (path: string): monaco.Uri => monaco.Uri.parse(`file:///${path}`); + +/** Project path of a Monaco model URI. */ +export const pathOf = (uri: monaco.Uri): string => decodeURIComponent(uri.path.replace(/^\//, '')); diff --git a/plugins/code-editor/src/service/code-editor-service.ts b/plugins/code-editor/src/service/code-editor-service.ts new file mode 100644 index 00000000..050d8899 --- /dev/null +++ b/plugins/code-editor/src/service/code-editor-service.ts @@ -0,0 +1,446 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-sdk'; +import type { ThemeDefinition } from '@nanoforge-dev/editor-sdk/ui'; + +import { EditorDocument } from '../document/editor-document'; +import { createUnsavedStore } from '../document/unsaved-store'; +import { pathOf } from '../monaco/monaco'; +import { monacoThemeOf } from '../monaco/monaco-theme'; +import { TypeLibraries } from '../typing/type-libraries'; +import { SETTING } from './code-editor-settings.const'; +import type { + CodeEditorServiceOptions, + Comparison, + EditorGroup, + EditorLayout, + OpenOptions, + Reveal, +} from './code-editor.type'; +import { minimalEdit, toMarker } from './editor-edits'; +import { pairRenames } from './pair-renames'; + +export const SCRIPT_LIKE = /\.[cm]?[jt]sx?$/; +export const EMPTY: EditorLayout = { groups: [{ tabs: [], active: undefined }], focused: 0 }; + +/** + * State of the Script screen for the open project: open documents, editor groups and tabs, + * saving (explicit, autosave, actions on save), disk conflicts and diagnostics markers. + */ +export class CodeEditorService implements Disposable { + readonly types: TypeLibraries; + private readonly _layout = new ObservableValue(EMPTY); + private readonly _comparisons = new ObservableValue>(new Map()); + private readonly _documents = new Map(); + private readonly _loading = new Map>(); + private readonly _onReveal = new Emitter(); + private readonly _onDidChangeDocuments = new Emitter(); + private readonly _pendingReveals = new Map(); + private readonly _store = new DisposableStore(); + private readonly _perDocument = new Map(); + private readonly _autoSaveTimers = new Map>(); + private readonly _unsaved; + + readonly onReveal: Event = this._onReveal.event; + /** A document was loaded or released. */ + readonly onDidChangeDocuments: Event = this._onDidChangeDocuments.event; + + constructor(private readonly _options: CodeEditorServiceOptions) { + const { monaco, project } = _options; + this._unsaved = createUnsavedStore(project.id); + this.types = this._store.add(new TypeLibraries(monaco, project, _options.code)); + this._store.add( + project.fs.onDidChange((changes) => { + const renames = pairRenames(changes); + const written = new Set( + changes.filter((change) => change.type !== 'deleted').map((change) => change.path), + ); + for (const change of changes) { + const document = this._documents.get(change.path); + if (!document) continue; + if (change.type !== 'deleted' || written.has(change.path)) { + if (change.type !== 'deleted') void document.onDiskChange(false); + } else if (renames.has(change.path)) + void this._retarget(change.path, renames.get(change.path)!).catch((error: unknown) => + this._options.logger.warn(`Could not follow ${change.path}`, error), + ); + else if (!document.dirty.get() && !document.readOnly) this._closeEverywhere(change.path); + else void document.onDiskChange(true); + } + }), + ); + if (_options.diagnostics) { + this._store.add({ + dispose: _options.diagnostics.all.subscribe(() => this._updateMarkers()), + }); + } + this._store.add( + monaco.editor.registerEditorOpener({ + openCodeEditor: (_source, resource, selection) => { + const position = + selection && 'lineNumber' in selection + ? { line: selection.lineNumber, column: selection.column } + : selection + ? { line: selection.startLineNumber, column: selection.startColumn } + : {}; + void this.open(pathOf(resource), position); + return true; + }, + }), + ); + } + + get layout(): Observable { + return this._layout.readonly(); + } + + document(path: string): EditorDocument | undefined { + return this._documents.get(path); + } + + setting(key: string): T { + return this._options.settings.get(key); + } + + observeSetting(key: string): Observable { + return this._options.settings.observe(key); + } + + /** Restores tabs (from the widget state) without opening files that are gone. */ + async restore(layout: EditorLayout | undefined): Promise { + if (!layout?.groups.length) return; + const exists = (path: string) => + !!this._options.project.fs.entry(path) || !!this.types.library(path); + const groups = layout.groups.slice(0, 2).map((group) => { + const tabs = group.tabs.filter(exists); + const active = group.active && tabs.includes(group.active) ? group.active : tabs[0]; + return { tabs, active }; + }); + this._layout.set({ groups, focused: Math.min(layout.focused, groups.length - 1) }); + await Promise.all( + groups.flatMap((group) => group.tabs.map((path) => this._load(path).catch(() => undefined))), + ); + } + + /** Opens a file in a tab of the focused (or given) group and shows the position. */ + async open(path: string, options: OpenOptions = {}): Promise { + await this._load(path); + const layout = this._layout.get(); + const index = Math.min(options.group ?? layout.focused, layout.groups.length - 1); + const groups = layout.groups.map((group, i) => + i === index + ? { tabs: group.tabs.includes(path) ? group.tabs : [...group.tabs, path], active: path } + : group, + ); + this._layout.set({ groups, focused: index }); + if (options.line) { + const reveal = { group: index, path, line: options.line, column: options.column ?? 1 }; + this._pendingReveals.set(index, reveal); + this._onReveal.fire(reveal); + } + } + + /** A position to show once the group displays the file (consumed). */ + takeReveal(group: number, path: string): Reveal | undefined { + const reveal = this._pendingReveals.get(group); + if (reveal?.path !== path) return undefined; + this._pendingReveals.delete(group); + return reveal; + } + + activate(group: number, path: string): void { + this._update(group, (current) => ({ ...current, active: path })); + this.focusGroup(group); + } + + focusGroup(group: number): void { + const layout = this._layout.get(); + if (layout.focused !== group && group < layout.groups.length) { + this._layout.set({ ...layout, focused: group }); + } + } + + /** Closes a tab. The document is released when no group shows it anymore. */ + close(group: number, path: string): void { + this.closeComparison(path); + this._update(group, (current) => { + const index = current.tabs.indexOf(path); + const tabs = current.tabs.filter((tab) => tab !== path); + const active = + current.active === path + ? (tabs[Math.min(index, tabs.length - 1)] ?? undefined) + : current.active; + return { tabs, active }; + }); + let layout = this._layout.get(); + if (layout.groups.length > 1 && !layout.groups[group]?.tabs.length) { + const groups = layout.groups.filter((_, i) => i !== group); + layout = { groups, focused: Math.min(layout.focused, groups.length - 1) }; + this._layout.set(layout); + } + if (!layout.groups.some((candidate) => candidate.tabs.includes(path))) this._release(path); + } + + /** Shows the active file of the focused group in a second group on the right. */ + splitRight(): void { + const layout = this._layout.get(); + const active = layout.groups[layout.focused]?.active; + if (layout.groups.length > 1) { + if (active) void this.open(active, { group: 1 }); + return; + } + this._layout.set({ + groups: [...layout.groups, { tabs: active ? [active] : [], active }], + focused: 1, + }); + } + + /** Files shown side by side with another text (a previous version), by path. */ + get comparisons(): Observable> { + return this._comparisons.readonly(); + } + + /** Opens a file and shows it next to another text, e.g. its last committed version. */ + async compare(path: string, comparison: Comparison): Promise { + await this.open(path); + this._comparisons.set(new Map(this._comparisons.get()).set(path, comparison)); + } + + closeComparison(path: string): void { + if (!this._comparisons.get().has(path)) return; + const next = new Map(this._comparisons.get()); + next.delete(path); + this._comparisons.set(next); + } + + activePath(): string | undefined { + const layout = this._layout.get(); + return layout.groups[layout.focused]?.active; + } + + async save(path = this.activePath()): Promise { + const document = path ? this._documents.get(path) : undefined; + if (!document || document.readOnly) return; + await this._guard(`save ${path}`, () => document.save()); + } + + async saveAll(): Promise { + const failed = await this._options.documents.saveAll(); + for (const { uri, error } of failed) this._report(`Could not save ${uri}`, error); + } + + async revert(path = this.activePath()): Promise { + const document = path ? this._documents.get(path) : undefined; + await this._guard(`revert ${path}`, async () => document?.revert()); + } + + /** Reformats a file with Prettier (the project's, see `code.format`). */ + async format(path = this.activePath()): Promise { + const document = path ? this._documents.get(path) : undefined; + if (!document || document.readOnly || !this._options.code || !path) return; + const before = document.getText(); + const result = await this._options.code.format(path, before); + if (result.formatter === 'none' || document.getText() !== before) return; + const edit = minimalEdit(before, result.text); + if (edit.start !== edit.end || edit.text) { + await this._options.documents.edit(path, [edit], { label: 'Format with Prettier' }); + } + } + + async organizeImports(path = this.activePath()): Promise { + const document = path ? this._documents.get(path) : undefined; + if (!document || document.readOnly || !this._options.code || !path || !SCRIPT_LIKE.test(path)) + return; + const before = document.getText(); + const edits = await this._options.code.organizeImports(path); + if (edits.length && document.getText() === before) { + await this._options.documents.edit(path, edits, { label: 'Organize imports' }); + } + } + + /** Focus left the editor, a tab or the window: autosave when asked to. */ + focusLost(): void { + if (this.setting(SETTING.autoSaveOnFocusLoss)) void this._autoSaveAll(); + } + + applyTheme(theme: ThemeDefinition): void { + this._options.monaco.editor.defineTheme('nanoforge', monacoThemeOf(theme)); + this._options.monaco.editor.setTheme('nanoforge'); + } + + dispose(): void { + for (const timer of this._autoSaveTimers.values()) clearTimeout(timer); + for (const path of [...this._documents.keys()]) this._release(path); + this._store.dispose(); + this._onReveal.dispose(); + this._onDidChangeDocuments.dispose(); + } + + private _update(group: number, update: (group: EditorGroup) => EditorGroup): void { + const layout = this._layout.get(); + this._layout.set({ + ...layout, + groups: layout.groups.map((current, i) => (i === group ? update(current) : current)), + }); + } + + /** Closes the tabs of a file in every group. */ + private _closeEverywhere(path: string): void { + for (;;) { + const group = this._layout + .get() + .groups.findIndex((candidate) => candidate.tabs.includes(path)); + if (group < 0) return; + this.close(group, path); + } + } + + /** A file was renamed or moved: its tabs show the new path, unsaved text included. */ + private async _retarget(from: string, to: string): Promise { + const old = this._documents.get(from); + const unsaved = old?.dirty.get() ? old.getText() : undefined; + const next = await this._load(to); + if (unsaved !== undefined && next.getText() !== unsaved) + next.applyEdits([{ start: 0, end: next.getText().length, text: unsaved }], undefined); + const layout = this._layout.get(); + this._layout.set({ + ...layout, + groups: layout.groups.map((group) => ({ + tabs: [...new Set(group.tabs.map((tab) => (tab === from ? to : tab)))], + active: group.active === from ? to : group.active, + })), + }); + this.closeComparison(from); + this._release(from); + } + + private _load(path: string): Promise { + const open = this._documents.get(path); + if (open) return Promise.resolve(open); + let loading = this._loading.get(path); + if (!loading) { + loading = this._createDocument(path).finally(() => this._loading.delete(path)); + this._loading.set(path, loading); + } + return loading; + } + + private async _createDocument(path: string): Promise { + const { monaco, documents, code } = this._options; + const host = { + monaco, + backend: documents.backend, + unsaved: this._unsaved, + beforeSave: (document: EditorDocument) => this._beforeSave(document), + }; + const packaged = path === 'nf_modules' || path.startsWith('nf_modules/'); + const library = packaged + ? (await documents.backend.read(path)).text + : this._options.project.fs.entry(path) + ? undefined + : this.types.library(path); + const document = + library === undefined + ? await EditorDocument.open(host, path) + : EditorDocument.library(host, path, library); + const store = new DisposableStore(); + if (!document.readOnly) { + documents.historyContext(path); + this.types.modelOpened(path); + store.add(documents.register(document)); + if (code && SCRIPT_LIKE.test(path)) store.add(code.watchDiagnostics(path)); + store.add( + document.onDidChange(({ origin }) => { + void this.types.resolveImports(path, document.getText()); + if (!origin) this._scheduleAutoSave(path); + }), + ); + void this.types.resolveImports(path, document.getText()); + } + this._documents.set(path, document); + this._perDocument.set(path, store); + this._updateMarkers(); + this._onDidChangeDocuments.fire(); + return document; + } + + private _release(path: string): void { + const document = this._documents.get(path); + if (!document) return; + clearTimeout(this._autoSaveTimers.get(path)); + this._autoSaveTimers.delete(path); + this._perDocument.get(path)?.dispose(); + this._perDocument.delete(path); + this._documents.delete(path); + document.dispose(); + if (!document.readOnly) this.types.modelClosed(path); + this._onDidChangeDocuments.fire(); + } + + private async _beforeSave(document: EditorDocument): Promise { + if (this.setting(SETTING.organizeImportsOnSave)) { + await this.organizeImports(document.uri).catch((error: unknown) => + this._options.logger.warn(`Could not organize the imports of ${document.uri}`, error), + ); + } + if (this.setting(SETTING.formatOnSave)) { + await this.format(document.uri).catch((error: unknown) => + this._report(`Could not format ${document.uri}`, error), + ); + } + } + + private _scheduleAutoSave(path: string): void { + if (!this.setting(SETTING.autoSaveAfterDelay)) return; + clearTimeout(this._autoSaveTimers.get(path)); + this._autoSaveTimers.set( + path, + setTimeout(() => { + this._autoSaveTimers.delete(path); + const document = this._documents.get(path); + if (document?.dirty.get() && !document.conflict.get()) void this.save(path); + }, this.setting(SETTING.autoSaveDelayMs)), + ); + } + + private async _autoSaveAll(): Promise { + for (const document of this._documents.values()) { + if (document.dirty.get() && !document.conflict.get()) await this.save(document.uri); + } + } + + private _updateMarkers(): void { + const { monaco, diagnostics } = this._options; + if (!diagnostics) return; + for (const document of this._documents.values()) { + if (document.readOnly) continue; + monaco.editor.setModelMarkers( + document.model, + 'nanoforge', + diagnostics + .forUri(document.uri) + .map((diagnostic) => toMarker(monaco, document.model, diagnostic)), + ); + } + } + + private async _guard(action: string, run: () => Promise): Promise { + try { + await run(); + } catch (error) { + this._report(`Could not ${action}`, error); + } + } + + private _report(title: string, error: unknown): void { + this._options.logger.warn(title, error); + this._options.notifications?.notify('error', title, { + detail: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/plugins/code-editor/src/service/code-editor-settings.const.ts b/plugins/code-editor/src/service/code-editor-settings.const.ts new file mode 100644 index 00000000..a0fd1fa6 --- /dev/null +++ b/plugins/code-editor/src/service/code-editor-settings.const.ts @@ -0,0 +1,11 @@ +/** Settings of the plugin (declared in its manifest, prefixed with its name). */ +export const SETTING = { + autoSaveAfterDelay: '@nanoforge/code-editor.autoSave.afterDelay', + autoSaveDelayMs: '@nanoforge/code-editor.autoSave.delayMs', + autoSaveOnFocusLoss: '@nanoforge/code-editor.autoSave.onFocusLoss', + formatOnSave: '@nanoforge/code-editor.onSave.format', + organizeImportsOnSave: '@nanoforge/code-editor.onSave.organizeImports', + fontSize: '@nanoforge/code-editor.fontSize', + minimap: '@nanoforge/code-editor.minimap', + wordWrap: '@nanoforge/code-editor.wordWrap', +} as const; diff --git a/plugins/code-editor/src/service/code-editor.type.ts b/plugins/code-editor/src/service/code-editor.type.ts new file mode 100644 index 00000000..1e6c4b44 --- /dev/null +++ b/plugins/code-editor/src/service/code-editor.type.ts @@ -0,0 +1,53 @@ +import type { + ClientProject, + CodeService, + DiagnosticsService, + DocumentService, + Logger, + SettingsService, +} from '@nanoforge-dev/editor-sdk'; +import type { NotificationService } from '@nanoforge-dev/editor-sdk/ui'; + +import type { Monaco } from '../monaco/monaco'; + +export interface EditorGroup { + readonly tabs: readonly string[]; + readonly active: string | undefined; +} + +export interface EditorLayout { + /** One group, or two side by side. */ + readonly groups: readonly EditorGroup[]; + readonly focused: number; +} + +/** A text a file is compared with, and what it is ("Last commit"). */ +export interface Comparison { + readonly text: string; + readonly label: string; +} + +export interface OpenOptions { + readonly line?: number; + readonly column?: number; + readonly group?: number; +} + +/** Where to show a position in a group (consumed by the group's editor). */ +export interface Reveal { + readonly group: number; + readonly path: string; + readonly line: number; + readonly column: number; +} + +export interface CodeEditorServiceOptions { + readonly monaco: Monaco; + readonly project: ClientProject; + readonly documents: DocumentService; + readonly code: CodeService | undefined; + readonly diagnostics: DiagnosticsService | undefined; + readonly settings: SettingsService; + readonly notifications: NotificationService | undefined; + readonly logger: Logger; +} diff --git a/plugins/code-editor/src/service/editor-edits.ts b/plugins/code-editor/src/service/editor-edits.ts new file mode 100644 index 00000000..a145e0a9 --- /dev/null +++ b/plugins/code-editor/src/service/editor-edits.ts @@ -0,0 +1,47 @@ +import type { editor as MonacoEditor } from 'monaco-editor'; + +import type { CodeDiagnostic } from '@nanoforge-dev/editor-sdk'; + +import type { Monaco } from '../monaco/monaco'; + +/** The smallest edit turning `before` into `after` (keeps cursors and folding elsewhere). */ +export const minimalEdit = (before: string, after: string) => { + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start++; + let end = 0; + while ( + end < before.length - start && + end < after.length - start && + before[before.length - 1 - end] === after[after.length - 1 - end] + ) + end++; + return { start, end: before.length - end, text: after.slice(start, after.length - end) }; +}; + +export const toMarker = ( + monaco: Monaco, + model: MonacoEditor.ITextModel, + diagnostic: CodeDiagnostic, +): MonacoEditor.IMarkerData => { + const offset = + diagnostic.start >= 0 + ? diagnostic.start + : model.getOffsetAt({ lineNumber: diagnostic.line ?? 1, column: diagnostic.column ?? 1 }); + const start = model.getPositionAt(offset); + const end = model.getPositionAt(offset + Math.max(diagnostic.length, 1)); + return { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + message: diagnostic.message, + source: diagnostic.source, + ...(diagnostic.code !== undefined && { code: String(diagnostic.code) }), + severity: + diagnostic.severity === 'error' + ? monaco.MarkerSeverity.Error + : diagnostic.severity === 'warning' + ? monaco.MarkerSeverity.Warning + : monaco.MarkerSeverity.Info, + }; +}; diff --git a/plugins/code-editor/src/service/pair-renames.ts b/plugins/code-editor/src/service/pair-renames.ts new file mode 100644 index 00000000..da697654 --- /dev/null +++ b/plugins/code-editor/src/service/pair-renames.ts @@ -0,0 +1,58 @@ +/** A change of the project's files, as the file system reports it. */ +export interface PathChange { + readonly type: 'created' | 'changed' | 'deleted'; + readonly path: string; + readonly kind: 'file' | 'directory'; +} + +const dirname = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''); +const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1); + +/** + * Finds the renames and moves in a batch of changes. The file system only says "deleted" and + * "created": a deleted file is taken as moved when the batch creates one file with its name + * elsewhere, and as renamed when its folder lost one file and gained one in the same batch. + * Anything less clear stays a deletion. + */ +export const pairRenames = (changes: readonly PathChange[]): Map => { + const files = (type: PathChange['type']) => + changes.filter((change) => change.type === type && change.kind === 'file').map((c) => c.path); + const created = files('created'); + const deleted = files('deleted').filter((path) => !created.includes(path)); + const pairs = new Map(); + const taken = new Set(); + for (const from of deleted) { + const sameName = created.filter((to) => basename(to) === basename(from) && !taken.has(to)); + const moved = + sameName.length === 1 + ? sameName[0] + : sameName + .map((to) => ({ to, score: commonSuffix(from, to) })) + .sort((a, b) => b.score - a.score) + .find((candidate, index, all) => candidate.score > (all[index + 1]?.score ?? -1))?.to; + if (moved) { + pairs.set(from, moved); + taken.add(moved); + } + } + for (const from of deleted) { + if (pairs.has(from)) continue; + const here = (path: string) => dirname(path) === dirname(from); + const gone = deleted.filter((path) => here(path) && !pairs.has(path)); + const added = created.filter((path) => here(path) && !taken.has(path)); + if (gone.length === 1 && added.length === 1) { + pairs.set(from, added[0]!); + taken.add(added[0]!); + } + } + return pairs; +}; + +/** How many trailing path segments two paths share. */ +const commonSuffix = (a: string, b: string): number => { + const left = a.split('/').reverse(); + const right = b.split('/').reverse(); + let count = 0; + while (count < left.length && left[count] === right[count]) count++; + return count; +}; diff --git a/plugins/code-editor/src/session/code-editor-session.ts b/plugins/code-editor/src/session/code-editor-session.ts new file mode 100644 index 00000000..1b7ce2a9 --- /dev/null +++ b/plugins/code-editor/src/session/code-editor-session.ts @@ -0,0 +1,9 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; + +import type { Monaco } from '../monaco/monaco'; +import type { CodeEditorService } from '../service/code-editor-service'; + +/** Service of the open project (undefined without one), shared with the widgets. */ +export const current = new ObservableValue< + { service: CodeEditorService; monaco: Monaco } | undefined +>(undefined); diff --git a/plugins/code-editor/src/typing/type-libraries.ts b/plugins/code-editor/src/typing/type-libraries.ts new file mode 100644 index 00000000..464901a7 --- /dev/null +++ b/plugins/code-editor/src/typing/type-libraries.ts @@ -0,0 +1,127 @@ +import type { ClientProject, CodeService, Disposable } from '@nanoforge-dev/editor-sdk'; + +import type { Monaco } from '../monaco/monaco'; + +const CODE = /\.(?:[cm]?[jt]sx?|d\.ts)$/; +const IGNORED = /(?:^|\/)(?:node_modules|dist|\.nanoforge)\//; +const BARE_IMPORT = + /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"./][^'"]*)['"]/g; + +/** Package name of a bare import (`@scope/name/sub` → `@scope/name`). */ +export const packageOf = (specifier: string): string => { + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!; +}; + +/** Bare imports of a source text. */ +export const bareImports = (text: string): string[] => + [...new Set([...text.matchAll(BARE_IMPORT)].map((match) => packageOf(match[1]!)))].filter( + (name) => !name.startsWith('node:'), + ); + +/** + * What Monaco's TypeScript sees: every code file of the project that isn't open (open files + * are models), and the declarations of the packages they import, fetched once per app. + */ +export class TypeLibraries implements Disposable { + private readonly _files = new Map(); + private readonly _libraries = new Map(); + private readonly _resolved = new Set(); + private readonly _models = new Set(); + private readonly _subscription: Disposable; + + constructor( + private readonly _monaco: Monaco, + private readonly _project: ClientProject, + private readonly _code: CodeService | undefined, + ) { + this._subscription = _project.fs.onDidChange((changes) => { + for (const change of changes) { + if (!this._isCode(change.path)) continue; + if (change.type === 'deleted') this._remove(change.path); + else void this._load(change.path); + } + }); + for (const entry of _project.fs.entries) { + if (entry.kind === 'file' && this._isCode(entry.path)) void this._load(entry.path); + } + } + + /** Text of a type declaration file (read-only tabs from "go to definition"). */ + library(path: string): string | undefined { + return this._libraries.get(path); + } + + /** An open model now represents the file: its library copy would conflict. */ + modelOpened(path: string): void { + this._models.add(path); + this._files.get(path)?.dispose(); + this._files.delete(path); + } + + modelClosed(path: string): void { + this._models.delete(path); + if (this._isCode(path)) void this._load(path); + } + + /** Loads the declarations of the packages a text imports (unsaved edits included). */ + async resolveImports(path: string, text: string): Promise { + if (!this._code) return; + const app = this._appRoot(path); + for (const module of bareImports(text)) { + const key = `${app}:${module}`; + if (this._resolved.has(key)) continue; + this._resolved.add(key); + const files = await this._code.typeFiles(module, path).catch(() => []); + for (const file of files) { + if (this._libraries.has(file.path)) continue; + this._libraries.set(file.path, file.text); + this._add(file.path, file.text); + } + } + } + + dispose(): void { + this._subscription.dispose(); + for (const file of this._files.values()) file.dispose(); + this._files.clear(); + } + + private _isCode(path: string): boolean { + return CODE.test(path) && !IGNORED.test(path); + } + + /** Root of the app a file belongs to (packages resolve from there). */ + private _appRoot(path: string): string { + const roots = this._project.model + .get() + .apps.map((app) => app.root) + .filter((root) => root === '' || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length); + return roots[0] ?? ''; + } + + private async _load(path: string): Promise { + if (this._models.has(path)) return; + const { text } = await this._project.fs.readText(path).catch(() => ({ text: undefined })); + if (text === undefined || this._models.has(path)) return; + this._add(path, text); + await this.resolveImports(path, text); + } + + private _add(path: string, text: string): void { + this._files.get(path)?.dispose(); + const ts = this._monaco.typescript; + const uri = `file:///${path}`; + const disposables = [ + ts.typescriptDefaults.addExtraLib(text, uri), + ts.javascriptDefaults.addExtraLib(text, uri), + ]; + this._files.set(path, { dispose: () => disposables.forEach((item) => item.dispose()) }); + } + + private _remove(path: string): void { + this._files.get(path)?.dispose(); + this._files.delete(path); + } +} diff --git a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte new file mode 100644 index 00000000..42a1a13a --- /dev/null +++ b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte @@ -0,0 +1,381 @@ + + +
service.focusGroup(index)} +> +
+ {#each group.tabs as path (path)} + {@const state = stateOf(path)} + + {/each} +
+ + {#if conflict && active} + + {/if} + + {#if comparison && active && !(comparing && conflict)} + + {/if} + +
+ + {#if comparedText !== undefined} +
+ {/if} + {#if !active} + + {/if} +
+
+ + (closing = undefined)} +> + {#snippet footer()} + + + + {/snippet} + + + diff --git a/plugins/code-editor/src/widgets/script/ScriptScreen.svelte b/plugins/code-editor/src/widgets/script/ScriptScreen.svelte new file mode 100644 index 00000000..381a5c92 --- /dev/null +++ b/plugins/code-editor/src/widgets/script/ScriptScreen.svelte @@ -0,0 +1,167 @@ + + +{#if $current && $layout} +
+ {#each $layout.groups as group, index (index)} + + {/each} + {#if panel && activePath} + {#if panelOpen} + + {:else} +
+ (panelOpen = true)} + /> +
+ {/if} + {/if} +
+{:else} + +{/if} + + diff --git a/plugins/code-editor/svelte.config.js b/plugins/code-editor/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/code-editor/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/code-editor/test/service/pair-renames.spec.ts b/plugins/code-editor/test/service/pair-renames.spec.ts new file mode 100644 index 00000000..beda3173 --- /dev/null +++ b/plugins/code-editor/test/service/pair-renames.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { type PathChange, pairRenames } from '../../src/service/pair-renames'; + +const file = (type: PathChange['type'], path: string): PathChange => ({ type, path, kind: 'file' }); + +describe('pairRenames', () => { + it('pairs a rename in place and a move to another folder', () => { + expect([ + ...pairRenames([file('deleted', 'src/notes.md'), file('created', 'src/todo.md')]), + ]).toEqual([['src/notes.md', 'src/todo.md']]); + expect([ + ...pairRenames([file('created', 'docs/notes.md'), file('deleted', 'src/notes.md')]), + ]).toEqual([['src/notes.md', 'docs/notes.md']]); + }); + + it('follows the files of a renamed folder', () => { + const pairs = pairRenames([ + { type: 'deleted', path: 'old', kind: 'directory' }, + { type: 'created', path: 'new', kind: 'directory' }, + file('deleted', 'old/a/index.ts'), + file('deleted', 'old/b/index.ts'), + file('created', 'new/a/index.ts'), + file('created', 'new/b/index.ts'), + ]); + expect(Object.fromEntries(pairs)).toEqual({ + 'old/a/index.ts': 'new/a/index.ts', + 'old/b/index.ts': 'new/b/index.ts', + }); + }); + + it('leaves a plain deletion, and an unclear batch, alone', () => { + expect(pairRenames([file('deleted', 'src/a.ts')]).size).toBe(0); + expect(pairRenames([file('deleted', 'src/a.ts'), file('created', 'src/a.ts')]).size).toBe(0); + expect(pairRenames([file('deleted', 'src/a.ts'), file('changed', 'src/b.ts')]).size).toBe(0); + expect( + pairRenames([ + file('deleted', 'src/a.ts'), + file('deleted', 'src/b.ts'), + file('created', 'src/c.ts'), + file('created', 'src/d.ts'), + ]).size, + ).toBe(0); + }); +}); diff --git a/plugins/code-editor/tsconfig.json b/plugins/code-editor/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/code-editor/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/code-editor/vite.config.ts b/plugins/code-editor/vite.config.ts new file mode 100644 index 00000000..29b6d0d1 --- /dev/null +++ b/plugins/code-editor/vite.config.ts @@ -0,0 +1,10 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'code-editor' } }, + worker: { format: 'es' }, +}); diff --git a/plugins/command-palette/.prettierignore b/plugins/command-palette/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/command-palette/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/command-palette/LICENSE b/plugins/command-palette/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/command-palette/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/command-palette/README.md b/plugins/command-palette/README.md new file mode 100644 index 00000000..e889b6ba --- /dev/null +++ b/plugins/command-palette/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-command-palette` is an internal package of the NanoForge Editor. + +Built-in command palette and keyboard shortcuts page. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/command-palette +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/command-palette/nanoforge.manifest.json b/plugins/command-palette/nanoforge.manifest.json new file mode 100644 index 00000000..8dab1fc0 --- /dev/null +++ b/plugins/command-palette/nanoforge.manifest.json @@ -0,0 +1,62 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/command-palette", + "version": "0.1.0", + "displayName": "Command palette", + "description": "Run commands, open files, go to lines and symbols from one box, and edit the keyboard shortcuts.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onStartup"], + "contributes": { + "commands": [ + { "id": "palette.show", "title": "Command palette…", "category": "View" }, + { "id": "palette.files", "title": "Go to file…", "category": "Go" }, + { "id": "palette.line", "title": "Go to line…", "category": "Go" }, + { "id": "palette.symbols", "title": "Go to symbol in file…", "category": "Go" }, + { "id": "keymap.open", "title": "Keyboard shortcuts…", "category": "File" } + ], + "keybindings": [ + { "key": "Mod+Shift+P", "command": "palette.show" }, + { "key": "Mod+P", "command": "palette.files" }, + { "key": "Mod+G", "command": "palette.line" }, + { "key": "Mod+Shift+O", "command": "palette.symbols" } + ], + "menus": [ + { "menu": "view", "command": "palette.show", "group": "a-palette", "order": 0 }, + { "menu": "view", "command": "palette.files", "group": "a-palette", "order": 1 }, + { "menu": "file", "command": "keymap.open", "group": "z-settings", "order": 1 } + ], + "keymapPresets": [ + { + "id": "vscode", + "title": "VS Code", + "description": "Shortcuts close to Visual Studio Code's.", + "bindings": [ + { "key": "Mod+J", "command": "console.show" }, + { "key": "Mod+`", "command": "console.show" }, + { "key": "Mod+Shift+M", "command": "console.showProblems" }, + { "key": "Mod+B", "command": "workbench.toggleSlot", "args": ["leftTop"] }, + { "key": "Mod+K Mod+S", "command": "keymap.open" } + ] + }, + { + "id": "godot", + "title": "Godot", + "description": "Shortcuts close to the Godot editor's.", + "bindings": [ + { "key": "F8", "command": "runtime.stop", "when": "runtime.active" }, + { + "key": "F7", + "command": "runtime.pause", + "when": "runtime.state == 'running' && runtime.controllable" + }, + { "key": "F7", "command": "runtime.resume", "when": "runtime.state == 'paused'" }, + { "key": "Mod+F1", "command": "workbench.openWidget", "args": ["viewport.scene"] }, + { "key": "Mod+F2", "command": "workbench.openWidget", "args": ["viewport.game"] }, + { "key": "Mod+F3", "command": "workbench.openWidget", "args": ["code-editor.script"] } + ] + } + ] + } +} diff --git a/plugins/command-palette/package.json b/plugins/command-palette/package.json new file mode 100644 index 00000000..6ec3c005 --- /dev/null +++ b/plugins/command-palette/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-command-palette", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in command palette and keyboard shortcuts page", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/command-palette" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/command-palette/src/icons.ts b/plugins/command-palette/src/icons.ts new file mode 100644 index 00000000..7e707414 --- /dev/null +++ b/plugins/command-palette/src/icons.ts @@ -0,0 +1,6 @@ +import Keyboard from '@lucide/svelte/icons/keyboard'; +import Pencil from '@lucide/svelte/icons/pencil'; +import Plus from '@lucide/svelte/icons/plus'; + +/** Icons of the keymap page that the editor's kit doesn't register. */ +export const ICONS = { keyboard: Keyboard, pencil: Pencil, plus: Plus }; diff --git a/plugins/command-palette/src/index.ts b/plugins/command-palette/src/index.ts new file mode 100644 index 00000000..b46ffb03 --- /dev/null +++ b/plugins/command-palette/src/index.ts @@ -0,0 +1,136 @@ +import { type Component, mount, unmount } from 'svelte'; + +import { EditorServices, type ServiceAccessor, definePlugin } from '@nanoforge-dev/editor-sdk'; +import { + CODE_EDITOR_ACTIVE_FILE, + type EditorAction, + NotificationServiceToken, + SETTINGS_PAGES, + type SettingsPageApi, + StyleServiceToken, + isActionEnabled, + listActions, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { ICONS } from './icons'; +import KeymapPage from './keymap/KeymapPage.svelte'; +import { KEYMAP_PAGE, OVERRIDES, PRESET } from './keymap/keymap.const'; +import Palette, { type PaletteResult } from './palette/Palette.svelte'; +import { PREFIXES } from './palette/palette-model'; +import { readRecent, rememberRecent } from './palette/recent'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('command-palette.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + + const commands = services.get(EditorServices.Commands); + const extensions = services.get(EditorServices.Extensions); + const contextKeys = services.get(EditorServices.ContextKeys); + const notify = (kind: 'info' | 'error', title: string, detail?: string) => + services.tryGet(NotificationServiceToken)?.notify(kind, title, detail ? { detail } : {}); + + let palette: + | { + component: ReturnType; + target: HTMLElement; + previous: HTMLElement | null; + actions: readonly EditorAction[]; + activeFile: string | undefined; + } + | undefined; + + const run = async (result: PaletteResult) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + try { + if (result.kind === 'action') { + rememberRecent(result.action.id); + await commands.execute(result.action.command, ...result.action.args); + } else { + await commands.execute('documents.open', result.path, { + ...(result.line !== undefined && { line: result.line, column: result.column ?? 1 }), + }); + } + } catch (error) { + notify( + 'error', + result.kind === 'action' + ? `${result.action.title} could not run` + : `${result.path} could not be opened`, + error instanceof Error ? error.message : String(error), + ); + } + }; + + const close = (result?: PaletteResult) => { + if (!palette) return; + const { component, target, previous } = palette; + palette = undefined; + const back = result?.kind === 'open' ? null : previous; + if (back?.isConnected && back !== document.body) back.focus(); + else (document.activeElement as HTMLElement | null)?.blur?.(); + void unmount(component); + target.remove(); + if (result) void run(result); + }; + context.subscriptions.add({ dispose: () => close() }); + + const open = (prefix: string) => { + const previous = + palette?.previous ?? + (document.activeElement instanceof HTMLElement ? document.activeElement : null); + const actions = + palette?.actions ?? + listActions({ commands, extensions }).filter((action) => + isActionEnabled(action, contextKeys), + ); + const activeFile = palette + ? palette.activeFile + : contextKeys.get(CODE_EDITOR_ACTIVE_FILE); + if (palette) { + void unmount(palette.component); + palette.target.remove(); + } + const target = document.createElement('div'); + document.body.append(target); + palette = { + target, + previous, + actions, + activeFile, + component: mount(Palette, { + target, + props: { services, prefix, actions, activeFile, recent: readRecent(), onclose: close }, + }), + }; + }; + + for (const [id, prefix] of [ + ['palette.show', PREFIXES.commands], + ['palette.files', ''], + ['palette.line', PREFIXES.line], + ['palette.symbols', PREFIXES.symbols], + ] as const) { + context.subscriptions.add(context.registerCommand(id, () => open(prefix))); + } + + context.subscriptions.add( + context.contribute(SETTINGS_PAGES, { + id: KEYMAP_PAGE, + title: 'Keyboard shortcuts', + icon: 'keyboard', + order: 0, + settings: [OVERRIDES, PRESET], + component: KeymapPage as Component<{ services: ServiceAccessor; page: SettingsPageApi }>, + }), + ); + context.subscriptions.add( + context.registerCommand('keymap.open', () => commands.execute('settings.open', KEYMAP_PAGE)), + ); + }, +}); diff --git a/plugins/command-palette/src/keymap/KeymapPage.svelte b/plugins/command-palette/src/keymap/KeymapPage.svelte new file mode 100644 index 00000000..93ead76f --- /dev/null +++ b/plugins/command-palette/src/keymap/KeymapPage.svelte @@ -0,0 +1,332 @@ + + +
+
+ + {/if} +
+
+ + + +
+
    + {#each shown as row (row.action.id)} +
  • +
    + {row.label} + {row.action.command} +
    +
    + {#each row.bindings as binding, index (index)} + + {formatKeybinding(binding.key)} + {#if binding.when}when {binding.when}{/if} + {sourceOf(binding)} + {#if conflicting.has(binding)}Conflict{/if} + record(row, binding)} + /> + remove(binding)} + /> + + {/each} + + {#if row.changed} + + {/if} +
    +
  • + {:else} + + {/each} +
+
+ + diff --git a/plugins/command-palette/src/keymap/RecordDialog.svelte b/plugins/command-palette/src/keymap/RecordDialog.svelte new file mode 100644 index 00000000..bcf77577 --- /dev/null +++ b/plugins/command-palette/src/keymap/RecordDialog.svelte @@ -0,0 +1,201 @@ + + + +
+
+ {strokes.length ? formatKeybinding(key) : 'Press a shortcut'} +
+ + {#if invalidWhen} + + {/if} + {#if conflicts.length} + + {/if} +
+ {#snippet footer()} + + {#if conflicts.length} + + + {:else} + + {/if} + {/snippet} +
+ + diff --git a/plugins/command-palette/src/keymap/keymap-rows.ts b/plugins/command-palette/src/keymap/keymap-rows.ts new file mode 100644 index 00000000..5d63e7da --- /dev/null +++ b/plugins/command-palette/src/keymap/keymap-rows.ts @@ -0,0 +1,116 @@ +import { + type EditorAction, + type KeymapLayers, + type ResolvedKeybinding, + actionId, + actionLabel, + bindingsConflict, + findConflicts, + formatKeybinding, + isActionChanged, + parseKeybinding, +} from '@nanoforge-dev/editor-sdk/ui'; + +/** An action of the Keyboard shortcuts page, with its shortcuts. */ +export interface KeymapRow { + readonly action: EditorAction; + readonly label: string; + readonly bindings: readonly ResolvedKeybinding[]; + readonly changed: boolean; + readonly conflict: boolean; +} + +export interface KeymapFilter { + readonly query: string; + readonly onlyChanged: boolean; + readonly onlyConflicts: boolean; +} + +/** Shortcuts in a conflict the user's own changes are part of. */ +export const userConflicts = (bindings: readonly ResolvedKeybinding[]): Set => + new Set( + findConflicts(bindings) + .filter((group) => group.bindings.some((binding) => binding.source === 'user')) + .flatMap((group) => group.bindings), + ); + +/** Every action, and the commands that have shortcuts but no title, with their shortcuts. */ +export const keymapRows = ( + actions: readonly EditorAction[], + layers: KeymapLayers, + bindings: readonly ResolvedKeybinding[], + conflicting: ReadonlySet, +): KeymapRow[] => { + const overrides = [...layers.before, ...layers.own, ...layers.after]; + const known = new Set(actions.map((action) => action.id)); + const untitled = new Map(); + for (const binding of bindings) { + const id = actionId(binding.command, binding.args); + if (known.has(id) || untitled.has(id)) continue; + untitled.set(id, { + id, + command: binding.command, + args: binding.args, + title: id, + when: [], + }); + } + return [...actions, ...untitled.values()].map((action) => { + const own = bindings.filter((binding) => actionId(binding.command, binding.args) === action.id); + return { + action, + label: actionLabel(action), + bindings: own, + changed: isActionChanged(overrides, action.command, action.args), + conflict: own.some((binding) => conflicting.has(binding)), + }; + }); +}; + +/** The rows the filters keep: a search in labels, commands and shortcuts. */ +export const filterRows = (rows: readonly KeymapRow[], filter: KeymapFilter): KeymapRow[] => { + const needle = filter.query.trim().toLowerCase(); + return rows.filter( + (row) => + (!filter.onlyChanged || row.changed) && + (!filter.onlyConflicts || row.conflict) && + (!needle || + row.label.toLowerCase().includes(needle) || + row.action.command.toLowerCase().includes(needle) || + row.bindings.some( + (binding) => + binding.strokes.join(' ').toLowerCase().includes(needle) || + formatKeybinding(binding.key).toLowerCase().includes(needle), + )), + ); +}; + +/** The strokes of a shortcut, or undefined when it does not parse. */ +export const strokesOf = (key: string): string[] | undefined => { + try { + return parseKeybinding(key); + } catch { + return undefined; + } +}; + +/** Shortcuts of other actions a candidate would get in the way of. */ +export const conflictsOf = ( + bindings: readonly ResolvedKeybinding[], + action: EditorAction, + key: string, + when: string | undefined, + editing?: ResolvedKeybinding, +): ResolvedKeybinding[] => { + const strokes = strokesOf(key); + if (!strokes) return []; + const candidate: ResolvedKeybinding = { + command: action.command, + args: [...action.args], + key, + ...(when && { when }), + strokes, + source: 'user', + }; + return bindings.filter((binding) => binding !== editing && bindingsConflict(candidate, binding)); +}; diff --git a/plugins/command-palette/src/keymap/keymap.const.ts b/plugins/command-palette/src/keymap/keymap.const.ts new file mode 100644 index 00000000..c7a1bcb3 --- /dev/null +++ b/plugins/command-palette/src/keymap/keymap.const.ts @@ -0,0 +1,6 @@ +/** Settings the Keyboard shortcuts page edits. */ +export const OVERRIDES = 'keymap.overrides'; +export const PRESET = 'keymap.preset'; + +/** Id of the page in the Settings dialog (`settings.open` takes it). */ +export const KEYMAP_PAGE = 'keymap'; diff --git a/plugins/command-palette/src/keymap/open-record-dialog.ts b/plugins/command-palette/src/keymap/open-record-dialog.ts new file mode 100644 index 00000000..ef9a0762 --- /dev/null +++ b/plugins/command-palette/src/keymap/open-record-dialog.ts @@ -0,0 +1,21 @@ +import { type ComponentProps, mount, unmount } from 'svelte'; + +import RecordDialog from './RecordDialog.svelte'; + +let current: { component: ReturnType; host: HTMLElement } | undefined; + +/** Closes the shortcut recorder, when it is open. */ +export const closeRecordDialog = (): void => { + if (!current) return; + void unmount(current.component); + current.host.remove(); + current = undefined; +}; + +/** Shows the dialog that records a shortcut for an action. */ +export const openRecordDialog = (props: ComponentProps): void => { + closeRecordDialog(); + const host = document.createElement('div'); + document.body.append(host); + current = { host, component: mount(RecordDialog, { target: host, props }) }; +}; diff --git a/plugins/command-palette/src/palette/Palette.svelte b/plugins/command-palette/src/palette/Palette.svelte new file mode 100644 index 00000000..79c4a511 --- /dev/null +++ b/plugins/command-palette/src/palette/Palette.svelte @@ -0,0 +1,369 @@ + + + + + diff --git a/plugins/command-palette/src/palette/fuzzy.ts b/plugins/command-palette/src/palette/fuzzy.ts new file mode 100644 index 00000000..a4795b29 --- /dev/null +++ b/plugins/command-palette/src/palette/fuzzy.ts @@ -0,0 +1,100 @@ +export interface FuzzyMatch { + /** Higher is better. */ + readonly score: number; + /** Matched character ranges of the text, `[start, end)`, for highlighting. */ + readonly ranges: readonly (readonly [number, number])[]; +} + +const isBoundary = (text: string, index: number): boolean => { + if (index === 0) return true; + const previous = text[index - 1]!; + const current = text[index]!; + return ( + /[\s/\\._:›>-]/.test(previous) || + (previous === previous.toLowerCase() && current !== current.toLowerCase()) + ); +}; + +/** + * Matches the letters of a query in order in a text, not necessarily adjacent (spaces of the + * query are ignored). Letters at the start of words and runs of adjacent letters score higher, + * a query found in one piece higher still, and so do matches near the start and shorter texts. + * Undefined when a letter is missing. + */ +export const fuzzyMatch = (query: string, text: string): FuzzyMatch | undefined => { + const needle = query.replace(/\s+/g, '').toLowerCase(); + if (!needle) return { score: 0, ranges: [] }; + const haystack = text.toLowerCase(); + if (needle.length > haystack.length) return undefined; + + const NONE = -Infinity; + let previous: number[] = []; + const back: number[][] = []; + for (let i = 0; i < needle.length; i++) { + const current = new Array(haystack.length).fill(NONE); + const from = new Array(haystack.length).fill(-1); + let bestBefore = NONE; + let bestBeforeAt = -1; + for (let j = i; j < haystack.length; j++) { + if (i > 0 && j > 0 && previous[j - 1]! > bestBefore) { + bestBefore = previous[j - 1]!; + bestBeforeAt = j - 1; + } + if (haystack[j] !== needle[i]) continue; + let score = 1; + if (isBoundary(text, j)) score += 8; + if (i === 0) { + current[j] = score - Math.min(j, 10) * 0.2; + continue; + } + const adjacent = j > 0 ? previous[j - 1]! : NONE; + const run = adjacent === NONE ? NONE : adjacent + score + 5; + const jump = bestBefore === NONE ? NONE : bestBefore + score; + if (run >= jump && run !== NONE) { + current[j] = run; + from[j] = j - 1; + } else if (jump !== NONE) { + current[j] = jump; + from[j] = bestBeforeAt; + } + } + back.push(from); + previous = current; + } + + let end = -1; + for (let j = 0; j < previous.length; j++) { + if (previous[j]! > (end < 0 ? NONE : previous[end]!)) end = j; + } + if (end < 0) return undefined; + + const positions: number[] = []; + for (let i = needle.length - 1, j = end; i >= 0; i--) { + positions.unshift(j); + j = back[i]![j]!; + } + const ranges: [number, number][] = []; + for (const position of positions) { + const last = ranges[ranges.length - 1]; + if (last && last[1] === position) last[1] = position + 1; + else ranges.push([position, position + 1]); + } + const whole = ranges.length === 1 ? 10 : 0; + return { score: previous[end]! + whole - text.length * 0.01, ranges }; +}; + +/** Splits a text into plain and matched parts, for rendering highlights. */ +export const highlight = ( + text: string, + ranges: readonly (readonly [number, number])[], +): { text: string; match: boolean }[] => { + const parts: { text: string; match: boolean }[] = []; + let last = 0; + for (const [start, end] of ranges) { + if (start > last) parts.push({ text: text.slice(last, start), match: false }); + parts.push({ text: text.slice(start, end), match: true }); + last = end; + } + if (last < text.length) parts.push({ text: text.slice(last), match: false }); + return parts; +}; diff --git a/plugins/command-palette/src/palette/palette-model.ts b/plugins/command-palette/src/palette/palette-model.ts new file mode 100644 index 00000000..6fa5f11c --- /dev/null +++ b/plugins/command-palette/src/palette/palette-model.ts @@ -0,0 +1,108 @@ +import type { CodeSymbol } from '@nanoforge-dev/editor-sdk'; +import { type EditorAction, actionLabel } from '@nanoforge-dev/editor-sdk/ui'; + +import { type FuzzyMatch, fuzzyMatch } from './fuzzy'; + +export type PaletteMode = 'commands' | 'files' | 'line' | 'symbols'; + +/** The first character of the box picks what is searched. */ +export const PREFIXES: Record, string> = { + commands: '>', + line: ':', + symbols: '@', +}; + +export interface PaletteQuery { + readonly mode: PaletteMode; + readonly text: string; +} + +export const parseQuery = (input: string): PaletteQuery => { + for (const [mode, prefix] of Object.entries(PREFIXES)) { + if (input.startsWith(prefix)) + return { mode: mode as PaletteMode, text: input.slice(prefix.length).trim() }; + } + return { mode: 'files', text: input.trim() }; +}; + +/** `12`, `12:4` or `12,4`: a 1-based line and column. */ +export const parseLine = (text: string): { line: number; column: number } | undefined => { + const match = /^(\d+)(?:\s*[:,]\s*(\d+))?$/.exec(text.trim()); + if (!match) return undefined; + const line = Number(match[1]); + return line >= 1 ? { line, column: Math.max(Number(match[2] ?? 1), 1) } : undefined; +}; + +export interface Ranked { + readonly item: T; + readonly label: string; + readonly match: FuzzyMatch; +} + +const MAX_RESULTS = 60; + +/** + * Items whose label matches the query, best first. `boost` breaks ties and orders an empty + * query (recent actions, open files): lower comes first, undefined last. + */ +export const rank = ( + items: readonly T[], + query: string, + label: (item: T) => string, + boost: (item: T) => number | undefined = () => undefined, + limit = MAX_RESULTS, +): Ranked[] => { + const ranked: (Ranked & { boost: number; index: number })[] = []; + items.forEach((item, index) => { + const text = label(item); + const match = fuzzyMatch(query, text); + if (match) ranked.push({ item, label: text, match, boost: boost(item) ?? Infinity, index }); + }); + return ranked + .sort((a, b) => b.match.score - a.match.score || a.boost - b.boost || a.index - b.index) + .slice(0, limit) + .map(({ item, label: text, match }) => ({ item, label: text, match })); +}; + +/** Actions for a query: recently run ones first when nothing is typed, and on ties. */ +export const rankActions = ( + actions: readonly EditorAction[], + query: string, + recent: readonly string[], +): Ranked[] => + rank(actions, query, actionLabel, (action) => { + const index = recent.indexOf(action.id); + return index < 0 ? undefined : index; + }); + +/** Folders whose files are never what "go to file" is after. */ +const NOT_LISTED = /(^|\/)(node_modules|dist|coverage|\.git|\.nanoforge|\.turbo)(\/|$)/; + +export const listedFiles = (paths: readonly string[]): string[] => + paths.filter((path) => !NOT_LISTED.test(path)).sort((a, b) => a.localeCompare(b)); + +/** Files for a query: the open documents first when nothing is typed, and on ties. */ +export const rankFiles = ( + paths: readonly string[], + query: string, + open: readonly string[], +): Ranked[] => + rank( + paths, + query, + (path) => path, + (path) => { + const index = open.indexOf(path); + return index < 0 ? undefined : index; + }, + ); + +export const symbolLabel = (symbol: CodeSymbol): string => + symbol.container ? `${symbol.container}.${symbol.name}` : symbol.name; + +export const rankSymbols = (symbols: readonly CodeSymbol[], query: string): Ranked[] => + rank(symbols, query, symbolLabel, () => undefined, 200); + +/** Puts an action first in the recent list (the last `limit` are kept). */ +export const pushRecent = (recent: readonly string[], id: string, limit = 10): string[] => + [id, ...recent.filter((other) => other !== id)].slice(0, limit); diff --git a/plugins/command-palette/src/palette/recent.ts b/plugins/command-palette/src/palette/recent.ts new file mode 100644 index 00000000..de72dbba --- /dev/null +++ b/plugins/command-palette/src/palette/recent.ts @@ -0,0 +1,22 @@ +import { pushRecent } from './palette-model'; + +const KEY = 'nanoforge.command-palette.recent'; + +/** Actions run last from the palette, kept in this browser. */ +export const readRecent = (storage: Pick = localStorage): string[] => { + try { + const value: unknown = JSON.parse(storage.getItem(KEY) ?? '[]'); + return Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []; + } catch { + return []; + } +}; + +export const rememberRecent = ( + id: string, + storage: Pick = localStorage, +): void => { + try { + storage.setItem(KEY, JSON.stringify(pushRecent(readRecent(storage), id))); + } catch {} +}; diff --git a/plugins/command-palette/svelte.config.js b/plugins/command-palette/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/command-palette/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/command-palette/test/palette/fuzzy.spec.ts b/plugins/command-palette/test/palette/fuzzy.spec.ts new file mode 100644 index 00000000..d8487bbb --- /dev/null +++ b/plugins/command-palette/test/palette/fuzzy.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { fuzzyMatch, highlight } from '../../src/palette/fuzzy'; + +const rank = (query: string, texts: string[]) => + texts + .map((text) => ({ text, match: fuzzyMatch(query, text) })) + .filter((entry) => entry.match) + .sort((a, b) => b.match!.score - a.match!.score) + .map((entry) => entry.text); + +describe('fuzzyMatch', () => { + it('matches letters in order, ignoring case and spaces', () => { + expect(fuzzyMatch('rst', 'Run: Stop')).toBeDefined(); + expect(fuzzyMatch('run stop', 'Run: Stop')).toBeDefined(); + expect(fuzzyMatch('tsr', 'Run: Stop')).toBeUndefined(); + expect(fuzzyMatch('', 'anything')).toEqual({ score: 0, ranges: [] }); + expect(fuzzyMatch('longer than the text', 'short')).toBeUndefined(); + }); + + it('ranks word starts and runs above scattered letters', () => { + expect(rank('cp', ['Copy path', 'Close panel', 'Escape'])).toEqual([ + 'Copy path', + 'Close panel', + 'Escape', + ]); + expect(rank('stop', ['Run: Stop', 'Set top dock position'])[0]).toBe('Run: Stop'); + expect(rank('main', ['apps/client/src/main.ts', 'apps/client/domain/index.ts'])[0]).toBe( + 'apps/client/src/main.ts', + ); + expect(rank('om', ['fileManager.open', 'openMenu'])[0]).toBe('openMenu'); + }); + + it('prefers the shorter of two equal matches', () => { + expect(rank('save', ['File: Save all', 'File: Save'])[0]).toBe('File: Save'); + }); + + it('gives the matched ranges, merged', () => { + expect(fuzzyMatch('rust', 'Run: Stop')!.ranges).toEqual([ + [0, 2], + [5, 7], + ]); + expect( + highlight('Run: Stop', [ + [0, 2], + [5, 7], + ]), + ).toEqual([ + { text: 'Ru', match: true }, + { text: 'n: ', match: false }, + { text: 'St', match: true }, + { text: 'op', match: false }, + ]); + }); +}); diff --git a/plugins/command-palette/test/palette/palette-model.spec.ts b/plugins/command-palette/test/palette/palette-model.spec.ts new file mode 100644 index 00000000..a16af1e5 --- /dev/null +++ b/plugins/command-palette/test/palette/palette-model.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAction } from '@nanoforge-dev/editor-sdk/ui'; + +import { + listedFiles, + parseLine, + parseQuery, + pushRecent, + rankActions, + rankFiles, + rankSymbols, +} from '../../src/palette/palette-model'; +import { readRecent, rememberRecent } from '../../src/palette/recent'; + +vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({ + actionLabel: (action: { title: string; category?: string }) => + action.category ? `${action.category}: ${action.title}` : action.title, +})); + +const action = (id: string, title: string, category?: string): EditorAction => ({ + id, + command: id, + args: [], + title, + ...(category && { category }), + when: [], +}); + +const ACTIONS = [ + action('runtime.play', 'Play', 'Run'), + action('runtime.stop', 'Stop', 'Run'), + action('layout.reset', 'Reset layout', 'View'), + action('console.clear', 'Clear the console', 'Console'), +]; + +describe('query', () => { + it('picks the mode from the first character', () => { + expect(parseQuery('>stop')).toEqual({ mode: 'commands', text: 'stop' }); + expect(parseQuery('main.ts')).toEqual({ mode: 'files', text: 'main.ts' }); + expect(parseQuery(':12')).toEqual({ mode: 'line', text: '12' }); + expect(parseQuery('@ update')).toEqual({ mode: 'symbols', text: 'update' }); + expect(parseQuery('')).toEqual({ mode: 'files', text: '' }); + }); + + it('reads a line and an optional column', () => { + expect(parseLine('12')).toEqual({ line: 12, column: 1 }); + expect(parseLine('12:4')).toEqual({ line: 12, column: 4 }); + expect(parseLine(' 7 , 2 ')).toEqual({ line: 7, column: 2 }); + expect(parseLine('0')).toBeUndefined(); + expect(parseLine('abc')).toBeUndefined(); + expect(parseLine('')).toBeUndefined(); + }); +}); + +describe('ranking', () => { + it('lists recent actions first when nothing is typed', () => { + const ids = (recent: string[]) => + rankActions(ACTIONS, '', recent).map((entry) => entry.item.id); + expect(ids([])).toEqual(['runtime.play', 'runtime.stop', 'layout.reset', 'console.clear']); + expect(ids(['console.clear', 'runtime.stop'])).toEqual([ + 'console.clear', + 'runtime.stop', + 'runtime.play', + 'layout.reset', + ]); + }); + + it('searches the category and the title, the best match first', () => { + expect(rankActions(ACTIONS, 'stop', []).map((entry) => entry.label)).toEqual(['Run: Stop']); + expect(rankActions(ACTIONS, 'rese', [])[0]!.label).toBe('View: Reset layout'); + expect(rankActions(ACTIONS, 'zzz', [])).toEqual([]); + }); + + it('lets a recent action win a tie', () => { + const twins = [action('a.one', 'Open thing'), action('b.two', 'Open thing')]; + expect(rankActions(twins, 'open', ['b.two'])[0]!.item.id).toBe('b.two'); + }); + + it('leaves dependency and build folders out of the files, open documents first', () => { + const files = listedFiles([ + 'apps/client/src/main.ts', + 'node_modules/x/index.js', + 'apps/client/dist/main.js', + '.nanoforge/editor/settings.json', + 'README.md', + ]); + expect(files).toEqual(['apps/client/src/main.ts', 'README.md']); + expect(rankFiles(files, '', ['README.md']).map((entry) => entry.item)).toEqual([ + 'README.md', + 'apps/client/src/main.ts', + ]); + expect(rankFiles(files, 'mts', [])[0]!.item).toBe('apps/client/src/main.ts'); + }); + + it('keeps symbols in source order when nothing is typed, and searches containers', () => { + const symbols = [ + { name: 'Position', kind: 'class', line: 1, column: 1 }, + { name: 'move', kind: 'method', container: 'Position', line: 3, column: 3 }, + { name: 'update', kind: 'function', line: 9, column: 1 }, + ]; + expect(rankSymbols(symbols, '').map((entry) => entry.label)).toEqual([ + 'Position', + 'Position.move', + 'update', + ]); + expect(rankSymbols(symbols, 'pmove')[0]!.label).toBe('Position.move'); + }); +}); + +describe('recent actions', () => { + it('keeps the last ones, the newest first, without doubles', () => { + expect(pushRecent(['a', 'b', 'c'], 'b')).toEqual(['b', 'a', 'c']); + expect(pushRecent(['a', 'b', 'c'], 'd', 3)).toEqual(['d', 'a', 'b']); + }); + + it('survives a broken or blocked storage', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }; + rememberRecent('a', storage); + rememberRecent('b', storage); + expect(readRecent(storage)).toEqual(['b', 'a']); + values.set([...values.keys()][0]!, '{not json'); + expect(readRecent(storage)).toEqual([]); + expect(() => + rememberRecent('c', { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + }), + ).not.toThrow(); + }); +}); diff --git a/plugins/command-palette/tsconfig.json b/plugins/command-palette/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/command-palette/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/command-palette/vite.config.ts b/plugins/command-palette/vite.config.ts new file mode 100644 index 00000000..1b395ce9 --- /dev/null +++ b/plugins/command-palette/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'command-palette' }, + }, +}); diff --git a/plugins/console/.prettierignore b/plugins/console/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/console/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/console/LICENSE b/plugins/console/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/console/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/console/README.md b/plugins/console/README.md new file mode 100644 index 00000000..a60de3f4 --- /dev/null +++ b/plugins/console/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-console` is an internal package of the NanoForge Editor. + +Built-in Console and Problems panels. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/console +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/console/nanoforge.manifest.json b/plugins/console/nanoforge.manifest.json new file mode 100644 index 00000000..297097b2 --- /dev/null +++ b/plugins/console/nanoforge.manifest.json @@ -0,0 +1,56 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/console", + "version": "0.1.0", + "displayName": "Console", + "description": "The Console (game, build, task, editor and plugin output) and the Problems of the project.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onStartup"], + "contributes": { + "widgets": [ + { + "id": "console.console", + "title": "Console", + "icon": "square-terminal", + "defaultSlot": "bottom", + "order": 100 + }, + { + "id": "console.problems", + "title": "Problems", + "icon": "circle-alert", + "defaultSlot": "bottom", + "order": 110 + } + ], + "commands": [ + { "id": "console.show", "title": "Show the console", "category": "Console" }, + { "id": "console.clear", "title": "Clear the console", "category": "Console" }, + { "id": "console.showProblems", "title": "Show problems", "category": "Console" } + ], + "settings": [ + { + "key": "maxLines", + "type": "number", + "minimum": 100, + "maximum": 20000, + "default": 2000, + "title": "Console lines kept", + "description": "Older lines are dropped.", + "category": "Editor/Console", + "order": 0 + }, + { + "key": "checkWholeProject", + "type": "boolean", + "default": true, + "title": "Check the whole project for problems", + "description": "Check every code file, not only the open ones. Turn off on big projects if the editor feels slow.", + "category": "Editor/Console", + "order": 1 + } + ] + } +} diff --git a/plugins/console/package.json b/plugins/console/package.json new file mode 100644 index 00000000..3c3c079a --- /dev/null +++ b/plugins/console/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-console", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in Console and Problems panels", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/console" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/console/src/icons.ts b/plugins/console/src/icons.ts new file mode 100644 index 00000000..332f12d9 --- /dev/null +++ b/plugins/console/src/icons.ts @@ -0,0 +1,6 @@ +import Bug from '@lucide/svelte/icons/bug'; +import Info from '@lucide/svelte/icons/info'; +import ListFilter from '@lucide/svelte/icons/list-filter'; + +/** Icons of the panels that the editor's kit doesn't register. */ +export const ICONS = { bug: Bug, info: Info, 'list-filter': ListFilter }; diff --git a/plugins/console/src/index.ts b/plugins/console/src/index.ts new file mode 100644 index 00000000..0644c025 --- /dev/null +++ b/plugins/console/src/index.ts @@ -0,0 +1,103 @@ +import type { Component } from 'svelte'; + +import { + CodeServiceToken, + DiagnosticsServiceToken, + EditorServices, + MutableDisposable, + ProjectServiceToken, + SettingsServiceToken, + definePlugin, + derived, + observe, +} from '@nanoforge-dev/editor-sdk'; +import { + STATUS_BAR_ITEMS, + StyleServiceToken, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { ICONS } from './icons'; +import { summary } from './problems/group-problems'; +import { setConsoleStore } from './session/console-session'; +import { ConsoleStore } from './store/console-store'; +import ConsoleView from './widgets/console/ConsoleView.svelte'; +import ProblemsView from './widgets/problems/ProblemsView.svelte'; + +const CONSOLE = 'console.console'; +const PROBLEMS = 'console.problems'; +const MAX_LINES = '@nanoforge/console.maxLines'; +const CHECK_PROJECT = '@nanoforge/console.checkWholeProject'; +/** The first check waits for the project to settle: it starts the code worker. */ +const CHECK_DELAY_MS = 1500; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('console.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + + const settings = services.get(SettingsServiceToken); + const store = new ConsoleStore( + services.get(EditorServices.Logger), + () => settings.get(MAX_LINES) ?? 2000, + ); + setConsoleStore(store); + context.subscriptions.add(store); + context.subscriptions.add({ dispose: () => setConsoleStore(undefined) }); + + for (const [id, component] of [ + [CONSOLE, ConsoleView], + [PROBLEMS, ProblemsView], + ] as const) { + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id, + component: component as Component<{ instance: WidgetInstance }>, + }), + ); + } + + const commands = services.get(EditorServices.Commands); + const show = (widget: string) => commands.execute('workbench.openWidget', widget); + context.subscriptions.add(context.registerCommand('console.show', () => show(CONSOLE))); + context.subscriptions.add(context.registerCommand('console.clear', () => store.clear())); + context.subscriptions.add( + context.registerCommand('console.showProblems', () => show(PROBLEMS)), + ); + + const diagnostics = services.get(DiagnosticsServiceToken); + context.subscriptions.add( + context.contribute(STATUS_BAR_ITEMS, { + id: 'console.problems', + alignment: 'left', + order: 5, + text: derived([diagnostics.all], summary), + tooltip: 'Show problems', + command: 'console.showProblems', + }), + ); + + const check = context.subscriptions.add(new MutableDisposable()); + let timer: ReturnType | undefined; + const sync = () => { + clearTimeout(timer); + check.clear(); + if (!settings.get(CHECK_PROJECT)) return; + timer = setTimeout(() => { + const code = services.tryGet(CodeServiceToken); + if (code) check.value = code.checkProject(); + }, CHECK_DELAY_MS); + }; + context.subscriptions.add({ dispose: () => clearTimeout(timer) }); + context.subscriptions.add(observe(settings.observe(CHECK_PROJECT), sync)); + context.subscriptions.add({ + dispose: services.get(ProjectServiceToken).current.subscribe(sync), + }); + }, +}); diff --git a/plugins/console/src/link/frame-locator.ts b/plugins/console/src/link/frame-locator.ts new file mode 100644 index 00000000..55e18ddb --- /dev/null +++ b/plugins/console/src/link/frame-locator.ts @@ -0,0 +1,51 @@ +import { type FileTarget, type TextSegment, linkify } from './linkify'; + +export interface Frame { + readonly file: string; + readonly line: number; + readonly column?: number; +} + +/** Links of a line's texts, computed once per line. */ +export const createSegmenter = (exists: (path: string) => boolean) => { + const cache = new WeakMap>(); + return (line: object, text: string): TextSegment[] => { + let texts = cache.get(line); + if (!texts) cache.set(line, (texts = new Map())); + let segments = texts.get(text); + if (!segments) texts.set(text, (segments = linkify(text, exists))); + return segments; + }; +}; + +/** + * Project files behind positions in built bundles, looked up once per position (a stack frame + * shows on every render of its line). + */ +export class FrameLocator { + private _found = new Map>(); + + constructor( + private readonly _resolve: ( + frame: Frame, + ) => Promise<{ path: string; line: number; column: number } | undefined> | undefined, + private readonly _limit = 2000, + ) {} + + locate = (frame: Frame): Promise => { + const key = `${frame.file}:${frame.line}:${frame.column ?? 1}`; + let result = this._found.get(key); + if (!result) { + if (this._found.size >= this._limit) this._found.clear(); + result = Promise.resolve(this._resolve(frame)) + .then((found) => (found ? ({ kind: 'file', ...found } as const) : undefined)) + .catch(() => undefined); + this._found.set(key, result); + } + return result; + }; + + clear(): void { + this._found.clear(); + } +} diff --git a/plugins/console/src/link/linkify.ts b/plugins/console/src/link/linkify.ts new file mode 100644 index 00000000..dc73f818 --- /dev/null +++ b/plugins/console/src/link/linkify.ts @@ -0,0 +1,85 @@ +/** A position in a project file. */ +export interface FileTarget { + readonly kind: 'file'; + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +/** A position in a file outside of the project's sources: maybe a built game bundle. */ +export interface FrameTarget { + readonly kind: 'frame'; + readonly file: string; + readonly line: number; + readonly column: number; +} + +export interface TextSegment { + readonly text: string; + readonly target?: FileTarget | FrameTarget; +} + +/** + * Either a URL with a position (a stack frame of a client bundle), or something that looks like + * a file with an extension, then an optional position: `:12`, `:12:3` or TypeScript's `(12,3)`. + */ +const TOKEN = + /((?:https?|file):\/\/[^\s()]+?):(\d+):(\d+)(?=[\s)]|$)|((?:[A-Za-z]:)?[\w@.~\-/\\]*[\w-]\.[A-Za-z]\w*)(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?/g; + +/** The project file a path names: itself, or its longest tail that is a project file. */ +const projectFile = (path: string, exists: (path: string) => boolean): string | undefined => { + const normalized = path.replace(/\\/g, '/').replace(/^\.\//, ''); + if (!normalized.startsWith('/') && !/^[A-Za-z]:/.test(normalized)) + return exists(normalized) ? normalized : undefined; + const segments = normalized.split('/'); + for (let index = 1; index < segments.length; index++) { + const tail = segments.slice(index).join('/'); + if (exists(tail)) return tail; + } + return undefined; +}; + +/** + * Splits a console line into text and links: paths of project files (with their position), and + * positions in other files, which may be built bundles to map back to sources. + */ +export const linkify = (text: string, exists: (path: string) => boolean): TextSegment[] => { + const segments: TextSegment[] = []; + let last = 0; + const push = (match: RegExpExecArray, target: FileTarget | FrameTarget) => { + if (match.index > last) segments.push({ text: text.slice(last, match.index) }); + segments.push({ text: match[0], target }); + last = match.index + match[0].length; + }; + for (const match of text.matchAll(TOKEN) as Iterable) { + if (match[1]) { + push(match, { + kind: 'frame', + file: match[1], + line: Number(match[2]), + column: Number(match[3]), + }); + continue; + } + const line = match[5] ?? match[7]; + const column = match[6] ?? match[8]; + const path = projectFile(match[4]!, exists); + if (path) { + push(match, { + kind: 'file', + path, + ...(line && { line: Number(line) }), + ...(column && { column: Number(column) }), + }); + } else if (line && /^(\/|[A-Za-z]:)/.test(match[4]!)) { + push(match, { + kind: 'frame', + file: match[4]!, + line: Number(line), + column: Number(column ?? 1), + }); + } + } + if (last < text.length) segments.push({ text: text.slice(last) }); + return segments; +}; diff --git a/plugins/console/src/problems/group-problems.ts b/plugins/console/src/problems/group-problems.ts new file mode 100644 index 00000000..c663dc34 --- /dev/null +++ b/plugins/console/src/problems/group-problems.ts @@ -0,0 +1,100 @@ +import type { CodeDiagnostic } from '@nanoforge-dev/editor-sdk'; + +export type Severity = CodeDiagnostic['severity']; + +export interface ProblemGroup { + /** Stable key: the file path, or `source:` for problems without a file. */ + readonly key: string; + /** Project file, when the problems are in one. */ + readonly path?: string; + readonly label: string; + readonly problems: readonly CodeDiagnostic[]; + readonly errors: number; + readonly warnings: number; +} + +export interface ProblemFilter { + readonly severities: Readonly>; + readonly query: string; +} + +/** `build:apps/client` → `Build of apps/client`; other sources keep their name. */ +export const sourceTitle = (source: string): string => { + const build = /^build:(.*)$/.exec(source); + return build ? `Build of ${build[1] || 'the project'}` : source; +}; + +/** `typescript 2322`, or the app for build errors. */ +export const origin = (problem: CodeDiagnostic): string => + `${problem.source.startsWith('build:') ? 'build' : problem.source}${ + problem.code !== undefined ? ` ${problem.code}` : '' + }`; + +const count = (problems: readonly CodeDiagnostic[], severity: Severity) => + problems.filter((problem) => problem.severity === severity).length; + +/** + * Problems grouped by file, files in alphabetical order; problems without a file are grouped + * under their source, first. + */ +export const groupProblems = ( + all: ReadonlyMap, + filter: ProblemFilter, +): ProblemGroup[] => { + const needle = filter.query.trim().toLowerCase(); + const keep = (problem: CodeDiagnostic) => + filter.severities[problem.severity] && + (!needle || + problem.message.toLowerCase().includes(needle) || + problem.path.toLowerCase().includes(needle) || + origin(problem).toLowerCase().includes(needle)); + const group = ( + key: string, + label: string, + problems: readonly CodeDiagnostic[], + path?: string, + ): ProblemGroup => ({ + key, + label, + ...(path !== undefined && { path }), + problems, + errors: count(problems, 'error'), + warnings: count(problems, 'warning'), + }); + + const groups: ProblemGroup[] = []; + const bySource = new Map(); + for (const problem of all.get('') ?? []) { + if (keep(problem)) + bySource.set(problem.source, [...(bySource.get(problem.source) ?? []), problem]); + } + for (const [source, problems] of [...bySource].sort(([a], [b]) => a.localeCompare(b))) + groups.push(group(`source:${source}`, sourceTitle(source), problems)); + for (const path of [...all.keys()].filter(Boolean).sort((a, b) => a.localeCompare(b))) { + const problems = all.get(path)!.filter(keep); + if (problems.length) groups.push(group(path, path, problems, path)); + } + return groups; +}; + +/** `2 errors · 1 warning`, `1 error`, or `No problems`. */ +export const summary = (all: ReadonlyMap): string => { + let errors = 0; + let warnings = 0; + for (const problems of all.values()) { + errors += count(problems, 'error'); + warnings += count(problems, 'warning'); + } + const parts = [ + errors && `${errors} error${errors > 1 ? 's' : ''}`, + warnings && `${warnings} warning${warnings > 1 ? 's' : ''}`, + ].filter(Boolean); + return parts.length ? parts.join(' · ') : 'No problems'; +}; + +/** 1-based line and column of an offset in a text. */ +export const positionAt = (text: string, offset: number): { line: number; column: number } => { + const before = text.slice(0, Math.max(offset, 0)); + const line = before.split('\n').length; + return { line, column: before.length - before.lastIndexOf('\n') }; +}; diff --git a/plugins/console/src/session/console-session.ts b/plugins/console/src/session/console-session.ts new file mode 100644 index 00000000..0f4a866f --- /dev/null +++ b/plugins/console/src/session/console-session.ts @@ -0,0 +1,13 @@ +import type { ConsoleStore } from '../store/console-store'; + +/** Set by the plugin on activation, read by its widgets. */ +let store: ConsoleStore | undefined; + +export const setConsoleStore = (value: ConsoleStore | undefined): void => { + store = value; +}; + +export const consoleStore = (): ConsoleStore => { + if (!store) throw new Error('The console plugin is not active'); + return store; +}; diff --git a/plugins/console/src/store/console-store.ts b/plugins/console/src/store/console-store.ts new file mode 100644 index 00000000..fa16cc70 --- /dev/null +++ b/plugins/console/src/store/console-store.ts @@ -0,0 +1,119 @@ +import { + type Disposable, + type LogEntry, + LogLevel, + type LogLocation, + type LogValue, + type LoggerService, + ObservableValue, + formatLogValue, + serializeLogValue, +} from '@nanoforge-dev/editor-sdk'; + +import { type GroupId, groupOf } from './log-sources'; + +export type LineLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface ConsoleLine { + readonly id: number; + readonly time: number; + readonly level: LineLevel; + readonly source: string; + readonly group: GroupId; + /** The whole line as text (search, copy, repeats). */ + readonly text: string; + /** The logged values, when there is more than text to show. */ + readonly values?: readonly LogValue[]; + readonly location?: LogLocation; + /** How many identical lines in a row this one stands for. */ + readonly count: number; +} + +const LEVELS: Record = { + [LogLevel.Trace]: 'debug', + [LogLevel.Debug]: 'debug', + [LogLevel.Info]: 'info', + [LogLevel.Warn]: 'warn', + [LogLevel.Error]: 'error', +}; + +const hasTree = (values: readonly LogValue[]): boolean => + values.some((value) => value !== null && typeof value === 'object'); + +/** A log entry as a console line: values are snapshots taken now. */ +export const toLine = (entry: LogEntry, id: number): ConsoleLine => { + const values = + entry.values ?? + (entry.args.length + ? [entry.message, ...entry.args.map((arg) => serializeLogValue(arg))] + : undefined); + const text = entry.values + ? entry.message + : values + ? values.map((value) => formatLogValue(value)).join(' ') + : entry.message; + return { + id, + time: entry.time, + level: LEVELS[entry.level], + source: entry.source, + group: groupOf(entry.source), + text, + ...(values && hasTree(values) && { values }), + ...(entry.location && { location: entry.location }), + count: 1, + }; +}; + +/** + * The lines of the console: every log entry of the editor (its own loggers, plugins, and the + * game, build and task output the runtime writes), with identical consecutive lines merged and + * the oldest dropped. + */ +export class ConsoleStore implements Disposable { + private _lines: ConsoleLine[] = []; + private _next = 0; + private readonly _revision = new ObservableValue(0); + private readonly _sink: Disposable; + + /** Bumped when the lines change. */ + readonly revision = this._revision.readonly(); + + constructor( + logs: Pick, + private readonly _maxLines: () => number = () => 2000, + ) { + this._sink = logs.addSink((entry) => this.add(entry), { replay: true }); + } + + get lines(): readonly ConsoleLine[] { + return this._lines; + } + + add(entry: LogEntry): void { + const line = toLine(entry, this._next++); + const last = this._lines[this._lines.length - 1]; + if ( + last && + last.source === line.source && + last.level === line.level && + last.text === line.text + ) { + this._lines[this._lines.length - 1] = { ...last, time: line.time, count: last.count + 1 }; + } else { + this._lines.push(line); + const extra = this._lines.length - Math.max(this._maxLines(), 1); + if (extra > 0) this._lines.splice(0, extra); + } + this._revision.set(this._revision.get() + 1); + } + + clear(): void { + this._lines = []; + this._revision.set(this._revision.get() + 1); + } + + dispose(): void { + this._sink.dispose(); + } +} diff --git a/plugins/console/src/store/log-sources.ts b/plugins/console/src/store/log-sources.ts new file mode 100644 index 00000000..f4df9ccc --- /dev/null +++ b/plugins/console/src/store/log-sources.ts @@ -0,0 +1,28 @@ +/** Groups of console sources, in display order. */ +export const GROUPS = [ + { id: 'game', label: 'Game', shown: true }, + { id: 'build', label: 'Build', shown: true }, + { id: 'tasks', label: 'Tasks', shown: true }, + { id: 'editor', label: 'Editor', shown: false }, + { id: 'plugins', label: 'Plugins', shown: false }, +] as const; + +export type GroupId = (typeof GROUPS)[number]['id']; + +/** + * The group of a logger name: `game:client`, `build:` and `cli:` come from the + * runtime, plugins log under their name (`@scope/name`), the rest is the editor itself. + */ +export const groupOf = (source: string): GroupId => { + if (source.startsWith('game:')) return 'game'; + if (source.startsWith('build:')) return 'build'; + if (source.startsWith('cli:')) return 'tasks'; + if (source.startsWith('@')) return 'plugins'; + return 'editor'; +}; + +/** A source as shown on a line: without the prefix its group already tells. */ +export const sourceLabel = (source: string): string => { + const label = source.replace(/^(game|build|cli):/, ''); + return label || 'project'; +}; diff --git a/plugins/console/src/widgets/console/ConsoleLineRow.svelte b/plugins/console/src/widgets/console/ConsoleLineRow.svelte new file mode 100644 index 00000000..dd0ac196 --- /dev/null +++ b/plugins/console/src/widgets/console/ConsoleLineRow.svelte @@ -0,0 +1,143 @@ + + +
+ + {sourceLabel(line.source)} + + {#if line.values} + {#each line.values as value, index (index)} + + {#if typeof value === 'string'} + + {:else} + + {/if} + + {/each} + {:else} + + {/if} + + {#if line.count > 1} + + Repeated + {line.count} + times + + {/if} + {#if line.location} + {#await locate(line.location) then file} + {#if file} + + {/if} + {/await} + {/if} +
+ + diff --git a/plugins/console/src/widgets/console/ConsoleView.svelte b/plugins/console/src/widgets/console/ConsoleView.svelte new file mode 100644 index 00000000..2a0d570c --- /dev/null +++ b/plugins/console/src/widgets/console/ConsoleView.svelte @@ -0,0 +1,290 @@ + + +
+
+ + + {#snippet trigger({ props })} + 0} /> + {/snippet} + +
+ {#each LEVELS as level (level.id)} + (levels[level.id] = !levels[level.id])} + /> + {/each} +
+ + +
+ + +
+ {#if !visible.length} +
+ +
+ {/if} +
{ + const id = (event.target as HTMLElement).closest('[data-line]')?.dataset + .line; + contextLine = visible.find((line) => String(line.id) === id); + }} + > + {#each visible as line (line.id)} + + {/each} +
+ {#if !follow} +
+ +
+ {/if} +
+
+
+ + diff --git a/plugins/console/src/widgets/console/GroupChips.svelte b/plugins/console/src/widgets/console/GroupChips.svelte new file mode 100644 index 00000000..ad67e5ff --- /dev/null +++ b/plugins/console/src/widgets/console/GroupChips.svelte @@ -0,0 +1,85 @@ + + +
+ {#each GROUPS as group (group.id)} + {@const stat = stats[group.id]} + + {/each} +
+ + diff --git a/plugins/console/src/widgets/console/LinkedText.svelte b/plugins/console/src/widgets/console/LinkedText.svelte new file mode 100644 index 00000000..1f02b54e --- /dev/null +++ b/plugins/console/src/widgets/console/LinkedText.svelte @@ -0,0 +1,65 @@ + + +{#snippet link(text: string, target: FileTarget)} + +{/snippet} + +{#each segments as segment, index (index)} + {#if !segment.target} + {segment.text} + {:else if segment.target.kind === 'file'} + {@render link(segment.text, segment.target)} + {:else} + {#await locate(segment.target)} + {segment.text} + {:then file} + {#if file}{@render link(where(file), file)}{:else}{segment.text}{/if} + {:catch} + {segment.text} + {/await} + {/if} +{/each} + + diff --git a/plugins/console/src/widgets/console/ValueTree.svelte b/plugins/console/src/widgets/console/ValueTree.svelte new file mode 100644 index 00000000..0dbe2dc1 --- /dev/null +++ b/plugins/console/src/widgets/console/ValueTree.svelte @@ -0,0 +1,157 @@ + + +{#if expandable} + + + {#if open} + + {#if stack} + {stack} + {/if} + {#each children as [key, child], index (index)} + + {/each} + {#if more} + … {more} more + {/if} + + {/if} + +{:else} + + {#if name !== undefined}{name}:{/if} + {preview} + +{/if} + + diff --git a/plugins/console/src/widgets/console/console-filters.ts b/plugins/console/src/widgets/console/console-filters.ts new file mode 100644 index 00000000..118acab4 --- /dev/null +++ b/plugins/console/src/widgets/console/console-filters.ts @@ -0,0 +1,60 @@ +import type { MenuEntry } from '@nanoforge-dev/editor-sdk/ui'; + +import type { ConsoleLine } from '../../store/console-store'; +import { GROUPS, type GroupId, sourceLabel } from '../../store/log-sources'; + +export interface GroupStats { + readonly lines: number; + /** Warnings and errors: shown on a hidden group's chip. */ + readonly alerts: number; +} + +/** Lines per group, and the warnings and errors each holds. */ +export const consoleStats = (lines: readonly ConsoleLine[]): Record => { + const result = Object.fromEntries( + GROUPS.map((group) => [group.id, { lines: 0, alerts: 0 }]), + ) as Record; + for (const line of lines) { + const group = result[line.group]; + group.lines++; + if (line.level === 'warn' || line.level === 'error') group.alerts++; + } + return result; +}; + +/** The Sources menu: every source seen, by group, checked when shown. */ +export const sourcesMenu = ( + lines: readonly ConsoleLine[], + hidden: readonly string[], + toggle: (source: string) => void, + showAll: () => void, +): MenuEntry[] => { + const known = [...new Set([...lines.map((line) => line.source), ...hidden])].sort(); + const entries: MenuEntry[] = []; + for (const group of GROUPS) { + const sources = known.filter( + (source) => lines.find((line) => line.source === source)?.group === group.id, + ); + if (!sources.length) continue; + entries.push({ + kind: 'submenu', + label: group.label, + items: sources.map((source) => ({ + kind: 'item', + id: source, + label: sourceLabel(source), + checked: !hidden.includes(source), + onSelect: () => toggle(source), + })), + }); + } + if (hidden.length) { + entries.push( + { kind: 'separator' }, + { kind: 'item', id: 'all', label: 'Show every source', onSelect: showAll }, + ); + } + return entries.length + ? entries + : [{ kind: 'item', id: 'none', label: 'No sources yet', disabled: true, onSelect: () => {} }]; +}; diff --git a/plugins/console/src/widgets/problems/ProblemsView.svelte b/plugins/console/src/widgets/problems/ProblemsView.svelte new file mode 100644 index 00000000..6f5bcd97 --- /dev/null +++ b/plugins/console/src/widgets/problems/ProblemsView.svelte @@ -0,0 +1,226 @@ + + +
+
+
+ {#each SEVERITIES as severity (severity.id)} + (severities[severity.id] = !severities[severity.id])} + /> + {/each} +
+ +
+
+ {#each groups as group (group.key)} + {@const open_ = !collapsed.includes(group.key)} +
+ + {#if open_} + {#each group.problems as problem, index (index)} + + {/each} + {/if} +
+ {:else} + + {/each} +
+
+ + diff --git a/plugins/console/svelte.config.js b/plugins/console/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/console/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/console/test/link/linkify.spec.ts b/plugins/console/test/link/linkify.spec.ts new file mode 100644 index 00000000..34a18d7b --- /dev/null +++ b/plugins/console/test/link/linkify.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { linkify } from '../../src/link/linkify'; + +const FILES = new Set(['apps/client/src/main.ts', 'package.json', 'apps/server/src/main.ts']); +const exists = (path: string) => FILES.has(path); +const targets = (text: string) => + linkify(text, exists) + .filter((segment) => segment.target) + .map((segment) => [segment.text, segment.target]); + +describe('linkify', () => { + it('links project paths with their position', () => { + expect(targets('error at apps/client/src/main.ts:12:3 here')).toEqual([ + [ + 'apps/client/src/main.ts:12:3', + { kind: 'file', path: 'apps/client/src/main.ts', line: 12, column: 3 }, + ], + ]); + expect(targets('apps/client/src/main.ts(4,7): error TS2322')).toEqual([ + [ + 'apps/client/src/main.ts(4,7)', + { kind: 'file', path: 'apps/client/src/main.ts', line: 4, column: 7 }, + ], + ]); + expect(targets('see ./package.json')).toEqual([ + ['./package.json', { kind: 'file', path: 'package.json' }], + ]); + }); + + it('keeps the text around links', () => { + expect(linkify('a package.json b', exists).map((segment) => segment.text)).toEqual([ + 'a ', + 'package.json', + ' b', + ]); + expect(linkify('nothing here, v1.2 e.g. missing.ts:3', exists)).toEqual([ + { text: 'nothing here, v1.2 e.g. missing.ts:3' }, + ]); + }); + + it('finds the project file of an absolute path', () => { + expect(targets(' at /home/me/pong/apps/server/src/main.ts:8:1')).toEqual([ + [ + '/home/me/pong/apps/server/src/main.ts:8:1', + { kind: 'file', path: 'apps/server/src/main.ts', line: 8, column: 1 }, + ], + ]); + }); + + it('marks frames of bundles for source mapping', () => { + expect( + targets( + ' at update (http://localhost:4790/runtime/p/apps%2Fclient/main.js?v=ab&run=2:10:5)', + ), + ).toEqual([ + [ + 'http://localhost:4790/runtime/p/apps%2Fclient/main.js?v=ab&run=2:10:5', + { + kind: 'frame', + file: 'http://localhost:4790/runtime/p/apps%2Fclient/main.js?v=ab&run=2', + line: 10, + column: 5, + }, + ], + ]); + expect(targets('at /home/me/pong/apps/server/.nanoforge/server/main.js:30:2')).toEqual([ + [ + '/home/me/pong/apps/server/.nanoforge/server/main.js:30:2', + { + kind: 'frame', + file: '/home/me/pong/apps/server/.nanoforge/server/main.js', + line: 30, + column: 2, + }, + ], + ]); + }); +}); diff --git a/plugins/console/test/problems/group-problems.spec.ts b/plugins/console/test/problems/group-problems.spec.ts new file mode 100644 index 00000000..7a16d205 --- /dev/null +++ b/plugins/console/test/problems/group-problems.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import type { CodeDiagnostic } from '@nanoforge-dev/editor-sdk'; + +import { groupProblems, origin, positionAt, summary } from '../../src/problems/group-problems'; + +const problem = (patch: Partial): CodeDiagnostic => ({ + path: 'src/a.ts', + start: 0, + length: 1, + message: 'Type mismatch', + severity: 'error', + source: 'typescript', + ...patch, +}); + +const ALL = new Map([ + ['src/b.ts', [problem({ path: 'src/b.ts', severity: 'warning', message: 'Unused' })]], + ['src/a.ts', [problem({ code: 2322 }), problem({ severity: 'info', message: 'Hint' })]], + ['', [problem({ path: '', source: 'build:apps/client', message: 'Build of Client failed' })]], +]); +const EVERY = { error: true, warning: true, info: true }; + +describe('problems', () => { + it('groups by file in order, problems without a file under their source first', () => { + const groups = groupProblems(ALL, { severities: EVERY, query: '' }); + expect(groups.map((group) => [group.label, group.path, group.errors, group.warnings])).toEqual([ + ['Build of apps/client', undefined, 1, 0], + ['src/a.ts', 'src/a.ts', 1, 0], + ['src/b.ts', 'src/b.ts', 0, 1], + ]); + }); + + it('filters by severity and text', () => { + const errors = groupProblems(ALL, { + severities: { error: true, warning: false, info: false }, + query: '', + }); + expect(errors.map((group) => group.problems.length)).toEqual([1, 1]); + expect( + groupProblems(ALL, { severities: EVERY, query: 'UNUSED' }).map((group) => group.label), + ).toEqual(['src/b.ts']); + expect( + groupProblems(ALL, { severities: EVERY, query: '2322' }).map((group) => group.label), + ).toEqual(['src/a.ts']); + }); + + it('summarizes for the status bar', () => { + expect(summary(ALL)).toBe('2 errors · 1 warning'); + expect(summary(new Map())).toBe('No problems'); + expect(summary(new Map([['a', [problem({})]]]))).toBe('1 error'); + }); + + it('names where a problem comes from, and finds positions from offsets', () => { + expect(origin(problem({ code: 2322 }))).toBe('typescript 2322'); + expect(origin(problem({ source: 'build:apps/client' }))).toBe('build'); + expect(positionAt('ab\ncd\nef', 4)).toEqual({ line: 2, column: 2 }); + expect(positionAt('ab', 0)).toEqual({ line: 1, column: 1 }); + }); +}); diff --git a/plugins/console/test/store/console-store.spec.ts b/plugins/console/test/store/console-store.spec.ts new file mode 100644 index 00000000..f611dbcb --- /dev/null +++ b/plugins/console/test/store/console-store.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { type LogEntry, LogLevel } from '@nanoforge-dev/editor-sdk'; + +import { ConsoleStore } from '../../src/store/console-store'; +import { groupOf, sourceLabel } from '../../src/store/log-sources'; + +/** The part of the editor's logger the console uses: sinks, with a replay of earlier entries. */ +class Logs { + private readonly _sinks = new Set<(entry: LogEntry) => void>(); + private readonly _history: LogEntry[] = []; + + addSink(sink: (entry: LogEntry) => void, options: { replay?: boolean } = {}) { + if (options.replay) for (const entry of this._history) sink(entry); + this._sinks.add(sink); + return { dispose: () => void this._sinks.delete(sink) }; + } + + write(entry: LogEntry): void { + this._history.push(entry); + for (const sink of this._sinks) sink(entry); + } + + getLogger(source: string) { + const log = + (level: LogLevel) => + (message: string, ...args: unknown[]) => + this.write({ level, source, message, args, time: Date.now() }); + return { info: log(LogLevel.Info), warn: log(LogLevel.Warn) }; + } +} + +const setup = (maxLines = 100) => { + const logs = new Logs(); + return { logs, store: new ConsoleStore(logs, () => maxLines) }; +}; + +describe('sources', () => { + it('groups sources by where they come from', () => { + expect( + ['game:client', 'build:apps/client', 'cli:nf install', '@acme/hello', 'kernel.plugins'].map( + groupOf, + ), + ).toEqual(['game', 'build', 'tasks', 'plugins', 'editor']); + expect(sourceLabel('game:server')).toBe('server'); + expect(sourceLabel('build:')).toBe('project'); + expect(sourceLabel('@acme/hello')).toBe('@acme/hello'); + }); +}); + +describe('ConsoleStore', () => { + it('replays earlier entries and follows new ones', () => { + const logs = new Logs(); + logs.getLogger('editor').info('before'); + const store = new ConsoleStore(logs); + logs.getLogger('@acme/hello').warn('after'); + expect(store.lines.map((line) => [line.group, line.level, line.text])).toEqual([ + ['editor', 'info', 'before'], + ['plugins', 'warn', 'after'], + ]); + }); + + it('merges identical consecutive lines into one with a count', () => { + const { logs, store } = setup(); + const game = logs.getLogger('game:client'); + game.info('tick'); + game.info('tick'); + game.info('tick'); + game.warn('tick'); + logs.getLogger('game:server').warn('tick'); + game.info('tick'); + expect(store.lines.map((line) => [line.source, line.level, line.count])).toEqual([ + ['game:client', 'info', 3], + ['game:client', 'warn', 1], + ['game:server', 'warn', 1], + ['game:client', 'info', 1], + ]); + }); + + it('keeps logged values as trees, and plain text as text', () => { + const { logs, store } = setup(); + logs.getLogger('editor').info('saved', { files: 2 }, 'ok'); + logs.getLogger('editor').info('count', 3); + logs.write({ + level: LogLevel.Debug, + source: 'game:client', + message: 'score {"left":1}', + args: [], + time: 1, + values: ['score', { type: 'object', entries: [['left', 1]] }], + location: { file: 'http://x/main.js', line: 3, column: 1 }, + }); + const [saved, count, game] = store.lines; + expect(saved).toMatchObject({ text: 'saved { files: 2 } ok' }); + expect(saved!.values).toHaveLength(3); + expect(count).toMatchObject({ text: 'count 3' }); + expect(count!.values).toBeUndefined(); + expect(game).toMatchObject({ + level: 'debug', + text: 'score {"left":1}', + location: { line: 3 }, + }); + expect(game!.values).toHaveLength(2); + }); + + it('drops the oldest lines over the limit, and clears', () => { + const { logs, store } = setup(2); + const logger = logs.getLogger('editor'); + logger.info('a'); + logger.info('b'); + logger.info('c'); + expect(store.lines.map((line) => line.text)).toEqual(['b', 'c']); + let revisions = 0; + store.revision.subscribe(() => revisions++); + store.clear(); + expect(store.lines).toEqual([]); + expect(revisions).toBeGreaterThan(0); + }); +}); diff --git a/plugins/console/tsconfig.json b/plugins/console/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/console/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/console/vite.config.ts b/plugins/console/vite.config.ts new file mode 100644 index 00000000..a2c780b6 --- /dev/null +++ b/plugins/console/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'console' }, + }, +}); diff --git a/plugins/ecs/.prettierignore b/plugins/ecs/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/ecs/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/ecs/LICENSE b/plugins/ecs/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/ecs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/ecs/README.md b/plugins/ecs/README.md new file mode 100644 index 00000000..1ae3805c --- /dev/null +++ b/plugins/ecs/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-ecs` is an internal package of the NanoForge Editor. + +Built-in ECS plugin: entities, components, systems and the live world. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/ecs +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/ecs/nanoforge.manifest.json b/plugins/ecs/nanoforge.manifest.json new file mode 100644 index 00000000..4e8018cd --- /dev/null +++ b/plugins/ecs/nanoforge.manifest.json @@ -0,0 +1,103 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/ecs", + "version": "0.1.0", + "displayName": "ECS", + "description": "Entities, components and systems of each app: hierarchy, inspector, browser, and the live world while playing.", + "engines": { + "editor": ">=0.1.0" + }, + "engineLibs": { + "required": { + "@nanoforge-dev/ecs": "*" + }, + "optional": { + "@nanoforge-dev/graphics-2d": "*" + } + }, + "entry": { + "client": "index.js", + "worker": "worker.js" + }, + "activation": [ + "onWidget:ecs.hierarchy", + "onWidget:ecs.inspector", + "onWidget:ecs.browser", + "onWidget:ecs.systems", + "onCommand:ecs.addEntity", + "onCommand:ecs.removeEntity", + "onWidget:code-editor.script", + "onWidget:viewport.scene" + ], + "contributes": { + "itemOwner": { + "schema": 1, + "tags": ["component", "system", "query", "uses"] + }, + "widgets": [ + { + "id": "ecs.hierarchy", + "title": "Hierarchy", + "icon": "list-tree", + "defaultSlot": "leftTop", + "order": 200 + }, + { + "id": "ecs.inspector", + "title": "Inspector", + "icon": "sliders-horizontal", + "defaultSlot": "rightTop", + "order": 150 + }, + { + "id": "ecs.browser", + "title": "Components", + "icon": "blocks", + "defaultSlot": "leftBottom", + "order": 200 + }, + { + "id": "ecs.systems", + "title": "Systems", + "icon": "settings", + "defaultSlot": "leftBottom", + "order": 210 + } + ], + "commands": [ + { + "id": "ecs.addEntity", + "title": "Add entity", + "category": "ECS" + }, + { + "id": "ecs.removeEntity", + "title": "Remove selected entity", + "category": "ECS" + } + ], + "settings": [ + { + "key": "autoApply", + "type": "boolean", + "default": false, + "title": "Apply live edits to code", + "description": "Write every change made while playing to the code as it happens.", + "category": "Editor/ECS", + "order": 0 + }, + { + "key": "liveRateMs", + "type": "number", + "minimum": 16, + "maximum": 2000, + "default": 100, + "title": "Live world refresh (ms)", + "description": "How often the running game sends its world while playing.", + "category": "Editor/ECS", + "order": 1 + } + ] + } +} diff --git a/plugins/ecs/package.json b/plugins/ecs/package.json new file mode 100644 index 00000000..64ae78c9 --- /dev/null +++ b/plugins/ecs/package.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-ecs", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in ECS plugin: entities, components, systems and the live world", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/ecs" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-code": "workspace:*", + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-meta": "workspace:*", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "ts-morph": "catalog:code", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/ecs/src/create/create-item.ts b/plugins/ecs/src/create/create-item.ts new file mode 100644 index 00000000..73fb15a8 --- /dev/null +++ b/plugins/ecs/src/create/create-item.ts @@ -0,0 +1,82 @@ +/** New components and systems (templates with the TSDoc tags). */ +import type { AppModel, ClientProject } from '@nanoforge-dev/editor-sdk'; + +export type ItemKind = 'component' | 'system'; + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +export const kebab = (name: string): string => + name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^A-Za-z0-9]+/g, '-') + .toLowerCase() + .replace(/^-|-$/g, ''); + +export const validateItemName = (kind: ItemKind, name: string): string | undefined => { + if (!IDENTIFIER.test(name)) return 'Use letters, digits, _ or $, not starting with a digit.'; + if (kind === 'component' && !/^[A-Z]/.test(name)) + return 'Component names start with a capital letter.'; + if (kind === 'system' && !/^[a-z]/.test(name)) + return 'System names start with a lowercase letter.'; + return undefined; +}; + +/** `@side` of a new item: the app's side, `shared` in a shared library. */ +export const sideOf = (target: AppModel): 'client' | 'server' | 'shared' => + target.type === 'lib' ? 'shared' : target.type; + +export const itemTemplate = (kind: ItemKind, name: string, target: AppModel): string => { + const side = sideOf(target); + if (kind === 'component') { + return [ + '/**', + ` * ${name}: what it holds.`, + ' *', + ' * @component', + ` * @side ${side}`, + ' */', + `export class ${name} {`, + ` name = "${name}";`, + '', + ' constructor(', + ' /** A value of the component. */', + ' public value = 0,', + ' ) {}', + '}', + '', + ].join('\n'); + } + const ecs = side === 'server' ? '@nanoforge-dev/ecs/server' : '@nanoforge-dev/ecs/client'; + return [ + 'import type { Context } from "@nanoforge-dev/common";', + `import type { Registry } from "${ecs}";`, + '', + '/**', + ` * ${name}: what it does each tick.`, + ' *', + ' * @system', + ` * @side ${side}`, + ' */', + `export function ${name}(registry: Registry, ctx: Context) {`, + ' void registry;', + ' void ctx;', + '}', + '', + ].join('\n'); +}; + +/** Path of a new item's file in an app or shared library. */ +export const itemPath = (kind: ItemKind, name: string, target: AppModel): string => + `${kind === 'component' ? target.dirs.components : target.dirs.systems}/${kebab(name)}.ts`; + +export const createItem = async ( + project: ClientProject, + kind: ItemKind, + name: string, + target: AppModel, +): Promise => { + const path = itemPath(kind, name, target); + if (project.fs.entry(path)) throw new Error(`${path} already exists.`); + await project.fs.write(path, itemTemplate(kind, name, target), { expectedHash: null }); + return path; +}; diff --git a/plugins/ecs/src/extension/ecs-source.extension-point.ts b/plugins/ecs/src/extension/ecs-source.extension-point.ts new file mode 100644 index 00000000..41105746 --- /dev/null +++ b/plugins/ecs/src/extension/ecs-source.extension-point.ts @@ -0,0 +1,41 @@ +import { type AppModel, type Observable, defineExtensionPoint } from '@nanoforge-dev/editor-sdk'; + +import type { EntryScope } from '../model/ecs-model.type'; + +/** Where the ECS widgets read and write entities: a file, and the function body in it. */ +export interface EcsLocation { + readonly path: string; + /** `main` by default; a scene's `setup` is `{ kind: 'method', class, method: 'setup' }`. */ + readonly scope?: EntryScope; + /** Shown above the hierarchy (`Level1`). */ + readonly label?: string; + /** The scene id of the location: live entities spawned by that scene carry it. */ + readonly scene?: string; +} + +/** A location shown read-only under the edited one (a parent scene's entities). */ +export interface InheritedLocation extends EcsLocation { + readonly label: string; +} + +/** + * What the ECS widgets edit for an app, when it is not the app's `main` (a scene plugin edits + * the selected scene). The highest `priority` that applies to the app wins. + */ +export interface EcsSource { + readonly id: string; + readonly priority: number; + /** Shown by the widgets while `current` is `undefined`. */ + readonly emptyMessage?: string; + appliesTo(app: AppModel): boolean; + /** The edited location; `undefined` while nothing is chosen (`emptyMessage` is shown). */ + current(app: AppModel): Observable; + /** Read-only locations shown with it, outermost first (the scene's parents). */ + inherited(app: AppModel): Observable; + /** Makes an inherited location the edited one (_Edit this scene_). */ + edit?(app: AppModel, location: InheritedLocation): void; + /** The location of a scene, by the id live entities carry (live mode, _Apply to code_). */ + locationOf?(app: AppModel, scene: string): EcsLocation | undefined; +} + +export const ECS_SOURCES = defineExtensionPoint('ecs.sources'); diff --git a/plugins/ecs/src/extension/field-editor.extension-point.ts b/plugins/ecs/src/extension/field-editor.extension-point.ts new file mode 100644 index 00000000..6e6f8753 --- /dev/null +++ b/plugins/ecs/src/extension/field-editor.extension-point.ts @@ -0,0 +1,35 @@ +import type { Component } from 'svelte'; + +import { type Element, type ElementType, defineExtensionPoint } from '@nanoforge-dev/editor-sdk'; + +import type { ArgModel, ArgValue, ImportNeed } from '../model/ecs-model.type'; + +/** + * Extension points of the ECS plugin. Other plugins contribute with the same ids (and declare + * `@nanoforge/ecs` as a dependency): `defineExtensionPoint('ecs.fieldEditors')`. + */ + +export interface FieldEditorProps { + readonly element: Element; + readonly arg: ArgModel | undefined; + readonly label: string; + readonly disabled: boolean; + onchange(value: ArgValue, imports?: ImportNeed[]): void; +} + +/** The inspector field of an element type, or of one `ref` (`@nanoforge-dev/graphics-2d#Rect`). */ +export interface FieldEditor { + readonly type?: ElementType; + readonly ref?: string; + readonly component: Component; +} + +export const FIELD_EDITORS = defineExtensionPoint('ecs.fieldEditors'); + +/** The contributed editor for an element: one for its ref first, else one for its type. */ +export const fieldEditorFor = ( + editors: readonly FieldEditor[], + element: Element, +): FieldEditor | undefined => + (element.type === 'ref' ? editors.find((editor) => editor.ref === element.ref) : undefined) ?? + editors.find((editor) => !editor.ref && editor.type === element.type); diff --git a/plugins/ecs/src/extension/param-preset.extension-point.ts b/plugins/ecs/src/extension/param-preset.extension-point.ts new file mode 100644 index 00000000..afc34ca6 --- /dev/null +++ b/plugins/ecs/src/extension/param-preset.extension-point.ts @@ -0,0 +1,22 @@ +import type { Component } from 'svelte'; + +import { defineExtensionPoint } from '@nanoforge-dev/editor-sdk'; + +import type { PresetDefinition, PresetRow } from '../layout/param-layout'; +import type { ArgValue } from '../model/ecs-model.type'; + +export interface PresetEditorProps { + readonly row: PresetRow; + /** The value of each slot (same order as `row.slots`). */ + readonly values: readonly unknown[]; + readonly disabled: boolean; + /** New values by param index. */ + onchange(args: Record): void; +} + +/** A visual preset merging params (see "Param layout"), with its own control if it has one. */ +export interface ParamPreset extends PresetDefinition { + readonly component?: Component; +} + +export const PARAM_PRESETS = defineExtensionPoint('ecs.paramPresets'); diff --git a/plugins/ecs/src/index.ts b/plugins/ecs/src/index.ts new file mode 100644 index 00000000..f75ae349 --- /dev/null +++ b/plugins/ecs/src/index.ts @@ -0,0 +1,136 @@ +import Move from '@lucide/svelte/icons/move'; +import type { Component } from 'svelte'; + +import { + type AppModel, + CODEGEN_TARGETS, + type CodegenTarget, + EditorServices, + type Observable, + definePlugin, + observe, +} from '@nanoforge-dev/editor-sdk'; +import { + CODE_EDITOR_SIDE_PANELS, + SCENE_EDITORS, + StyleServiceToken, + VIEWPORT_OVERLAYS, + VIEWPORT_TOOLS, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { LiveService, LiveServiceToken } from './live/live-service'; +import { MOVE_IN_GAME_KEY, moveInGame } from './live/move-in-game'; +import type { EntryModel, EntryOp } from './model/ecs-model.type'; +import { ecsApps } from './service/ecs-data'; +import { EcsService, EcsServiceToken } from './service/ecs-service'; +import { setPanelServices } from './session/panel-services'; +import Browser from './widgets/browser/Browser.svelte'; +import ComponentPanel from './widgets/component-panel/ComponentPanel.svelte'; +import Hierarchy from './widgets/hierarchy/Hierarchy.svelte'; +import Inspector from './widgets/inspector/Inspector.svelte'; +import GameMoveOverlay from './widgets/scene/GameMoveOverlay.svelte'; +import SceneView from './widgets/scene/SceneView.svelte'; +import Systems from './widgets/systems/Systems.svelte'; + +type WidgetComponent = Component<{ instance: WidgetInstance }>; + +export default definePlugin({ + async activate(context) { + const css = await fetch(context.resolveAsset('ecs.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(context.services.get(StyleServiceToken).inject(context.name, css)); + + const ecs = context.subscriptions.add(new EcsService(context)); + context.provide(EcsServiceToken, ecs); + const live = context.subscriptions.add(new LiveService(context, ecs)); + context.provide(LiveServiceToken, live); + + const widgets: [string, WidgetComponent][] = [ + ['ecs.hierarchy', Hierarchy as WidgetComponent], + ['ecs.inspector', Inspector as WidgetComponent], + ['ecs.browser', Browser as WidgetComponent], + ['ecs.systems', Systems as WidgetComponent], + ]; + for (const [id, component] of widgets) context.contribute(WIDGET_VIEWS, { id, component }); + + setPanelServices(context.services); + context.subscriptions.add({ dispose: () => setPanelServices(undefined) }); + context.contribute(CODE_EDITOR_SIDE_PANELS, { + id: 'ecs.component', + title: 'Component', + icon: 'blocks', + appliesTo: (path) => ecs.componentsIn(path).length > 0, + changes: ecs.catalogState, + component: ComponentPanel as Component<{ path: string }>, + }); + + const target: CodegenTarget = { + id: 'ecs.entry-file', + label: 'Entry file (main.ts)', + priority: 0, + appliesTo: (app: AppModel) => ecsApps({ apps: [app] } as never).length > 0, + load: (app: AppModel): Observable => { + let current: EntryModel | undefined; + return { + get: () => current, + subscribe: (run) => + ecs.model.subscribe(() => { + void ecs.analyzeApp(app).then((model) => { + current = model; + run(model); + }); + }), + }; + }, + apply: (app: AppModel, op: EntryOp) => ecs.commandFor(app, op, `ECS: ${op.kind}`), + }; + context.contribute(CODEGEN_TARGETS, target as CodegenTarget); + + context.contribute(SCENE_EDITORS, { + id: 'ecs.scene-2d', + title: '2D scene', + targets: ['ecs.entry-file'], + component: SceneView as Component<{ instance?: WidgetInstance }>, + }); + + context.subscriptions.add(registerIcons({ move: Move })); + const contextKeys = context.services.get(EditorServices.ContextKeys); + context.subscriptions.add(observe(moveInGame, (on) => contextKeys.set(MOVE_IN_GAME_KEY, on))); + context.subscriptions.add({ dispose: () => moveInGame.set(false) }); + context.registerCommand('ecs.toggleMoveInGame', () => moveInGame.set(!moveInGame.get()), { + title: 'Move entities in the game', + category: 'ECS', + }); + context.contribute(VIEWPORT_TOOLS, { + id: 'ecs.moveInGame', + screen: 'game', + icon: 'move', + title: 'Move entities', + command: 'ecs.toggleMoveInGame', + order: 5, + toggled: MOVE_IN_GAME_KEY, + }); + context.contribute(VIEWPORT_OVERLAYS, { + id: 'ecs.moveInGame', + screen: 'game', + component: GameMoveOverlay as Component>, + }); + + context.registerCommand('ecs.addEntity', () => ecs.apply({ kind: 'addEntity' }, 'Add entity'), { + title: 'Add entity', + category: 'ECS', + }); + context.registerCommand( + 'ecs.removeEntity', + () => { + const entity = ecs.selection.get(); + return entity ? ecs.apply({ kind: 'removeEntity', entity }, `Remove ${entity}`) : false; + }, + { title: 'Remove selected entity', category: 'ECS' }, + ); + }, +}); diff --git a/plugins/ecs/src/layout/param-layout.ts b/plugins/ecs/src/layout/param-layout.ts new file mode 100644 index 00000000..7699efdd --- /dev/null +++ b/plugins/ecs/src/layout/param-layout.ts @@ -0,0 +1,243 @@ +/** + * How a component's params are laid out in the inspector (spec: "Param layout"): groups, + * presets that merge params into one control, labels, colors, and what's hidden. + */ +import type { Element, ParamGroup } from '@nanoforge-dev/editor-sdk'; + +export interface PresetDefinition { + readonly id: string; + readonly title: string; + /** Slots the params fill, in display order. Absent: a one-param preset. */ + readonly slots?: readonly string[]; + /** Slots that must all be filled. */ + readonly required?: readonly string[]; + /** Least number of slots filled (e.g. a vector needs 2). */ + readonly minimum?: number; + /** Element types a param must have to use it. */ + readonly types: readonly Element['type'][]; +} + +export const BUILT_IN_PRESETS: readonly PresetDefinition[] = [ + { + id: 'vector', + title: 'Vector', + slots: ['x', 'y', 'z', 'w'], + required: ['x', 'y'], + minimum: 2, + types: ['number'], + }, + { + id: 'size', + title: 'Size', + slots: ['width', 'height'], + required: ['width', 'height'], + types: ['number'], + }, + { id: 'color', title: 'Color', types: ['string'] }, + { + id: 'color', + title: 'Color', + slots: ['r', 'g', 'b', 'a'], + required: ['r', 'g', 'b'], + types: ['number'], + }, +]; + +export interface ParamRow { + readonly kind: 'param'; + readonly index: number; + readonly element: Element; + readonly label: string; + readonly color?: string; + /** A one-param preset (a color picker). */ + readonly preset?: string; + readonly hidden: boolean; +} + +export interface PresetRow { + readonly kind: 'preset'; + readonly preset: PresetDefinition; + readonly label: string; + readonly slots: readonly { + readonly slot: string; + readonly index: number; + readonly element: Element; + }[]; + readonly hidden: boolean; +} + +export type LayoutRow = ParamRow | PresetRow; + +export interface GroupBlock { + readonly kind: 'group'; + readonly group: ParamGroup; + readonly rows: readonly LayoutRow[]; + readonly hidden: boolean; +} + +export type LayoutBlock = LayoutRow | GroupBlock; + +export interface HiddenEntry { + /** `param:` or `group:`. */ + readonly id: string; + readonly label: string; +} + +export interface ParamLayout { + readonly blocks: readonly LayoutBlock[]; + /** What is hidden now (for the "Show hidden" menu). */ + readonly hidden: readonly HiddenEntry[]; + readonly warnings: readonly string[]; +} + +export const paramLabel = (element: Element): string => element.layout?.label ?? element.name; + +const presetFor = ( + presets: readonly PresetDefinition[], + id: string, + element: Element, + slotted: boolean, +): PresetDefinition | undefined => + presets.find( + (preset) => + preset.id === id && !!preset.slots === slotted && preset.types.includes(element.type), + ); + +/** + * The blocks of a component's params. `shown` holds the ids (`param:x`, `group:Debug`) the user + * chose to show although they are hidden. + */ +export const buildLayout = ( + params: readonly Element[], + groups: readonly ParamGroup[], + shown: ReadonlySet = new Set(), + presets: readonly PresetDefinition[] = BUILT_IN_PRESETS, +): ParamLayout => { + const warnings: string[] = []; + const groupOf = new Map(groups.map((group) => [group.name, group])); + const isHidden = (id: string, flag: boolean | undefined) => !!flag && !shown.has(id); + + const members = new Map(); + params.forEach((element, index) => { + const key = element.layout?.group ?? ''; + if (!members.has(key)) members.set(key, []); + members.get(key)!.push({ index, element }); + }); + + const rowsOf = ( + list: readonly { index: number; element: Element }[], + groupName: string, + ): LayoutRow[] => { + const slotted = new Map(); + for (const { index, element } of list) { + const preset = element.layout?.preset; + if (preset?.slot) { + const entries = slotted.get(preset.id) ?? []; + entries.push({ slot: preset.slot, index, element }); + slotted.set(preset.id, entries); + } + } + const valid = new Map(); + for (const [id, entries] of slotted) { + const definition = presetFor(presets, id, entries[0]!.element, true); + const names = entries.map((entry) => entry.slot); + const problem = !definition + ? `Unknown preset "${id}" for ${entries.map((entry) => entry.element.name).join(', ')}.` + : entries.some((entry) => !definition.types.includes(entry.element.type)) + ? `The preset "${id}" needs ${definition.types.join(' or ')} params.` + : names.some((name) => !definition.slots!.includes(name)) + ? `The preset "${id}" has no slot "${names.find((name) => !definition.slots!.includes(name))}" (slots: ${definition.slots!.join(', ')}).` + : new Set(names).size !== names.length + ? `Two params use the same slot of the preset "${id}".` + : (definition.required ?? []).some((slot) => !names.includes(slot)) || + names.length < (definition.minimum ?? 1) + ? `The preset "${id}" needs the slots ${(definition.required ?? []).join(', ')}.` + : undefined; + if (problem) warnings.push(groupName ? `${groupName}: ${problem}` : problem); + else valid.set(id, { definition: definition!, entries }); + } + + const rows: LayoutRow[] = []; + const placed = new Set(); + for (const { index, element } of list) { + const preset = element.layout?.preset; + if (preset?.slot && valid.has(preset.id)) { + if (placed.has(preset.id)) continue; + placed.add(preset.id); + const { definition, entries } = valid.get(preset.id)!; + const slots = definition + .slots!.map((slot) => { + const entry = entries.find( + (candidate) => candidate.element.layout?.preset?.slot === slot, + ); + return entry && { slot, index: entry.index, element: entry.element }; + }) + .filter((entry): entry is NonNullable => !!entry); + rows.push({ + kind: 'preset', + preset: definition, + label: groupName || definition.title, + slots, + hidden: slots.every((slot) => + isHidden(`param:${slot.element.name}`, slot.element.layout?.hidden), + ), + }); + continue; + } + const single = + preset && !preset.slot ? presetFor(presets, preset.id, element, false) : undefined; + if (preset && !preset.slot && !single) + warnings.push(`The preset "${preset.id}" doesn't fit ${element.name} (${element.type}).`); + rows.push({ + kind: 'param', + index, + element, + label: paramLabel(element), + ...(element.layout?.color && { color: element.layout.color }), + ...(single && { preset: single.id }), + hidden: isHidden(`param:${element.name}`, element.layout?.hidden), + }); + } + return rows; + }; + + const loose = new Map(); + for (const row of rowsOf(members.get('') ?? [], '')) + loose.set( + row.kind === 'param' ? row.index : Math.min(...row.slots.map((slot) => slot.index)), + row, + ); + const blocks: LayoutBlock[] = []; + const emitted = new Set(); + params.forEach((element, index) => { + const key = element.layout?.group ?? ''; + if (!key) { + const row = loose.get(index); + if (row) blocks.push(row); + return; + } + if (emitted.has(key)) return; + emitted.add(key); + const group = groupOf.get(key) ?? { name: key }; + blocks.push({ + kind: 'group', + group, + rows: rowsOf(members.get(key)!, key), + hidden: isHidden(`group:${key}`, group.hidden), + }); + }); + + const hidden: HiddenEntry[] = []; + for (const block of blocks) { + if (block.kind === 'group') { + if (block.hidden) hidden.push({ id: `group:${block.group.name}`, label: block.group.name }); + else + for (const row of block.rows) + if (row.kind === 'param' && row.hidden) + hidden.push({ id: `param:${row.element.name}`, label: row.label }); + } else if (block.kind === 'param' && block.hidden) { + hidden.push({ id: `param:${block.element.name}`, label: block.label }); + } + } + return { blocks, hidden, warnings }; +}; diff --git a/plugins/ecs/src/library/check-libraries.ts b/plugins/ecs/src/library/check-libraries.ts new file mode 100644 index 00000000..d7b88803 --- /dev/null +++ b/plugins/ecs/src/library/check-libraries.ts @@ -0,0 +1,77 @@ +import { + type PluginContext, + ProjectServiceToken, + libraryImporters, + runnableApps, + workspaceIo, +} from '@nanoforge-dev/editor-sdk'; + +import { RELOCATED_TEXT_ANALYZER } from '../model/ecs.const'; +import { codeServices } from '../service/code-services'; + +/** + * The import problems of the shared libraries: a library importing an app, libraries importing + * each other, an app importing a library it does not use. Undefined without a project or code. + */ +export const checkLibraries = async (context: PluginContext): Promise => { + const { code } = codeServices(context); + const project = context.services.get(ProjectServiceToken).current.get(); + if (!code || !project) return undefined; + const apps = project.model.get().apps; + const libraries = apps.filter((app) => app.type === 'lib'); + if (!libraries.length) { + return []; + } + const ownerOf = (path: string) => + apps.find((app) => app.root && (path === app.root || path.startsWith(`${app.root}/`))); + const problems: string[] = []; + const edges = new Map>(); + for (const library of libraries) { + const files = project.fs.entries.filter( + (entry) => + entry.kind === 'file' && + /\.[cm]?[jt]sx?$/.test(entry.path) && + entry.path.startsWith(`${library.root}/`), + ); + for (const file of files) { + const { imports } = await code + .analyze<{ imports: string[] }>(file.path, RELOCATED_TEXT_ANALYZER, { to: file.path }) + .catch(() => ({ imports: [] as string[] })); + for (const target of imports) { + const owner = ownerOf(target); + if (!owner || owner === library) continue; + if (owner.type !== 'lib') + problems.push(`${file.path} imports ${target}: a shared library can't import an app.`); + else edges.set(library.name, (edges.get(library.name) ?? new Set()).add(owner.name)); + } + } + } + const visit = (name: string, path: string[]): string[] | undefined => { + if (path.includes(name)) return [...path.slice(path.indexOf(name)), name]; + for (const next of edges.get(name) ?? []) { + const cycle = visit(next, [...path, name]); + if (cycle) return cycle; + } + return undefined; + }; + for (const library of libraries) { + const cycle = visit(library.name, []); + if (cycle) { + problems.push(`Shared libraries import each other: ${cycle.join(' → ')}.`); + break; + } + } + const io = workspaceIo(project); + for (const library of libraries) { + const importers = await libraryImporters(io, library).catch(() => [] as string[]); + for (const app of runnableApps(project.model.get())) { + if (app.libraries.includes(library.name)) continue; + const file = importers.find((path) => path.startsWith(`${app.root}/`)); + if (file) + problems.push( + `${file} imports ${library.name}, which ${app.name} does not use: tick ${app.name} under "Used by" on the Project screen.`, + ); + } + } + return problems; +}; diff --git a/plugins/ecs/src/library/create-shared-library.ts b/plugins/ecs/src/library/create-shared-library.ts new file mode 100644 index 00000000..71f9d8ed --- /dev/null +++ b/plugins/ecs/src/library/create-shared-library.ts @@ -0,0 +1,46 @@ +import { + type HistoryCommand, + type PluginContext, + ProjectServiceToken, + createLibrary, + runnableApps, + workspaceIo, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { refactorsHistory } from './refactors-history'; + +/** Creates a shared library (ADR 0004): one undo step in the Refactors history. */ +export const createSharedLibrary = async ( + context: PluginContext, + folder: string, + packageName: string, +): Promise => { + const project = context.services.get(ProjectServiceToken).current.get(); + if (!project) return false; + let undo: (() => Promise) | undefined; + const command: HistoryCommand = { + label: `New shared library ${packageName}`, + do: async () => { + undo = await createLibrary(workspaceIo(project), { + folder, + packageName, + usedBy: runnableApps(project.model.get()), + }); + }, + undo: async () => { + await undo?.(); + }, + }; + try { + await refactorsHistory(context).push(command); + return true; + } catch (error) { + context.services + .tryGet(NotificationServiceToken) + ?.notify('error', 'Could not create the shared library', { + detail: error instanceof Error ? error.message : String(error), + }); + return false; + } +}; diff --git a/plugins/ecs/src/library/refactors-history.ts b/plugins/ecs/src/library/refactors-history.ts new file mode 100644 index 00000000..1f63d7fb --- /dev/null +++ b/plugins/ecs/src/library/refactors-history.ts @@ -0,0 +1,28 @@ +import { + type HistoryCommand, + HistoryServiceToken, + type PluginContext, +} from '@nanoforge-dev/editor-sdk'; + +import { REFACTORS_HISTORY } from '../service/ecs-data'; + +/** The "Refactors" undo context (moves, copies, new libraries). */ +export const refactorsHistory = ( + context: PluginContext, +): { push(command: HistoryCommand): Promise } => { + const history = context.services.tryGet(HistoryServiceToken); + const refactors = history + ? (history.get(REFACTORS_HISTORY) ?? + history.registerContext({ + id: REFACTORS_HISTORY, + label: 'Refactors', + owner: context.name, + })) + : undefined; + return { + push: async (command) => { + if (refactors) await refactors.stack.push(command); + else await command.do(); + }, + }; +}; diff --git a/plugins/ecs/src/library/relocate-item.ts b/plugins/ecs/src/library/relocate-item.ts new file mode 100644 index 00000000..a8722c3e --- /dev/null +++ b/plugins/ecs/src/library/relocate-item.ts @@ -0,0 +1,103 @@ +import { + type AppModel, + type CatalogItem, + type HistoryCommand, + type PluginContext, + ProjectServiceToken, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { + IMPORTERS_ANALYZER, + RELOCATED_TEXT_ANALYZER, + REWRITE_IMPORTS_TRANSFORMER, +} from '../model/ecs.const'; +import { codeServices } from '../service/code-services'; +import { ecsData } from '../service/ecs-data'; +import { refactorsHistory } from './refactors-history'; + +/** + * Moves or copies an item's file into an app or a shared library (ADR 0004): the file's own + * imports follow it, and a move rewrites every import of it. One undo step ("Refactors"). + */ +export const relocateItem = async ( + context: PluginContext, + item: CatalogItem, + target: AppModel, + move: boolean, +): Promise => { + const { code, documents } = codeServices(context); + const project = context.services.get(ProjectServiceToken).current.get(); + const notify = (title: string, detail: string) => + context.services.tryGet(NotificationServiceToken)?.notify('warning', title, { detail }); + if (!code || !documents || !project) return false; + const from = item.meta.source; + const kind = ecsData(item)?.type === 'system' ? 'systems' : 'components'; + const to = `${kind === 'systems' ? target.dirs.systems : target.dirs.components}/${from.split('/').at(-1)}`; + const title = move ? `Move ${item.meta.export}` : `Copy ${item.meta.export}`; + if (project.fs.entry(to)) { + notify(`${title}: not possible`, `${to} already exists.`); + return false; + } + const relocated = await code.analyze<{ text: string; imports: string[] }>( + from, + RELOCATED_TEXT_ANALYZER, + { to }, + ); + if (target.type === 'lib') { + const apps = (project.model.get().apps ?? []).filter((app) => app.type !== 'lib'); + const appImport = relocated.imports.find((path) => + apps.some((app) => path === app.root || path.startsWith(`${app.root}/`)), + ); + if (appImport) { + notify( + `${title}: not possible`, + `It imports ${appImport}, and a shared library can't import an app. Move that file first.`, + ); + return false; + } + } + const importers = move + ? await code.analyze(from, IMPORTERS_ANALYZER) + : ([] as string[]); + const edits: HistoryCommand[] = []; + for (const importer of importers) { + if (importer === from) continue; + const command = await code.edit( + importer, + REWRITE_IMPORTS_TRANSFORMER, + { from, to }, + { + label: `Imports of ${item.meta.export}`, + }, + ); + if (command) edits.push(command); + } + const original = await documents.getText(from); + const fs = project.fs; + const command: HistoryCommand = { + label: move ? `${title} to ${target.name}` : `${title} into ${target.name}`, + children: edits, + do: async () => { + if (move) await fs.rename(from, to); + await fs.write(to, relocated.text); + for (const edit of edits) await edit.do(); + }, + undo: async () => { + for (const edit of [...edits].reverse()) await edit.undo(); + if (move) { + await fs.write(to, original); + await fs.rename(to, from); + } else { + await fs.delete(to); + } + }, + }; + try { + await refactorsHistory(context).push(command); + return true; + } catch (error) { + notify(`${title}: not possible`, error instanceof Error ? error.message : String(error)); + return false; + } +}; diff --git a/plugins/ecs/src/live/live-service.ts b/plugins/ecs/src/live/live-service.ts new file mode 100644 index 00000000..4c488ea6 --- /dev/null +++ b/plugins/ecs/src/live/live-service.ts @@ -0,0 +1,366 @@ +import { + type AppModel, + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + type PluginContext, + ProjectServiceToken, + RuntimeServiceToken, + SettingsServiceToken, + createToken, + isPlaying, + runtimeOutputPath, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import type { EcsLocation } from '../extension/ecs-source.extension-point'; +import type { EntryModel, EntryOp } from '../model/ecs-model.type'; +import { ecsData } from '../service/ecs-data'; +import type { EcsService } from '../service/ecs-service'; +import type { EngineWorld, LiveEntity, LiveInstance, LiveSelection, LiveSource } from './live.type'; + +export const AUTO_APPLY = '@nanoforge/ecs.autoApply'; +export const RATE = '@nanoforge/ecs.liveRateMs'; + +/** Changes made while playing, by app, entity key (`liveKey`), component name and field. */ +export type Pending = Map>>>; + +export const LiveServiceToken = createToken('ecs.live'); + +export class LiveService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _session = new DisposableStore(); + private readonly _playing = new ObservableValue(false); + private readonly _instances = new ObservableValue([]); + private readonly _selection = new ObservableValue(undefined); + private readonly _pending = new ObservableValue(new Map()); + /** Per app: the bundle text (fetched once per session) and spawn indexes by site. */ + private readonly _bundles = new Map>(); + private readonly _siteIndex = new Map(); + private _generation = 0; + + constructor( + private readonly _context: PluginContext, + private readonly _ecs: EcsService, + ) { + this._store.add(this._session); + const runtime = _context.services.tryGet(RuntimeServiceToken); + if (!runtime) return; + this._store.add({ + dispose: runtime.session.subscribe((session) => { + const playing = isPlaying(session.state); + if (playing === this._playing.get()) return; + this._playing.set(playing); + if (playing) this._start(); + else this._stop(); + }), + }); + } + + get playing(): Observable { + return this._playing.readonly(); + } + + get instances(): Observable { + return this._instances.readonly(); + } + + get selection(): Observable { + return this._selection.readonly(); + } + + get pending(): Observable { + return this._pending.readonly(); + } + + select(selection: LiveSelection | undefined): void { + this._selection.set(selection); + } + + instance(source: LiveSource): LiveInstance | undefined { + return this._instances.get().find((instance) => instance.source === source); + } + + entity(selection: LiveSelection | undefined): LiveEntity | undefined { + return selection + ? this.instance(selection.source)?.entities.find((entity) => entity.id === selection.id) + : undefined; + } + + /** Changes a component field in the running game (and remembers it for "Apply to code"). */ + setField( + source: LiveSource, + entity: LiveEntity, + component: string, + field: string, + value: unknown, + ) { + this._send('ecs-set-component', [entity.id, component, field, value], source); + const app = this.instance(source)?.app; + if (!app || !entity.key) return; + const pending = clonePending(this._pending.get()); + const byEntity = pending.get(app) ?? new Map(); + const byComponent = byEntity.get(entity.key) ?? new Map(); + byComponent.set(component, { ...(byComponent.get(component) ?? {}), [field]: value }); + byEntity.set(entity.key, byComponent); + pending.set(app, byEntity); + this._pending.set(pending); + if (this._context.services.tryGet(SettingsServiceToken)?.get(AUTO_APPLY)) + void this.applyToCode(app, entity.key); + } + + addComponent( + source: LiveSource, + entity: LiveEntity, + className: string, + args: readonly unknown[], + ) { + this._send('ecs-add-component', [entity.id, className, [...args]], source); + } + + removeComponent(source: LiveSource, entity: LiveEntity, component: string) { + this._send('ecs-remove-component', [entity.id, component], source); + } + + spawnEntity(source: LiveSource) { + this._send('ecs-spawn-entity', [], source); + } + + removeEntity(source: LiveSource, entity: LiveEntity) { + this._send('ecs-remove-entity', [entity.id], source); + } + + setSystemEnabled(source: LiveSource, index: number, enabled: boolean) { + this._send('ecs-set-system-enabled', [index, enabled], source); + } + + /** Whether an entity (by `LiveEntity.key`) has changes made while playing not in the code. */ + hasPending(app: string, key: string | undefined): boolean { + return !!key && !!this._pending.get().get(app)?.get(key)?.size; + } + + /** + * Writes an entity's changes made while playing to the code (one undo step per component): + * into `main`, or into the `setup` of the scene that spawned it. + */ + async applyToCode(appId: string, key: string): Promise { + const app = this._app(appId); + const changes = this._pending.get().get(appId)?.get(key); + if (!app || !changes) return; + const { scene, name: entityName } = parseKey(key); + const location: EcsLocation | undefined = scene + ? this._ecs.sceneLocation(app, scene) + : undefined; + if (scene && !location) return; + const model = location + ? await this._ecs.analyzeLocation(location) + : await this._ecs.analyzeApp(app); + const entity = model?.entities.find((candidate) => candidate.name === entityName); + if (!entity) return; + for (const [componentName, fields] of changes) { + const index = entity.components.findIndex((use) => { + const item = this._ecs.item(use.item); + const data = ecsData(item); + return (data?.type === 'component' ? data.name : use.className) === componentName; + }); + const use = entity.components[index]; + const item = this._ecs.item(use?.item); + if (index < 0 || !item) continue; + const args: Record = {}; + item.meta.params.forEach((param, position) => { + if (param.name in fields) args[position] = { value: fields[param.name] }; + }); + if (!Object.keys(args).length) continue; + const fill = item.meta.params.map((param) => + 'default' in param && param.default !== undefined + ? { value: param.default } + : param.defaultCode + ? { code: param.defaultCode } + : { code: 'undefined' }, + ); + const op: EntryOp = { kind: 'setArgs', entity: entityName, index, args, fill }; + await this._ecs.applyTo(app, op, `Apply live ${componentName} of ${entityName}`, location); + } + const pending = clonePending(this._pending.get()); + pending.get(appId)?.delete(key); + this._pending.set(pending); + } + + /** Applies every change made while playing. */ + async applyAll(): Promise { + for (const [app, entities] of this._pending.get()) + for (const entity of entities.keys()) await this.applyToCode(app, entity); + } + + dispose(): void { + this._store.dispose(); + } + + private _send(event: string, args: readonly unknown[], source: LiveSource): void { + this._context.services.tryGet(RuntimeServiceToken)?.send(event, args, source); + } + + private _app(id: string): AppModel | undefined { + return this._context.services + .get(ProjectServiceToken) + .current.get() + ?.model.get() + .apps.find((app) => app.id === id); + } + + private _start(): void { + const runtime = this._context.services.tryGet(RuntimeServiceToken); + if (!runtime) return; + const generation = ++this._generation; + this._bundles.clear(); + this._siteIndex.clear(); + const rate = this._context.services.tryGet(SettingsServiceToken)?.get(RATE) ?? 100; + this._session.add(runtime.useFeatures({ ecsWorld: { intervalMs: rate } })); + this._session.add( + runtime.onEvent((event) => { + if (event.event !== 'ecs-world') return; + const world = event.args[0] as EngineWorld; + void this._linked(event.source, event.app, world).then((instance) => { + if (generation !== this._generation) return; + const others = this._instances.get().filter((other) => other.source !== event.source); + this._instances.set( + [...others, instance].sort((a, b) => a.source.localeCompare(b.source)), + ); + }); + }), + ); + } + + private _stop(): void { + this._generation++; + this._session.clear(); + this._instances.set([]); + this._selection.set(undefined); + const count = [...this._pending.get().values()].reduce( + (sum, entities) => sum + entities.size, + 0, + ); + if (!count) return; + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('info', `${count} ${count > 1 ? 'entities' : 'entity'} changed while playing`, { + detail: 'The game is stopped: apply the changes to the code, or they are lost.', + timeout: 0, + actions: [ + { title: 'Apply to code', run: () => void this.applyAll() }, + { title: 'Discard', run: () => this._pending.set(new Map()) }, + ], + }); + } + + /** + * Links the live entities of an app to the code: the variables of its entry file's `main`, or + * of the `setup` of the scene that spawned them; "from code" ones; runtime ones. + */ + private async _linked( + source: LiveSource, + appId: string, + world: EngineWorld, + ): Promise { + const app = this._app(appId); + const model = app ? await this._ecs.analyzeApp(app).catch(() => undefined) : undefined; + const spawnsOf = new Map>([['', spawnSites(model)]]); + if (app) { + for (const scene of new Set(world.entities.map((entity) => entity.scene))) { + if (!scene) continue; + const location = this._ecs.sceneLocation(app, scene); + const sceneModel = location + ? await this._ecs.analyzeLocation(location).catch(() => undefined) + : undefined; + spawnsOf.set(scene, spawnSites(sceneModel)); + } + } + const unknown = world.entities.filter( + (entity) => entity.site && !this._siteIndex.has(siteKey(appId, entity.site, entity.scene)), + ); + if (unknown.length && app) { + const bundle = await this._bundle(app); + if (bundle) { + const sites = unknown.map((entity) => ({ + ...entity.site!, + ...(entity.scene && { enclosing: true }), + })); + const indexes = await this._ecs.analyzeBundle(app, bundle, sites); + unknown.forEach((entity, i) => + this._siteIndex.set(siteKey(appId, entity.site!, entity.scene), indexes[i] ?? -1), + ); + } + } + return { + source, + app: appId, + systems: world.systems, + entities: world.entities.map((entity) => { + const index = entity.site + ? (this._siteIndex.get(siteKey(appId, entity.site, entity.scene)) ?? -1) + : -1; + const spawn = index >= 0 ? spawnsOf.get(entity.scene ?? '')?.[index] : undefined; + return { + id: entity.id, + components: entity.components, + runtime: !spawn, + ...(entity.scene && { scene: entity.scene }), + ...(spawn?.name && { + code: spawn.name, + key: entity.scene ? `${entity.scene}:${spawn.name}` : spawn.name, + }), + ...(spawn && !spawn.name && { codeOnlyLine: spawn.line }), + }; + }), + }; + } + + private _bundle(app: AppModel): Promise { + let bundle = this._bundles.get(app.id); + if (!bundle) { + const project = this._context.services.get(ProjectServiceToken).current.get(); + const url = project ? `${runtimeOutputPath(project.id, app.id)}main.js` : undefined; + bundle = url + ? fetch(url) + .then((response) => (response.ok ? response.text() : undefined)) + .catch(() => undefined) + : Promise.resolve(undefined); + this._bundles.set(app.id, bundle); + } + return bundle; + } +} + +export const siteKey = ( + app: string, + site: { file: string; line: number; column: number }, + scene?: string, +) => `${app}|${scene ?? ''}|${site.file.replace(/\?.*$/, '')}|${site.line}|${site.column}`; + +/** The scene and variable of a `LiveEntity.key`. */ +export const parseKey = (key: string): { scene?: string; name: string } => { + const colon = key.indexOf(':'); + return colon < 0 ? { name: key } : { scene: key.slice(0, colon), name: key.slice(colon + 1) }; +}; + +/** The `spawnEntity()` calls of `main` in text order: entity variables and "from code" ones. */ +export const spawnSites = ( + model: EntryModel | undefined, +): { name?: string; line: number; start: number }[] => + [ + ...(model?.entities ?? []).map((entity) => ({ + name: entity.name, + line: entity.line, + start: entity.node.start, + })), + ...(model?.codeOnly ?? []).map((entry) => ({ line: entry.line, start: entry.start })), + ].sort((a, b) => a.start - b.start); + +export const clonePending = (pending: Pending): Pending => + new Map( + [...pending].map(([app, entities]) => [ + app, + new Map([...entities].map(([entity, components]) => [entity, new Map(components)])), + ]), + ); diff --git a/plugins/ecs/src/live/live.type.ts b/plugins/ecs/src/live/live.type.ts new file mode 100644 index 00000000..900ab816 --- /dev/null +++ b/plugins/ecs/src/live/live.type.ts @@ -0,0 +1,53 @@ +/** + * Live mode (spec: "Live mode"): the world of the running games, linked to the code, and the + * edits made while playing (changed in the game only, until "Apply to code"). + */ + +export type LiveSource = 'client' | 'server'; + +/** Engine `EditorWorld` (modules/ecs editor-world.ts). */ +export interface EngineWorld { + entities: { + id: number; + site?: { file: string; line: number; column: number }; + /** The id of the scene that spawned it (`EcsScene`). */ + scene?: string; + components: { name: string; value: Record }[]; + }[]; + systems: { index: number; name: string; enabled: boolean }[]; +} + +export interface LiveEntity { + readonly id: number; + /** The entity's variable in `main` (or in its scene's `setup`), when it is declared there. */ + readonly code?: string; + /** The id of the scene that spawned it, when a scene did. */ + readonly scene?: string; + /** Names its changes made while playing: `code`, or `scene:code` for a scene's entity. */ + readonly key?: string; + /** Spawned by code the editor can't model (its line in the entry file). */ + readonly codeOnlyLine?: number; + /** Spawned at runtime (by a system…): not in `main`. */ + readonly runtime: boolean; + readonly components: readonly { + readonly name: string; + readonly value: Record; + }[]; +} + +export interface LiveInstance { + readonly source: LiveSource; + /** App id. */ + readonly app: string; + readonly entities: readonly LiveEntity[]; + readonly systems: readonly { + readonly index: number; + readonly name: string; + readonly enabled: boolean; + }[]; +} + +export interface LiveSelection { + readonly source: LiveSource; + readonly id: number; +} diff --git a/plugins/ecs/src/live/move-in-game.ts b/plugins/ecs/src/live/move-in-game.ts new file mode 100644 index 00000000..72fef2e4 --- /dev/null +++ b/plugins/ecs/src/live/move-in-game.ts @@ -0,0 +1,10 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; + +/** Context key of the Game screen's _Move entities_ tool (its pressed state). */ +export const MOVE_IN_GAME_KEY = 'ecs.moveInGame'; + +/** + * Whether the Game screen moves entities: pointer events go to the editor's overlay, which + * outlines the entities and drags them, instead of the game. + */ +export const moveInGame = new ObservableValue(false); diff --git a/plugins/ecs/src/model/arg-values.ts b/plugins/ecs/src/model/arg-values.ts new file mode 100644 index 00000000..3a8bfcaa --- /dev/null +++ b/plugins/ecs/src/model/arg-values.ts @@ -0,0 +1,69 @@ +/** Values of component params: what an argument holds, and what to write for a new value. */ +import type { Element } from '@nanoforge-dev/editor-sdk'; + +import type { ArgModel, ArgValue, ImportNeed } from './ecs-model.type'; + +/** The value a field shows: the argument's, else the param's default. */ +export const currentValue = (element: Element, arg: ArgModel | undefined): unknown => + arg ? (arg.value ?? undefined) : 'default' in element ? element.default : undefined; + +/** The argument to write when a param has no argument yet (filling earlier positions). */ +export const defaultArg = (element: Element): ArgValue => { + if ('default' in element && element.default !== undefined) return { value: element.default }; + if (element.defaultCode) return { code: element.defaultCode }; + if (element.optional) return { code: 'undefined' }; + switch (element.type) { + case 'number': + return { value: 0 }; + case 'boolean': + return { value: false }; + case 'string': + case 'asset': + return { value: element.type === 'string' && element.enum?.[0] ? element.enum[0] : '' }; + case 'array': + return { value: [] }; + default: + return { code: 'undefined' }; + } +}; + +/** Arguments of a new component: every param up to the last one without a default. */ +export const initialArgs = (params: readonly Element[]): ArgValue[] => { + let last = -1; + params.forEach((param, index) => { + if (!param.optional) last = index; + }); + return params.slice(0, last + 1).map(defaultArg); +}; + +/** `@nanoforge-dev/input#InputEnum` → the enum's name and module. */ +export const splitRef = (ref: string): { module: string; name: string } => { + const index = ref.lastIndexOf('#'); + return { module: ref.slice(0, index), name: ref.slice(index + 1) }; +}; + +/** An enum member's code (`InputEnum.ArrowUp`) and the import it needs. */ +export const enumMemberArg = ( + enumRef: string, + member: string, +): { value: ArgValue; imports: ImportNeed[] } => { + const { module, name } = splitRef(enumRef); + return { value: { code: `${name}.${member}` }, imports: [{ name, from: module }] }; +}; + +/** `#rrggbb` of a color string, when it is one. */ +export const hexColor = (value: unknown): string | undefined => + typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : undefined; + +const hex = (value: number) => + Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, '0'); + +export const rgbToHex = (r: number, g: number, b: number): string => `#${hex(r)}${hex(g)}${hex(b)}`; + +export const hexToRgb = (value: string): [number, number, number] => [ + Number.parseInt(value.slice(1, 3), 16), + Number.parseInt(value.slice(3, 5), 16), + Number.parseInt(value.slice(5, 7), 16), +]; diff --git a/plugins/ecs/src/model/ecs-model.type.ts b/plugins/ecs/src/model/ecs-model.type.ts new file mode 100644 index 00000000..d978ff60 --- /dev/null +++ b/plugins/ecs/src/model/ecs-model.type.ts @@ -0,0 +1,157 @@ +import type { ItemRef, NodeRef } from '@nanoforge-dev/editor-sdk'; + +/** + * Types shared by the plugin's worker entry (analyzers, transformers, item owner) and its widgets. + * Type-only: nothing here runs. + */ + +/** The `@nanoforge/ecs` object of an item (ADR 0003). */ +export type EcsItemData = + | { + type: 'component'; + /** The ECS registry key: the class's `name = "X"` field. */ + name: string; + /** Found without `@component` (a class with a `name` field in a components folder). */ + inferred?: boolean; + } + | { + type: 'system'; + /** One list per `getZipper([...])` (or `getComponents(X)`): item refs, or the code. */ + query: string[][]; + /** `ctx` libraries it uses: app, graphics, input, network… */ + uses: string[]; + inferred?: boolean; + }; + +/** An argument of `new X(...)`. */ +export interface ArgModel { + readonly code: string; + readonly start: number; + readonly end: number; + /** JSON value when the argument is a literal (an enum member gives its value). */ + readonly value?: unknown; + /** `new Rect({ width: 30 })`: the class and its literal arguments (the 2D scene draws them). */ + readonly construct?: { readonly className: string; readonly args: readonly unknown[] }; +} + +/** `registry.addComponent(entity, new X(...))`. */ +export interface ComponentUse { + readonly node: NodeRef; + /** The class, when the component is `new X(...)`. */ + readonly className?: string; + /** The catalog item of the class, when it is exported by an app, library or package. */ + readonly item?: ItemRef; + /** Project path of the class's file. */ + readonly source?: string; + readonly args: readonly ArgModel[]; + /** The component expression, as written. */ + readonly code: string; + /** False when the component isn't `new X(...)`: shown, not edited. */ + readonly editable: boolean; +} + +/** `const paddle = registry.spawnEntity();` and its components. */ +export interface EntityModel { + /** The variable name, also the entity's name in the hierarchy. */ + readonly name: string; + readonly node: NodeRef; + readonly line: number; + readonly components: readonly ComponentUse[]; +} + +/** A `spawnEntity()` the editor can't model (in a loop, a helper…): read-only. */ +export interface CodeOnlyEntity { + readonly line: number; + readonly start: number; + readonly end: number; + /** The statement that spawns it. */ + readonly code: string; +} + +/** `registry.addSystem(fn)`. */ +export interface SystemUse { + readonly node: NodeRef; + /** The function name when it's an identifier. */ + readonly name?: string; + readonly item?: ItemRef; + readonly code: string; +} + +export interface EntryModel { + readonly path: string; + /** `main` was found. */ + readonly found: boolean; + /** The registry expression (`registry`). */ + readonly registry?: string; + readonly entities: readonly EntityModel[]; + readonly codeOnly: readonly CodeOnlyEntity[]; + readonly systems: readonly SystemUse[]; + /** Why parts of the file can't be edited. */ + readonly problems: readonly string[]; +} + +/** The folder and item reference base of each source (like the catalog's roots). */ +export interface EntryRoot { + readonly path: string; + readonly ref: string; +} + +/** + * Which function body holds the spawns: the app's `main` (the default), or a method of an + * exported class (a scene's `setup`). + */ +export type EntryScope = + | { readonly kind: 'main' } + | { readonly kind: 'method'; readonly class: string; readonly method: string }; + +export interface EntryAnalyzeArgs { + readonly roots: readonly EntryRoot[]; + readonly scope?: EntryScope; +} + +/** An import a change needs: `import { name } from `. */ +export interface ImportNeed { + readonly name: string; + /** Project path of the file that exports it, or a package name (`@nanoforge-dev/input`). */ + readonly from: string; +} + +/** An argument's new code: a JSON value (written as a literal) or code as is. */ +export type ArgValue = { readonly value: unknown } | { readonly code: string }; + +export type EntryOp = + | { readonly kind: 'addEntity'; readonly name?: string } + | { readonly kind: 'removeEntity'; readonly entity: string } + | { readonly kind: 'renameEntity'; readonly entity: string; readonly name: string } + | { readonly kind: 'duplicateEntity'; readonly entity: string; readonly name?: string } + | { readonly kind: 'moveEntity'; readonly entity: string; readonly before?: string } + | { + readonly kind: 'addComponent'; + readonly entity: string; + readonly className: string; + readonly args: readonly ArgValue[]; + readonly imports: readonly ImportNeed[]; + } + | { readonly kind: 'removeComponent'; readonly entity: string; readonly index: number } + | { + readonly kind: 'moveComponent'; + readonly entity: string; + readonly from: number; + readonly to: number; + } + | { + readonly kind: 'setArgs'; + readonly entity: string; + readonly index: number; + /** New arguments by position; missing positions between are filled with `fill`. */ + readonly args: Readonly>; + /** Each parameter's default, to fill positions before a set one. */ + readonly fill?: readonly ArgValue[]; + readonly imports?: readonly ImportNeed[]; + } + | { readonly kind: 'addSystem'; readonly name: string; readonly imports: readonly ImportNeed[] } + | { readonly kind: 'removeSystem'; readonly index: number } + | { readonly kind: 'moveSystem'; readonly from: number; readonly to: number }; + +/** An `EntryOp` on a scope other than `main` (the transformer's input). */ +export type ScopedEntryOp = EntryOp & { readonly scope?: EntryScope }; diff --git a/plugins/ecs/src/model/ecs.const.ts b/plugins/ecs/src/model/ecs.const.ts new file mode 100644 index 00000000..80320324 --- /dev/null +++ b/plugins/ecs/src/model/ecs.const.ts @@ -0,0 +1,9 @@ +export const ECS_KEY = '@nanoforge/ecs'; + +export const ENTRY_ANALYZER = 'ecs.entry-file'; +export const ITEM_DOCS_TRANSFORMER = 'ecs.item-docs'; +export const IMPORTERS_ANALYZER = 'ecs.importers'; +export const RELOCATED_TEXT_ANALYZER = 'ecs.relocated-text'; +export const REWRITE_IMPORTS_TRANSFORMER = 'ecs.rewrite-imports'; +export const BUNDLE_SPAWNS_ANALYZER = 'ecs.bundle-spawns'; +export const ENTRY_TRANSFORMER = 'ecs.entry-file'; diff --git a/plugins/ecs/src/model/item-docs.type.ts b/plugins/ecs/src/model/item-docs.type.ts new file mode 100644 index 00000000..a088397c --- /dev/null +++ b/plugins/ecs/src/model/item-docs.type.ts @@ -0,0 +1,26 @@ +/** Docs of one param or field, as the Component panel edits them. */ +export interface ParamDocs { + readonly description?: string; + readonly label?: string; + readonly group?: string; + /** `vector.x`, or `color`. */ + readonly preset?: string; + readonly color?: string; + readonly hidden?: boolean; +} + +/** The Component panel's change: what to write in a component's doc comments. */ +export interface ItemDocsOp { + readonly export: string; + /** The class summary. */ + readonly description?: string; + /** Every `@group` of the header (replaces the current ones). */ + readonly groups?: readonly { + readonly name: string; + readonly description?: string; + readonly color?: string; + readonly hidden?: boolean; + }[]; + /** The full layout of each param named here (layout tags not given are removed). */ + readonly params?: Readonly>; +} diff --git a/plugins/ecs/src/model/scope-name.ts b/plugins/ecs/src/model/scope-name.ts new file mode 100644 index 00000000..67876519 --- /dev/null +++ b/plugins/ecs/src/model/scope-name.ts @@ -0,0 +1,5 @@ +import type { EntryScope } from './ecs-model.type'; + +/** How a scope is named in messages: `main`, `Level1.setup`. */ +export const scopeName = (scope: EntryScope | undefined): string => + scope?.kind === 'method' ? `${scope.class}.${scope.method}` : 'main'; diff --git a/plugins/ecs/src/scene/build-scene.ts b/plugins/ecs/src/scene/build-scene.ts new file mode 100644 index 00000000..59404a91 --- /dev/null +++ b/plugins/ecs/src/scene/build-scene.ts @@ -0,0 +1,119 @@ +/** + * The 2D scene of an app (spec: "Scene screen"), drawn from its entry file: each entity's + * position (a component with numeric `x` and `y` params) and its graphics-2d shapes (`Rect`, + * `Circle` arguments written as literals). While playing, positions come from the live world. + */ +import type { CatalogItem } from '@nanoforge-dev/editor-sdk'; + +import type { LiveEntity } from '../live/live.type'; +import type { ArgModel, EntryModel } from '../model/ecs-model.type'; + +export interface ScenePosition { + /** Index of the component in the entity (for `setArgs`). */ + readonly component: number; + /** ECS name of the component (for live edits). */ + readonly componentName: string; + readonly xParam: number; + readonly yParam: number; +} + +export interface SceneShape { + /** The entity's variable. */ + readonly entity: string; + readonly kind: 'rect' | 'circle'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly radius: number; + readonly fill?: string; + readonly stroke?: string; + readonly position?: ScenePosition; +} + +const SHAPES: Record = { Rect: 'rect', Circle: 'circle' }; + +const num = (value: unknown, fallback = 0) => (typeof value === 'number' ? value : fallback); +const str = (value: unknown) => (typeof value === 'string' ? value : undefined); + +const argNumber = (arg: ArgModel | undefined, fallback: unknown) => + num(arg?.value !== undefined ? arg.value : fallback); + +/** The shapes of the entry file's entities. */ +export const buildScene = ( + model: EntryModel | undefined, + itemOf: (ref: string | undefined) => CatalogItem | undefined, + ecsNameOf: (item: CatalogItem | undefined, className: string | undefined) => string, +): SceneShape[] => { + const shapes: SceneShape[] = []; + for (const entity of model?.entities ?? []) { + let position: (ScenePosition & { x: number; y: number }) | undefined; + let named = false; + entity.components.forEach((use, index) => { + if (named) return; + const item = itemOf(use.item); + const params = item?.meta.params ?? []; + const xParam = params.findIndex((param) => param.name === 'x' && param.type === 'number'); + const yParam = params.findIndex((param) => param.name === 'y' && param.type === 'number'); + if (xParam < 0 || yParam < 0) return; + const isNamed = /position|transform|location/i.test(ecsNameOf(item, use.className)); + if (position && !isNamed) return; + named = isNamed; + const defaults = params as readonly { default?: unknown }[]; + position = { + component: index, + componentName: ecsNameOf(item, use.className), + xParam, + yParam, + x: argNumber(use.args[xParam], defaults[xParam]?.default), + y: argNumber(use.args[yParam], defaults[yParam]?.default), + }; + }); + for (const use of entity.components) { + for (const arg of use.args) { + const kind = arg.construct ? SHAPES[arg.construct.className] : undefined; + if (!kind || !arg.construct) continue; + const config = (arg.construct.args[0] ?? {}) as Record; + shapes.push({ + entity: entity.name, + kind, + x: (position?.x ?? 0) + num(config.x), + y: (position?.y ?? 0) + num(config.y), + width: num(config.width, 50), + height: num(config.height, 50), + radius: num(config.radius, 25), + ...(str(config.fill) && { fill: str(config.fill) }), + ...(str(config.stroke) && { stroke: str(config.stroke) }), + ...(position && { + position: { + component: position.component, + componentName: position.componentName, + xParam: position.xParam, + yParam: position.yParam, + }, + }), + }); + } + } + } + return shapes; +}; + +/** The code shapes moved to their live positions (entities of the running game). */ +export const withLivePositions = ( + shapes: readonly SceneShape[], + entities: readonly LiveEntity[], + /** The scene of the shapes: live entities of other scenes don't match. */ + scene?: string, +): SceneShape[] => + shapes.map((shape) => { + const entity = entities.find( + (candidate) => candidate.code === shape.entity && candidate.scene === scene, + ); + const position = shape.position; + const value = position + ? entity?.components.find((component) => component.name === position.componentName)?.value + : undefined; + if (!value || typeof value.x !== 'number' || typeof value.y !== 'number') return shape; + return { ...shape, x: value.x, y: value.y }; + }); diff --git a/plugins/ecs/src/service/code-services.ts b/plugins/ecs/src/service/code-services.ts new file mode 100644 index 00000000..1b4ee194 --- /dev/null +++ b/plugins/ecs/src/service/code-services.ts @@ -0,0 +1,18 @@ +import { + type CodeService, + CodeServiceToken, + type DocumentService, + DocumentServiceToken, + type PluginContext, +} from '@nanoforge-dev/editor-sdk'; + +/** The code and document services, when the editor has them. */ +export const codeServices = ( + context: PluginContext, +): { code?: CodeService; documents?: DocumentService } => { + const services = context.services; + return { + code: services.tryGet(CodeServiceToken), + documents: services.tryGet(DocumentServiceToken), + }; +}; diff --git a/plugins/ecs/src/service/ecs-data.ts b/plugins/ecs/src/service/ecs-data.ts new file mode 100644 index 00000000..50c51cc3 --- /dev/null +++ b/plugins/ecs/src/service/ecs-data.ts @@ -0,0 +1,64 @@ +import { + type AppModel, + type CatalogItem, + type Observable, + type ProjectModel, + appRefName, +} from '@nanoforge-dev/editor-sdk'; + +import type { EcsLocation, InheritedLocation } from '../extension/ecs-source.extension-point'; +import type { EcsItemData, EntryModel, EntryRoot } from '../model/ecs-model.type'; +import { ECS_KEY } from '../model/ecs.const'; + +export const ACTIVE_APP_KEY = 'activeApp'; +/** Undo context of moves and copies between apps and shared libraries. */ +export const REFACTORS_HISTORY = 'ecs:refactors'; +export const SHOWN_STORAGE_KEY = 'nanoforge.ecs.shown'; +export const REFRESH_DELAY_MS = 120; + +/** Apps whose scene the plugin edits: an entry file and the ECS engine library. */ +export const ecsApps = (model: ProjectModel | undefined): AppModel[] => + (model?.apps ?? []).filter( + (app) => app.type !== 'lib' && !!app.entryFile && '@nanoforge-dev/ecs' in app.engineLibs, + ); + +export const ecsData = (item: CatalogItem | undefined): EcsItemData | undefined => + item?.meta[ECS_KEY] as EcsItemData | undefined; + +/** A parent scene's entities and systems, shown read-only (see `ecs.sources`). */ +export interface InheritedModel { + readonly location: InheritedLocation; + readonly model: EntryModel; +} + +/** Where each source's items come from, for the analyzer's item references. */ +export const rootsOf = (model: ProjectModel, paths: readonly string[]): EntryRoot[] => [ + ...model.apps.map((app) => ({ + path: app.root, + ref: app.type === 'lib' ? app.name : `app:${appRefName(app)}`, + })), + ...paths + .filter((path) => /^nf_modules\/@[^/]+\/[^/]+$/.test(path)) + .map((path) => ({ path, ref: path.slice('nf_modules/'.length) })), +]; + +export const sameLocation = (a: EcsLocation | undefined, b: EcsLocation | undefined): boolean => + a?.path === b?.path && JSON.stringify(a?.scope) === JSON.stringify(b?.scope); + +export const readShown = (): Record => { + try { + const raw = localStorage.getItem(SHOWN_STORAGE_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +}; + +/** The values of an extension point's contributions. */ +export const values = ( + contributions: Observable, +): Observable => ({ + get: () => contributions.get().map((contribution) => contribution.value), + subscribe: (run) => + contributions.subscribe((list) => run(list.map((contribution) => contribution.value))), +}); diff --git a/plugins/ecs/src/service/ecs-service.ts b/plugins/ecs/src/service/ecs-service.ts new file mode 100644 index 00000000..0d671f7a --- /dev/null +++ b/plugins/ecs/src/service/ecs-service.ts @@ -0,0 +1,546 @@ +import { + type AppModel, + type CatalogItem, + CatalogServiceToken, + type CatalogState, + CodeServiceToken, + type Disposable, + DisposableStore, + EditorServices, + type Logger, + type Observable, + ObservableValue, + type PluginContext, + type ProjectModel, + ProjectServiceToken, + createToken, + itemFitsApp, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { + ECS_SOURCES, + type EcsLocation, + type EcsSource, + type InheritedLocation, +} from '../extension/ecs-source.extension-point'; +import { FIELD_EDITORS, type FieldEditor } from '../extension/field-editor.extension-point'; +import { PARAM_PRESETS, type ParamPreset } from '../extension/param-preset.extension-point'; +import { checkLibraries } from '../library/check-libraries'; +import { createSharedLibrary } from '../library/create-shared-library'; +import { relocateItem } from '../library/relocate-item'; +import type { EntryModel, EntryOp, ImportNeed, ScopedEntryOp } from '../model/ecs-model.type'; +import { + BUNDLE_SPAWNS_ANALYZER, + ENTRY_ANALYZER, + ENTRY_TRANSFORMER, + ITEM_DOCS_TRANSFORMER, +} from '../model/ecs.const'; +import type { ItemDocsOp } from '../model/item-docs.type'; +import { codeServices } from './code-services'; +import { + ACTIVE_APP_KEY, + type InheritedModel, + REFRESH_DELAY_MS, + SHOWN_STORAGE_KEY, + ecsApps, + ecsData, + readShown, + rootsOf, + sameLocation, + values, +} from './ecs-data'; + +/** + * The ECS view of the open project: the app being edited, its entry file's model (entities, + * components, systems), the selected entity, and the operations that edit the code. + */ +export class EcsService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _project = new DisposableStore(); + private readonly _app = new ObservableValue(undefined); + private readonly _model = new ObservableValue(undefined); + private readonly _selection = new ObservableValue(undefined); + private readonly _catalog = new ObservableValue(undefined); + private readonly _openedItem = new ObservableValue(undefined); + private readonly _projectModel = new ObservableValue(undefined); + private readonly _libraryProblems = new ObservableValue([]); + private _libraryTimer: ReturnType | undefined; + private readonly _shown = new ObservableValue>(readShown()); + private _timer: ReturnType | undefined; + private _generation = 0; + /** The `ecs.sources` contribution used for the selected app, and its subscriptions. */ + private readonly _sourceStore = new DisposableStore(); + private readonly _source = new ObservableValue(undefined); + private readonly _location = new ObservableValue(undefined); + private readonly _inheritedLocations = new ObservableValue([]); + private readonly _inherited = new ObservableValue([]); + + constructor(private readonly _context: PluginContext) { + this._store.add(this._project); + this._store.add(this._sourceStore); + this._store.add({ + dispose: _context.scope.observe(CodeServiceToken).subscribe(() => this._bindProject()), + }); + this._store.add({ + dispose: _context.contributions(ECS_SOURCES).subscribe(() => this._bindSource()), + }); + } + + /** + * What the widgets edit: the selected app's `main`, or what an `ecs.sources` contribution + * says (a scene). `undefined` while that source has nothing chosen. + */ + get location(): Observable { + return this._location.readonly(); + } + /** The `ecs.sources` contribution used for the selected app, if any. */ + get source(): Observable { + return this._source.readonly(); + } + /** The read-only models shown with the edited one (a scene's parents), outermost first. */ + get inherited(): Observable { + return this._inherited.readonly(); + } + get logger(): Logger { + return this._context.logger; + } + /** Inspector fields contributed by other plugins (`ecs.fieldEditors`). */ + get fieldEditors(): Observable { + return values(this._context.contributions(FIELD_EDITORS)); + } + + /** Presets contributed by other plugins (`ecs.paramPresets`). */ + get paramPresets(): Observable { + return values(this._context.contributions(PARAM_PRESETS)); + } + + /** Apps with an entry file and the ECS. */ + get apps(): Observable { + return { + get: () => ecsApps(this._projectModel.get()), + subscribe: (run) => this._projectModel.subscribe((model) => run(ecsApps(model))), + }; + } + /** + * Import rules of shared libraries (ADR 0004): a library never imports an app, and libraries + * don't import each other in a cycle. + */ + get libraryProblems(): Observable { + return this._libraryProblems.readonly(); + } + + /** Shared libraries of the project (ADR 0004). */ + get libraries(): Observable { + const libs = (model: ProjectModel | undefined) => + (model?.apps ?? []).filter((app) => app.type === 'lib'); + return { + get: () => libs(this._projectModel.get()), + subscribe: (run) => this._projectModel.subscribe((model) => run(libs(model))), + }; + } + + get appId(): Observable { + return this._app.readonly(); + } + get app(): AppModel | undefined { + return ecsApps(this._projectModel.get()).find((app) => app.id === this._app.get()); + } + get model(): Observable { + return this._model.readonly(); + } + get selection(): Observable { + return this._selection.readonly(); + } + get catalogState(): Observable { + return this._catalog.readonly(); + } + /** Items the selected app can use. */ + items(): CatalogItem[] { + const app = this.app; + const state = this._catalog.get(); + return app && state ? state.items.filter((item) => itemFitsApp(item, app)) : []; + } + item(ref: string | undefined): CatalogItem | undefined { + return ref ? this._catalog.get()?.items.find((item) => item.ref === ref) : undefined; + } + selectApp(id: string): void { + if (this._app.get() === id) return; + this._app.set(id); + this._selection.set(undefined); + this._context.contextKeys.set(ACTIVE_APP_KEY, id); + this._bindSource(); + this._refresh(0); + } + select(entity: string | undefined): void { + this._selection.set(entity); + } + /** + * Hidden params and groups the user chose to show, for an item (`param:x`, `group:Debug`). + * Local to this browser and project, never committed. + */ + get shown(): Observable> { + return this._shown.readonly(); + } + shownFor(ref: string | undefined): Set { + return new Set(ref ? (this._shown.get()[this._shownKey(ref)] ?? []) : []); + } + setShown(ref: string, id: string, visible: boolean): void { + const key = this._shownKey(ref); + const current = new Set(this._shown.get()[key] ?? []); + if (visible) current.add(id); + else current.delete(id); + const next = Object.fromEntries( + Object.entries({ ...this._shown.get(), [key]: [...current] }).filter( + ([, ids]) => ids.length > 0, + ), + ); + this._shown.set(next); + try { + localStorage.setItem(SHOWN_STORAGE_KEY, JSON.stringify(next)); + } catch {} + } + /** The import a use of an item needs. */ + importOf(item: CatalogItem): ImportNeed { + return { name: item.meta.export, from: item.meta.source }; + } + /** Edits the edited location's file; one undo step in its history. Returns false when refused. */ + async apply(op: EntryOp, label: string): Promise { + const location = this._location.get(); + const code = codeServices(this._context).code; + const documents = codeServices(this._context).documents; + if (!location || !code || !documents) return false; + try { + const scoped: ScopedEntryOp = location.scope ? { ...op, scope: location.scope } : op; + const command = await code.edit(location.path, ENTRY_TRANSFORMER, scoped, { label }); + if (!command) return true; + const context = documents.historyContext(location.path); + if (context) await context.stack.push(command); + else await command.do(); + if (op.kind === 'renameEntity' && this._selection.get() === op.entity) + this._selection.set(op.name); + this._refresh(0); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('warning', `${label}: not possible`, { detail: message }); + return false; + } + } + /** + * Edits an app's entry file, or another location of it (a scene: live mode's "Apply to + * code"); one undo step there. + */ + async applyTo( + app: AppModel, + op: EntryOp, + label: string, + location?: EcsLocation, + ): Promise { + const { documents } = codeServices(this._context); + const path = location?.path ?? app.entryFile; + if (!path || !documents) return false; + try { + const command = await this.commandFor(app, op, label, location); + const context = documents.historyContext(path); + if (context) await context.stack.push(command); + else await command.do(); + if (app.id === this._app.get()) this._refresh(0); + return true; + } catch (error) { + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('warning', `${label}: not possible`, { + detail: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + /** The location of a scene of an app, by its id (from the `ecs.sources` that applies). */ + sceneLocation(app: AppModel, scene: string): EcsLocation | undefined { + return this._context + .contributions(ECS_SOURCES) + .get() + .map((contribution) => contribution.value) + .filter((source) => source.appliesTo(app)) + .sort((a, b) => b.priority - a.priority)[0] + ?.locationOf?.(app, scene); + } + + /** + * Which spawn call each call site of a running game is: among those of `main`, or of the + * function it is in for `enclosing` sites (a scene's `setup`). See live mode. + */ + async analyzeBundle( + app: AppModel, + bundle: string, + sites: readonly { line: number; column: number; enclosing?: boolean }[], + ): Promise { + const { code } = codeServices(this._context); + if (!code || !app.entryFile) return sites.map(() => -1); + return code.analyze(app.entryFile, BUNDLE_SPAWNS_ANALYZER, { bundle, sites }); + } + + /** Writes a component's docs (Component panel); one undo step in that file's history. */ + async editDocs(path: string, op: ItemDocsOp, label: string): Promise { + const { code, documents } = codeServices(this._context); + if (!code || !documents) return false; + try { + const command = await code.edit(path, ITEM_DOCS_TRANSFORMER, op, { label }); + if (!command) return true; + const context = documents.historyContext(path); + if (context) await context.stack.push(command); + else await command.do(); + return true; + } catch (error) { + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('warning', `${label}: not possible`, { + detail: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + /** + * Moves an app's component or system file into a shared library (ADR 0004): the file's own + * imports follow it, and every import of it is rewritten. One undo step ("Refactors"). + */ + async moveToLibrary(item: CatalogItem, library: AppModel): Promise { + return relocateItem(this._context, item, library, true); + } + + /** Copies an item's file (an installed package's, usually) into an app or shared library. */ + async copyTo(item: CatalogItem, target: AppModel): Promise { + return relocateItem(this._context, item, target, false); + } + + /** Creates a shared library (ADR 0004): one undo step in the Refactors history. */ + createLibrary(folder: string, packageName: string): Promise { + return createSharedLibrary(this._context, folder, packageName); + } + + /** Components defined in a file (the Component panel's subjects). */ + componentsIn(path: string): CatalogItem[] { + return (this._catalog.get()?.items ?? []).filter( + (item) => item.meta.source === path && ecsData(item)?.type === 'component', + ); + } + + /** Opens a file in the code editor, at a line. */ + async openInCode(path: string, line?: number): Promise { + await this._context.executeCommand('codeEditor.open', path, line ? { line } : {}); + } + /** Ref of the item last opened in code: the Component panel shows that one. */ + get openedItem(): Observable { + return this._openedItem.readonly(); + } + /** Opens the source of an item at its declaration. */ + async openItem(item: CatalogItem): Promise { + this._openedItem.set(item.ref); + let line: number | undefined; + try { + const text = await codeServices(this._context).documents?.getText(item.meta.source); + const name = item.meta.export.replace(/[^\w]/g, ''); + const declaration = new RegExp( + `^[ \\t]*export\\s+(?:default\\s+)?(?:abstract\\s+)?(?:async\\s+)?(?:class|function\\*?|const|let|var)\\s+${name}\\b`, + 'm', + ).exec(text ?? ''); + if (text && declaration) line = text.slice(0, declaration.index).split('\n').length; + } catch {} + await this.openInCode(item.meta.source, line); + } + dispose(): void { + clearTimeout(this._timer); + clearTimeout(this._libraryTimer); + this._store.dispose(); + } + /** The entry file model of any app (the codegen target's `load`). */ + async analyzeApp(app: AppModel): Promise { + return app.entryFile ? this.analyzeLocation({ path: app.entryFile }) : undefined; + } + /** The model of a location: a file's `main`, or a method of a class in it. */ + async analyzeLocation(location: EcsLocation): Promise { + const { code } = codeServices(this._context); + const project = this._context.services.get(ProjectServiceToken).current.get(); + if (!code || !project) return undefined; + const roots = rootsOf( + project.model.get(), + project.fs.entries.filter((entry) => entry.kind === 'directory').map((entry) => entry.path), + ); + return code.analyze(location.path, ENTRY_ANALYZER, { + roots, + ...(location.scope && { scope: location.scope }), + }); + } + /** + * An undoable command applying an op to an app's entry file, or to another location (the + * codegen target's `apply`). + */ + async commandFor(app: AppModel, op: EntryOp, label: string, location?: EcsLocation) { + const { code } = codeServices(this._context); + const path = location?.path ?? app.entryFile; + if (!path || !code) throw new Error('No entry file or code service'); + const scoped: ScopedEntryOp = location?.scope ? { ...op, scope: location.scope } : op; + const command = await code.edit(path, ENTRY_TRANSFORMER, scoped, { label }); + if (!command) throw new Error('Nothing to change'); + return command; + } + /** + * Follows the `ecs.sources` contribution that applies to the selected app: its location and + * inherited ones. Without one, the app's `main` is edited. + */ + private _bindSource(): void { + this._sourceStore.clear(); + const app = this.app; + const source = app + ? this._context + .contributions(ECS_SOURCES) + .get() + .map((contribution) => contribution.value) + .filter((candidate) => candidate.appliesTo(app)) + .sort((a, b) => b.priority - a.priority)[0] + : undefined; + this._source.set(source); + if (!app || !source) { + this._location.set(app?.entryFile ? { path: app.entryFile } : undefined); + this._inheritedLocations.set([]); + return; + } + const current = source.current(app); + const inherited = source.inherited(app); + this._location.set(current.get()); + this._inheritedLocations.set(inherited.get()); + this._sourceStore.add({ + dispose: current.subscribe((location) => { + if (sameLocation(location, this._location.get())) return; + this._location.set(location); + this._selection.set(undefined); + this._refresh(0); + }), + }); + this._sourceStore.add({ + dispose: inherited.subscribe((locations) => { + this._inheritedLocations.set(locations); + this._refresh(0); + }), + }); + } + + /** Checks the imports of every shared library file (debounced; after catalog changes). */ + private _checkLibraries(): void { + clearTimeout(this._libraryTimer); + this._libraryTimer = setTimeout( + () => + void checkLibraries(this._context).then( + (problems) => problems && this._libraryProblems.set(problems), + ), + 500, + ); + } + + private _shownKey(ref: string): string { + const project = this._context.services.get(ProjectServiceToken).current.get(); + return `${project?.id ?? ''}|${ref}`; + } + private _bindProject(): void { + this._project.clear(); + this._model.set(undefined); + this._selection.set(undefined); + const services = this._context.services; + const project = services.get(ProjectServiceToken).current.get(); + const { code, documents } = codeServices(this._context); + const catalog = services.tryGet(CatalogServiceToken); + if (!project || !code || !documents) { + this._projectModel.set(undefined); + return; + } + this._project.add({ + dispose: project.model.subscribe((model) => { + this._projectModel.set(model); + const apps = ecsApps(model); + if (!apps.some((app) => app.id === this._app.get())) { + const preferred = + apps.find((app) => app.id === this._context.contextKeys.get(ACTIVE_APP_KEY)) ?? + apps.find((app) => app.type === 'client') ?? + apps[0]; + this._app.set(preferred?.id); + if (preferred) this._context.contextKeys.set(ACTIVE_APP_KEY, preferred.id); + } + this._bindSource(); + this._refresh(0); + }), + }); + if (catalog) + this._project.add({ + dispose: catalog.state.subscribe((state) => { + this._catalog.set(state); + this._refresh(); + this._checkLibraries(); + }), + }); + this._project.add( + documents.onDidChange(({ uri }) => { + if (uri.endsWith('.ts') || uri.endsWith('.js')) this._refresh(); + }), + ); + this._project.add( + project.fs.onDidChange((changes) => { + if (changes.some((change) => /\.[cm]?[jt]sx?$/.test(change.path))) this._refresh(); + }), + ); + } + /** Analyzes the entry file again (debounced). */ + private _refresh(delay = REFRESH_DELAY_MS): void { + clearTimeout(this._timer); + this._timer = setTimeout(() => void this._analyze(), delay); + } + private async _analyze(): Promise { + const generation = ++this._generation; + const location = this._location.get(); + if (!location) { + this._model.set(undefined); + this._inherited.set([]); + return; + } + try { + const model = await this.analyzeLocation(location); + const inherited: InheritedModel[] = []; + for (const parent of this._inheritedLocations.get()) { + const parentModel = await this.analyzeLocation(parent).catch(() => undefined); + if (parentModel) inherited.push({ location: parent, model: parentModel }); + } + if (generation !== this._generation) return; + this._inherited.set(inherited); + if (!model) { + this._model.set(undefined); + return; + } + this._model.set(model); + const selected = this._selection.get(); + if (selected && !model.entities.some((entity) => entity.name === selected)) + this._selection.set(undefined); + } catch (error) { + if (generation !== this._generation) return; + if (error instanceof Error && error.message.includes('Unknown analyzer')) { + this._refresh(300); + return; + } + this.logger.warn(`Could not read ${location.path}`, error); + this._model.set({ + path: location.path, + found: false, + entities: [], + codeOnly: [], + systems: [], + problems: [error instanceof Error ? error.message : String(error)], + }); + } + } +} +export const EcsServiceToken = createToken('ecs.service'); + +export { EditorServices }; diff --git a/plugins/ecs/src/session/panel-services.ts b/plugins/ecs/src/session/panel-services.ts new file mode 100644 index 00000000..11f53ecb --- /dev/null +++ b/plugins/ecs/src/session/panel-services.ts @@ -0,0 +1,13 @@ +import type { ServiceAccessor } from '@nanoforge-dev/editor-sdk'; + +let services: ServiceAccessor | undefined; + +/** The plugin's services, for components mounted by other plugins (code editor side panels). */ +export const panelServices = (): ServiceAccessor => { + if (!services) throw new Error('The ECS plugin is not active'); + return services; +}; + +export const setPanelServices = (accessor: ServiceAccessor | undefined): void => { + services = accessor; +}; diff --git a/plugins/ecs/src/widgets/browser/Browser.svelte b/plugins/ecs/src/widgets/browser/Browser.svelte new file mode 100644 index 00000000..bc62fa26 --- /dev/null +++ b/plugins/ecs/src/widgets/browser/Browser.svelte @@ -0,0 +1,216 @@ + + +
+
+ + + {#snippet trigger({ props })} + + {/snippet} + +
+ +
+ {#each $libraryProblems as problem (problem)} + + {/each} + {#each collisions as [name, items] (name)} + + {/each} +
+ {#if !$catalog || $catalog.loading} +

Reading components and systems…

+ {:else if !sections.length} + + {/if} + {#each sections as section (section.title)} +

+ {section.title} + {#if section.readonly}🔒{/if} +

+ {#each section.items as item (item.ref)} + {@const data = ecsData(item)} + + {/each} + {/each} +
+ + {#if selected && selectedData} + + {/if} +
+
+ + diff --git a/plugins/ecs/src/widgets/browser/ItemDetails.svelte b/plugins/ecs/src/widgets/browser/ItemDetails.svelte new file mode 100644 index 00000000..ff822027 --- /dev/null +++ b/plugins/ecs/src/widgets/browser/ItemDetails.svelte @@ -0,0 +1,154 @@ + + +
+

+ {item.meta.export} + {data.type} · {item.meta.side} +

+ {#if item.meta.description}

{item.meta.description}

{/if} + {#if data.type === 'component' && item.meta.params.length} +
    + {#each item.meta.params as param (param.name)} +
  • + {param.name} + {param.type}{#if param.description} + — {param.description}{/if} +
  • + {/each} +
+ {/if} + {#if data.type === 'system'} + {#if data.query.length} +

+ Reads {data.query + .map((list) => list.map((ref) => ref.slice(ref.indexOf('#') + 1)).join(' + ')) + .join('; ')} +

+ {/if} + {#if data.uses.length}

+ Uses ctx.{data.uses.join(', ctx.')} +

{/if} + {/if} + {#if item.meta.example}
{item.meta.example}
{/if} +
+ {#if data.type === 'component'} + + {:else} + + {/if} + + {#if item.source.kind === 'app' && libraries.length} + + {#snippet trigger({ props })} + + {/snippet} + + {:else if item.readonly} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +
+
+ + diff --git a/plugins/ecs/src/widgets/browser/browser-sections.ts b/plugins/ecs/src/widgets/browser/browser-sections.ts new file mode 100644 index 00000000..dcaabef3 --- /dev/null +++ b/plugins/ecs/src/widgets/browser/browser-sections.ts @@ -0,0 +1,55 @@ +import type { CatalogItem } from '@nanoforge-dev/editor-sdk'; + +import { ecsData } from '../../service/ecs-data'; + +/** Items of one source: an app, a shared library or an installed package. */ +export interface BrowserSection { + readonly title: string; + readonly readonly: boolean; + readonly items: CatalogItem[]; +} + +const RANK = { app: 0, lib: 1, module: 2 } as const; + +/** The components and systems a search keeps, by source: apps, libraries, then packages. */ +export const browserSections = (items: readonly CatalogItem[], query: string): BrowserSection[] => { + const search = query.trim().toLowerCase(); + const bySource = new Map(); + for (const item of items) { + if (!ecsData(item)) continue; + if ( + search && + !item.meta.export.toLowerCase().includes(search) && + !(item.meta.description ?? '').toLowerCase().includes(search) + ) + continue; + const key = `${item.source.kind}:${item.source.name}`; + const title = + item.source.kind === 'app' + ? `App ${item.source.name}` + : item.source.kind === 'lib' + ? `Shared library ${item.source.name}` + : `Installed package ${item.source.name}`; + const section = bySource.get(key) ?? { title, readonly: item.readonly, items: [] }; + section.items.push(item); + bySource.set(key, section); + } + return [...bySource.entries()] + .sort( + ([a], [b]) => + RANK[a.split(':')[0] as keyof typeof RANK] - RANK[b.split(':')[0] as keyof typeof RANK] || + a.localeCompare(b), + ) + .map(([, section]) => section); +}; + +/** Components the app can use that share an ECS name: the registry would mix them up. */ +export const nameCollisions = (items: readonly CatalogItem[]): [string, CatalogItem[]][] => { + const byName = new Map(); + for (const item of items) { + const data = ecsData(item); + if (data?.type !== 'component') continue; + byName.set(data.name, [...(byName.get(data.name) ?? []), item]); + } + return [...byName].filter(([, list]) => list.length > 1); +}; diff --git a/plugins/ecs/src/widgets/browser/create-flows.ts b/plugins/ecs/src/widgets/browser/create-flows.ts new file mode 100644 index 00000000..d24bcb4a --- /dev/null +++ b/plugins/ecs/src/widgets/browser/create-flows.ts @@ -0,0 +1,79 @@ +import { + type AppModel, + type ProjectService, + validateFolder, + validatePackageName, +} from '@nanoforge-dev/editor-sdk'; +import type { NotificationService, PromptService } from '@nanoforge-dev/editor-sdk/ui'; + +import { type ItemKind, createItem, validateItemName } from '../../create/create-item'; +import type { EcsService } from '../../service/ecs-service'; + +export interface CreateFlowServices { + readonly ecs: EcsService; + readonly projects: ProjectService; + readonly prompts: PromptService | undefined; + readonly notifications: NotificationService | undefined; +} + +/** Asks for a name, creates a component or system file in an app or library, and opens it. */ +export const createItemFlow = async ( + { ecs, projects, prompts, notifications }: CreateFlowServices, + kind: ItemKind, + target: AppModel, +): Promise => { + const project = projects.current.get(); + if (!project || !prompts) return; + const name = await prompts.ask({ + title: kind === 'component' ? 'New component' : 'New system', + label: 'Name', + value: kind === 'component' ? 'Health' : 'regenerate', + confirm: 'Create', + validate: (value) => validateItemName(kind, value), + }); + if (!name) return; + try { + const path = await createItem(project, kind, name, target); + await ecs.openInCode(path); + } catch (error) { + notifications?.notify('error', `Could not create ${name}`, { + detail: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** Asks for a folder and a package name, then creates a shared library (ADR 0004). */ +export const createLibraryFlow = async ({ + ecs, + projects, + prompts, + notifications, +}: CreateFlowServices): Promise => { + const project = projects.current.get(); + if (!project || !prompts) return; + const folder = await prompts.ask({ + title: 'New shared library', + label: 'Folder in libs/', + value: 'shared', + confirm: 'Next', + validate: (value) => + validateFolder({ exists: (path) => project.fs.entry(path) !== undefined }, 'libs', value), + }); + if (!folder) return; + const scope = project.model + .get() + .name.toLowerCase() + .replace(/[^a-z0-9-]+/g, '-'); + const packageName = await prompts.ask({ + title: 'New shared library', + label: 'Package name (how apps import it)', + value: `@${scope}/${folder}`, + confirm: 'Create', + validate: validatePackageName, + }); + if (!packageName) return; + if (await ecs.createLibrary(folder, packageName)) + notifications?.notify('success', `Shared library ${packageName} created`, { + detail: `In libs/${folder}. Apps import it as ${packageName}/components/….`, + }); +}; diff --git a/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte b/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte new file mode 100644 index 00000000..c76649b0 --- /dev/null +++ b/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte @@ -0,0 +1,207 @@ + + +
+ {#if !meta} +

No component in this file.

+ {:else} + {#if items.length > 1} + + + + setGroups([...groups, { name: nextGroupName(groups) }], 'Add a group')} + onrename={renameGroup} + onupdate={updateGroup} + onremove={removeGroup} + /> + +
+

Params

+ {#each params as element (element.name)} + setParam(element, change, label)} + onnewgroup={() => newGroupFor(element)} + onpreset={(presetId) => setPreset(element, presetId)} + /> + {/each} +
+ + {#if preview} +
+

Preview

+ undefined} + onshow={() => undefined} + onmove={() => undefined} + onremove={() => undefined} + onopen={() => undefined} + /> +
+ {/if} + {/if} +
+ + diff --git a/plugins/ecs/src/widgets/component-panel/GroupsSection.svelte b/plugins/ecs/src/widgets/component-panel/GroupsSection.svelte new file mode 100644 index 00000000..2eaa6a73 --- /dev/null +++ b/plugins/ecs/src/widgets/component-panel/GroupsSection.svelte @@ -0,0 +1,94 @@ + + +
+
+

Groups

+ +
+ {#each groups as group (group.name)} +
+ onrename(group, (event.currentTarget as HTMLInputElement).value)} + /> + + final && onupdate(group, { color: value }, `Color group ${group.name}`)} + /> + onupdate(group, { hidden }, `Hide group ${group.name}`)} + /> + onremove(group)} + /> + + onupdate( + group, + { description: (event.currentTarget as HTMLInputElement).value.trim() }, + `Describe group ${group.name}`, + )} + /> +
+ {/each} +
+ + diff --git a/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte b/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte new file mode 100644 index 00000000..7983cb9c --- /dev/null +++ b/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte @@ -0,0 +1,152 @@ + + +
+ {element.name} {element.type} + + +
+ Group + ({ value: id, label: id }))]} + disabled={readonly} + onchange={(value) => onpreset(value === NONE ? undefined : value)} + /> + {#if element.layout?.preset?.slot} + {@const slots = [ + element.layout.preset.slot, + ...freeSlots(params, element, presetId).filter( + (slot) => slot !== element.layout?.preset?.slot, + ), + ]} + ({ + value: candidate.source, + label: `${candidate.source === 'client' ? 'Client' : 'Server'} (running)`, + }))} + onchange={(value) => (chosenSource = value as LiveSource)} + /> + {:else if $apps.length > 1} + + {#if liveMode} + source && live.spawnEntity(source)} + /> + {:else} + + {/if} +
+ + {#if liveMode} + + {:else if !$apps.length} + + {:else if $ecsSource && !$location} + + {:else if $model && !$model.found} + + {:else} + + {#if $location?.label} +
{$location.label}
+ {/if} + + + + {/if} +
+ + diff --git a/plugins/ecs/src/widgets/hierarchy/InheritedEntitiesTree.svelte b/plugins/ecs/src/widgets/hierarchy/InheritedEntitiesTree.svelte new file mode 100644 index 00000000..1fbc7c18 --- /dev/null +++ b/plugins/ecs/src/widgets/hierarchy/InheritedEntitiesTree.svelte @@ -0,0 +1,102 @@ + + +{#if nodes.length} + + + +{/if} + + diff --git a/plugins/ecs/src/widgets/hierarchy/LiveEntitiesTree.svelte b/plugins/ecs/src/widgets/hierarchy/LiveEntitiesTree.svelte new file mode 100644 index 00000000..63d7fc05 --- /dev/null +++ b/plugins/ecs/src/widgets/hierarchy/LiveEntitiesTree.svelte @@ -0,0 +1,127 @@ + + + + + + + diff --git a/plugins/ecs/src/widgets/inspector/ComponentCard.svelte b/plugins/ecs/src/widgets/inspector/ComponentCard.svelte new file mode 100644 index 00000000..4fa0c038 --- /dev/null +++ b/plugins/ecs/src/widgets/inspector/ComponentCard.svelte @@ -0,0 +1,342 @@ + + +{#snippet info(text: string | undefined, label: string)} + {#if text} + + {#snippet children({ props })} + ⓘ + {/snippet} + + {/if} +{/snippet} + +{#snippet row(entry: LayoutRow)} + {#if entry.hidden} + + {:else if entry.kind === 'param'} + set(entry.index, value, imports)} + /> + {:else if (entry.preset as ParamPreset).component} + {@const Editor = (entry.preset as ParamPreset).component!} +
+ {entry.label} + currentValue(slot.element, component.args[slot.index]))} + disabled={!component.editable} + onchange={(args) => onargs(args, fill())} + /> +
+ {:else if entry.preset.id === 'color'} + {@const [r, g, b] = entry.slots.map((slot) => numberOf(slot.index))} +
+ {entry.label} + { + if (!final) return; + const rgb = hexToRgb(next); + const args: Record = {}; + entry.slots.forEach((slot, i) => { + if (i < 3) args[slot.index] = { value: rgb[i] }; + }); + onargs(args, fill()); + }} + /> +
+ {:else} +
+ {entry.label} + + {#each entry.slots as slot (slot.slot)} + { + if (!final) return; + const args: Record = { [slot.index]: { value: next } }; + if (entry.preset.id === 'size' && ratioLocked) { + const other = entry.slots.find((candidate) => candidate !== slot); + const before = numberOf(slot.index); + if (other && before) + args[other.index] = { value: (numberOf(other.index) * next) / before }; + } + onargs(args, fill()); + }} + /> + {/each} + {#if entry.preset.id === 'size'} + (ratioLocked = !ratioLocked)} + /> + {/if} + +
+ {/if} +{/snippet} + +
+
+ {title} + {@render info(item?.meta.description, title)} + + {#if layout.hidden.length} + ({ + kind: 'item' as const, + id: entry.id, + label: entry.label, + onSelect: () => onshow(entry.id, true), + }))} + > + {#snippet trigger({ props })} + + {/snippet} + + {/if} + + {#snippet trigger({ props })} + + {/snippet} + +
+ + {#if !component.editable} +

{component.code}

+ {:else} +
+ {#each layout.blocks as block, index (index)} + {#if block.kind === 'group'} + {#if !block.hidden} +
+ + {@render info(block.group.description, block.group.name)} + {#if !collapsed.has(block.group.name)} +
+ {#each block.rows as entry, rowIndex (rowIndex)} + {@render row(entry)} + {/each} +
+ {/if} +
+ {/if} + {:else} + {@render row(block)} + {/if} + {/each} + {#each layout.warnings as warning (warning)} +

{warning}

+ {/each} +
+ {/if} +
+ + diff --git a/plugins/ecs/src/widgets/inspector/Inspector.svelte b/plugins/ecs/src/widgets/inspector/Inspector.svelte new file mode 100644 index 00000000..09e7240f --- /dev/null +++ b/plugins/ecs/src/widgets/inspector/Inspector.svelte @@ -0,0 +1,171 @@ + + +
+ {#if liveTarget} + + {:else if $playing && $instances.length} + + {:else if !entity} + + {:else} +

{entity.name}

+
+ {#each entity.components as component, index (component.node.id)} + {@const item = ecs.item(component.item)} + {@const shown = shownFor(component.item)} + + void ecs.apply( + { + kind: 'setArgs', + entity: entity.name, + index, + args, + fill, + ...(imports && { imports }), + }, + `Edit ${component.className ?? 'component'} of ${entity.name}`, + )} + onshow={(id, visible) => component.item && ecs.setShown(component.item, id, visible)} + onmove={(offset) => + void ecs.apply( + { kind: 'moveComponent', entity: entity.name, from: index, to: index + offset }, + `Move ${component.className ?? 'component'}`, + )} + onremove={() => + void ecs.apply( + { kind: 'removeComponent', entity: entity.name, index }, + `Remove ${component.className ?? 'component'} from ${entity.name}`, + )} + onopen={() => item && void ecs.openItem(item)} + /> + {/each} +
+
+ +
+ {/if} +
+ + diff --git a/plugins/ecs/src/widgets/inspector/LiveInspector.svelte b/plugins/ecs/src/widgets/inspector/LiveInspector.svelte new file mode 100644 index 00000000..1b2ebca5 --- /dev/null +++ b/plugins/ecs/src/widgets/inspector/LiveInspector.svelte @@ -0,0 +1,199 @@ + + +
+

{title}

+ {entity.runtime ? 'runtime' : entity.codeOnlyLine ? 'from code' : 'live'} + + {#if entity.key} + + {/if} +
+ +{#each entity.components as component (component.name)} + {@const item = itemFor(component.name)} + {@const params = item?.meta.params ?? fieldsOf(component.value)} + {@const use: ComponentUse = { + node: { id: `live:${entity.id}:${component.name}`, path: '', kind: 'live', start: 0, end: 0 }, + className: component.name, + ...(item && { item: item.ref }), + args: argsOf(params, component.value), + code: component.name, + editable: true, + }} + { + for (const [index, value] of Object.entries(args)) { + const param = params[Number(index)]; + if (param && 'value' in value) + live.setField(source, entity, component.name, param.name, value.value); + } + }} + onshow={(id, visible) => item && ecs.setShown(item.ref, id, visible)} + onmove={() => undefined} + onremove={() => live.removeComponent(source, entity, component.name)} + onopen={() => item && void ecs.openItem(item)} + /> +{/each} + +
+ +
+ + diff --git a/plugins/ecs/src/widgets/inspector/ParamField.svelte b/plugins/ecs/src/widgets/inspector/ParamField.svelte new file mode 100644 index 00000000..561e7424 --- /dev/null +++ b/plugins/ecs/src/widgets/inspector/ParamField.svelte @@ -0,0 +1,161 @@ + + +
+ + {label} + {#if element.description} + + {#snippet children({ props })} + ⓘ + {/snippet} + + {/if} + + + {#if contributed} + {@const Editor = contributed.component} + + {:else if members.length && 'enumRef' in element && element.enumRef} + {@const enumRef = element.enumRef} + onchange({ code: (event.currentTarget as HTMLInputElement).value })} + /> + {:else if element.type === 'number'} + final && onchange({ value: next })} + /> + {:else if element.type === 'boolean'} + onchange({ value: next })} + /> + {:else if element.type === 'string' && element.enum?.length} + onchange({ value: text })} + /> + {/if} + +
+ + diff --git a/plugins/ecs/src/widgets/scene/GameMoveOverlay.svelte b/plugins/ecs/src/widgets/scene/GameMoveOverlay.svelte new file mode 100644 index 00000000..4273cad0 --- /dev/null +++ b/plugins/ecs/src/widgets/scene/GameMoveOverlay.svelte @@ -0,0 +1,207 @@ + + +{#if $active && viewport} + (drag = undefined)} + > + + {#each shapes as shape, index (`${shape.entity}:${index}`)} + {@const delta = offset(shape)} + start(event, shape)} + > + {#if shape.kind === 'rect'} + + {:else} + + {/if} + + {/each} + + +{/if} + + diff --git a/plugins/ecs/src/widgets/scene/SceneView.svelte b/plugins/ecs/src/widgets/scene/SceneView.svelte new file mode 100644 index 00000000..a5ec812b --- /dev/null +++ b/plugins/ecs/src/widgets/scene/SceneView.svelte @@ -0,0 +1,344 @@ + + + +
+ {#if !$model?.found} + + {:else} + { + drag = undefined; + pan = undefined; + }} + > + + {#each inheritedShapes as shape, index (`inherited:${shape.entity}:${index}`)} + + {/each} + {#each shapes as shape, index (`${shape.entity}:${index}`)} + {@const delta = offset(shape)} + {@const selected = shape.entity === selectedEntity} + start(event, shape)} + > + {#if shape.kind === 'rect'} + + {#if selected}{/if} + {:else} + + {#if selected}{/if} + {/if} + + {/each} + +
+ {Math.round(zoom * 100)}% + +
+

+ {$playing + ? 'Dragging moves the entity in the running game.' + : `Drag an entity to move it in ${$location?.label ?? 'main.ts'}.`} + Scroll to zoom; drag the background or use the middle button to pan. +

+ {/if} +
+ + diff --git a/plugins/ecs/src/widgets/systems/Systems.svelte b/plugins/ecs/src/widgets/systems/Systems.svelte new file mode 100644 index 00000000..1b5f2d99 --- /dev/null +++ b/plugins/ecs/src/widgets/systems/Systems.svelte @@ -0,0 +1,237 @@ + + +
+ {#if $playing && running} +
    + {#each running.systems as system (system.index)} +
  • + live.setSystemEnabled(running.source, system.index, enabled)} + /> + {system.name} +
  • + {/each} +
+ {:else if $ecsSource && !$location} + + {:else if !$model?.found} + + {:else} + {#if inheritedSystems.length} +
    + {#each inheritedSystems as system, index (index)} +
  • {system.name} {system.scene}
  • + {/each} +
+ {/if} + +
+ {#if nodes.length} + (menuTarget = Number(id))} + onactivate={(id) => { + const item = ecs.item($model?.systems[Number(id)]?.item); + if (item) void ecs.openItem(item); + }} + /> + {:else} + + {/if} +
+
+
+ +
+ {/if} +
+ + diff --git a/plugins/ecs/src/worker/bundle-spawns.ts b/plugins/ecs/src/worker/bundle-spawns.ts new file mode 100644 index 00000000..64421dfd --- /dev/null +++ b/plugins/ecs/src/worker/bundle-spawns.ts @@ -0,0 +1,99 @@ +import { ts } from 'ts-morph'; + +import type { AnalyzeContext } from '@nanoforge-dev/editor-sdk/worker'; + +export interface BundleSite { + /** 1-based, as in stack traces. */ + readonly line: number; + readonly column: number; + /** + * Count the spawns of the function the site is in (a scene's `setup`), not those of `main`. + */ + readonly enclosing?: boolean; +} + +export interface BundleSpawnsArgs { + /** The built entry file (`main.js`). */ + readonly bundle: string; + readonly sites: readonly BundleSite[]; +} + +const isSpawnCall = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'spawnEntity'; + +/** The body of the bundle's `main` (a function, or a const holding one). */ +const mainBody = (file: ts.SourceFile): ts.Node | undefined => { + let found: ts.Node | undefined; + const visit = (node: ts.Node) => { + if (found) return; + if (ts.isFunctionDeclaration(node) && node.name?.text === 'main') found = node.body; + else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'main' && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) + found = node.initializer.body; + else ts.forEachChild(node, visit); + }; + visit(file); + return found; +}; + +/** The body of the innermost function or method holding a position. */ +const enclosingBody = (file: ts.SourceFile, offset: number): ts.Node | undefined => { + let found: ts.Node | undefined; + const visit = (node: ts.Node) => { + if (offset < node.getStart(file) || offset > node.getEnd()) return; + if (ts.isFunctionLike(node) && 'body' in node && node.body) found = node.body as ts.Node; + ts.forEachChild(node, visit); + }; + visit(file); + return found; +}; + +/** + * Which `spawnEntity()` of `main` each call site of a running game is: its index among the + * spawn calls of `main` in the built bundle (the bundler keeps their order), or -1 when it + * isn't in `main` (a system or a helper spawned it). No source maps needed. + */ +export const analyzeBundleSpawns = (_context: AnalyzeContext, rawArgs: unknown): number[] => { + const { bundle, sites } = rawArgs as BundleSpawnsArgs; + const file = ts.createSourceFile( + 'main.js', + bundle, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + const spawnsIn = (body: ts.Node): ts.CallExpression[] => { + const calls: ts.CallExpression[] = []; + const collect = (node: ts.Node) => { + if (isSpawnCall(node)) calls.push(node); + ts.forEachChild(node, collect); + }; + collect(body); + return calls; + }; + const main = mainBody(file); + const mainCalls = main ? spawnsIn(main) : []; + const lineStarts = file.getLineStarts(); + return sites.map((site) => { + const lineStart = lineStarts[site.line - 1]; + if (lineStart === undefined) return -1; + const offset = lineStart + site.column - 1; + const body = site.enclosing ? enclosingBody(file, offset) : main; + if (!body) return -1; + const calls = site.enclosing ? spawnsIn(body) : mainCalls; + const inside = calls.findIndex( + (call) => call.getStart(file) <= offset && offset <= call.getEnd(), + ); + if (inside >= 0) return inside; + return calls.findIndex( + (call) => file.getLineAndCharacterOfPosition(call.getStart(file)).line === site.line - 1, + ); + }); +}; diff --git a/plugins/ecs/src/worker/component-args.ts b/plugins/ecs/src/worker/component-args.ts new file mode 100644 index 00000000..ed3b598e --- /dev/null +++ b/plugins/ecs/src/worker/component-args.ts @@ -0,0 +1,39 @@ +import { type CallExpression, type ExpressionStatement, type NewExpression, Node } from 'ts-morph'; + +import type { TransformContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { ArgValue } from '../model/ecs-model.type'; +import { componentExpression } from './entry-file'; +import { fail, literal } from './transform-helpers'; + +export const componentCall = (statement: ExpressionStatement): CallExpression => + statement.getExpression() as CallExpression; + +/** Rewrites `new X(...)` arguments: in place when they exist, else the whole argument list. */ +export const setArgs = ( + context: TransformContext, + statement: ExpressionStatement, + args: Readonly>, + fill: readonly ArgValue[], +) => { + const expression = componentExpression(statement); + if (!Node.isNewExpression(expression)) + fail('This component is not `new X(...)`: edit it in the code.'); + const existing = (expression as NewExpression).getArguments() as Node[]; + const indent = context.edit.indentationOf(statement); + const positions = Object.keys(args).map(Number); + if (positions.every((position) => position < existing.length)) { + for (const position of positions) + context.edit.replace(existing[position]!, literal(context, args[position]!, indent)); + return; + } + const count = Math.max(existing.length, ...positions.map((position) => position + 1)); + const list = Array.from({ length: count }, (_, position) => + args[position] !== undefined + ? literal(context, args[position], indent) + : (existing[position]?.getText() ?? + (fill[position] ? literal(context, fill[position], indent) : 'undefined')), + ); + const callee = (expression as NewExpression).getExpression().getText(); + context.edit.replace(expression, `new ${callee}(${list.join(', ')})`); +}; diff --git a/plugins/ecs/src/worker/ensure-imports.ts b/plugins/ecs/src/worker/ensure-imports.ts new file mode 100644 index 00000000..17a8b1ce --- /dev/null +++ b/plugins/ecs/src/worker/ensure-imports.ts @@ -0,0 +1,53 @@ +import type { TransformContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { ImportNeed } from '../model/ecs-model.type'; +import { CODE_EXTENSION } from './transform-helpers'; + +/** Adds the imports a change needs, merging into existing imports of the same module. */ +export const ensureImports = (context: TransformContext, needs: readonly ImportNeed[]): void => { + const { file, edit } = context; + const imported = new Set( + file + .getImportDeclarations() + .flatMap((declaration) => [ + ...declaration + .getNamedImports() + .map((named) => (named.getAliasNode() ?? named.getNameNode()).getText()), + ...(declaration.getDefaultImport() ? [declaration.getDefaultImport()!.getText()] : []), + ]), + ); + const declared = new Set( + [...file.getClasses(), ...file.getFunctions(), ...file.getEnums()].flatMap((node) => + node.getName() ? [node.getName()!] : [], + ), + ); + const byModule = new Map(); + for (const need of needs) { + if (imported.has(need.name) || declared.has(need.name)) continue; + const specifier = CODE_EXTENSION.test(need.from) + ? context.moduleSpecifier(need.from) + : need.from; + const names = byModule.get(specifier) ?? []; + if (!names.includes(need.name)) names.push(need.name); + byModule.set(specifier, names); + imported.add(need.name); + } + const quote = context.literals.quoteStyle(file); + for (const [specifier, names] of byModule) { + const existing = file + .getImportDeclarations() + .find( + (declaration) => + declaration.getModuleSpecifierValue() === specifier && !declaration.isTypeOnly(), + ); + const last = existing?.getNamedImports().at(-1); + if (last) { + edit.insertAfter(last, `, ${names.join(', ')}`); + continue; + } + const line = `import { ${names.join(', ')} } from ${quote}${specifier}${quote};`; + const lastImport = file.getImportDeclarations().at(-1); + if (lastImport) edit.insertAfter(lastImport, `\n${line}`); + else edit.insertAt(0, `${line}\n\n`); + } +}; diff --git a/plugins/ecs/src/worker/entry-file.ts b/plugins/ecs/src/worker/entry-file.ts new file mode 100644 index 00000000..bc590367 --- /dev/null +++ b/plugins/ecs/src/worker/entry-file.ts @@ -0,0 +1,297 @@ +import { + type Block, + type CallExpression, + type ExpressionStatement, + Node, + type PropertyAccessExpression, + type SourceFile, + SyntaxKind, + type VariableStatement, +} from 'ts-morph'; + +import type { AnalyzeContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { + ArgModel, + CodeOnlyEntity, + ComponentUse, + EntityModel, + EntryAnalyzeArgs, + EntryModel, + EntryRoot, + EntryScope, + SystemUse, +} from '../model/ecs-model.type'; +import { scopeName } from '../model/scope-name'; + +/** The body of `export const main = async (…) => { … }` or `export function main(…) { … }`. */ +export const findMain = (file: SourceFile): Block | undefined => { + const fn = file.getFunction('main'); + if (fn?.isExported()) return fn.getBody() as Block | undefined; + const variable = file.getVariableDeclaration('main'); + const initializer = variable?.getInitializer(); + if ( + variable?.isExported() && + initializer && + (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) + ) { + const body = initializer.getBody(); + return Node.isBlock(body) ? body : undefined; + } + return undefined; +}; + +/** The body of a scope: `main`, or a method of a class of the file (`Level1.setup`). */ +export const findBody = (file: SourceFile, scope: EntryScope | undefined): Block | undefined => { + if (scope?.kind !== 'method') return findMain(file); + const body = file.getClass(scope.class)?.getMethod(scope.method)?.getBody(); + return body && Node.isBlock(body) ? body : undefined; +}; + +/** A method's first parameter: the registry a scene's `setup(registry, ctx)` gets. */ +const registryParameter = (body: Block): string | undefined => { + const method = body.getParent(); + if (!method || !Node.isMethodDeclaration(method)) return undefined; + const first = method.getParameters()[0]; + return first && Node.isIdentifier(first.getNameNode()) ? first.getName() : undefined; +}; + +/** `.(…)` of an expression, if it is that call. */ +const methodCall = (node: Node | undefined, method: string): CallExpression | undefined => { + if (!node || !Node.isCallExpression(node)) return undefined; + const callee = node.getExpression(); + return Node.isPropertyAccessExpression(callee) && callee.getName() === method ? node : undefined; +}; + +const receiverOf = (call: CallExpression) => + (call.getExpression() as PropertyAccessExpression).getExpression().getText(); + +export interface ParsedEntity { + readonly name: string; + readonly declaration: VariableStatement; + readonly components: ExpressionStatement[]; +} + +/** What the analyzer and the transformers see of an entry file. */ +export interface ParsedEntry { + readonly main: Block; + readonly registry?: string; + readonly entities: ParsedEntity[]; + readonly systems: ExpressionStatement[]; + /** `spawnEntity()` calls that aren't `const x = registry.spawnEntity();` in `main`. */ + readonly codeOnly: CallExpression[]; +} + +export const parseEntry = (file: SourceFile, scope?: EntryScope): ParsedEntry | undefined => { + const main = findBody(file, scope); + if (!main) return undefined; + const spawns = main + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => methodCall(call, 'spawnEntity')); + const registry = spawns[0] + ? receiverOf(spawns[0]) + : (registryVariable(main) ?? registryParameter(main)); + + const entities: ParsedEntity[] = []; + const byName = new Map(); + const systems: ExpressionStatement[] = []; + const modeled = new Set(); + for (const statement of main.getStatements()) { + if (Node.isVariableStatement(statement)) { + const [declaration, ...others] = statement.getDeclarations(); + const spawn = methodCall(declaration?.getInitializer(), 'spawnEntity'); + if ( + declaration && + !others.length && + spawn && + !spawn.getArguments().length && + receiverOf(spawn) === registry && + Node.isIdentifier(declaration.getNameNode()) + ) { + const entity = { name: declaration.getName(), declaration: statement, components: [] }; + entities.push(entity); + byName.set(entity.name, entity); + modeled.add(spawn); + } + continue; + } + if (!Node.isExpressionStatement(statement)) continue; + const expression = statement.getExpression(); + const add = methodCall(expression, 'addComponent'); + if (add && receiverOf(add) === registry) { + const [target] = add.getArguments(); + const entity = target && Node.isIdentifier(target) ? byName.get(target.getText()) : undefined; + if (entity && add.getArguments().length === 2) entity.components.push(statement); + continue; + } + const system = methodCall(expression, 'addSystem'); + if (system && receiverOf(system) === registry && system.getArguments().length === 1) + systems.push(statement); + } + return { + main, + ...(registry && { registry }), + entities, + systems, + codeOnly: spawns.filter((call) => !modeled.has(call)), + }; +}; + +/** `const registry = ecs.registry` (or any `… = .registry`). */ +const registryVariable = (main: Block): string | undefined => { + for (const statement of main.getStatements()) { + if (!Node.isVariableStatement(statement)) continue; + for (const declaration of statement.getDeclarations()) { + const initializer = declaration.getInitializer(); + if ( + initializer && + Node.isPropertyAccessExpression(initializer) && + initializer.getName() === 'registry' + ) + return declaration.getName(); + } + } + return undefined; +}; + +/** `new X(...)` of an `addComponent` statement. */ +export const componentExpression = (statement: ExpressionStatement): Node => + (statement.getExpression() as CallExpression).getArguments()[1]!; + +/** The item a class or function identifier refers to, and its file. */ +const itemOf = (node: Node, roots: readonly EntryRoot[]): { source?: string; item?: string } => { + let symbol = node.getSymbol(); + if (symbol?.isAlias()) symbol = symbol.getAliasedSymbol(); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration) return {}; + const path = declaration + .getSourceFile() + .getFilePath() + .replace(/^\/project\//, ''); + const exportable = Node.isVariableDeclaration(declaration) + ? declaration.getVariableStatement() + : declaration; + const exported = !!exportable && Node.isExportable(exportable) && exportable.isExported(); + const root = [...roots] + .sort((a, b) => b.path.length - a.path.length) + .find((candidate) => candidate.path === '' || path.startsWith(`${candidate.path}/`)); + return { + source: path, + ...(exported && root && { item: `${root.ref}#${symbol.getName()}` }), + }; +}; + +const argValue = (context: AnalyzeContext, node: Node): unknown => { + try { + return context.literals.literalToValue(node); + } catch { + if (Node.isPropertyAccessExpression(node)) { + const value = node + .getProject() + .getTypeChecker() + .compilerObject.getConstantValue(node.compilerNode); + if (value !== undefined) return value; + } + return undefined; + } +}; + +const componentUse = ( + context: AnalyzeContext, + statement: ExpressionStatement, + roots: readonly EntryRoot[], +): ComponentUse => { + const expression = componentExpression(statement); + const base = { node: context.ref(statement), code: expression.getText() }; + if (!Node.isNewExpression(expression) || !Node.isIdentifier(expression.getExpression())) + return { ...base, args: [], editable: false }; + const identifier = expression.getExpression(); + const args: ArgModel[] = (expression.getArguments() as Node[]).map((arg) => { + const value = argValue(context, arg); + const construct = + Node.isNewExpression(arg) && Node.isIdentifier(arg.getExpression()) + ? { + className: arg.getExpression().getText(), + args: (arg.getArguments() as Node[]).map((inner) => argValue(context, inner) ?? null), + } + : undefined; + return { + code: arg.getText(), + start: arg.getStart(), + end: arg.getEnd(), + ...(value !== undefined && { value }), + ...(construct && { construct }), + }; + }); + return { + ...base, + className: identifier.getText(), + ...itemOf(identifier, roots), + args, + editable: true, + }; +}; + +const lineOf = (node: Node) => node.getStartLineNumber(); + +/** The entities, their components, the systems and the read-only parts of an entry file. */ +export const analyzeEntry = (context: AnalyzeContext, rawArgs: unknown): EntryModel => { + const args = (rawArgs ?? { roots: [] }) as EntryAnalyzeArgs; + const parsed = parseEntry(context.file, args.scope); + if (!parsed) { + return { + path: context.path, + found: false, + entities: [], + codeOnly: [], + systems: [], + problems: [ + args.scope?.kind === 'method' + ? `No \`${scopeName(args.scope)}\` method in this file.` + : 'No exported `main` function in this file.', + ], + }; + } + const entities: EntityModel[] = parsed.entities.map((entity) => ({ + name: entity.name, + node: context.ref(entity.declaration), + line: lineOf(entity.declaration), + components: entity.components.map((statement) => componentUse(context, statement, args.roots)), + })); + const codeOnly: CodeOnlyEntity[] = parsed.codeOnly.map((call) => { + const statement = + call.getFirstAncestor((node) => Node.isStatement(node) && node.getParent() === parsed.main) ?? + call; + return { + line: lineOf(call), + start: statement.getStart(), + end: statement.getEnd(), + code: statement.getText(), + }; + }); + const systems: SystemUse[] = parsed.systems.map((statement) => { + const [fn] = (statement.getExpression() as CallExpression).getArguments(); + const name = fn && Node.isIdentifier(fn) ? fn.getText() : undefined; + const item = fn && Node.isIdentifier(fn) ? itemOf(fn, args.roots).item : undefined; + return { + node: context.ref(statement), + code: fn?.getText() ?? '', + ...(name && { name }), + ...(item && { item }), + }; + }); + const problems: string[] = []; + if (!parsed.registry) + problems.push( + `No ECS registry found in \`${scopeName(args.scope)}\` (\`registry.spawnEntity()\`).`, + ); + return { + path: context.path, + found: true, + ...(parsed.registry && { registry: parsed.registry }), + entities, + codeOnly, + systems, + problems, + }; +}; diff --git a/plugins/ecs/src/worker/index.ts b/plugins/ecs/src/worker/index.ts new file mode 100644 index 00000000..28cf9aeb --- /dev/null +++ b/plugins/ecs/src/worker/index.ts @@ -0,0 +1,34 @@ +/** + * Code worker entry (`entry.worker`): the `@nanoforge/ecs` item owner, and the entry file's + * analyzer and transformer. `ts-morph` and the worker SDK come from the editor's worker. + */ +import { defineWorkerPlugin } from '@nanoforge-dev/editor-sdk/worker'; + +import { + BUNDLE_SPAWNS_ANALYZER, + ENTRY_ANALYZER, + ENTRY_TRANSFORMER, + IMPORTERS_ANALYZER, + ITEM_DOCS_TRANSFORMER, + RELOCATED_TEXT_ANALYZER, + REWRITE_IMPORTS_TRANSFORMER, +} from '../model/ecs.const'; +import { analyzeBundleSpawns } from './bundle-spawns'; +import { analyzeEntry } from './entry-file'; +import { transformItemDocs } from './item-docs'; +import { ecsOwner } from './owner'; +import { analyzeImporters, analyzeRelocatedText, transformRewriteImports } from './refactor'; +import { transformEntry } from './transforms'; + +export default defineWorkerPlugin({ + activate(sdk) { + sdk.registerItemOwner(ecsOwner); + sdk.registerAnalyzer(ENTRY_ANALYZER, analyzeEntry); + sdk.registerTransformer(ENTRY_TRANSFORMER, transformEntry); + sdk.registerTransformer(ITEM_DOCS_TRANSFORMER, transformItemDocs); + sdk.registerAnalyzer(IMPORTERS_ANALYZER, analyzeImporters); + sdk.registerAnalyzer(RELOCATED_TEXT_ANALYZER, analyzeRelocatedText); + sdk.registerTransformer(REWRITE_IMPORTS_TRANSFORMER, transformRewriteImports); + sdk.registerAnalyzer(BUNDLE_SPAWNS_ANALYZER, analyzeBundleSpawns); + }, +}); diff --git a/plugins/ecs/src/worker/item-docs.ts b/plugins/ecs/src/worker/item-docs.ts new file mode 100644 index 00000000..9ecb3408 --- /dev/null +++ b/plugins/ecs/src/worker/item-docs.ts @@ -0,0 +1,176 @@ +import type { ClassDeclaration, Node } from 'ts-morph'; + +import { + CodeError, + type Doc, + type TransformContext, + leadingDocComments, + parseDocComment, +} from '@nanoforge-dev/editor-sdk/worker'; + +import type { ItemDocsOp, ParamDocs } from '../model/item-docs.type'; + +/** Tags the Component panel manages on a param; the others are kept as written. */ +const LAYOUT_TAGS = new Set(['group', 'preset', 'label', 'color', 'hidden']); + +const lastDocRange = (node: Node) => leadingDocComments(node).at(-1); + +const docOf = (node: Node): Doc => { + const range = lastDocRange(node); + return range ? parseDocComment(range.text) : { description: '', tags: [] }; +}; + +/** A multi-line doc comment from its description and tags, at an indentation. */ +const blockComment = ( + description: string, + tags: readonly { name: string; text: string }[], + indent: string, +) => { + const lines: string[] = []; + if (description) lines.push(...description.split('\n')); + if (description && tags.length) lines.push(''); + for (const tag of tags) { + const [first = '', ...rest] = tag.text.split('\n'); + lines.push(`@${tag.name}${first ? ` ${first}` : ''}`, ...rest); + } + return `/**\n${lines.map((line) => `${indent} *${line ? ` ${line}` : ''}`).join('\n')}\n${indent} */`; +}; + +/** A one-line doc comment when it fits, else a block. */ +const paramComment = ( + description: string, + tags: readonly { name: string; text: string }[], + indent: string, + ownLine: boolean, +) => { + const inline = [ + description, + ...tags.map((tag) => `@${tag.name}${tag.text ? ` ${tag.text}` : ''}`), + ] + .filter(Boolean) + .join(' '); + if (!inline) return ''; + if (inline.length <= 90 && !inline.includes('\n')) return `/** ${inline} */`; + return ownLine + ? blockComment(description, tags, indent) + : `/** ${inline.replace(/\s+/g, ' ')} */`; +}; + +const layoutTags = (docs: ParamDocs): { name: string; text: string }[] => [ + ...(docs.group ? [{ name: 'group', text: docs.group }] : []), + ...(docs.preset ? [{ name: 'preset', text: docs.preset }] : []), + ...(docs.label ? [{ name: 'label', text: docs.label }] : []), + ...(docs.color ? [{ name: 'color', text: docs.color }] : []), + ...(docs.hidden ? [{ name: 'hidden', text: '' }] : []), +]; + +const groupText = ( + group: ItemDocsOp['groups'] extends readonly (infer G)[] | undefined ? G : never, +) => + [ + group.name, + ...(group.color ? [`color=${group.color}`] : []), + ...(group.hidden ? ['hidden'] : []), + ...(group.description ? [`- ${group.description.replace(/\s+/g, ' ')}`] : []), + ].join(' '); + +/** Replaces (or inserts) the doc comment right before a node. */ +const writeDoc = (context: TransformContext, node: Node, comment: string, ownLine: boolean) => { + const range = lastDocRange(node); + const indent = context.edit.indentationOf(node); + if (range) { + if (!comment) { + context.edit.remove({ start: range.start, end: node.getStart() }); + return; + } + context.edit.replace({ start: range.start, end: range.end }, comment); + return; + } + if (!comment) return; + context.edit.insertAt(node.getStart(), ownLine ? `${comment}\n${indent}` : `${comment} `); +}; + +const isOnOwnLine = (context: TransformContext, node: Node) => { + const lineStart = context.text.lastIndexOf('\n', node.getStart() - 1) + 1; + return context.text.slice(lineStart, node.getStart()).trim() === ''; +}; + +/** + * Writes a component's docs from the Component panel: its description and `@group` + * declarations (other tags kept), and each param's description and layout tags, as inline docs. + */ +export const transformItemDocs = (context: TransformContext, rawOp: unknown): void => { + const op = rawOp as ItemDocsOp; + const declaration = context.file.getClass(op.export); + if (!declaration) throw new CodeError(`No class ${op.export} in this file.`); + writeClassDocs(context, declaration, op); + if (op.params) writeParamDocs(context, declaration, op.params); +}; + +const writeClassDocs = ( + context: TransformContext, + declaration: ClassDeclaration, + op: ItemDocsOp, +) => { + if (op.description === undefined && op.groups === undefined) return; + const doc = docOf(declaration); + const description = op.description ?? doc.description; + const kept = doc.tags.filter((tag) => op.groups === undefined || tag.name !== 'group'); + const groups = op.groups?.map((group) => ({ name: 'group', text: groupText(group) })) ?? []; + const comment = blockComment( + description, + [...kept, ...groups], + context.edit.indentationOf(declaration), + ); + writeDoc(context, declaration, comment, true); +}; + +const writeParamDocs = ( + context: TransformContext, + declaration: ClassDeclaration, + params: Readonly>, +) => { + const constructor = + declaration.getConstructors().find((candidate) => candidate.hasBody()) ?? + declaration.getConstructors()[0]; + const byName = new Map( + constructor?.getParameters().map((param) => [param.getName(), param]) ?? [], + ); + const fields = new Map( + declaration.getProperties().map((property) => [property.getName(), property]), + ); + for (const [name, docs] of Object.entries(params)) { + const node: Node | undefined = byName.get(name) ?? fields.get(name); + if (!node) + throw new CodeError(`No param or field ${name} in ${declaration.getName() ?? 'this class'}.`); + const doc = docOf(node); + const others = doc.tags.filter((tag) => !LAYOUT_TAGS.has(tag.name)); + const description = docs.description ?? doc.description; + const ownLine = isOnOwnLine(context, node); + const comment = paramComment( + description.replace(/\s+/g, ' ').trim(), + [...layoutTags(docs), ...others], + context.edit.indentationOf(node), + ownLine, + ); + writeDoc(context, node, comment, ownLine); + } + if (!constructor) return; + const described = Object.entries(params) + .filter(([name, docs]) => docs.description !== undefined && byName.has(name)) + .map(([name]) => name); + const range = lastDocRange(constructor); + if (!described.length || !range) return; + const doc = parseDocComment(range.text); + const tags = doc.tags.filter( + (tag) => + tag.name !== 'param' || + !described.some((name) => new RegExp(`^(\\{[^}]*\\}\\s*)?\\[?${name}\\b`).test(tag.text)), + ); + if (tags.length === doc.tags.length) return; + const comment = + doc.description || tags.length + ? blockComment(doc.description, tags, context.edit.indentationOf(constructor)) + : ''; + writeDoc(context, constructor, comment, true); +}; diff --git a/plugins/ecs/src/worker/owner.ts b/plugins/ecs/src/worker/owner.ts new file mode 100644 index 00000000..1f8c058b --- /dev/null +++ b/plugins/ecs/src/worker/owner.ts @@ -0,0 +1,127 @@ +import { Node, SyntaxKind } from 'ts-morph'; + +import type { ItemOwner, OwnerInput } from '@nanoforge-dev/editor-sdk/worker'; + +import manifest from '../../nanoforge.manifest.json'; +import type { EcsItemData } from '../model/ecs-model.type'; + +/** Registry methods whose component arguments are what a system reads. */ +const QUERY_METHODS = new Set(['getZipper', 'getIndexedZipper']); +const SINGLE_QUERY_METHODS = new Set(['getComponents', 'getComponentsConst']); + +/** The literal of the class's `name = "X"` field, the ECS registry key. */ +const registryName = (input: OwnerInput): string | undefined => { + const { declaration } = input; + if (!Node.isClassDeclaration(declaration)) return undefined; + const initializer = declaration.getProperty('name')?.getInitializer(); + if (!initializer) return undefined; + if (Node.isStringLiteral(initializer)) return initializer.getLiteralValue(); + const text = initializer.getText().replace(/\s+/g, ''); + const className = declaration.getName(); + if (className && (text === 'this.constructor.name' || text === `${className}.name`)) + return className; + return undefined; +}; + +/** The function node of a system: the function itself, or an arrow/function const. */ +const systemFunction = (input: OwnerInput) => { + const { declaration } = input; + if (Node.isFunctionDeclaration(declaration)) return declaration; + const initializer = Node.isVariableDeclaration(declaration) + ? declaration.getInitializer() + : undefined; + return initializer && + (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) + ? initializer + : undefined; +}; + +const words = (texts: readonly string[] | undefined) => + (texts ?? []).flatMap((text) => text.split(/[\s,]+/)).filter(Boolean); + +/** What a system reads (`getZipper([A, B])` → `[A, B]`), and the `ctx` libraries it uses. */ +const systemData = (input: OwnerInput): EcsItemData => { + const fn = systemFunction(input); + const { context, tags } = input; + const query: string[][] = []; + const uses = new Set(); + if (fn) { + const ctx = fn.getParameters()[1]?.getName(); + for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee)) continue; + const method = callee.getName(); + const [first] = call.getArguments(); + if (QUERY_METHODS.has(method) && first && Node.isArrayLiteralExpression(first)) { + query.push( + first.getElements().map((element) => context.refOf(element) ?? element.getText()), + ); + } else if (SINGLE_QUERY_METHODS.has(method) && first) { + query.push([context.refOf(first) ?? first.getText()]); + } + } + if (ctx) { + for (const access of fn.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + const target = access.getExpression(); + if (Node.isIdentifier(target) && target.getText() === ctx) uses.add(access.getName()); + } + } + } + const queryTags = tags.query; + if (queryTags?.length) { + const byName = new Map(); + for (const identifier of context.file.getDescendantsOfKind(SyntaxKind.Identifier)) { + const ref = context.refOf(identifier); + if (ref) byName.set(identifier.getText(), ref); + } + query.splice( + 0, + query.length, + ...queryTags.map((text) => words([text]).map((name) => byName.get(name) ?? name)), + ); + } + const usesTags = words(tags.uses); + return { + type: 'system', + query, + uses: usesTags.length ? usesTags : [...uses].sort(), + }; +}; + +/** + * `@nanoforge/ecs` item data (ADR 0003): components (`@component`, or classes with a literal + * `name` field in a components folder) and systems (`@system`, or functions in a systems folder). + */ +export const ecsOwner: ItemOwner = { + name: manifest.name, + version: manifest.version, + schema: manifest.contributes.itemOwner.schema, + tags: manifest.contributes.itemOwner.tags, + infer: (input) => { + if (input.context.folder === 'components') return registryName(input) !== undefined; + if (input.context.folder === 'systems') return systemFunction(input) !== undefined; + return false; + }, + claims: (input) => + systemFunction(input) && !input.tags.component + ? { + params: systemFunction(input)! + .getParameters() + .map((param) => param.getName()), + } + : { fields: ['name'] }, + extract: (input) => { + if (input.tags.system || (!input.tags.component && systemFunction(input))) { + return systemFunction(input) + ? { ...systemData(input), ...(!input.tags.system && { inferred: true }) } + : undefined; + } + if (!Node.isClassDeclaration(input.declaration)) return undefined; + const data: EcsItemData = { + type: 'component', + name: registryName(input) ?? input.declaration.getName() ?? 'Component', + ...(!input.tags.component && { inferred: true }), + }; + return { ...data }; + }, +}; diff --git a/plugins/ecs/src/worker/refactor.ts b/plugins/ecs/src/worker/refactor.ts new file mode 100644 index 00000000..cf37d28f --- /dev/null +++ b/plugins/ecs/src/worker/refactor.ts @@ -0,0 +1,67 @@ +import type { ExportDeclaration, ImportDeclaration, SourceFile } from 'ts-morph'; + +import type { AnalyzeContext, TransformContext } from '@nanoforge-dev/editor-sdk/worker'; + +const ROOT = '/project/'; +const projectPath = (file: SourceFile) => file.getFilePath().replace(ROOT, ''); + +const moduleDeclarations = (file: SourceFile): (ImportDeclaration | ExportDeclaration)[] => [ + ...file.getImportDeclarations(), + ...file.getExportDeclarations().filter((declaration) => declaration.getModuleSpecifier()), +]; + +/** Project files that import the analyzed file (Move to shared library rewrites them). */ +export const analyzeImporters = (context: AnalyzeContext): string[] => + context.project + .getSourceFiles() + .filter((file) => + moduleDeclarations(file).some( + (declaration) => declaration.getModuleSpecifierSourceFile() === context.file, + ), + ) + .map(projectPath) + .filter((path) => !path.startsWith('node_modules/')) + .sort(); + +/** + * The analyzed file's text with its own imports written as if it were at `to`, and the project + * files it imports (a shared library must not import an app). + */ +export const analyzeRelocatedText = ( + context: AnalyzeContext, + args: unknown, +): { text: string; imports: string[] } => { + const { to } = args as { to: string }; + const replacements: { start: number; end: number; text: string }[] = []; + const imports: string[] = []; + for (const declaration of moduleDeclarations(context.file)) { + const target = declaration.getModuleSpecifierSourceFile(); + const specifier = declaration.getModuleSpecifier(); + if (!target || !specifier) continue; + if (!projectPath(target).startsWith('node_modules/')) imports.push(projectPath(target)); + const value = declaration.getModuleSpecifierValue() ?? ''; + if (!value.startsWith('.')) continue; + const quote = specifier.getText()[0]; + replacements.push({ + start: specifier.getStart(), + end: specifier.getEnd(), + text: `${quote}${context.moduleSpecifier(projectPath(target), to)}${quote}`, + }); + } + let text = context.text; + for (const replacement of replacements.sort((a, b) => b.start - a.start)) + text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end); + return { text, imports: [...new Set(imports)].sort() }; +}; + +/** Points the imports of `from` to `to` (a file moved to a shared library). */ +export const transformRewriteImports = (context: TransformContext, op: unknown): void => { + const { from, to } = op as { from: string; to: string }; + for (const declaration of moduleDeclarations(context.file)) { + const target = declaration.getModuleSpecifierSourceFile(); + const specifier = declaration.getModuleSpecifier(); + if (!target || !specifier || projectPath(target) !== from) continue; + const quote = specifier.getText()[0]; + context.edit.replace(specifier, `${quote}${context.moduleSpecifier(to)}${quote}`); + } +}; diff --git a/plugins/ecs/src/worker/transform-helpers.ts b/plugins/ecs/src/worker/transform-helpers.ts new file mode 100644 index 00000000..f67f6e66 --- /dev/null +++ b/plugins/ecs/src/worker/transform-helpers.ts @@ -0,0 +1,84 @@ +import { Node, type Statement } from 'ts-morph'; + +import { CodeError, type TransformContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { ArgValue } from '../model/ecs-model.type'; +import type { ParsedEntity, ParsedEntry } from './entry-file'; + +export const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +export const CODE_EXTENSION = /\.[cm]?[jt]sx?$/; + +export const fail = (message: string): never => { + throw new CodeError(message); +}; + +export const entityOf = (parsed: ParsedEntry, name: string): ParsedEntity => + parsed.entities.find((entity) => entity.name === name) ?? + fail(`The entity "${name}" isn't declared as \`const ${name} = registry.spawnEntity();\`.`); + +export const lastStatementOf = (entity: ParsedEntity): Statement => + entity.components.at(-1) ?? entity.declaration; + +/** Names already used in the file (declarations and references). */ +export const usedNames = (context: TransformContext): Set => + new Set( + context.file + .getDescendants() + .filter(Node.isIdentifier) + .map((node) => node.getText()), + ); + +export const uniqueName = (context: TransformContext, base: string): string => { + const used = usedNames(context); + const clean = IDENTIFIER.test(base) ? base : 'entity'; + if (!used.has(clean)) return clean; + for (let index = 2; ; index++) if (!used.has(`${clean}${index}`)) return `${clean}${index}`; +}; + +export const checkNewName = (context: TransformContext, name: string) => { + if (!IDENTIFIER.test(name)) fail(`"${name}" isn't a valid name: use letters, digits, _ or $.`); + if (usedNames(context).has(name)) fail(`"${name}" is already used in this file.`); +}; + +export const literal = (context: TransformContext, value: ArgValue, indent: string): string => + 'code' in value + ? value.code + : context.literals.valueToLiteral(value.value as never, { + quote: context.literals.quoteStyle(context.file), + indent, + }); + +/** Writes the first statement of an empty block (`setup() {}` of a new scene). */ +export const insertInEmpty = (context: TransformContext, block: Node, text: string): void => { + const indent = context.edit.indentationOf(block.getParentOrThrow()); + const step = indent.includes('\t') ? '\t' : ' '; + const open = block.getStart() + 1; + const close = block.getEnd() - 1; + context.edit.replace({ start: open, end: close }, `\n${indent}${step}${text}\n${indent}`); +}; + +/** Inserts a statement on its own line after another one. */ +export const insertAfter = (context: TransformContext, anchor: Node, text: string) => + context.edit.insertAfter(anchor, `\n${context.edit.indentationOf(anchor)}${text}`); + +/** Start of the line of a node, and end of the line of another (with its line break). */ +export const lineRange = (context: TransformContext, first: Node, last: Node) => { + const start = context.text.lastIndexOf('\n', first.getStart() - 1) + 1; + const newline = context.text.indexOf('\n', last.getEnd()); + return { start, end: newline < 0 ? context.text.length : newline + 1 }; +}; + +/** Moves whole lines [first..last] to the start of the line of `target`. */ +export const moveLines = ( + context: TransformContext, + first: Node, + last: Node, + target: Node | number, +) => { + const block = lineRange(context, first, last); + const at = + typeof target === 'number' ? target : context.text.lastIndexOf('\n', target.getStart() - 1) + 1; + if (at > block.start && at < block.end) return; + context.edit.remove(block); + context.edit.insertAt(at, context.text.slice(block.start, block.end)); +}; diff --git a/plugins/ecs/src/worker/transforms.ts b/plugins/ecs/src/worker/transforms.ts new file mode 100644 index 00000000..104f423a --- /dev/null +++ b/plugins/ecs/src/worker/transforms.ts @@ -0,0 +1,203 @@ +import { type Identifier, Node } from 'ts-morph'; + +import type { TransformContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { ScopedEntryOp } from '../model/ecs-model.type'; +import { scopeName } from '../model/scope-name'; +import { componentCall, setArgs } from './component-args'; +import { ensureImports } from './ensure-imports'; +import { componentExpression, parseEntry } from './entry-file'; +import { + IDENTIFIER, + checkNewName, + entityOf, + fail, + insertAfter, + insertInEmpty, + lastStatementOf, + lineRange, + literal, + moveLines, + uniqueName, +} from './transform-helpers'; + +/** Applies an `EntryOp` to the entry file (see `analyzeEntry`). */ +export const transformEntry = (context: TransformContext, rawOp: unknown): void => { + const op = rawOp as ScopedEntryOp; + const where = scopeName(op.scope); + const parsed = + parseEntry(context.file, op.scope) ?? + fail( + op.scope?.kind === 'method' + ? `No \`${where}\` method in this file.` + : 'No exported `main` function in this file.', + ); + const registry = parsed.registry ?? fail(`No ECS registry found in \`${where}\`.`); + const { edit } = context; + + switch (op.kind) { + case 'addEntity': { + const name = op.name + ? (checkNewName(context, op.name), op.name) + : uniqueName(context, 'entity'); + const text = `const ${name} = ${registry}.spawnEntity();`; + const last = parsed.entities.at(-1); + if (last) insertAfter(context, lastStatementOf(last), text); + else { + const anchor = parsed.main + .getStatements() + .find( + (statement) => + Node.isVariableStatement(statement) && + statement.getDeclarations().some((declaration) => declaration.getName() === registry), + ); + if (anchor) insertAfter(context, anchor, text); + else { + const first = parsed.main.getStatements()[0]; + if (first) edit.insertBefore(first, `${text}\n${edit.indentationOf(first)}`); + else insertInEmpty(context, parsed.main, text); + } + } + return; + } + case 'removeEntity': { + const entity = entityOf(parsed, op.entity); + const own = new Set([entity.declaration, ...entity.components]); + const nameNode = entity.declaration.getDeclarations()[0]!.getNameNode(); + const elsewhere = (nameNode as Identifier) + .findReferencesAsNodes() + .filter((node) => node.getSourceFile() === context.file) + .filter( + (node) => + ![...own].some((statement) => statement.containsRange(node.getStart(), node.getEnd())), + ); + if (elsewhere.length) + fail( + `"${entity.name}" is used elsewhere in the code (line ${elsewhere[0]!.getStartLineNumber()}). Remove those uses first.`, + ); + for (const statement of own) edit.removeLines(statement); + return; + } + case 'renameEntity': { + const entity = entityOf(parsed, op.entity); + if (op.name === entity.name) return; + checkNewName(context, op.name); + const nameNode = entity.declaration.getDeclarations()[0]!.getNameNode() as Identifier; + const nodes = new Set([nameNode, ...nameNode.findReferencesAsNodes()]); + for (const node of nodes) + if (node.getSourceFile() === context.file) edit.replace(node, op.name); + return; + } + case 'duplicateEntity': { + const entity = entityOf(parsed, op.entity); + const name = op.name + ? (checkNewName(context, op.name), op.name) + : uniqueName(context, `${entity.name}Copy`); + const indent = edit.indentationOf(entity.declaration); + const lines = [ + `const ${name} = ${registry}.spawnEntity();`, + ...entity.components.map( + (statement) => + `${registry}.addComponent(${name}, ${componentExpression(statement).getText()});`, + ), + ]; + edit.insertAfter(lastStatementOf(entity), lines.map((line) => `\n${indent}${line}`).join('')); + return; + } + case 'moveEntity': { + const entity = entityOf(parsed, op.entity); + const statements = parsed.main.getStatements(); + const indices = [entity.declaration, ...entity.components].map((statement) => + statements.indexOf(statement), + ); + if (indices.some((index, position) => position > 0 && index !== indices[position - 1]! + 1)) + fail( + `"${entity.name}" and its components aren't next to each other: move them in the code.`, + ); + if (op.before === entity.name) return; + if (op.before) + moveLines( + context, + entity.declaration, + lastStatementOf(entity), + entityOf(parsed, op.before).declaration, + ); + else { + const last = parsed.entities.at(-1)!; + if (last === entity) return; + moveLines( + context, + entity.declaration, + lastStatementOf(entity), + lineRange(context, lastStatementOf(last), lastStatementOf(last)).end, + ); + } + return; + } + case 'addComponent': { + const entity = entityOf(parsed, op.entity); + if (!IDENTIFIER.test(op.className)) fail(`"${op.className}" isn't a class name.`); + const indent = edit.indentationOf(entity.declaration); + const args = op.args.map((arg) => literal(context, arg, indent)).join(', '); + insertAfter( + context, + lastStatementOf(entity), + `${registry}.addComponent(${entity.name}, new ${op.className}(${args}));`, + ); + ensureImports(context, op.imports); + return; + } + case 'removeComponent': { + const entity = entityOf(parsed, op.entity); + edit.removeLines( + entity.components[op.index] ?? fail(`"${entity.name}" has no component ${op.index}.`), + ); + return; + } + case 'moveComponent': { + const entity = entityOf(parsed, op.entity); + const from = + entity.components[op.from] ?? fail(`"${entity.name}" has no component ${op.from}.`); + const to = entity.components[op.to] ?? fail(`"${entity.name}" has no component ${op.to}.`); + if (from === to) return; + if (op.to < op.from) moveLines(context, from, from, to); + else moveLines(context, from, from, lineRange(context, to, to).end); + return; + } + case 'setArgs': { + const entity = entityOf(parsed, op.entity); + const statement = + entity.components[op.index] ?? fail(`"${entity.name}" has no component ${op.index}.`); + setArgs(context, statement, op.args, op.fill ?? []); + if (op.imports?.length) ensureImports(context, op.imports); + return; + } + case 'addSystem': { + if (!IDENTIFIER.test(op.name)) fail(`"${op.name}" isn't a function name.`); + const text = `${registry}.addSystem(${op.name});`; + const last = + parsed.systems.at(-1) ?? + parsed.entities.at(-1)?.components.at(-1) ?? + parsed.entities.at(-1)?.declaration; + const anchor = last ?? parsed.main.getStatements().at(-1); + if (anchor) insertAfter(context, anchor, text); + else insertInEmpty(context, parsed.main, text); + ensureImports(context, op.imports); + return; + } + case 'removeSystem': { + edit.removeLines(parsed.systems[op.index] ?? fail(`There is no system ${op.index}.`)); + return; + } + case 'moveSystem': { + const from = parsed.systems[op.from] ?? fail(`There is no system ${op.from}.`); + const to = parsed.systems[op.to] ?? fail(`There is no system ${op.to}.`); + if (from === to) return; + if (op.to < op.from) moveLines(context, from, from, to); + else moveLines(context, from, from, lineRange(context, to, to).end); + return; + } + } +}; + +export { componentCall }; diff --git a/plugins/ecs/svelte.config.js b/plugins/ecs/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/ecs/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/ecs/test/extension/field-editor.spec.ts b/plugins/ecs/test/extension/field-editor.spec.ts new file mode 100644 index 00000000..fd70945f --- /dev/null +++ b/plugins/ecs/test/extension/field-editor.spec.ts @@ -0,0 +1,52 @@ +import type { Component } from 'svelte'; +import { describe, expect, it } from 'vitest'; + +import { type FieldEditor, fieldEditorFor } from '../../src/extension/field-editor.extension-point'; +import { buildLayout } from '../../src/layout/param-layout'; + +const component = {} as Component; + +describe('contributed field editors and presets', () => { + it('prefers an editor for the ref, then one for the type', () => { + const rect: FieldEditor = { ref: '@nanoforge-dev/graphics-2d#Rect', component }; + const anyRef: FieldEditor = { type: 'ref', component }; + const numbers: FieldEditor = { type: 'number', component }; + const editors = [anyRef, rect, numbers]; + expect( + fieldEditorFor(editors, { + type: 'ref', + name: 'shape', + ref: '@nanoforge-dev/graphics-2d#Rect', + }), + ).toBe(rect); + expect(fieldEditorFor(editors, { type: 'ref', name: 'shape', ref: '@x/y#Circle' })).toBe( + anyRef, + ); + expect(fieldEditorFor(editors, { type: 'number', name: 'x' })).toBe(numbers); + expect(fieldEditorFor(editors, { type: 'string', name: 's' })).toBeUndefined(); + }); + + it('merges params with a contributed preset', () => { + const layout = buildLayout( + [ + { type: 'number', name: 'min', layout: { preset: { id: 'range', slot: 'min' } } }, + { type: 'number', name: 'max', layout: { preset: { id: 'range', slot: 'max' } } }, + ], + [], + new Set(), + [ + { + id: 'range', + title: 'Range', + slots: ['min', 'max'], + required: ['min', 'max'], + types: ['number'], + }, + ], + ); + expect(layout.warnings).toEqual([]); + expect(layout.blocks).toMatchObject([ + { kind: 'preset', label: 'Range', slots: [{ slot: 'min' }, { slot: 'max' }] }, + ]); + }); +}); diff --git a/plugins/ecs/test/fixtures/bundle.js b/plugins/ecs/test/fixtures/bundle.js new file mode 100644 index 00000000..be435564 --- /dev/null +++ b/plugins/ecs/test/fixtures/bundle.js @@ -0,0 +1,14 @@ +// helper.ts +var make = () => registry.spawnEntity(); + +// main.ts +var main = async () => { + const terrain = registry.spawnEntity(); + for (let i = 0;i < 2; i++) + registry.spawnEntity(); + const ball = registry.spawnEntity(); + make(); +}; +export { + main +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/client/nanoforge.config.ts b/plugins/ecs/test/fixtures/e2e-game/apps/client/nanoforge.config.ts new file mode 100644 index 00000000..77607cbd --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/client/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "client", +}); diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/client/src/components/example.component.ts b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/components/example.component.ts new file mode 100644 index 00000000..25f486bd --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/components/example.component.ts @@ -0,0 +1,55 @@ +import { type EditorComponentManifest } from "@nanoforge-dev/ecs/client"; + +export class ExampleComponent { + name = this.constructor.name; + + constructor( + public paramA: string, + public paramB: number, + public paramC: boolean = false, + ) {} + + get foo() { + return "bar"; + } + + get paramAOrDefault() { + return this.paramC ? this.paramA : "default"; + } + + addOne() { + this.paramB += 1; + } +} + +// * Required to generate code +export default ExampleComponent.name; + +// * Required for the editor to display the component and generate code +export const EDITOR_COMPONENT_MANIFEST: EditorComponentManifest = { + name: "Example", + description: "Example component description", + params: [ + { + type: "string", + name: "Param A", + description: "Param A description", + example: "Example value", + }, + { + type: "number", + name: "Param B", + description: "Param B description", + example: 3, + }, + { + type: "boolean", + name: "Param C", + description: "Param C description", + example: true, + default: false, + // Not required because it has a default value + optional: true, + }, + ], +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/client/src/main.ts b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/main.ts new file mode 100644 index 00000000..989aa900 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/main.ts @@ -0,0 +1,48 @@ +import type { ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d"; +import { InputLibrary } from "@nanoforge-dev/input"; +import { MusicLibrary } from "@nanoforge-dev/music"; +import { NetworkClientLibrary } from "@nanoforge-dev/network/client"; +import { SoundLibrary } from "@nanoforge-dev/sound"; + +import { ExampleComponent } from "./components/example.component"; +import { exampleSystem } from "./systems/example.system"; + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + + const graphics = new Graphics2DLibrary(); + const ecs = new EcsLibrary(); + const input = new InputLibrary(); + const music = new MusicLibrary(); + const network = new NetworkClientLibrary(); + const sound = new SoundLibrary(); + + app.use(graphics); + app.use(ecs); + app.use(input); + app.use(music); + app.use(network); + app.use(sound); + + await app.init({ + ...options, + env: { + ...options.env, + SERVER_ADDRESS: "127.0.0.1", + SERVER_TCP_PORT: "4445", + SERVER_UDP_PORT: "4444", + }, + }); + + const registry = ecs.registry; + + const exampleEntity = registry.spawnEntity(); + registry.addComponent(exampleEntity, new ExampleComponent("example", 10)); + + registry.addSystem(exampleSystem); + + await app.run(); +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/client/src/systems/example.system.ts b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/systems/example.system.ts new file mode 100644 index 00000000..e98af998 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/client/src/systems/example.system.ts @@ -0,0 +1,31 @@ +import { type Context } from "@nanoforge-dev/common"; +import { type EditorSystemManifest, type Registry } from "@nanoforge-dev/ecs/client"; + +import { ExampleComponent } from "../components/example.component"; + +export const exampleSystem = (registry: Registry, ctx: Context) => { + const entities = registry.getZipper([ExampleComponent]); + + entities.forEach((entity) => { + if (entity.ExampleComponent.paramA === "end") { + ctx.app.requestStop(); + return; + } + + if (entity.ExampleComponent.paramB === 0) entity.ExampleComponent.paramA = "end"; + + if (entity.ExampleComponent.paramB >= 0) + entity.ExampleComponent.paramB = entity.ExampleComponent.paramB - 1; + }); +}; + +// * Required to generate code +export default exampleSystem.name; + +// * Required for the editor to display the system and generate code +export const EDITOR_SYSTEM_MANIFEST: EditorSystemManifest = { + name: "Example", + description: + "This system end the game when paramB reaches 0 for any entity with ExampleComponent", + dependencies: ["ExampleComponent"], +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/server/nanoforge.config.ts b/plugins/ecs/test/fixtures/e2e-game/apps/server/nanoforge.config.ts new file mode 100644 index 00000000..43a3fef2 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/server/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "server", +}); diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/server/src/components/example.component.ts b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/components/example.component.ts new file mode 100644 index 00000000..39d9fce0 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/components/example.component.ts @@ -0,0 +1,55 @@ +import { type EditorComponentManifest } from "@nanoforge-dev/ecs/server"; + +export class ExampleComponent { + name = this.constructor.name; + + constructor( + public paramA: string, + public paramB: number, + public paramC: boolean = false, + ) {} + + get foo() { + return "bar"; + } + + get paramAOrDefault() { + return this.paramC ? this.paramA : "default"; + } + + addOne() { + this.paramB += 1; + } +} + +// * Required to generate code +export default ExampleComponent.name; + +// * Required for the editor to display the component and generate code +export const EDITOR_COMPONENT_MANIFEST: EditorComponentManifest = { + name: "Example", + description: "Example component description", + params: [ + { + type: "string", + name: "Param A", + description: "Param A description", + example: "Example value", + }, + { + type: "number", + name: "Param B", + description: "Param B description", + example: 3, + }, + { + type: "boolean", + name: "Param C", + description: "Param C description", + example: true, + default: false, + // Not required because it has a default value + optional: true, + }, + ], +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/server/src/main.ts b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/main.ts new file mode 100644 index 00000000..0fef76aa --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/main.ts @@ -0,0 +1,30 @@ +import { type RunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/server"; + +import { ExampleComponent } from "./components/example.component"; +import { exampleSystem } from "./systems/example.system"; +import { TickerLibrary } from "./ticker.library"; + +export const main = async (options: RunOptions): Promise => { + const app = NanoforgeFactory.createServer({ tickRate: 60 }); + + const ecs = new EcsLibrary(); + const ticker = new TickerLibrary(5); + + app.use(ecs); + app.use(ticker); + + await app.init(options); + + const registry = ecs.registry; + + const exampleEntity = registry.spawnEntity(); + registry.addComponent(exampleEntity, new ExampleComponent("example", 10)); + + registry.addSystem(exampleSystem); + + await app.run(); + + await ticker.done; +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/server/src/systems/example.system.ts b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/systems/example.system.ts new file mode 100644 index 00000000..ee900db8 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/systems/example.system.ts @@ -0,0 +1,31 @@ +import { type Context } from "@nanoforge-dev/common"; +import { type EditorSystemManifest, type Registry } from "@nanoforge-dev/ecs/server"; + +import { ExampleComponent } from "../components/example.component"; + +export const exampleSystem = (registry: Registry, ctx: Context) => { + const entities = registry.getZipper([ExampleComponent]); + + entities.forEach((entity) => { + if (entity.ExampleComponent.paramA === "end") { + ctx.app.requestStop(); + return; + } + + if (entity.ExampleComponent.paramB === 0) entity.ExampleComponent.paramA = "end"; + + if (entity.ExampleComponent.paramB >= 0) + entity.ExampleComponent.paramB = entity.ExampleComponent.paramB - 1; + }); +}; + +// * Required to generate code +export default exampleSystem.name; + +// * Required for the editor to display the system and generate code +export const EDITOR_SYSTEM_MANIFEST: EditorSystemManifest = { + name: "Example", + description: + "This system end the game when paramB reaches 0 for any entity with ExampleComponent", + dependencies: ["ExampleComponent"], +}; diff --git a/plugins/ecs/test/fixtures/e2e-game/apps/server/src/ticker.library.ts b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/ticker.library.ts new file mode 100644 index 00000000..a3867475 --- /dev/null +++ b/plugins/ecs/test/fixtures/e2e-game/apps/server/src/ticker.library.ts @@ -0,0 +1,35 @@ +import { type Context, Library, defineLibraryKey } from "@nanoforge-dev/common"; + +declare module "@nanoforge-dev/common" { + interface Context { + ticker: unknown; + } +} + +/** + * Stops the game loop after a fixed number of ticks. + * Exposes `done`, which resolves once the loop has stopped. + */ +export class TickerLibrary extends Library { + readonly key = defineLibraryKey("ticker"); + + private _remaining: number; + private _resolve!: () => void; + + public readonly done: Promise = new Promise((resolve) => { + this._resolve = resolve; + }); + + constructor(ticks: number) { + super(); + this._remaining = ticks; + } + + public override async __run(ctx: Context): Promise { + this._remaining--; + if (this._remaining <= 0) { + ctx.app.requestStop(); + this._resolve(); + } + } +} diff --git a/plugins/ecs/test/fixtures/pong-game/apps/client/src/components/components.ts b/plugins/ecs/test/fixtures/pong-game/apps/client/src/components/components.ts new file mode 100644 index 00000000..7a867570 --- /dev/null +++ b/plugins/ecs/test/fixtures/pong-game/apps/client/src/components/components.ts @@ -0,0 +1,23 @@ +import type { Circle, Rect } from "@nanoforge-dev/graphics-2d"; + +import { layer } from "../main"; + +export class CircleComponent { + name = "CircleComponent"; + component: Circle; + + constructor(component: Circle) { + this.component = component; + layer.add(this.component); + } +} + +export class RectangleComponent { + name = "RectangleComponent"; + component: Rect; + + constructor(component: Rect) { + this.component = component; + layer.add(this.component); + } +} diff --git a/plugins/ecs/test/fixtures/pong-game/apps/client/src/main.ts b/plugins/ecs/test/fixtures/pong-game/apps/client/src/main.ts new file mode 100644 index 00000000..d0e9124a --- /dev/null +++ b/plugins/ecs/test/fixtures/pong-game/apps/client/src/main.ts @@ -0,0 +1,51 @@ +import type { ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d"; +import { Position } from "@pong-game/shared/components/position"; +import { Velocity } from "@pong-game/shared/components/velocity"; + +import { CircleComponent, RectangleComponent } from "./components/components"; + +export const layer = new Layer(); + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ + tickRate: 60, + viewport: { width: 1920, height: 1080, fit: "contain" }, + }); + const graphics = new Graphics2DLibrary(); + const ecs = new EcsLibrary(); + + app.use(graphics); + app.use(ecs); + + await app.init(options); + + const registry = ecs.registry; + + graphics.stage.add(layer); + + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Velocity(4, 4)); + registry.addComponent(ball, new Position(960, 540)); + registry.addComponent(ball, new CircleComponent(new Circle({ radius: 30, fill: "red" }))); + + const paddle1 = registry.spawnEntity(); + registry.addComponent(paddle1, new Position(40, 390)); + registry.addComponent(paddle1, new Velocity(0, 0)); + registry.addComponent( + paddle1, + new RectangleComponent(new Rect({ fill: "blue", width: 30, height: 300 })), + ); + + const paddle2 = registry.spawnEntity(); + registry.addComponent(paddle2, new Position(1850, 390)); + registry.addComponent(paddle2, new Velocity(0, 0)); + registry.addComponent( + paddle2, + new RectangleComponent(new Rect({ fill: "blue", width: 30, height: 300 })), + ); + + await app.run(); +}; diff --git a/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/position.ts b/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/position.ts new file mode 100644 index 00000000..a909baa2 --- /dev/null +++ b/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/position.ts @@ -0,0 +1,16 @@ +/** + * Where an entity is, in game units. + * + * @component + * @side shared + */ +export class Position { + name = "Position"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} diff --git a/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/velocity.ts b/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/velocity.ts new file mode 100644 index 00000000..e80b5afd --- /dev/null +++ b/plugins/ecs/test/fixtures/pong-game/libs/shared/src/components/velocity.ts @@ -0,0 +1,16 @@ +/** + * How fast an entity moves, in game units per tick. + * + * @component + * @side shared + */ +export class Velocity { + name = "Velocity"; + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} diff --git a/plugins/ecs/test/fixtures/pong-game/tsconfig.json b/plugins/ecs/test/fixtures/pong-game/tsconfig.json new file mode 100644 index 00000000..69f05e58 --- /dev/null +++ b/plugins/ecs/test/fixtures/pong-game/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "baseUrl": ".", + "paths": { + "@pong-game/shared/*": ["libs/shared/src/*"] + } + }, + "include": ["apps/*/src/**/*.ts", "libs/*/src/**/*.ts"] +} diff --git a/plugins/ecs/test/fixtures/run-bundle.mjs b/plugins/ecs/test/fixtures/run-bundle.mjs new file mode 100644 index 00000000..621fbfb0 --- /dev/null +++ b/plugins/ecs/test/fixtures/run-bundle.mjs @@ -0,0 +1,14 @@ +// Runs a bundle with a registry that records the caller frame of each spawnEntity() (like the +// engine's live mode), and prints the sites as JSON. +const sites = []; +globalThis.registry = { + spawnEntity() { + const frame = new Error().stack.split('\n')[2]; + const match = /:(\d+):(\d+)\)?\s*$/.exec(frame.trim()); + sites.push({ line: Number(match[1]), column: Number(match[2]) }); + }, + addComponent() {}, +}; +const { main } = await import(process.argv[2]); +await main(); +console.log(JSON.stringify(sites)); diff --git a/plugins/ecs/test/layout/param-layout.spec.ts b/plugins/ecs/test/layout/param-layout.spec.ts new file mode 100644 index 00000000..c86a8d1d --- /dev/null +++ b/plugins/ecs/test/layout/param-layout.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import type { Element } from '@nanoforge-dev/editor-sdk'; + +import { buildLayout } from '../../src/layout/param-layout'; + +const number = (name: string, layout?: Element['layout']): Element => ({ + type: 'number', + name, + ...(layout && { layout }), +}); + +describe('buildLayout', () => { + const params: Element[] = [ + number('x', { group: 'Coords', preset: { id: 'vector', slot: 'x' } }), + number('y', { group: 'Coords', preset: { id: 'vector', slot: 'y' } }), + number('z', { label: 'Layer', color: 'orange' }), + { type: 'boolean', name: 'trail', layout: { group: 'Debug', hidden: true } }, + { type: 'string', name: 'tint', layout: { preset: { id: 'color' } } }, + ]; + const groups = [ + { name: 'Coords', color: '#4aa3ff', description: 'Where.' }, + { name: 'Debug', hidden: true }, + ]; + + it('merges a group preset into one row named after the group', () => { + const layout = buildLayout(params, groups); + expect(layout.warnings).toEqual([]); + expect(layout.blocks.map((block) => block.kind)).toEqual(['group', 'param', 'group', 'param']); + const coords = layout.blocks[0]!; + expect(coords).toMatchObject({ kind: 'group', group: { name: 'Coords', color: '#4aa3ff' } }); + expect(coords.kind === 'group' && coords.rows).toMatchObject([ + { + kind: 'preset', + label: 'Coords', + slots: [ + { slot: 'x', index: 0 }, + { slot: 'y', index: 1 }, + ], + }, + ]); + expect(layout.blocks[1]).toMatchObject({ kind: 'param', label: 'Layer', color: 'orange' }); + expect(layout.blocks[3]).toMatchObject({ kind: 'param', preset: 'color' }); + }); + + it('lists hidden groups and params, and shows the ones chosen', () => { + expect(buildLayout(params, groups).hidden).toEqual([{ id: 'group:Debug', label: 'Debug' }]); + const shown = buildLayout(params, groups, new Set(['group:Debug'])); + expect(shown.hidden).toEqual([{ id: 'param:trail', label: 'trail' }]); + expect(buildLayout(params, groups, new Set(['group:Debug', 'param:trail'])).hidden).toEqual([]); + }); + + it('falls back to plain fields when a preset is incomplete', () => { + const layout = buildLayout([number('x', { preset: { id: 'vector', slot: 'x' } })], []); + expect(layout.blocks).toMatchObject([{ kind: 'param', element: { name: 'x' } }]); + expect(layout.warnings).toEqual(['The preset "vector" needs the slots x, y.']); + }); +}); diff --git a/plugins/ecs/test/scene/build-scene.spec.ts b/plugins/ecs/test/scene/build-scene.spec.ts new file mode 100644 index 00000000..2b748ff8 --- /dev/null +++ b/plugins/ecs/test/scene/build-scene.spec.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { CODE_FILE, CodeEngine } from '@nanoforge-dev/editor-code/engine'; + +import type { EntryModel } from '../../src/model/ecs-model.type'; +import { buildScene, withLivePositions } from '../../src/scene/build-scene'; +import { analyzeEntry } from '../../src/worker/entry-file'; +import { ecsOwner } from '../../src/worker/owner'; + +const FIXTURE = join( + import.meta.dirname, + '../../../../packages/server-core/test/fixtures/pong-network', +); +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (name === 'node_modules') return []; + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +describe('2D scene', () => { + it('places an entity by its position, not by the first component with x and y', async () => { + const root = join(import.meta.dirname, '../fixtures/pong-game'); + const engine = new CodeEngine(); + engine.setFiles( + walk(root).map((file) => ({ path: relative(root, file), text: readFileSync(file, 'utf8') })), + ); + engine.setTsconfig(readFileSync(join(root, 'tsconfig.json'), 'utf8')); + engine.registerItemOwner(ecsOwner, '@nanoforge/ecs'); + engine.registerAnalyzer('entry', analyzeEntry); + const roots = [ + { path: 'apps/client', ref: 'app:client' }, + { path: 'libs/shared', ref: '@pong-game/shared' }, + ]; + const model = engine.analyze('apps/client/src/main.ts', 'entry', { roots }) as EntryModel; + const app = await engine.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [{ path: 'apps/client/src/components/components.ts', folder: 'components' }], + roots, + }); + const library = await engine.extractMeta({ + source: { kind: 'lib', name: '@pong-game/shared', path: 'libs/shared' }, + files: ['position', 'velocity'].map((name) => ({ + path: `libs/shared/src/components/${name}.ts`, + folder: 'components', + })), + roots, + }); + const items = new Map( + [ + ...app.items.map((item) => [`app:client#${item.export}`, item] as const), + ...library.items.map((item) => [`@pong-game/shared#${item.export}`, item] as const), + ].map(([ref, meta]) => [ref, { ref, meta }]), + ); + const shapes = buildScene( + model, + (ref) => items.get(ref ?? '') as never, + (_item, name) => name ?? '', + ); + const ball = shapes.find((shape) => shape.entity === 'ball')!; + expect([ball.x, ball.y]).toEqual([960, 540]); + expect(ball.position).toMatchObject({ component: 1, componentName: 'Position' }); + expect(shapes.find((shape) => shape.entity === 'paddle2')).toMatchObject({ x: 1850, y: 390 }); + }); + + it('draws pong from main.ts: rectangles and the ball at their positions', async () => { + const engine = new CodeEngine(); + engine.setFiles( + walk(FIXTURE).map((file) => ({ + path: relative(FIXTURE, file), + text: readFileSync(file, 'utf8'), + })), + ); + engine.registerItemOwner(ecsOwner, '@nanoforge/ecs'); + engine.registerAnalyzer('entry', analyzeEntry); + const roots = [{ path: 'apps/client', ref: 'app:client' }]; + const model = engine.analyze('apps/client/src/main.ts', 'entry', { roots }) as EntryModel; + const meta = await engine.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [{ path: 'apps/client/src/components/components.ts', folder: 'components' }], + roots, + }); + const items = new Map( + meta.items.map((item) => [ + `app:client#${item.export}`, + { ref: `app:client#${item.export}`, meta: item }, + ]), + ); + const shapes = buildScene( + model, + (ref) => items.get(ref ?? '') as never, + (_item, name) => name ?? '', + ); + expect( + shapes.map((shape) => [shape.entity, shape.kind, shape.x, shape.width, shape.fill]), + ).toEqual([ + ['terrain', 'rect', 0, 1920, 'rgb(58,99,39)'], + ['terrainLine', 'rect', 955, 10, 'rgb(148,204,117)'], + ['ball', 'circle', 0, 50, 'red'], + ['paddle1', 'rect', 0, 30, 'blue'], + ['paddle2', 'rect', 0, 30, 'blue'], + ]); + expect(shapes[1]!.position).toEqual({ + component: 0, + componentName: 'Position', + xParam: 0, + yParam: 1, + }); + const live = withLivePositions(shapes, [ + { + id: 1, + code: 'terrainLine', + runtime: false, + components: [{ name: 'Position', value: { x: 900, y: 5 } }], + }, + ]); + expect([live[1]!.x, live[1]!.y]).toEqual([900, 5]); + }); +}); diff --git a/plugins/ecs/test/worker/__snapshots__/entry-file.spec.ts.snap b/plugins/ecs/test/worker/__snapshots__/entry-file.spec.ts.snap new file mode 100644 index 00000000..6240123f --- /dev/null +++ b/plugins/ecs/test/worker/__snapshots__/entry-file.spec.ts.snap @@ -0,0 +1,146 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`entry file analyzer (pong-network client) > reads entities, components, systems 1`] = ` +[ + { + "components": [ + { + "args": [ + 0, + 0, + ], + "class": "Position", + "item": "app:client#Position", + }, + { + "args": [ + "new Rect({ fill: "rgb(58,99,39)", width: 1920, height: 1080 })", + ], + "class": "RectangleComponent", + "item": "app:client#RectangleComponent", + }, + ], + "name": "terrain", + }, + { + "components": [ + { + "args": [ + 955, + 0, + ], + "class": "Position", + "item": "app:client#Position", + }, + { + "args": [ + "new Rect({ fill: "rgb(148,204,117)", width: 10, height: 1080 })", + ], + "class": "RectangleComponent", + "item": "app:client#RectangleComponent", + }, + ], + "name": "terrainLine", + }, + { + "components": [ + { + "args": [ + 0, + 0, + ], + "class": "Velocity", + "item": "app:client#Velocity", + }, + { + "args": [ + 0, + 0, + ], + "class": "Position", + "item": "app:client#Position", + }, + { + "args": [ + "new Circle({ + radius: 30, + fill: "red", + })", + ], + "class": "CircleComponent", + "item": "app:client#CircleComponent", + }, + ], + "name": "ball", + }, + { + "components": [ + { + "args": [ + "InputEnum.ArrowUp", + "InputEnum.ArrowDown", + ], + "class": "Controller", + "item": "app:client#Controller", + }, + ], + "name": "me", + }, + { + "components": [ + { + "args": [ + 0, + 0, + ], + "class": "Position", + "item": "app:client#Position", + }, + { + "args": [ + 0, + 0, + ], + "class": "Velocity", + "item": "app:client#Velocity", + }, + { + "args": [ + "new Rect({ fill: "blue", width: 30, height: 300 })", + ], + "class": "RectangleComponent", + "item": "app:client#RectangleComponent", + }, + ], + "name": "paddle1", + }, + { + "components": [ + { + "args": [ + 0, + 0, + ], + "class": "Position", + "item": "app:client#Position", + }, + { + "args": [ + 0, + 0, + ], + "class": "Velocity", + "item": "app:client#Velocity", + }, + { + "args": [ + "new Rect({ fill: "blue", width: 30, height: 300 })", + ], + "class": "RectangleComponent", + "item": "app:client#RectangleComponent", + }, + ], + "name": "paddle2", + }, +] +`; diff --git a/plugins/ecs/test/worker/bundle-spawns.spec.ts b/plugins/ecs/test/worker/bundle-spawns.spec.ts new file mode 100644 index 00000000..1e8501b2 --- /dev/null +++ b/plugins/ecs/test/worker/bundle-spawns.spec.ts @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { analyzeBundleSpawns } from '../../src/worker/bundle-spawns'; + +const BUNDLE = join(import.meta.dirname, '../fixtures/bundle.js'); +const RUN = join(import.meta.dirname, '../fixtures/run-bundle.mjs'); + +const sitesWith = (runtime: string) => { + const result = spawnSync(runtime, [RUN, BUNDLE], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr); + return JSON.parse(result.stdout) as { line: number; column: number }[]; +}; + +describe('bundle spawns (live entity → code)', () => { + for (const runtime of ['node', 'bun']) { + it(`maps the call sites recorded by ${runtime}`, () => { + const sites = sitesWith(runtime); + expect(sites).toHaveLength(5); + const indexes = analyzeBundleSpawns({} as never, { + bundle: readFileSync(BUNDLE, 'utf8'), + sites, + }); + expect(indexes).toEqual([0, 1, 1, 2, -1]); + }); + } +}); + +describe('bundle spawns of a scene setup', () => { + const bundle = [ + 'class Level1 extends EcsScene {', + ' setup(registry) {', + ' const wall = registry.spawnEntity();', + ' const ball = registry.spawnEntity();', + ' }', + '}', + 'const main = async () => {', + ' const terrain = registry.spawnEntity();', + '};', + ].join('\n'); + + it('counts the spawns of the method the site is in', () => { + expect( + analyzeBundleSpawns({} as never, { + bundle, + sites: [ + { line: 4, column: 27, enclosing: true }, + { line: 3, column: 27, enclosing: true }, + { line: 8, column: 27 }, + { line: 8, column: 27, enclosing: true }, + ], + }), + ).toEqual([1, 0, 0, 0]); + }); +}); diff --git a/plugins/ecs/test/worker/e2e-game.spec.ts b/plugins/ecs/test/worker/e2e-game.spec.ts new file mode 100644 index 00000000..da81a21b --- /dev/null +++ b/plugins/ecs/test/worker/e2e-game.spec.ts @@ -0,0 +1,103 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { CODE_FILE, CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import type { EntryModel } from '../../src/model/ecs-model.type'; +import { ECS_KEY } from '../../src/model/ecs.const'; +import { analyzeEntry } from '../../src/worker/entry-file'; +import { ecsOwner } from '../../src/worker/owner'; +import { transformEntry } from '../../src/worker/transforms'; + +/** A copy of engine/e2e/game (the engine's end-to-end test game). */ +const FIXTURE = join(import.meta.dirname, '../fixtures/e2e-game'); +const MAIN = 'apps/client/src/main.ts'; +const ROOTS = [{ path: 'apps/client', ref: 'app:client' }]; + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +const setup = () => { + const engine = new CodeEngine(); + engine.setFiles( + walk(FIXTURE).map((file) => ({ + path: relative(FIXTURE, file), + text: readFileSync(file, 'utf8'), + })), + ); + engine.registerItemOwner(ecsOwner, '@nanoforge/ecs'); + engine.registerAnalyzer('entry', analyzeEntry); + engine.registerTransformer('entry', transformEntry); + return engine; +}; + +describe('engine e2e game', () => { + it('reads its entity, component and system', () => { + const model = setup().analyze(MAIN, 'entry', { roots: ROOTS }) as EntryModel; + expect(model.entities).toMatchObject([ + { + name: 'exampleEntity', + components: [ + { + className: 'ExampleComponent', + item: 'app:client#ExampleComponent', + args: [{ value: 'example' }, { value: 10 }], + }, + ], + }, + ]); + expect(model.systems).toMatchObject([ + { name: 'exampleSystem', item: 'app:client#exampleSystem' }, + ]); + }); + + it('infers the component (name = this.constructor.name) and the system', async () => { + const meta = await setup().extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [ + { path: 'apps/client/src/components/example.component.ts', folder: 'components' }, + { path: 'apps/client/src/systems/example.system.ts', folder: 'systems' }, + ], + roots: ROOTS, + }); + const byName = Object.fromEntries(meta.items.map((item) => [item.export, item])); + expect(byName.ExampleComponent).toMatchObject({ + params: [ + { name: 'paramA', type: 'string' }, + { name: 'paramB', type: 'number' }, + { name: 'paramC', type: 'boolean', default: false, optional: true }, + ], + [ECS_KEY]: { type: 'component', name: 'ExampleComponent' }, + }); + expect(byName.exampleSystem).toMatchObject({ + [ECS_KEY]: { type: 'system', query: [['app:client#ExampleComponent']], uses: ['app'] }, + }); + }); + + it('round-trips an edit: only the argument changes', () => { + const engine = setup(); + const before = engine.text(MAIN)!; + const { text } = applyTextEdits( + before, + engine.transform(MAIN, 'entry', { + kind: 'setArgs', + entity: 'exampleEntity', + index: 0, + args: { 2: { value: true } }, + fill: [{ value: '' }, { value: 0 }, { value: false }], + }), + ); + expect(text).toBe( + before.replace( + 'new ExampleComponent("example", 10)', + 'new ExampleComponent("example", 10, true)', + ), + ); + }); +}); diff --git a/plugins/ecs/test/worker/entry-file.spec.ts b/plugins/ecs/test/worker/entry-file.spec.ts new file mode 100644 index 00000000..9c5a4309 --- /dev/null +++ b/plugins/ecs/test/worker/entry-file.spec.ts @@ -0,0 +1,370 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { CODE_FILE, CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import type { EntryModel, EntryOp } from '../../src/model/ecs-model.type'; +import { ECS_KEY } from '../../src/model/ecs.const'; +import { analyzeEntry } from '../../src/worker/entry-file'; +import { ecsOwner } from '../../src/worker/owner'; +import { transformEntry } from '../../src/worker/transforms'; + +const FIXTURE = join( + import.meta.dirname, + '../../../../packages/server-core/test/fixtures/pong-network', +); +const MAIN = 'apps/client/src/main.ts'; +const ROOTS = [ + { path: 'apps/client', ref: 'app:client' }, + { path: 'apps/server', ref: 'app:server' }, +]; + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (name === 'node_modules') return []; + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +const setup = () => { + const engine = new CodeEngine(); + engine.setFiles( + walk(FIXTURE).map((file) => ({ + path: relative(FIXTURE, file).split('\\').join('/'), + text: readFileSync(file, 'utf8'), + })), + ); + engine.registerItemOwner(ecsOwner, '@nanoforge/ecs'); + engine.registerAnalyzer('ecs.entry-file', analyzeEntry); + engine.registerTransformer('ecs.entry-file', transformEntry); + return engine; +}; + +const analyze = (engine: CodeEngine, path = MAIN) => + engine.analyze(path, 'ecs.entry-file', { roots: ROOTS }) as EntryModel; + +/** Applies an op and returns the new text (and puts it in the engine). */ +const apply = (engine: CodeEngine, op: EntryOp, path = MAIN) => { + const before = engine.text(path)!; + const { text } = applyTextEdits(before, engine.transform(path, 'ecs.entry-file', op)); + engine.setFiles([{ path, text }]); + return { before, text }; +}; + +/** Lines added and removed by a change (a readable golden diff). */ +const diff = (before: string, after: string) => { + const a = before.split('\n'); + const b = after.split('\n'); + return { + removed: a.filter((line) => !b.includes(line)), + added: b.filter((line) => !a.includes(line)), + }; +}; + +describe('entry file analyzer (pong-network client)', () => { + it('reads entities, components, systems', () => { + const model = analyze(setup()); + expect(model.found).toBe(true); + expect(model.registry).toBe('registry'); + expect(model.codeOnly).toEqual([]); + expect( + model.entities.map((entity) => ({ + name: entity.name, + components: entity.components.map((component) => ({ + class: component.className, + item: component.item, + args: component.args.map((arg) => arg.value ?? arg.code), + })), + })), + ).toMatchSnapshot(); + expect(model.systems.map((system) => [system.name, system.item])).toEqual([ + ['packetHandler', 'app:client#packetHandler'], + ['move', 'app:client#move'], + ['controlPlayer', 'app:client#controlPlayer'], + ['draw', 'app:client#draw'], + ]); + }); + + it('marks spawns it cannot model as code-only', () => { + const engine = setup(); + engine.setFiles([ + { + path: 'apps/client/src/extra.ts', + text: [ + 'declare const registry: { spawnEntity(): unknown; addComponent(e: unknown, c: unknown): void };', + 'export const main = () => {', + ' const hero = registry.spawnEntity();', + ' for (let i = 0; i < 3; i++) registry.spawnEntity();', + '};', + ].join('\n'), + }, + ]); + const model = analyze(engine, 'apps/client/src/extra.ts'); + expect(model.entities.map((entity) => entity.name)).toEqual(['hero']); + expect(model.codeOnly).toMatchObject([ + { line: 4, code: 'for (let i = 0; i < 3; i++) registry.spawnEntity();' }, + ]); + }); +}); + +describe('ecs item owner', () => { + it('infers pong components and systems, with queries and ctx uses', async () => { + const engine = setup(); + const result = await engine.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [ + { path: 'apps/client/src/components/components.ts', folder: 'components' }, + { path: 'apps/client/src/systems/systems.ts', folder: 'systems' }, + ], + roots: ROOTS, + }); + const byName = Object.fromEntries(result.items.map((item) => [item.export, item])); + expect(byName.Position).toMatchObject({ + params: [ + { name: 'x', type: 'number' }, + { name: 'y', type: 'number' }, + ], + fields: [], + [ECS_KEY]: { type: 'component', name: 'Position', inferred: true }, + }); + expect(byName.move).toMatchObject({ + params: [], + [ECS_KEY]: { + type: 'system', + query: [['app:client#Position', 'app:client#Velocity']], + uses: ['app'], + inferred: true, + }, + }); + }); +}); + +describe('entry file transformer', () => { + it('adds, renames, duplicates and removes an entity', () => { + const engine = setup(); + let step = apply(engine, { kind: 'addEntity', name: 'score' }); + expect(diff(step.before, step.text)).toEqual({ + removed: [], + added: [' const score = registry.spawnEntity();'], + }); + expect(step.text.indexOf('const score')).toBeGreaterThan(step.text.indexOf('paddle2,\n')); + + step = apply(engine, { kind: 'renameEntity', entity: 'me', name: 'player' }); + expect(step.text).toContain('const player = registry.spawnEntity();'); + expect(step.text).toContain('registry.addComponent(player, new Controller('); + + step = apply(engine, { kind: 'duplicateEntity', entity: 'player' }); + expect(diff(step.before, step.text).added).toEqual([ + ' const playerCopy = registry.spawnEntity();', + ' registry.addComponent(playerCopy, new Controller(InputEnum.ArrowUp, InputEnum.ArrowDown));', + ]); + + step = apply(engine, { kind: 'removeEntity', entity: 'playerCopy' }); + expect(diff(step.before, step.text)).toEqual({ + removed: [ + ' const playerCopy = registry.spawnEntity();', + ' registry.addComponent(playerCopy, new Controller(InputEnum.ArrowUp, InputEnum.ArrowDown));', + ], + added: [], + }); + }); + + it('adds a component with its import, edits and removes it', () => { + const engine = setup(); + let step = apply(engine, { + kind: 'addComponent', + entity: 'ball', + className: 'Player', + args: [{ value: 'one' }, { value: 3 }], + imports: [{ name: 'Player', from: 'libs/shared/src/components/player.ts' }], + }); + expect(diff(step.before, step.text).added).toEqual([ + 'import { Player } from "../../../libs/shared/src/components/player";', + ' registry.addComponent(ball, new Player("one", 3));', + ]); + + const model = analyze(engine); + const ball = model.entities.find((entity) => entity.name === 'ball')!; + const index = ball.components.findIndex((component) => component.className === 'Player'); + step = apply(engine, { kind: 'setArgs', entity: 'ball', index, args: { 1: { value: 5 } } }); + expect(diff(step.before, step.text)).toEqual({ + removed: [' registry.addComponent(ball, new Player("one", 3));'], + added: [' registry.addComponent(ball, new Player("one", 5));'], + }); + step = apply(engine, { + kind: 'setArgs', + entity: 'ball', + index, + args: { 3: { code: 'InputEnum.ArrowUp' } }, + fill: [{ value: '' }, { value: 0 }, { value: false }], + }); + expect(step.text).toContain('new Player("one", 5, false, InputEnum.ArrowUp)'); + + step = apply(engine, { kind: 'removeComponent', entity: 'ball', index }); + expect(diff(step.before, step.text).removed).toEqual([ + ' registry.addComponent(ball, new Player("one", 5, false, InputEnum.ArrowUp));', + ]); + }); + + it('merges an import into an existing one and keeps the rest byte-identical', () => { + const engine = setup(); + const { before, text } = apply(engine, { + kind: 'addComponent', + entity: 'terrain', + className: 'NetworkId', + args: [{ value: 1 }], + imports: [{ name: 'NetworkId', from: 'apps/client/src/components/components.ts' }], + }); + expect(text).toContain(' Velocity, NetworkId,\n} from "./components/components";'); + const untouched = before.slice( + before.indexOf('export const layer'), + before.indexOf(' const terrainLine'), + ); + expect(text).toContain( + untouched.replace('new Position(0, 0));\n', 'new Position(0, 0));\n').slice(0, 200), + ); + expect(text.slice(text.indexOf(' const terrainLine'))).toBe( + before.slice(before.indexOf(' const terrainLine')), + ); + }); + + it('reorders entities, components and systems', () => { + const engine = setup(); + let step = apply(engine, { kind: 'moveEntity', entity: 'me', before: 'ball' }); + let model = analyze(engine); + expect(model.entities.map((entity) => entity.name)).toEqual([ + 'terrain', + 'terrainLine', + 'me', + 'ball', + 'paddle1', + 'paddle2', + ]); + expect(step.text.length).toBe(step.before.length); + + step = apply(engine, { kind: 'moveComponent', entity: 'ball', from: 0, to: 1 }); + model = analyze(engine); + expect( + model.entities.find((entity) => entity.name === 'ball')!.components.map((c) => c.className), + ).toEqual(['Position', 'Velocity', 'CircleComponent']); + + apply(engine, { kind: 'moveSystem', from: 3, to: 0 }); + apply(engine, { kind: 'removeSystem', index: 1 }); + apply(engine, { + kind: 'addSystem', + name: 'score', + imports: [{ name: 'score', from: 'apps/client/src/systems/score.ts' }], + }); + model = analyze(engine); + expect(model.systems.map((system) => system.name)).toEqual([ + 'draw', + 'move', + 'controlPlayer', + 'score', + ]); + expect(engine.text(MAIN)).toContain('import { score } from "./systems/score";'); + }); + + it('refuses to remove an entity used elsewhere', () => { + const engine = setup(); + engine.setFiles([ + { + path: MAIN, + text: engine + .text(MAIN)! + .replace('await app.run();', 'console.log(ball);\n await app.run();'), + }, + ]); + expect(() => + engine.transform(MAIN, 'ecs.entry-file', { kind: 'removeEntity', entity: 'ball' }), + ).toThrow(/used elsewhere/); + }); +}); + +describe('entry file on a scene setup', () => { + const SCENE = 'apps/client/src/scenes/level.ts'; + const SCOPE = { kind: 'method', class: 'Level', method: 'setup' } as const; + const sceneEngine = (setupBody: string) => { + const engine = setup(); + engine.setFiles([ + { + path: SCENE, + text: [ + "import type { Context } from '@nanoforge-dev/common';", + "import type { Registry } from '@nanoforge-dev/ecs/client';", + "import { EcsScene } from '@nanoforge-dev/ecs/scene';", + '', + "import { Position } from '../components/components';", + '', + 'export class Level extends EcsScene {', + ` override setup(registry: Registry, ctx: Context) {${setupBody}}`, + '}', + '', + ].join('\n'), + }, + ]); + return engine; + }; + + it('reads the spawns and systems of the method', () => { + const engine = sceneEngine( + '\n const wall = registry.spawnEntity();\n registry.addComponent(wall, new Position(1, 2));\n registry.addSystem(move);\n ', + ); + const model = engine.analyze(SCENE, 'ecs.entry-file', { + roots: ROOTS, + scope: SCOPE, + }) as EntryModel; + expect(model.found).toBe(true); + expect(model.registry).toBe('registry'); + expect(model.entities.map((entity) => entity.name)).toEqual(['wall']); + expect(model.entities[0]!.components[0]!.className).toBe('Position'); + expect(model.systems.map((system) => system.code)).toEqual(['move']); + }); + + it('says so when the class has no such method', () => { + const model = sceneEngine('').analyze(SCENE, 'ecs.entry-file', { + roots: ROOTS, + scope: { kind: 'method', class: 'Level', method: 'teardown' }, + }) as EntryModel; + expect(model.found).toBe(false); + expect(model.problems).toEqual(['No `Level.teardown` method in this file.']); + }); + + it('writes into an empty setup, the registry being its first parameter', () => { + const engine = sceneEngine(''); + expect( + (engine.analyze(SCENE, 'ecs.entry-file', { roots: ROOTS, scope: SCOPE }) as EntryModel) + .registry, + ).toBe('registry'); + const added = apply( + engine, + { kind: 'addEntity', name: 'wall', scope: SCOPE } as EntryOp, + SCENE, + ); + expect(diff(added.before, added.text)).toEqual({ + removed: [' override setup(registry: Registry, ctx: Context) {}'], + added: [ + ' override setup(registry: Registry, ctx: Context) {', + ' const wall = registry.spawnEntity();', + ' }', + ], + }); + const system = apply( + engine, + { + kind: 'addSystem', + name: 'move', + imports: [{ name: 'move', from: 'apps/client/src/systems/systems.ts' }], + scope: SCOPE, + } as EntryOp, + SCENE, + ); + expect(diff(system.before, system.text).added).toEqual([ + "import { move } from '../systems/systems';", + ' registry.addSystem(move);', + ]); + }); +}); diff --git a/plugins/ecs/test/worker/item-docs.spec.ts b/plugins/ecs/test/worker/item-docs.spec.ts new file mode 100644 index 00000000..ef7190a6 --- /dev/null +++ b/plugins/ecs/test/worker/item-docs.spec.ts @@ -0,0 +1,135 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { describe, expect, it } from 'vitest'; + +import { CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import type { ItemDocsOp } from '../../src/model/item-docs.type'; +import { transformItemDocs } from '../../src/worker/item-docs'; +import { ecsOwner } from '../../src/worker/owner'; + +const PATH = 'apps/client/src/components/position.ts'; +const SOURCE = [ + '/**', + ' * Where an entity is.', + ' *', + ' * @component', + ' * @side client', + ' */', + 'export class Position {', + ' name = "Position";', + '', + ' /**', + ' * @param x - Distance from the left edge.', + ' */', + ' constructor(', + ' public x = 0,', + ' /** Distance from the top. @deprecated use z */', + ' public y = 0,', + ' ) {}', + '}', + '', +].join('\n'); + +const setup = () => { + const engine = new CodeEngine(); + engine.registerTransformer('ecs.item-docs', transformItemDocs); + engine.registerItemOwner(ecsOwner, '@nanoforge/ecs'); + engine.setFiles([{ path: PATH, text: SOURCE }]); + return engine; +}; + +const apply = (engine: CodeEngine, op: ItemDocsOp) => { + const { text } = applyTextEdits(engine.text(PATH)!, engine.transform(PATH, 'ecs.item-docs', op)); + engine.setFiles([{ path: PATH, text }]); + return text; +}; + +describe('item docs transformer (Component panel)', () => { + it('writes groups in the header and layout tags on params, keeping other tags', async () => { + const engine = setup(); + const text = apply(engine, { + export: 'Position', + groups: [{ name: 'Coords', color: '#4aa3ff', description: 'Position in pixels.' }], + params: { + x: { group: 'Coords', preset: 'vector.x', description: 'Distance from the left.' }, + y: { group: 'Coords', preset: 'vector.y', label: 'Top', hidden: true }, + }, + }); + expect(text).toContain( + ' * @component\n * @side client\n * @group Coords color=#4aa3ff - Position in pixels.\n */', + ); + expect(text).toContain( + ' /** Distance from the left. @group Coords @preset vector.x */\n public x = 0,', + ); + expect(text).toContain( + ' /** Distance from the top. @group Coords @preset vector.y @label Top @hidden @deprecated use z */', + ); + expect(text).not.toContain('@param x'); + expect(text).toContain(' constructor('); + + const meta = await engine.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [{ path: PATH, folder: 'components' }], + roots: [{ path: 'apps/client', ref: 'app:client' }], + }); + expect(meta.items[0]).toMatchObject({ + groups: [{ name: 'Coords', color: '#4aa3ff', description: 'Position in pixels.' }], + params: [ + { + name: 'x', + description: 'Distance from the left.', + layout: { group: 'Coords', preset: { id: 'vector', slot: 'x' } }, + }, + { name: 'y', layout: { group: 'Coords', label: 'Top', hidden: true }, deprecated: 'use z' }, + ], + }); + }); + + it('removes layout tags and the description of a param', () => { + const engine = setup(); + apply(engine, { export: 'Position', params: { y: { group: 'Debug' } } }); + const text = apply(engine, { export: 'Position', params: { y: { description: '' } } }); + expect(text).toContain(' /** @deprecated use z */\n public y = 0,'); + }); + + it('updates the description and keeps the example', () => { + const engine = setup(); + const text = apply(engine, { export: 'Position', description: 'Where it is,\nin pixels.' }); + expect( + text.startsWith( + '/**\n * Where it is,\n * in pixels.\n *\n * @component\n * @side client\n */', + ), + ).toBe(true); + }); +}); + +describe('item docs on one-line constructors', () => { + it('replaces the inline doc of a first parameter instead of adding one', () => { + const engine = new CodeEngine(); + engine.registerTransformer('ecs.item-docs', transformItemDocs); + const path = 'apps/client/src/components/components.ts'; + engine.setFiles([ + { + path, + text: 'export class Position {\n name = "Position";\n constructor(x: number, y: number) {}\n}\n', + }, + ]); + const run = (op: ItemDocsOp) => { + const { text } = applyTextEdits( + engine.text(path)!, + engine.transform(path, 'ecs.item-docs', op), + ); + engine.setFiles([{ path, text }]); + return text; + }; + run({ export: 'Position', params: { x: { group: 'Coords' } } }); + const text = run({ + export: 'Position', + params: { x: { group: 'Coords', preset: 'vector.x' } }, + }); + expect(text).toContain( + 'constructor(/** @group Coords @preset vector.x */ x: number, y: number)', + ); + }); +}); diff --git a/plugins/ecs/test/worker/refactor.spec.ts b/plugins/ecs/test/worker/refactor.spec.ts new file mode 100644 index 00000000..ae1079ce --- /dev/null +++ b/plugins/ecs/test/worker/refactor.spec.ts @@ -0,0 +1,65 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { describe, expect, it } from 'vitest'; + +import { CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import { + analyzeImporters, + analyzeRelocatedText, + transformRewriteImports, +} from '../../src/worker/refactor'; + +const setup = () => { + const engine = new CodeEngine(); + engine.registerAnalyzer('importers', analyzeImporters); + engine.registerAnalyzer('relocated', analyzeRelocatedText); + engine.registerTransformer('rewrite', transformRewriteImports); + engine.setTsconfig( + '{ "compilerOptions": { "paths": { "@pong/shared/*": ["./libs/shared/src/*"] } } }', + ); + engine.setFiles([ + { + path: 'apps/client/src/components/health.ts', + text: 'import { max } from "../config";\nexport class Health { name = "Health"; constructor(public points = max) {} }\n', + }, + { path: 'apps/client/src/config.ts', text: 'export const max = 3;\n' }, + { + path: 'apps/client/src/main.ts', + text: 'import { Health } from "./components/health";\nnew Health();\n', + }, + { + path: 'apps/client/src/systems/regen.ts', + text: "import type { Health } from '../components/health';\nexport const regen = (h: Health) => h;\n", + }, + ]); + return engine; +}; + +describe('move to shared library', () => { + it('finds the importers, relocates the file and rewrites their imports', () => { + const engine = setup(); + const from = 'apps/client/src/components/health.ts'; + const to = 'libs/shared/src/components/health.ts'; + expect(engine.analyze(from, 'importers')).toEqual([ + 'apps/client/src/main.ts', + 'apps/client/src/systems/regen.ts', + ]); + expect(engine.analyze(from, 'relocated', { to })).toMatchObject({ + imports: ['apps/client/src/config.ts'], + }); + expect((engine.analyze(from, 'relocated', { to }) as { text: string }).text).toContain( + 'import { max } from "../../../../apps/client/src/config";', + ); + const main = 'apps/client/src/main.ts'; + const { text } = applyTextEdits( + engine.text(main)!, + engine.transform(main, 'rewrite', { from, to }), + ); + expect(text).toBe('import { Health } from "@pong/shared/components/health";\nnew Health();\n'); + const regen = 'apps/client/src/systems/regen.ts'; + expect( + applyTextEdits(engine.text(regen)!, engine.transform(regen, 'rewrite', { from, to })).text, + ).toContain("import type { Health } from '@pong/shared/components/health';"); + }); +}); diff --git a/plugins/ecs/tsconfig.json b/plugins/ecs/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/ecs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/ecs/vite.config.ts b/plugins/ecs/vite.config.ts new file mode 100644 index 00000000..8fac285c --- /dev/null +++ b/plugins/ecs/vite.config.ts @@ -0,0 +1,15 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { + entry: { index: 'src/index.ts', worker: 'src/worker/index.ts' }, + formats: ['es'], + cssFileName: 'ecs', + }, + }, +}); diff --git a/plugins/file-manager/.prettierignore b/plugins/file-manager/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/file-manager/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/file-manager/LICENSE b/plugins/file-manager/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/file-manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/file-manager/README.md b/plugins/file-manager/README.md new file mode 100644 index 00000000..22467115 --- /dev/null +++ b/plugins/file-manager/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-file-manager` is an internal package of the NanoForge Editor. + +Built-in file manager. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/file-manager +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/file-manager/nanoforge.manifest.json b/plugins/file-manager/nanoforge.manifest.json new file mode 100644 index 00000000..8aeaccd4 --- /dev/null +++ b/plugins/file-manager/nanoforge.manifest.json @@ -0,0 +1,216 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/file-manager", + "version": "0.1.0", + "displayName": "File manager", + "description": "Browse and organize the project's files: tree, asset grid, templates, import, undo.", + "engines": { + "editor": ">=0.1.0" + }, + "entry": { + "client": "index.js" + }, + "activation": [ + "onWidget:file-manager.files", + "onCommand:fileManager.import", + "onCommand:fileManager.preview" + ], + "contributes": { + "widgets": [ + { + "id": "file-manager.files", + "title": "Files", + "icon": "folder-tree", + "defaultSlot": "leftTop", + "order": 100, + "historyContext": "file-manager" + } + ], + "commands": [ + { + "id": "fileManager.newFile", + "title": "New file\u2026", + "category": "File" + }, + { + "id": "fileManager.newFolder", + "title": "New folder\u2026", + "category": "File" + }, + { + "id": "fileManager.newFromTemplate", + "title": "New from template\u2026", + "category": "File", + "palette": false + }, + { + "id": "fileManager.import", + "title": "Import files\u2026", + "category": "File" + }, + { + "id": "fileManager.rename", + "title": "Rename\u2026", + "category": "Files" + }, + { + "id": "fileManager.delete", + "title": "Delete", + "category": "Files" + }, + { + "id": "fileManager.duplicate", + "title": "Duplicate", + "category": "Files" + }, + { + "id": "fileManager.copy", + "title": "Copy", + "category": "Files" + }, + { + "id": "fileManager.cut", + "title": "Cut", + "category": "Files" + }, + { + "id": "fileManager.paste", + "title": "Paste", + "category": "Files" + }, + { + "id": "fileManager.reveal", + "title": "Reveal in file manager", + "category": "Files" + }, + { + "id": "fileManager.download", + "title": "Download", + "category": "Files" + }, + { + "id": "fileManager.copyPath", + "title": "Copy path", + "category": "Files" + }, + { + "id": "fileManager.toggleHidden", + "title": "Show or hide hidden files", + "category": "View" + }, + { + "id": "fileManager.preview", + "title": "Preview", + "category": "File", + "palette": false + } + ], + "keybindings": [ + { + "key": "Delete", + "command": "fileManager.delete", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + }, + { + "key": "Mod+C", + "command": "fileManager.copy", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + }, + { + "key": "Mod+X", + "command": "fileManager.cut", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + }, + { + "key": "Mod+V", + "command": "fileManager.paste", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + }, + { + "key": "Mod+D", + "command": "fileManager.duplicate", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + } + ], + "menus": [ + { + "menu": "file", + "command": "fileManager.newFile", + "group": "new", + "order": 0 + }, + { + "menu": "file", + "command": "fileManager.newFolder", + "group": "new", + "order": 1 + }, + { + "menu": "file", + "command": "fileManager.import", + "group": "new", + "order": 2 + }, + { + "menu": "view", + "command": "fileManager.toggleHidden", + "group": "files" + } + ], + "settings": [ + { + "key": "exclude", + "type": "array", + "items": { + "type": "string" + }, + "default": ["node_modules/", "dist/", ".turbo/", "coverage/", ".*", "!.env"], + "title": "Hidden files", + "description": "Files the file manager hides (gitignore syntax). .nfignore hides files from the whole editor.", + "category": "Editor/Files", + "order": 0 + }, + { + "key": "showHidden", + "type": "boolean", + "default": false, + "title": "Show hidden files", + "category": "Editor/Files", + "order": 1, + "scopes": ["account", "machine"] + }, + { + "key": "view", + "type": "string", + "enum": ["tree", "split"], + "default": "split", + "title": "View", + "description": "Tree only, or tree and asset grid.", + "category": "Editor/Files", + "order": 2, + "scopes": ["account", "machine"] + }, + { + "key": "gridSize", + "type": "number", + "integer": true, + "minimum": 48, + "maximum": 256, + "default": 96, + "title": "Thumbnail size (px)", + "category": "Editor/Files", + "order": 3, + "scopes": ["account", "machine"] + } + ], + "documentEditors": [ + { + "pattern": "**/*.{png,jpg,jpeg,gif,webp,avif,bmp,ico,mp3,wav,ogg,flac,m4a,mp4,webm}", + "widget": "file-manager.files", + "command": "fileManager.preview", + "title": "Preview" + } + ] + } +} diff --git a/plugins/file-manager/package.json b/plugins/file-manager/package.json new file mode 100644 index 00000000..dedd6711 --- /dev/null +++ b/plugins/file-manager/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-file-manager", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in file manager", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/file-manager" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "ignore": "catalog:libs", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/file-manager/src/dialogs/preview/PreviewDialog.svelte b/plugins/file-manager/src/dialogs/preview/PreviewDialog.svelte new file mode 100644 index 00000000..8d8d638e --- /dev/null +++ b/plugins/file-manager/src/dialogs/preview/PreviewDialog.svelte @@ -0,0 +1,86 @@ + + + +
+ {#if kind === 'image'} + {path} { + const image = event.currentTarget as HTMLImageElement; + size = `${image.naturalWidth} × ${image.naturalHeight}`; + small = image.naturalWidth <= 64 && image.naturalHeight <= 64; + }} + /> + {:else if kind === 'audio'} + + {:else} + + + {/if} +
+

{path}{size ? ` · ${size}` : ''}

+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/plugins/file-manager/src/filter/file-filter.ts b/plugins/file-manager/src/filter/file-filter.ts new file mode 100644 index 00000000..a5ea87f6 --- /dev/null +++ b/plugins/file-manager/src/filter/file-filter.ts @@ -0,0 +1,45 @@ +import ignore, { type Ignore } from 'ignore'; + +import { type FileEntry, basename } from '@nanoforge-dev/editor-sdk'; + +/** Folder of installed bundles, shown apart and read-only. */ +export const PACKAGES = 'nf_modules'; + +export const isPackagePath = (path: string): boolean => + path === PACKAGES || path.startsWith(`${PACKAGES}/`); + +/** `fileManager.exclude`: gitignore patterns hidden in the file manager. */ +export class FileFilter { + private readonly _rules: Ignore; + + constructor(patterns: readonly string[]) { + this._rules = ignore().add([...patterns]); + } + + excludes(entry: Pick): boolean { + const path = entry.kind === 'directory' ? `${entry.path}/` : entry.path; + return this._rules.ignores(path); + } +} + +/** + * Fuzzy match of a query on a file name: every query character in order, case-insensitive. + * Returns a score (higher is better: consecutive and early matches) or -1. + */ +export const fuzzyScore = (query: string, path: string): number => { + const name = basename(path).toLowerCase(); + const needle = query.toLowerCase(); + if (!needle) return 0; + let score = 0; + let from = 0; + let previous = -2; + for (const char of needle) { + const index = name.indexOf(char, from); + if (index < 0) return -1; + score += index === previous + 1 ? 3 : 1; + if (index === 0) score += 2; + previous = index; + from = index + 1; + } + return score - name.length / 100; +}; diff --git a/plugins/file-manager/src/icons.ts b/plugins/file-manager/src/icons.ts new file mode 100644 index 00000000..f2e1f354 --- /dev/null +++ b/plugins/file-manager/src/icons.ts @@ -0,0 +1,45 @@ +import Box from '@lucide/svelte/icons/box'; +import ClipboardPaste from '@lucide/svelte/icons/clipboard-paste'; +import Download from '@lucide/svelte/icons/download'; +import Eye from '@lucide/svelte/icons/eye'; +import EyeOff from '@lucide/svelte/icons/eye-off'; +import FileJson from '@lucide/svelte/icons/file-braces'; +import FileImage from '@lucide/svelte/icons/file-image'; +import FileMusic from '@lucide/svelte/icons/file-music'; +import FileText from '@lucide/svelte/icons/file-text'; +import FileType from '@lucide/svelte/icons/file-type'; +import LayoutGrid from '@lucide/svelte/icons/layout-grid'; +import Music from '@lucide/svelte/icons/music'; +import Scissors from '@lucide/svelte/icons/scissors'; +import Upload from '@lucide/svelte/icons/upload'; + +import type { FileIcon } from '@nanoforge-dev/editor-sdk/ui'; + +/** Icons the file manager uses beyond the editor's own set. */ +export const ICONS = { + box: Box, + 'clipboard-paste': ClipboardPaste, + download: Download, + eye: Eye, + 'eye-off': EyeOff, + 'file-image': FileImage, + 'file-json': FileJson, + 'file-music': FileMusic, + 'file-text': FileText, + 'file-type': FileType, + 'layout-grid': LayoutGrid, + music: Music, + scissors: Scissors, + upload: Upload, +}; + +/** Default file icons (other plugins contribute more, with a higher priority). */ +export const FILE_ICON_DEFAULTS: FileIcon[] = [ + { pattern: '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,svelte,vue}', icon: 'file-code' }, + { pattern: '**/*.{json,jsonc}', icon: 'file-json' }, + { pattern: '**/*.{md,mdx,txt,log}', icon: 'file-text' }, + { pattern: '**/*.{png,jpg,jpeg,gif,webp,svg,bmp,avif}', icon: 'file-image' }, + { pattern: '**/*.{mp3,wav,ogg,flac,m4a}', icon: 'file-music' }, + { pattern: '**/*.{ttf,otf,woff,woff2}', icon: 'file-type' }, + { pattern: '**/*.wasm', icon: 'box' }, +]; diff --git a/plugins/file-manager/src/index.ts b/plugins/file-manager/src/index.ts new file mode 100644 index 00000000..bcd0ee28 --- /dev/null +++ b/plugins/file-manager/src/index.ts @@ -0,0 +1,158 @@ +import { type Component, mount, unmount } from 'svelte'; + +import { + EditorServices, + HistoryServiceToken, + ProjectServiceToken, + RuntimeServiceToken, + SettingsServiceToken, + definePlugin, +} from '@nanoforge-dev/editor-sdk'; +import { + FILE_ICONS, + NotificationServiceToken, + PromptServiceToken, + StyleServiceToken, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import PreviewDialog from './dialogs/preview/PreviewDialog.svelte'; +import { FILE_ICON_DEFAULTS, ICONS } from './icons'; +import { FileManagerService } from './service/file-manager-service'; +import { current } from './session/file-manager-session'; +import { SETTING } from './settings/file-manager-settings.const'; +import FilesView from './widgets/files/FilesView.svelte'; + +export const FILES_WIDGET = 'file-manager.files'; +/** Undo/redo context of file operations (the widget's `historyContext`). */ +export const FILES_HISTORY = 'file-manager'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('file-manager.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + for (const icon of FILE_ICON_DEFAULTS) { + context.subscriptions.add(context.contribute(FILE_ICONS, icon, { priority: -100 })); + } + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id: FILES_WIDGET, + component: FilesView as Component<{ instance: WidgetInstance }>, + }), + ); + + const history = services.get(HistoryServiceToken); + const extensions = services.get(EditorServices.Extensions); + const unsubscribe = services.get(ProjectServiceToken).current.subscribe((project) => { + current.get()?.dispose(); + current.set(undefined); + if (!project) return; + current.set( + new FileManagerService({ + project, + history: history.registerContext({ + id: FILES_HISTORY, + label: 'Files', + owner: context.name, + }), + extensions, + prompts: services.tryGet(PromptServiceToken), + notifications: services.tryGet(NotificationServiceToken), + executeCommand: (id, ...args) => context.executeCommand(id, ...args), + logger: context.logger, + local: services.tryGet(RuntimeServiceToken) !== undefined, + }), + ); + }); + context.subscriptions.add({ + dispose: () => { + unsubscribe(); + current.get()?.dispose(); + current.set(undefined); + }, + }); + + const paths = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined; + const folder = (value: unknown) => (typeof value === 'string' ? value : undefined); + const settings = services.get(SettingsServiceToken); + + let dialog: ReturnType | undefined; + const closePreview = () => { + if (dialog) void unmount(dialog); + dialog = undefined; + }; + context.subscriptions.add({ dispose: closePreview }); + const preview = (service: FileManagerService, arg: unknown) => { + const path = folder(arg); + if (!path) return; + closePreview(); + const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + const target = document.createElement('div'); + document.body.append(target); + dialog = mount(PreviewDialog, { + target, + props: { + path, + url: service.fileUrl(path, service.entry(path)?.mtime), + kind: ['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(extension) + ? 'audio' + : ['mp4', 'webm'].includes(extension) + ? 'video' + : 'image', + ondownload: () => void service.download(path), + onclose: () => { + closePreview(); + target.remove(); + }, + }, + }); + }; + const commands: [string, (service: FileManagerService, arg: unknown) => unknown][] = [ + ['fileManager.newFile', (service, arg) => service.newFile(folder(arg))], + ['fileManager.newFolder', (service, arg) => service.newFolder(folder(arg))], + ['fileManager.newFromTemplate', (service, arg) => service.newFromTemplate(String(arg))], + ['fileManager.import', (service, arg) => service.pickFiles(folder(arg))], + ['fileManager.rename', (service, arg) => service.rename(folder(arg))], + ['fileManager.delete', (service, arg) => service.delete(paths(arg))], + ['fileManager.duplicate', (service, arg) => service.duplicate(paths(arg))], + ['fileManager.copy', (service, arg) => service.copyToClipboard('copy', paths(arg))], + ['fileManager.cut', (service, arg) => service.copyToClipboard('cut', paths(arg))], + ['fileManager.paste', (service, arg) => service.paste(folder(arg))], + ['fileManager.reveal', (service, arg) => service.reveal(folder(arg))], + ['fileManager.download', (service, arg) => service.download(folder(arg))], + ['fileManager.copyPath', (service, arg) => service.copyPath(folder(arg))], + ['fileManager.preview', (service, arg) => preview(service, arg)], + [ + 'fileManager.toggleHidden', + () => + settings.set( + SETTING.showHidden, + !settings.get(SETTING.showHidden), + settings.hasStore('machine') ? 'machine' : 'account', + ), + ], + ]; + let queue: Promise = Promise.resolve(); + for (const [id, run] of commands) { + context.subscriptions.add( + context.registerCommand(id, (_services, arg?: unknown) => { + const next = queue.then(() => { + const service = current.get(); + return service ? run(service, arg) : undefined; + }); + queue = next.catch(() => undefined); + return next; + }), + ); + } + }, +}); diff --git a/plugins/file-manager/src/menu/file-menu.ts b/plugins/file-manager/src/menu/file-menu.ts new file mode 100644 index 00000000..c2581b68 --- /dev/null +++ b/plugins/file-manager/src/menu/file-menu.ts @@ -0,0 +1,160 @@ +import { type ContextKeyService, basename } from '@nanoforge-dev/editor-sdk'; +import { FILE_ACTIONS, type FileAction, type MenuEntry } from '@nanoforge-dev/editor-sdk/ui'; + +import type { FileManagerService } from '../service/file-manager-service'; +import type { Contributions } from '../service/file-manager-service.type'; + +const extname = (path: string) => { + const name = basename(path); + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot) : ''; +}; + +const item = ( + id: string, + label: string, + onSelect: () => unknown, + extra: { icon?: string; shortcut?: string; disabled?: boolean } = {}, +): MenuEntry => ({ kind: 'item', id, label, onSelect: () => void onSelect(), ...extra }); + +const SEPARATOR: MenuEntry = { kind: 'separator' }; + +/** "New" entries: file, folder and the contributed templates by category. */ +export const newEntries = (service: FileManagerService, folder: string): MenuEntry[] => { + const templates = service.templates(); + const categories = [...new Set(templates.map((template) => template.category ?? ''))]; + return [ + item('new-file', 'File…', () => service.newFile(folder), { icon: 'file' }), + item('new-folder', 'Folder…', () => service.newFolder(folder), { icon: 'folder' }), + ...categories.flatMap((category) => [ + SEPARATOR, + ...templates + .filter((template) => (template.category ?? '') === category) + .map((template) => + item( + `template-${template.id}`, + `${template.title}…`, + () => service.newFromTemplate(template.id, folder), + { ...(template.icon && { icon: template.icon }) }, + ), + ), + ]), + ]; +}; + +/** + * Context menu of entries (empty `paths`: the project root). Contributed file actions are + * filtered by their `when` clause over `fileKind`, `fileExtname`, `fileReadOnly` and + * `fileSelectionCount`. + */ +export const fileMenu = ( + service: FileManagerService, + paths: readonly string[], + contextKeys: ContextKeyService, + extensions: Contributions, + executeCommand: (id: string, ...args: unknown[]) => Promise, +): MenuEntry[] => { + const [first] = paths; + const entry = first !== undefined ? service.entry(first) : undefined; + const folder = service.targetFolder(first ?? ''); + const readOnly = paths.some((path) => service.readOnly(path)) || service.readOnly(folder); + const single = paths.length === 1 && first !== undefined; + const entries: MenuEntry[] = []; + + if (single && entry?.kind === 'file') { + entries.push(item('open', 'Open', () => service.open(first))); + const editors = service.editorsOf(first); + if (editors.length > 1) { + entries.push({ + kind: 'submenu', + label: 'Open with', + items: editors.map((editor) => + item(`open-with-${editor.widget}`, editor.title ?? editor.widget, () => + service.openWith(first, editor), + ), + ), + }); + } + entries.push(SEPARATOR); + } + if (!readOnly) { + entries.push({ kind: 'submenu', label: 'New', items: newEntries(service, folder) }); + entries.push( + item('import', 'Import files…', () => executeCommand('fileManager.import', folder), { + icon: 'upload', + }), + ); + entries.push(SEPARATOR); + } + if (paths.length) { + entries.push( + item('cut', 'Cut', () => service.copyToClipboard('cut', paths), { + shortcut: 'Ctrl+X', + disabled: readOnly, + }), + item('copy', 'Copy', () => service.copyToClipboard('copy', paths), { shortcut: 'Ctrl+C' }), + ); + } + entries.push( + item('paste', 'Paste', () => service.paste(folder), { + shortcut: 'Ctrl+V', + disabled: readOnly || !service.clipboard.get(), + }), + ); + if (paths.length && !readOnly) { + entries.push( + item('duplicate', 'Duplicate', () => service.duplicate(paths), { shortcut: 'Ctrl+D' }), + ...(single + ? [item('rename', 'Rename…', () => service.rename(first), { shortcut: 'F2' })] + : []), + item('delete', 'Delete', () => service.delete(paths), { icon: 'trash-2', shortcut: 'Del' }), + ); + } + entries.push(SEPARATOR); + if (single || !paths.length) { + const target = first ?? ''; + if (service.local) { + entries.push(item('reveal', 'Reveal in file manager', () => service.reveal(target))); + } + entries.push( + item( + 'download', + entry?.kind === 'file' ? 'Download' : 'Download as .zip', + () => service.download(target), + { icon: 'download' }, + ), + ); + if (single) entries.push(item('copy-path', 'Copy path', () => service.copyPath(first))); + } + + const scope = contextKeys.createScoped('file-manager.menu'); + scope.set('fileKind', entry?.kind ?? 'directory'); + scope.set('fileExtname', first !== undefined ? extname(first) : ''); + scope.set('fileReadOnly', readOnly); + scope.set('fileSelectionCount', paths.length); + const actions = extensions + .getValues(FILE_ACTIONS) + .filter((action) => scope.evaluate(action.when)) + .sort( + (a, b) => (a.group ?? '').localeCompare(b.group ?? '') || (a.order ?? 0) - (b.order ?? 0), + ); + scope.dispose(); + let group: string | undefined; + for (const action of actions as FileAction[]) { + if (action.group !== group) { + entries.push(SEPARATOR); + group = action.group; + } + entries.push( + item(`action-${action.id}`, action.title, () => executeCommand(action.command, [...paths]), { + ...(action.icon && { icon: action.icon }), + }), + ); + } + + return entries.filter( + (entry, index, all) => + entry.kind !== 'separator' || + (index > 0 && index < all.length - 1 && all[index - 1]?.kind !== 'separator'), + ); +}; diff --git a/plugins/file-manager/src/service/file-manager-service.ts b/plugins/file-manager/src/service/file-manager-service.ts new file mode 100644 index 00000000..7082ca6e --- /dev/null +++ b/plugins/file-manager/src/service/file-manager-service.ts @@ -0,0 +1,315 @@ +import { + type ClientProject, + type Disposable, + type DocumentEditor, + type FileEntry, + type Observable, + ObservableValue, + basename, + dirname, + editorsFor, + matchesGlob, +} from '@nanoforge-dev/editor-sdk'; +import { FILE_ICONS, FILE_TEMPLATES, type FileTemplate } from '@nanoforge-dev/editor-sdk/ui'; + +import { isPackagePath } from '../filter/file-filter'; +import type { Clipboard, FileManagerServiceOptions } from './file-manager-service.type'; +import { FileOperations } from './file-operations'; + +export const NAME = /^[^/\\:*?"<>|]+$/; +export const validateName = (value: string) => + !value.trim() + ? 'Enter a name' + : !NAME.test(value) || value === '.' || value === '..' + ? 'Names cannot contain / \\ : * ? " < > |' + : undefined; + +/** + * State and actions of the file manager for the open project: selection, grid folder, + * clipboard, pending deletes, and every operation of the context menu and shortcuts. + */ +export class FileManagerService implements Disposable { + readonly operations: FileOperations; + private readonly _selection = new ObservableValue([]); + private readonly _folder = new ObservableValue(''); + private readonly _clipboard = new ObservableValue(undefined); + private readonly _pendingDelete = new ObservableValue(undefined); + + constructor(private readonly _options: FileManagerServiceOptions) { + this.operations = new FileOperations(_options.project, _options.history); + } + + get project(): ClientProject { + return this._options.project; + } + + get local(): boolean { + return this._options.local; + } + + get selection(): Observable { + return this._selection.readonly(); + } + + /** Folder shown by the grid. */ + get folder(): Observable { + return this._folder.readonly(); + } + + get clipboard(): Observable { + return this._clipboard.readonly(); + } + + /** Several entries or a folder waiting for a delete confirmation. */ + get pendingDelete(): Observable { + return this._pendingDelete.readonly(); + } + + entry(path: string): FileEntry | undefined { + return this._options.project.fs.entry(path); + } + + select(paths: readonly string[]): void { + this._selection.set(paths); + const [first] = paths; + if (first !== undefined) this._folder.set(this.targetFolder(first)); + } + + showFolder(path: string): void { + this._folder.set(path); + } + + /** Where new entries go for a target: the folder itself, or the file's folder. */ + targetFolder(path: string | undefined = this._selection.get()[0]): string { + if (path === undefined) return this._folder.get(); + return this.entry(path)?.kind === 'directory' ? path : dirname(path); + } + + readOnly(path: string): boolean { + return isPackagePath(path); + } + + templates(): FileTemplate[] { + return this._options.extensions.getValues(FILE_TEMPLATES); + } + + editorsOf(path: string): DocumentEditor[] { + return editorsFor(this._options.extensions, path); + } + + /** Icon of an entry: contributed icons (highest priority first), else a default. */ + iconOf(entry: Pick, expanded = false): string { + if (entry.kind === 'directory') return expanded ? 'folder-open' : 'folder'; + const icon = this._options.extensions + .getValues(FILE_ICONS) + .filter((candidate) => matchesGlob(candidate.pattern, entry.path)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; + return icon?.icon ?? 'file'; + } + + open(path: string): Promise { + if (this.entry(path)?.kind === 'directory') { + this.showFolder(path); + return Promise.resolve(); + } + return this._options.executeCommand('documents.open', path); + } + + openWith(path: string, editor: DocumentEditor): Promise { + return editor.command + ? this._options.executeCommand(editor.command, path) + : this._options.executeCommand('workbench.openWidget', editor.widget); + } + + async newFile(folder = this.targetFolder()): Promise { + const name = await this._ask('New file', 'File name', 'untitled.ts', folder); + if (name) + await this._run(async () => { + const path = await this.operations.createFile(folder, name); + this.select([path]); + await this._options.executeCommand('documents.open', path, { line: 1 }); + }); + } + + async newFolder(folder = this.targetFolder()): Promise { + const name = await this._ask('New folder', 'Folder name', 'folder', folder); + if (name) { + await this._run(async () => this.select([await this.operations.createFolder(folder, name)])); + } + } + + async newFromTemplate(id: string, folder = this.targetFolder()): Promise { + const template = this.templates().find((candidate) => candidate.id === id); + if (!template) return; + const name = await this._ask( + `New ${template.title.toLowerCase()}`, + 'Name', + template.defaultName, + folder, + ); + if (!name) return; + await this._run(async () => { + const [main] = await this.operations.createFromTemplate(template, folder, name); + if (main) { + this.select([main]); + await this.open(main); + } + }); + } + + async rename(path = this._selection.get()[0]): Promise { + if (path === undefined || this.readOnly(path)) return; + const name = await this._options.prompts?.ask({ + title: `Rename ${basename(path)}`, + label: 'New name', + value: basename(path), + confirm: 'Rename', + validate: (value) => + validateName(value) ?? + (value !== basename(path) && + this.entry(`${dirname(path) ? `${dirname(path)}/` : ''}${value}`) + ? `${value} already exists` + : undefined), + }); + if (name && name !== basename(path)) await this.renameTo(path, name); + } + + /** Inline rename (tree). */ + async renameTo(path: string, name: string): Promise { + const invalid = validateName(name); + if (invalid || this.readOnly(path)) { + if (invalid) this._report(invalid); + return false; + } + return this._run(async () => this.select([await this.operations.rename(path, name)])); + } + + async move(paths: readonly string[], folder: string): Promise { + const movable = paths.filter((path) => !this.readOnly(path)); + if (!movable.length || this.readOnly(folder)) return; + await this._run(async () => { + const moved = await this.operations.move(movable, folder); + if (moved.length) this.select(moved); + }); + } + + copyToClipboard(mode: Clipboard['mode'], paths = this._selection.get()): void { + const usable = mode === 'cut' ? paths.filter((path) => !this.readOnly(path)) : paths; + if (usable.length) this._clipboard.set({ mode, paths: [...usable] }); + } + + async paste(folder = this.targetFolder()): Promise { + const clipboard = this._clipboard.get(); + if (!clipboard || this.readOnly(folder)) return; + await this._run(async () => { + const pasted = + clipboard.mode === 'cut' + ? await this.operations.move(clipboard.paths, folder) + : await this.operations.copy(clipboard.paths, folder); + if (clipboard.mode === 'cut') this._clipboard.set(undefined); + if (pasted.length) this.select(pasted); + }); + } + + async duplicate(paths = this._selection.get()): Promise { + const copies = paths.filter((path) => !this.readOnly(path)); + if (!copies.length) return; + await this._run(async () => this.select(await this.operations.duplicate(copies))); + } + + /** Deletes at once for one file; asks first for folders or several entries. */ + async delete(paths = this._selection.get()): Promise { + const targets = paths.filter((path) => !this.readOnly(path)); + if (!targets.length) return; + const [only] = targets; + if (targets.length === 1 && only !== undefined && this.entry(only)?.kind === 'file') { + await this._run(() => this.operations.delete(targets)); + } else this._pendingDelete.set(targets); + } + + async confirmDelete(confirmed: boolean): Promise { + const paths = this._pendingDelete.get(); + this._pendingDelete.set(undefined); + if (confirmed && paths) await this._run(() => this.operations.delete(paths)); + } + + async importFiles(folder: string, files: readonly File[]): Promise { + if (!files.length) return; + if (this.readOnly(folder)) { + this._report('Installed packages (nf_modules) cannot be changed'); + return; + } + await this._run(async () => this.select(await this.operations.import(folder, files))); + } + + /** Asks the computer for files to import into a folder. */ + pickFiles(folder = this.targetFolder()): void { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = () => void this.importFiles(folder, [...(input.files ?? [])]); + input.click(); + } + + async reveal(path = this._selection.get()[0] ?? ''): Promise { + await this._run(() => this.project.fs.reveal(path)); + } + + /** URL of a project file (thumbnails, previews). */ + fileUrl(path: string, version?: number): string { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `/files/${this.project.id}/${encoded}${version ? `?v=${Math.round(version)}` : ''}`; + } + + /** Downloads a file, or a folder as .zip. */ + download(path = this._selection.get()[0] ?? ''): void { + const link = document.createElement('a'); + const encoded = path.split('/').map(encodeURIComponent).join('/'); + link.href = `/files/${this.project.id}/${encoded}?download`; + link.download = ''; + document.body.append(link); + link.click(); + link.remove(); + } + + async copyPath(path = this._selection.get()[0]): Promise { + if (path !== undefined) await navigator.clipboard?.writeText(path).catch(() => undefined); + } + + dispose(): void { + this._options.history.dispose(); + } + + private async _ask(title: string, label: string, value: string, folder: string) { + if (this.readOnly(folder)) { + this._report('Installed packages (nf_modules) cannot be changed'); + return undefined; + } + return this._options.prompts?.ask({ + title, + label, + value, + confirm: 'Create', + validate: (candidate) => + validateName(candidate) ?? + (this.entry(folder ? `${folder}/${candidate}` : candidate) + ? `${candidate} already exists` + : undefined), + }); + } + + private async _run(task: () => Promise): Promise { + try { + await task(); + return true; + } catch (error) { + this._report(error instanceof Error ? error.message : String(error)); + return false; + } + } + + private _report(message: string): void { + this._options.logger.warn(message); + this._options.notifications?.notify('error', message); + } +} diff --git a/plugins/file-manager/src/service/file-manager-service.type.ts b/plugins/file-manager/src/service/file-manager-service.type.ts new file mode 100644 index 00000000..60a82614 --- /dev/null +++ b/plugins/file-manager/src/service/file-manager-service.type.ts @@ -0,0 +1,29 @@ +import type { + ClientProject, + ExtensionPoint, + HistoryContext, + Logger, +} from '@nanoforge-dev/editor-sdk'; +import type { NotificationService, PromptService } from '@nanoforge-dev/editor-sdk/ui'; + +export interface Clipboard { + readonly mode: 'copy' | 'cut'; + readonly paths: readonly string[]; +} + +/** The part of the extension registry the file manager reads. */ +export interface Contributions { + getValues(point: ExtensionPoint): T[]; +} + +export interface FileManagerServiceOptions { + readonly project: ClientProject; + readonly history: HistoryContext; + readonly extensions: Contributions; + readonly prompts: PromptService | undefined; + readonly notifications: NotificationService | undefined; + readonly executeCommand: (id: string, ...args: unknown[]) => Promise; + readonly logger: Logger; + /** Local editors can reveal files in the OS file manager. */ + readonly local: boolean; +} diff --git a/plugins/file-manager/src/service/file-operations.ts b/plugins/file-manager/src/service/file-operations.ts new file mode 100644 index 00000000..e2d5b59a --- /dev/null +++ b/plugins/file-manager/src/service/file-operations.ts @@ -0,0 +1,229 @@ +import { + type ClientProject, + type HistoryCommand, + type HistoryContext, + basename, + dirname, + joinPath, +} from '@nanoforge-dev/editor-sdk'; +import type { FileTemplate } from '@nanoforge-dev/editor-sdk/ui'; + +/** Splits `name.ext` (dotfiles and folders have no extension). */ +const splitName = (name: string): [string, string] => { + const dot = name.lastIndexOf('.'); + return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']; +}; + +/** A free name in a folder: `name`, else `name copy`, `name copy 2`… */ +export const freeName = ( + exists: (path: string) => boolean, + folder: string, + name: string, + suffix = ' copy', +): string => { + if (!exists(joinPath(folder, name))) return name; + const [stem, extension] = splitName(name); + for (let index = 1; ; index++) { + const candidate = `${stem}${suffix}${index > 1 ? ` ${index}` : ''}${extension}`; + if (!exists(joinPath(folder, candidate))) return candidate; + } +}; + +/** Whether `path` is `folder` or inside it. */ +export const isInside = (path: string, folder: string): boolean => + folder === '' || path === folder || path.startsWith(`${folder}/`); + +/** + * File operations of the file manager, each one an undo step of its history context: + * create, rename, move, copy, duplicate, delete (to the trash), import. + */ +export class FileOperations { + constructor( + private readonly _project: ClientProject, + private readonly _history: HistoryContext, + ) {} + + private get _fs() { + return this._project.fs; + } + + exists = (path: string): boolean => !!this._fs.entry(path); + + async createFile( + folder: string, + name: string, + content: string | Uint8Array = '', + ): Promise { + const path = joinPath(folder, name); + await this._push({ + label: `Create ${name}`, + do: async () => { + await this._fs.write(path, content, { expectedHash: null }); + }, + undo: async () => { + await this._fs.delete(path); + }, + }); + return path; + } + + async createFolder(folder: string, name: string): Promise { + const path = joinPath(folder, name); + await this._push({ + label: `Create folder ${name}`, + do: () => this._fs.mkdir(path), + undo: async () => { + await this._fs.delete(path); + }, + }); + return path; + } + + /** Creates the files of a template; resolves with their paths (the first is the main one). */ + async createFromTemplate( + template: FileTemplate, + folder: string, + name: string, + ): Promise { + const files = await template.create({ folder, name }); + const paths = files.map((file) => joinPath(folder, file.path)); + for (const path of paths) { + if (this.exists(path)) throw new Error(`${path} already exists`); + } + await this._push({ + label: `Create ${template.title.toLowerCase()} ${name}`, + do: async () => { + for (const [index, file] of files.entries()) { + await this._fs.write(paths[index]!, file.content, { expectedHash: null }); + } + }, + undo: async () => { + for (const path of [...paths].reverse()) await this._fs.delete(path); + }, + }); + return paths; + } + + async rename(path: string, name: string): Promise { + const target = joinPath(dirname(path), name); + if (target === path) return path; + await this._push({ + label: `Rename ${basename(path)} to ${name}`, + do: () => this._fs.rename(path, target), + undo: () => this._fs.rename(target, path), + }); + return target; + } + + /** Moves entries into a folder (entries already there, or the folder itself, are skipped). */ + async move(paths: readonly string[], folder: string): Promise { + const moves = paths + .filter((path) => dirname(path) !== folder && !isInside(folder, path)) + .map((path) => [path, joinPath(folder, basename(path))] as const); + for (const [, target] of moves) { + if (this.exists(target)) throw new Error(`${target} already exists`); + } + if (!moves.length) return []; + await this._push({ + label: moves.length === 1 ? `Move ${basename(moves[0]![0])}` : `Move ${moves.length} items`, + do: async () => { + for (const [from, to] of moves) await this._fs.rename(from, to); + }, + undo: async () => { + for (const [from, to] of [...moves].reverse()) await this._fs.rename(to, from); + }, + }); + return moves.map(([, to]) => to); + } + + /** Copies entries into a folder, under free names. */ + async copy(paths: readonly string[], folder: string, suffix?: string): Promise { + const taken = new Set(); + const copies = paths.map((path) => { + const name = freeName( + (candidate) => this.exists(candidate) || taken.has(candidate), + folder, + basename(path), + suffix, + ); + const target = joinPath(folder, name); + taken.add(target); + return [path, target] as const; + }); + if (!copies.length) return []; + await this._push({ + label: + copies.length === 1 ? `Copy ${basename(copies[0]![0])}` : `Copy ${copies.length} items`, + do: async () => { + for (const [from, to] of copies) await this._fs.copy(from, to); + }, + undo: async () => { + for (const [, to] of [...copies].reverse()) await this._fs.delete(to); + }, + }); + return copies.map(([, to]) => to); + } + + duplicate(paths: readonly string[]): Promise { + return Promise.all(paths.map((path) => this.copy([path], dirname(path)))).then((all) => + all.flat(), + ); + } + + /** Moves entries to the project trash; undo brings them back. */ + async delete(paths: readonly string[]): Promise { + const roots = paths.filter( + (path) => !paths.some((other) => other !== path && isInside(path, other)), + ); + let trashed: { path: string; trashPath: string }[] = []; + await this._push({ + label: roots.length === 1 ? `Delete ${basename(roots[0]!)}` : `Delete ${roots.length} items`, + do: async () => { + trashed = []; + for (const path of roots) trashed.push({ path, ...(await this._fs.delete(path)) }); + }, + undo: async () => { + for (const { path, trashPath } of [...trashed].reverse()) { + await this._fs.restore(trashPath, path); + } + }, + }); + } + + /** Imports files from the computer into a folder (free names when they exist). */ + async import(folder: string, files: readonly File[]): Promise { + const taken = new Set(); + const entries = await Promise.all( + files.map(async (file) => { + const name = freeName( + (candidate) => this.exists(candidate) || taken.has(candidate), + folder, + file.name, + ); + const path = joinPath(folder, name); + taken.add(path); + return { path, content: new Uint8Array(await file.arrayBuffer()) }; + }), + ); + if (!entries.length) return []; + await this._push({ + label: + entries.length === 1 + ? `Import ${basename(entries[0]!.path)}` + : `Import ${entries.length} files`, + do: async () => { + for (const { path, content } of entries) { + await this._fs.write(path, content, { expectedHash: null }); + } + }, + undo: async () => { + for (const { path } of [...entries].reverse()) await this._fs.delete(path); + }, + }); + return entries.map((entry) => entry.path); + } + + private _push(command: HistoryCommand): Promise { + return this._history.stack.push(command); + } +} diff --git a/plugins/file-manager/src/session/file-manager-session.ts b/plugins/file-manager/src/session/file-manager-session.ts new file mode 100644 index 00000000..82ebb78d --- /dev/null +++ b/plugins/file-manager/src/session/file-manager-session.ts @@ -0,0 +1,6 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; + +import type { FileManagerService } from '../service/file-manager-service'; + +/** Service of the open project (undefined without one), shared with the widget. */ +export const current = new ObservableValue(undefined); diff --git a/plugins/file-manager/src/settings/file-manager-settings.const.ts b/plugins/file-manager/src/settings/file-manager-settings.const.ts new file mode 100644 index 00000000..c9fda15f --- /dev/null +++ b/plugins/file-manager/src/settings/file-manager-settings.const.ts @@ -0,0 +1,7 @@ +/** Settings of the plugin (declared in its manifest, prefixed with its name). */ +export const SETTING = { + exclude: '@nanoforge/file-manager.exclude', + showHidden: '@nanoforge/file-manager.showHidden', + view: '@nanoforge/file-manager.view', + gridSize: '@nanoforge/file-manager.gridSize', +} as const; diff --git a/plugins/file-manager/src/widgets/files/FilesView.svelte b/plugins/file-manager/src/widgets/files/FilesView.svelte new file mode 100644 index 00000000..0dc2a10c --- /dev/null +++ b/plugins/file-manager/src/widgets/files/FilesView.svelte @@ -0,0 +1,354 @@ + + +{#if service} +
+ + + +
+ + {#if $view === 'split'} +
+

{$folder || 'Project'}

+ (menuTarget = path ? [...($selection ?? [path])] : [])} + onimport={(target, files) => void service.importFiles(target, files)} + /> +
+ {/if} +
+
+
+ + void service.confirmDelete(false)} + > + {#snippet footer()} + + + {/snippet} + +{:else} + +{/if} + + diff --git a/plugins/file-manager/src/widgets/files/GridView.svelte b/plugins/file-manager/src/widgets/files/GridView.svelte new file mode 100644 index 00000000..2161645b --- /dev/null +++ b/plugins/file-manager/src/widgets/files/GridView.svelte @@ -0,0 +1,246 @@ + + +
{ + if (event.target === event.currentTarget) oncontext(undefined); + }} + ondragover={(event) => dragOver(event, folder)} + ondragleave={() => (dropTarget = undefined)} + ondrop={(event) => drop(event, folder)} +> + {#if folder} + + {/if} + {#each entries as entry (entry.path)} + {@const selected = selection.includes(entry.path)} +
select(event, entry.path)} + ondblclick={() => void service.open(entry.path)} + onkeydown={(event) => event.key === 'Enter' && void service.open(entry.path)} + oncontextmenu={() => { + if (!selected) service.select([entry.path]); + oncontext(entry.path); + }} + ondragstart={(event) => dragStart(event, entry.path)} + ondragover={(event) => entry.kind === 'directory' && dragOver(event, entry.path)} + ondrop={(event) => entry.kind === 'directory' && drop(event, entry.path)} + > + + {#if entry.kind === 'file' && IMAGE.test(entry.path)} + + {:else if entry.kind === 'file' && SOUND.test(entry.path)} + + + { + event.stopPropagation(); + togglePlay(entry.path); + }} + /> + + {:else} + + {/if} + + {basename(entry.path)} + {#if decorate(entry)} + {@const decoration = decorate(entry)!} + {decoration.badge} + {/if} +
+ {:else} +

This folder is empty. Drop files here to import them.

+ {/each} +
+ + diff --git a/plugins/file-manager/src/widgets/files/file-tree.ts b/plugins/file-manager/src/widgets/files/file-tree.ts new file mode 100644 index 00000000..7b7fe466 --- /dev/null +++ b/plugins/file-manager/src/widgets/files/file-tree.ts @@ -0,0 +1,88 @@ +import { type FileEntry, basename } from '@nanoforge-dev/editor-sdk'; +import type { FileDecoration, TreeNode } from '@nanoforge-dev/editor-sdk/ui'; + +import { type FileFilter, PACKAGES, fuzzyScore, isPackagePath } from '../../filter/file-filter'; +import type { FileManagerService } from '../../service/file-manager-service'; + +/** What the Files panel shows: the project, its filters and the open folders. */ +export interface FileTreeContext { + readonly service: FileManagerService; + readonly filter: FileFilter; + readonly showHidden: boolean; + readonly expanded: ReadonlySet; + readonly decorate: (entry: FileEntry) => FileDecoration | undefined; +} + +/** Entries shown under `path` (hidden ones only with the toggle; packages apart). */ +export const visibleEntries = (context: FileTreeContext, path: string): FileEntry[] => + context.service.project.fs + .children(path) + .filter((entry) => path !== '' || entry.path !== PACKAGES) + .filter((entry) => context.showHidden || !context.filter.excludes(entry)); + +export const treeNode = ( + context: FileTreeContext, + entry: FileEntry, + children?: TreeNode[], +): TreeNode => { + const directory = entry.kind === 'directory'; + const hidden = context.filter.excludes(entry); + const readOnly = isPackagePath(entry.path); + const decoration = context.decorate(entry); + return { + id: entry.path, + label: basename(entry.path), + icon: + readOnly && entry.path === PACKAGES + ? 'lock' + : context.service.iconOf(entry, context.expanded.has(entry.path)), + ...(hidden + ? { detail: 'hidden' } + : decoration && { + detail: decoration.badge, + ...(decoration.tone && { tone: decoration.tone }), + ...(decoration.tooltip && { detailTitle: decoration.tooltip }), + }), + ...(readOnly && { draggable: false }), + ...(directory && { children: children ?? [], droppable: !readOnly }), + }; +}; + +export const buildTree = (context: FileTreeContext, path: string): TreeNode[] => + visibleEntries(context, path).map((entry) => + treeNode( + context, + entry, + entry.kind === 'directory' ? buildTree(context, entry.path) : undefined, + ), + ); + +/** Search: matching entries, inside their (expanded) folders. */ +export const searchTree = (context: FileTreeContext, path: string, needle: string): TreeNode[] => + visibleEntries(context, path).flatMap((entry) => { + const children = entry.kind === 'directory' ? searchTree(context, entry.path, needle) : []; + if (fuzzyScore(needle, entry.path) < 0 && !children.length) return []; + return [treeNode(context, entry, entry.kind === 'directory' ? children : undefined)]; + }); + +/** The folders a search result opens: every one that holds a match. */ +export const openFolders = (nodes: readonly TreeNode[]): Set => { + const open = new Set(); + const walk = (list: readonly TreeNode[]) => { + for (const item of list) { + if (item.children?.length) { + open.add(item.id); + walk(item.children); + } + } + }; + walk(nodes); + return open; +}; + +/** The entries of the grid: folders first, then by path. */ +export const gridOrder = (entries: FileEntry[]): FileEntry[] => + entries.sort( + (a, b) => + (a.kind === b.kind ? 0 : a.kind === 'directory' ? -1 : 1) || a.path.localeCompare(b.path), + ); diff --git a/plugins/file-manager/svelte.config.js b/plugins/file-manager/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/file-manager/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/file-manager/tsconfig.json b/plugins/file-manager/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/file-manager/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/file-manager/vite.config.ts b/plugins/file-manager/vite.config.ts new file mode 100644 index 00000000..76dcb5c2 --- /dev/null +++ b/plugins/file-manager/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'file-manager' }, + }, +}); diff --git a/plugins/git/.prettierignore b/plugins/git/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/git/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/git/LICENSE b/plugins/git/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/git/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/git/README.md b/plugins/git/README.md new file mode 100644 index 00000000..cc40c3df --- /dev/null +++ b/plugins/git/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-plugin-git` is an internal package of the NanoForge Editor. + +Built-in Git panel: changes, commits, branches, sync, stashes, history. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/git +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/git/nanoforge.manifest.json b/plugins/git/nanoforge.manifest.json new file mode 100644 index 00000000..7a9a706d --- /dev/null +++ b/plugins/git/nanoforge.manifest.json @@ -0,0 +1,66 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/git", + "version": "0.1.0", + "displayName": "Git", + "description": "The VCS menu, the Commit panel and the Git log of the project's repository (local editors).", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onStartup"], + "contributes": { + "widgets": [ + { + "id": "git.changes", + "title": "Commit", + "icon": "check", + "defaultSlot": "leftTop", + "order": 300 + }, + { + "id": "git.log", + "title": "Git", + "icon": "git-branch", + "defaultSlot": "bottom", + "order": 115 + } + ], + "commands": [ + { + "id": "git.init", + "title": "Initialize repository", + "category": "VCS", + "when": "!git.repository" + }, + { "id": "git.commit", "title": "Commit…", "category": "VCS", "when": "git.repository" }, + { "id": "git.pull", "title": "Update project", "category": "VCS", "when": "git.repository" }, + { "id": "git.push", "title": "Push…", "category": "VCS", "when": "git.repository" }, + { "id": "git.fetch", "title": "Fetch", "category": "VCS", "when": "git.repository" }, + { + "id": "git.newBranch", + "title": "New branch…", + "category": "VCS", + "when": "git.repository" + }, + { + "id": "git.stash", + "title": "Stash changes…", + "category": "VCS", + "when": "git.repository" + }, + { "id": "git.showLog", "title": "Show Git log", "category": "VCS" }, + { "id": "git.refresh", "title": "Refresh the repository state", "category": "VCS" } + ], + "menus": [ + { "menu": "vcs", "command": "git.init", "group": "a-init", "when": "!git.repository" }, + { "menu": "vcs", "command": "git.commit", "group": "b-commit", "order": 0 }, + { "menu": "vcs", "command": "git.pull", "group": "b-commit", "order": 1 }, + { "menu": "vcs", "command": "git.push", "group": "b-commit", "order": 2 }, + { "menu": "vcs", "command": "git.fetch", "group": "b-commit", "order": 3 }, + { "menu": "vcs", "command": "git.newBranch", "group": "c-branch", "order": 0 }, + { "menu": "vcs", "command": "git.stash", "group": "c-branch", "order": 1 }, + { "menu": "vcs", "command": "git.showLog", "group": "d-log", "order": 0 }, + { "menu": "vcs", "command": "git.refresh", "group": "d-log", "order": 1 } + ] + } +} diff --git a/plugins/git/package.json b/plugins/git/package.json new file mode 100644 index 00000000..1b8e35c9 --- /dev/null +++ b/plugins/git/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-git", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in Git panel: changes, commits, branches, sync, stashes, history", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/git" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/git/src/actions/create-actions.ts b/plugins/git/src/actions/create-actions.ts new file mode 100644 index 00000000..a7079e42 --- /dev/null +++ b/plugins/git/src/actions/create-actions.ts @@ -0,0 +1,174 @@ +import { + EditorServices, + ObservableValue, + type ProjectFs, + type ServiceAccessor, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken, PromptServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { openPushDialog } from '../dialogs/open-push-dialog'; +import type { GitRpc } from '../session/git-session'; +import type { GitStore } from '../store/git-store'; + +const BRANCH_NAME = /^(?!-)(?!.*\.\.)[^\s~^:?*[\\]+$/; + +export interface CommitRequest { + readonly message: string; + /** Files to commit; undefined commits what is staged (a merge). */ + readonly paths?: readonly string[]; + readonly amend?: boolean; + /** Push once committed. */ + readonly push?: boolean; +} + +/** + * What the VCS menu, the Commit panel and the Git panel do: each runs a git operation of the + * open project, reports a failure with git's own message, and refreshes the panels. + */ +export const createActions = (options: { + services: ServiceAccessor; + store: GitStore; + api: GitRpc; + project: () => string | undefined; + /** Files of the open project. */ + fs: () => ProjectFs | undefined; +}) => { + const { services, store, api } = options; + const input = (extra?: T) => + ({ project: options.project() ?? '', ...extra }) as T & { project: string }; + const notify = (kind: 'info' | 'warning', title: string, detail?: string) => + services.tryGet(NotificationServiceToken)?.notify(kind, title, detail ? { detail } : {}); + const commands = services.get(EditorServices.Commands); + /** Untracked folders are listed as `folder/`: the server takes paths without the slash. */ + const clean = (paths: readonly string[]) => paths.map((path) => path.replace(/\/$/, '')); + /** Asks the Commit panel to focus its message box (bumped by `Commit…`). */ + const focusMessage = new ObservableValue(0); + + const actions = { + input, + focusMessage: focusMessage.readonly(), + + init: () => store.run('Could not initialize the repository', () => api.init(input())), + fetch: () => store.run('Could not fetch', () => api.fetch(input())), + /** Pushes at once (after the Push dialog's confirmation). */ + pushNow: () => store.run('Could not push', () => api.push(input())), + /** Push…: shows the commits a push would send, and pushes on confirmation. */ + push: () => { + const status = store.state.get().status; + const branch = status?.branch ?? 'HEAD'; + openPushDialog({ + target: `${branch} → ${status?.upstream ?? `origin/${branch}`}`, + commits: api.outgoing(input()), + onpush: () => void actions.pushNow(), + }); + return Promise.resolve(true); + }, + + /** Update project: a merge pull. Conflicts are listed in the Commit panel. */ + pull: async () => { + let conflicts = false; + const done = await store.run('Could not update the project', async () => { + conflicts = (await api.pull(input())).conflicts; + }); + if (done && conflicts) { + notify( + 'warning', + 'The update stopped on conflicts', + 'Fix the files under Merge conflicts in the Commit panel, mark them resolved, then commit.', + ); + await commands.execute('workbench.openWidget', 'git.changes'); + } + return done; + }, + + commit: async ({ message, paths, amend = false, push = false }: CommitRequest) => { + const done = await store.run('Could not commit', () => + api.commit(input({ message, amend, all: false, ...(paths && { paths: clean(paths) }) })), + ); + return done && push ? actions.push() : done; + }, + + /** Opens the Commit panel with the focus in its message box. */ + showCommit: async () => { + await commands.execute('workbench.openWidget', 'git.changes'); + focusMessage.set(focusMessage.get() + 1); + }, + showLog: () => commands.execute('workbench.openWidget', 'git.log'), + + rollback: (paths: readonly string[]) => + store.run('Could not roll back', () => api.discard(input({ paths: clean(paths) }))), + /** Add to VCS: git starts tracking the files (they move from Unversioned to Changes). */ + add: (paths: readonly string[]) => + store.run('Could not add the files', () => api.stage(input({ paths: clean(paths) }))), + /** Appends the paths to the project's `.gitignore`. */ + ignore: (paths: readonly string[]) => + store.run('Could not write .gitignore', async () => { + const fs = options.fs(); + if (!fs) throw new Error('No project is open'); + const current = fs.entry('.gitignore') ? (await fs.readText('.gitignore')).text : ''; + const lines = clean(paths).map((path) => `/${path}`); + const text = `${current}${current && !current.endsWith('\n') ? '\n' : ''}${lines.join('\n')}\n`; + await fs.write('.gitignore', text); + }), + markResolved: (path: string) => + store.run('Could not mark the file resolved', () => + api.stage(input({ paths: clean([path]) })), + ), + + /** Asks for a name and creates a branch at the current commit, or from a branch. */ + newBranch: async (from?: string) => { + const name = await services.get(PromptServiceToken).ask({ + title: from ? `New branch from ${from}` : 'New branch', + label: 'Branch name', + confirm: 'Create branch', + validate: (value) => + BRANCH_NAME.test(value) ? undefined : 'This is not a valid branch name', + }); + return name + ? store.run('Could not create the branch', () => + api.createBranch(input({ name, ...(from && { from }) })), + ) + : false; + }, + checkout: (name: string) => + store.run('Could not check out the branch', () => api.switchBranch(input({ name }))), + /** Deletes a branch; rejects with git's message when it is not merged and `force` is off. */ + deleteBranch: async (name: string, force: boolean) => { + await api.deleteBranch(input({ name, force })); + await store.refresh(); + }, + + stash: async () => { + const text = await services.get(PromptServiceToken).ask({ + title: 'Stash changes', + label: 'Message (optional)', + confirm: 'Stash', + }); + if (text === undefined) return false; + const message = text.trim(); + return store.run('Could not stash', () => api.stash(input(message ? { message } : {}))); + }, + applyStash: (index: number, pop: boolean) => + store.run(pop ? 'Could not pop the stash' : 'Could not apply the stash', () => + api.applyStash(input({ index, pop })), + ), + dropStash: (index: number) => + store.run('Could not drop the stash', () => api.dropStash(input({ index }))), + + /** Shows a file next to an earlier version of it, in the code editor. */ + compare: async (path: string, revision: string, label: string) => { + await store.run(`Could not compare ${path}`, async () => { + const text = await api.show(input({ path, revision })); + await commands.execute('codeEditor.compare', path, { + text: text ?? '', + label: text === null ? `${label} (the file did not exist)` : label, + }); + }); + }, + open: (path: string) => commands.execute('documents.open', path), + info: (title: string) => notify('info', title), + }; + return actions; +}; + +export type GitActions = ReturnType; diff --git a/plugins/git/src/dialogs/PushDialog.svelte b/plugins/git/src/dialogs/PushDialog.svelte new file mode 100644 index 00000000..eecc50b4 --- /dev/null +++ b/plugins/git/src/dialogs/PushDialog.svelte @@ -0,0 +1,87 @@ + + + + {#await commits} +

Reading the commits to push…

+ {:then list} + {#if list.length} +
    + {#each list as commit (commit.hash)} +
  • + {commit.subject} + {commit.author} · {commit.shortHash} +
  • + {/each} +
+ {:else} +

Everything is already pushed.

+ {/if} + {:catch} +

The commits could not be read. You can still push.

+ {/await} + {#snippet footer()} + + + {/snippet} +
+ + diff --git a/plugins/git/src/dialogs/open-push-dialog.ts b/plugins/git/src/dialogs/open-push-dialog.ts new file mode 100644 index 00000000..d219f4d8 --- /dev/null +++ b/plugins/git/src/dialogs/open-push-dialog.ts @@ -0,0 +1,42 @@ +import { mount, unmount } from 'svelte'; + +import type { GitCommit } from '@nanoforge-dev/editor-sdk'; + +import PushDialog from './PushDialog.svelte'; + +let current: { component: ReturnType; host: HTMLElement } | undefined; + +const close = () => { + if (!current) return; + void unmount(current.component); + current.host.remove(); + current = undefined; +}; + +/** Shows what a push would send, and pushes on confirmation (IntelliJ's Push dialog). */ +export const openPushDialog = (options: { + target: string; + commits: Promise; + onpush: () => void; +}): void => { + close(); + const host = document.createElement('div'); + document.body.append(host); + current = { + host, + component: mount(PushDialog, { + target: host, + props: { + target: options.target, + commits: options.commits, + onpush: () => { + close(); + options.onpush(); + }, + onclose: close, + }, + }), + }; +}; + +export const closePushDialog = close; diff --git a/plugins/git/src/icons.ts b/plugins/git/src/icons.ts new file mode 100644 index 00000000..66fb7ecd --- /dev/null +++ b/plugins/git/src/icons.ts @@ -0,0 +1,34 @@ +import Archive from '@lucide/svelte/icons/archive'; +import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line'; +import ArrowUpFromLine from '@lucide/svelte/icons/arrow-up-from-line'; +import Check from '@lucide/svelte/icons/check'; +import ChevronsDownUp from '@lucide/svelte/icons/chevrons-down-up'; +import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; +import Ellipsis from '@lucide/svelte/icons/ellipsis'; +import FileDiff from '@lucide/svelte/icons/file-diff'; +import GitBranch from '@lucide/svelte/icons/git-branch'; +import History from '@lucide/svelte/icons/history'; +import Minus from '@lucide/svelte/icons/minus'; +import Plus from '@lucide/svelte/icons/plus'; +import RefreshCw from '@lucide/svelte/icons/refresh-cw'; +import Tag from '@lucide/svelte/icons/tag'; +import Undo2 from '@lucide/svelte/icons/undo-2'; + +/** Icons of the Git panel (registering one the editor already has is harmless). */ +export const ICONS = { + archive: Archive, + 'arrow-down-to-line': ArrowDownToLine, + 'arrow-up-from-line': ArrowUpFromLine, + check: Check, + 'chevrons-down-up': ChevronsDownUp, + 'chevrons-up-down': ChevronsUpDown, + 'file-diff': FileDiff, + history: History, + ellipsis: Ellipsis, + 'git-branch': GitBranch, + minus: Minus, + plus: Plus, + 'refresh-cw': RefreshCw, + tag: Tag, + 'undo-2': Undo2, +}; diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts new file mode 100644 index 00000000..28a634ee --- /dev/null +++ b/plugins/git/src/index.ts @@ -0,0 +1,136 @@ +import type { Component } from 'svelte'; + +import { + EditorServices, + ProjectServiceToken, + RpcClientToken, + definePlugin, + derived, +} from '@nanoforge-dev/editor-sdk'; +import { + FILE_DECORATIONS, + MENU_BAR, + NotificationServiceToken, + STATUS_BAR_ITEMS, + StyleServiceToken, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { createActions } from './actions/create-actions'; +import { closePushDialog } from './dialogs/open-push-dialog'; +import { ICONS } from './icons'; +import { branchLabel } from './model/branch-label'; +import { decorationOf } from './model/file-decoration'; +import { gitApi, setSession } from './session/git-session'; +import { GitStore } from './store/git-store'; +import CommitView from './widgets/commit/CommitView.svelte'; +import LogView from './widgets/log/LogView.svelte'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('git.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + + const api = gitApi(services.get(RpcClientToken)); + const projects = services.get(ProjectServiceToken); + const contextKeys = services.get(EditorServices.ContextKeys); + const store = new GitStore((title, error) => + services.tryGet(NotificationServiceToken)?.notify('error', title, { + detail: error instanceof Error ? error.message : String(error), + }), + ); + context.subscriptions.add(store); + const actions = createActions({ + services, + store, + api, + project: () => projects.current.get()?.id, + fs: () => projects.current.get()?.fs, + }); + setSession({ store, api, actions }); + context.subscriptions.add({ dispose: () => setSession(undefined) }); + context.subscriptions.add({ dispose: closePushDialog }); + + let stopWatching: (() => void) | undefined; + context.subscriptions.add({ + dispose: projects.current.subscribe((project) => { + stopWatching?.(); + stopWatching = undefined; + if (!project) { + store.setApi(undefined); + return; + } + const input = { project: project.id }; + store.setApi({ + status: () => api.status(input), + branches: () => api.branches(input), + stashes: () => api.stashes(input), + log: (limit, branch) => api.log({ ...input, limit, ...(branch && { branch }) }), + }); + const watching = project.fs.onDidChange(() => store.schedule()); + stopWatching = () => watching.dispose(); + }), + }); + context.subscriptions.add({ dispose: () => stopWatching?.() }); + const onFocus = () => store.schedule(); + window.addEventListener('focus', onFocus); + context.subscriptions.add({ dispose: () => window.removeEventListener('focus', onFocus) }); + + context.subscriptions.add({ + dispose: store.state.subscribe(({ status }) => + contextKeys.set('git.repository', status?.repository === true), + ), + }); + + for (const [id, component] of [ + ['git.changes', CommitView], + ['git.log', LogView], + ] as const) { + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id, + component: component as unknown as Component<{ instance: WidgetInstance }>, + }), + ); + } + context.subscriptions.add( + context.contribute(STATUS_BAR_ITEMS, { + id: 'git.branch', + alignment: 'left', + order: 3, + when: 'git.repository', + text: derived([store.state], ({ status }) => branchLabel(status)), + tooltip: 'Show branches and history', + command: 'git.showLog', + }), + ); + context.subscriptions.add(context.contribute(MENU_BAR, { id: 'vcs', title: 'VCS', order: 4 })); + context.subscriptions.add( + context.contribute(FILE_DECORATIONS, { + id: 'git.status', + changes: store.state, + decorate: (path, kind) => decorationOf(store.state.get().status, path, kind), + }), + ); + + for (const [id, run] of [ + ['git.init', actions.init], + ['git.commit', actions.showCommit], + ['git.pull', actions.pull], + ['git.push', actions.push], + ['git.fetch', actions.fetch], + ['git.newBranch', () => actions.newBranch()], + ['git.stash', actions.stash], + ['git.showLog', actions.showLog], + ['git.refresh', () => store.refresh()], + ] as const) { + context.subscriptions.add(context.registerCommand(id, () => run())); + } + }, +}); diff --git a/plugins/git/src/model/branch-groups.ts b/plugins/git/src/model/branch-groups.ts new file mode 100644 index 00000000..ba8d1f40 --- /dev/null +++ b/plugins/git/src/model/branch-groups.ts @@ -0,0 +1,11 @@ +import type { GitBranch } from '@nanoforge-dev/editor-sdk'; + +/** Local branches (the current one first) and the branches of remotes. */ +export const branchGroups = ( + branches: readonly GitBranch[], +): { local: GitBranch[]; remote: GitBranch[] } => ({ + local: branches + .filter((branch) => !branch.remote) + .sort((a, b) => Number(b.current) - Number(a.current) || a.name.localeCompare(b.name)), + remote: branches.filter((branch) => branch.remote).sort((a, b) => a.name.localeCompare(b.name)), +}); diff --git a/plugins/git/src/model/branch-label.ts b/plugins/git/src/model/branch-label.ts new file mode 100644 index 00000000..f56911b8 --- /dev/null +++ b/plugins/git/src/model/branch-label.ts @@ -0,0 +1,11 @@ +import type { GitStatus } from '@nanoforge-dev/editor-sdk'; + +/** `main ↑1 ↓2`: the branch and how far it is from its upstream. */ +export const branchLabel = (status: GitStatus | undefined): string => { + if (!status?.repository) return ''; + const name = status.branch ?? (status.head ? `detached at ${status.head}` : 'no branch'); + const sync = [status.ahead && `↑${status.ahead}`, status.behind && `↓${status.behind}`] + .filter(Boolean) + .join(' '); + return sync ? `${name} ${sync}` : name; +}; diff --git a/plugins/git/src/model/change-groups.ts b/plugins/git/src/model/change-groups.ts new file mode 100644 index 00000000..3cd1fb65 --- /dev/null +++ b/plugins/git/src/model/change-groups.ts @@ -0,0 +1,61 @@ +import type { GitChange, GitFile, GitStatus } from '@nanoforge-dev/editor-sdk'; + +import { CHANGE_TITLES } from './change-titles.const'; + +export interface FileRow { + readonly file: GitFile; + /** M, A, D, R, U (unversioned) or C (conflict). */ + readonly letter: GitChange | 'C'; + readonly title: string; + /** File name, and the folder it is in (shown dimmed after the name). */ + readonly name: string; + readonly folder: string; +} + +const row = (file: GitFile, letter: GitChange | 'C'): FileRow => { + const path = file.path.replace(/\/$/, ''); + const slash = path.lastIndexOf('/'); + return { + file, + letter, + title: file.from ? `${CHANGE_TITLES[letter]} (was ${file.from})` : CHANGE_TITLES[letter], + name: path.slice(slash + 1) + (file.path.endsWith('/') ? '/' : ''), + folder: slash < 0 ? '' : path.slice(0, slash), + }; +}; + +export interface ChangeGroups { + readonly conflicts: FileRow[]; + /** Files git knows that changed, staged or not. */ + readonly changes: FileRow[]; + /** Files git does not know yet. */ + readonly unversioned: FileRow[]; +} + +/** The files of a status as the Commit panel lists them. */ +export const changeGroups = (status: Pick): ChangeGroups => ({ + conflicts: status.files.filter((file) => file.conflicted).map((file) => row(file, 'C')), + changes: status.files + .filter((file) => !file.conflicted && (file.staged || file.unstaged !== 'U')) + .map((file) => row(file, file.staged ?? file.unstaged ?? 'M')), + unversioned: status.files + .filter((file) => !file.conflicted && !file.staged && file.unstaged === 'U') + .map((file) => row(file, 'U')), +}); + +/** + * The files checked for the next commit after the list changed: files already listed keep + * their state, new changes are checked, new unversioned files are not. + */ +export const nextChecked = ( + checked: ReadonlySet, + known: ReadonlySet, + groups: ChangeGroups, +): Set => { + const next = new Set(); + for (const { file } of [...groups.changes, ...groups.unversioned]) { + if (known.has(file.path) ? checked.has(file.path) : file.unstaged !== 'U' || !!file.staged) + next.add(file.path); + } + return next; +}; diff --git a/plugins/git/src/model/change-titles.const.ts b/plugins/git/src/model/change-titles.const.ts new file mode 100644 index 00000000..2fa56290 --- /dev/null +++ b/plugins/git/src/model/change-titles.const.ts @@ -0,0 +1,11 @@ +import type { GitChange } from '@nanoforge-dev/editor-sdk'; + +/** What each change letter means, in tooltips and row titles. */ +export const CHANGE_TITLES: Record = { + M: 'Modified', + A: 'Added', + D: 'Deleted', + R: 'Renamed', + U: 'Unversioned', + C: 'Conflict', +}; diff --git a/plugins/git/src/model/file-decoration.ts b/plugins/git/src/model/file-decoration.ts new file mode 100644 index 00000000..951ec5ba --- /dev/null +++ b/plugins/git/src/model/file-decoration.ts @@ -0,0 +1,33 @@ +import type { GitStatus } from '@nanoforge-dev/editor-sdk'; +import type { FileDecoration } from '@nanoforge-dev/editor-sdk/ui'; + +import { CHANGE_TITLES } from './change-titles.const'; + +const TONES = { M: 'modified', R: 'modified', A: 'added', U: 'added', D: 'deleted' } as const; + +/** The mark of a file or folder in the Files panel. */ +export const decorationOf = ( + status: Pick | undefined, + path: string, + kind: 'file' | 'directory', +): FileDecoration | undefined => { + if (!status?.files.length) return undefined; + if (kind === 'directory') { + const inside = status.files.filter((file) => file.path.startsWith(`${path}/`)); + if (!inside.length) return undefined; + const conflict = inside.some((file) => file.conflicted); + return { + badge: '•', + tone: conflict ? 'conflict' : 'modified', + tooltip: `${inside.length} changed ${inside.length === 1 ? 'file' : 'files'} inside`, + }; + } + const file = status.files.find( + (candidate) => + candidate.path === path || (candidate.path.endsWith('/') && path.startsWith(candidate.path)), + ); + if (!file) return undefined; + if (file.conflicted) return { badge: 'C', tone: 'conflict', tooltip: CHANGE_TITLES.C }; + const letter = file.unstaged ?? file.staged; + return letter && { badge: letter, tone: TONES[letter], tooltip: CHANGE_TITLES[letter] }; +}; diff --git a/plugins/git/src/model/relative-time.ts b/plugins/git/src/model/relative-time.ts new file mode 100644 index 00000000..ecadfb3b --- /dev/null +++ b/plugins/git/src/model/relative-time.ts @@ -0,0 +1,21 @@ +/** `3 min ago`, `2 days ago`, from seconds since epoch. */ +export const relativeTime = (seconds: number, now = Date.now()): string => { + const elapsed = Math.max(Math.round(now / 1000 - seconds), 0); + const units: [number, string][] = [ + [60, 'second'], + [60, 'minute'], + [24, 'hour'], + [30, 'day'], + [12, 'month'], + [Infinity, 'year'], + ]; + let value = elapsed; + for (const [size, unit] of units) { + if (value < size) { + if (unit === 'second') return 'just now'; + return `${value} ${unit}${value === 1 ? '' : 's'} ago`; + } + value = Math.floor(value / size); + } + return ''; +}; diff --git a/plugins/git/src/session/git-session.ts b/plugins/git/src/session/git-session.ts new file mode 100644 index 00000000..5411e6de --- /dev/null +++ b/plugins/git/src/session/git-session.ts @@ -0,0 +1,25 @@ +import { GitContract, type RpcClient } from '@nanoforge-dev/editor-sdk'; + +import type { GitActions } from '../actions/create-actions'; +import type { GitStore } from '../store/git-store'; + +export const gitApi = (rpc: RpcClient) => rpc.api(GitContract); +export type GitRpc = ReturnType; + +/** What the plugin shares with its panel. */ +export interface GitSession { + readonly store: GitStore; + readonly api: GitRpc; + readonly actions: GitActions; +} + +let session: GitSession | undefined; + +export const setSession = (value: GitSession | undefined): void => { + session = value; +}; + +export const getSession = (): GitSession => { + if (!session) throw new Error('The git plugin is not active'); + return session; +}; diff --git a/plugins/git/src/store/git-store.ts b/plugins/git/src/store/git-store.ts new file mode 100644 index 00000000..5ee3d82b --- /dev/null +++ b/plugins/git/src/store/git-store.ts @@ -0,0 +1,171 @@ +import { + type Disposable, + type GitBranch, + type GitCommit, + type GitStash, + type GitStatus, + ObservableValue, +} from '@nanoforge-dev/editor-sdk'; + +/** The git API of one project (the RPC contract with the project bound). */ +export interface GitApi { + status(): Promise; + branches(): Promise; + stashes(): Promise; + /** The history of a branch (the current one when undefined). */ + log(limit: number, branch: string | undefined): Promise; +} + +export interface GitState { + /** Undefined until the first answer. */ + readonly status: GitStatus | undefined; + readonly branches: readonly GitBranch[]; + readonly stashes: readonly GitStash[]; + readonly log: readonly GitCommit[]; + /** Branch whose history is listed; undefined: the current one. */ + readonly logBranch: string | undefined; + /** Commits asked for (_Show more_ raises it). */ + readonly logLimit: number; + /** Whether the history may hold more commits than shown. */ + readonly more: boolean; + /** An operation is running. */ + readonly busy: boolean; + /** Why git can't be used here (a hosted editor), if so. */ + readonly unavailable: string | undefined; +} + +const EMPTY: GitState = { + status: undefined, + branches: [], + stashes: [], + log: [], + logBranch: undefined, + logLimit: 50, + more: false, + busy: false, + unavailable: undefined, +}; + +const PAGE = 50; + +/** + * What the Git panel, the status bar and the Files marks show: the repository state of the open + * project, refreshed after each operation and when files change (debounced). + */ +export class GitStore implements Disposable { + private readonly _state = new ObservableValue(EMPTY); + private _api: GitApi | undefined; + private _timer: ReturnType | undefined; + private _generation = 0; + private _refreshing: Promise | undefined; + private _queued: Promise | undefined; + + readonly state = this._state.readonly(); + + constructor( + private readonly _onError: (title: string, error: unknown) => void = () => undefined, + private readonly _delayMs = 400, + ) {} + + /** Follows a project's repository (undefined: no project). */ + setApi(api: GitApi | undefined): void { + this._api = api; + this._generation++; + clearTimeout(this._timer); + this._state.set(EMPTY); + if (api) void this.refresh(); + } + + /** Refreshes soon: for bursts of file changes. */ + schedule(): void { + if (!this._api || this._state.get().unavailable) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => void this.refresh(), this._delayMs); + } + + /** + * Reads the repository again. A call made while a read runs gets one more read after it + * (shared by every such call), so its answer is never older than the call. + */ + refresh(): Promise { + if (!this._refreshing) { + this._refreshing = this._read().finally(() => (this._refreshing = undefined)); + return this._refreshing; + } + this._queued ??= this._refreshing.then(() => { + this._queued = undefined; + return this.refresh(); + }); + return this._queued; + } + + /** Runs an operation, then refreshes. Resolves false when it failed (the error is reported). */ + async run(title: string, operation: () => Promise): Promise { + this._patch({ busy: true }); + try { + await operation(); + return true; + } catch (error) { + this._onError(title, error); + return false; + } finally { + this._patch({ busy: false }); + await this.refresh(); + } + } + + /** Lists the history of a branch (undefined: the current one). */ + showBranch(branch: string | undefined): Promise { + this._patch({ logBranch: branch, logLimit: PAGE }); + return this.refresh(); + } + + showMore(): Promise { + this._patch({ logLimit: this._state.get().logLimit + PAGE }); + return this.refresh(); + } + + dispose(): void { + clearTimeout(this._timer); + this._api = undefined; + this._generation++; + } + + private async _read(): Promise { + const api = this._api; + const generation = this._generation; + if (!api) return; + try { + const status = await api.status(); + const limit = this._state.get().logLimit; + const branches = status.repository ? await api.branches() : []; + const wanted = this._state.get().logBranch; + const logBranch = + wanted !== status.branch && branches.some((branch) => branch.name === wanted) + ? wanted + : undefined; + const [stashes, log] = status.repository + ? await Promise.all([api.stashes(), api.log(limit, logBranch)]) + : [[], []]; + if (generation !== this._generation) return; + this._patch({ + status, + branches, + stashes, + log, + logBranch, + more: log.length >= limit, + unavailable: undefined, + }); + } catch (error) { + if (generation !== this._generation) return; + if ((error as { code?: string }).code === 'FORBIDDEN') { + this._patch({ ...EMPTY, unavailable: 'Git is available in local editors only.' }); + } else this._onError('Could not read the repository', error); + } + } + + private _patch(patch: Partial): void { + this._state.set({ ...this._state.get(), ...patch }); + } +} diff --git a/plugins/git/src/widgets/ConfirmDialog.svelte b/plugins/git/src/widgets/ConfirmDialog.svelte new file mode 100644 index 00000000..202cfd23 --- /dev/null +++ b/plugins/git/src/widgets/ConfirmDialog.svelte @@ -0,0 +1,26 @@ + + + (request = undefined)} +> + {#snippet footer()} + + + {/snippet} + diff --git a/plugins/git/src/widgets/RepositoryGate.svelte b/plugins/git/src/widgets/RepositoryGate.svelte new file mode 100644 index 00000000..5861c99f --- /dev/null +++ b/plugins/git/src/widgets/RepositoryGate.svelte @@ -0,0 +1,34 @@ + + +{#if $git.unavailable} + +{:else if !$git.status} + +{:else if !$git.status.repository} + +{:else} + {@render children($git.status)} +{/if} diff --git a/plugins/git/src/widgets/commit/ChangeGroupNode.svelte b/plugins/git/src/widgets/commit/ChangeGroupNode.svelte new file mode 100644 index 00000000..2baa8f59 --- /dev/null +++ b/plugins/git/src/widgets/commit/ChangeGroupNode.svelte @@ -0,0 +1,223 @@ + + +{#snippet fileRow(row: FileRow, checkable: boolean)} +
  • onselect(event, row.file.path)} + ondblclick={() => ondiff(row)} + oncontextmenu={(event) => { + if (!selected.includes(row.file.path)) onselect(event, row.file.path); + }} + onkeydown={() => undefined} + > + {#if checkable} + event.stopPropagation()} + onchange={(event) => oncheck([row.file.path], event.currentTarget.checked)} + /> + {/if} + + {row.name} + {#if row.folder}{row.folder}{/if} + {#if row.letter === 'C'} + + {/if} +
  • +{/snippet} + +
    + +
    + + {#if group.checkable} + 0 && count === group.rows.length} + indeterminate={count > 0 && count < group.rows.length} + disabled={!group.rows.length} + onclick={(event) => event.stopPropagation()} + onchange={(event) => + oncheck( + group.rows.map((row) => row.file.path), + event.currentTarget.checked, + )} + /> + {/if} + {group.title} + {group.rows.length} {group.rows.length === 1 ? 'file' : 'files'} +
    + {#if !folded} +
      + {#each group.rows.slice(0, MAX_ROWS) as row (row.file.path)} + {@render fileRow(row, group.checkable)} + {/each} +
    + {#if group.rows.length > MAX_ROWS} +

    + {group.rows.length - MAX_ROWS} more files are not shown. If they are dependencies or build output, + add their folder to .gitignore. +

    + {/if} + {/if} +
    + + diff --git a/plugins/git/src/widgets/commit/ChangesTab.svelte b/plugins/git/src/widgets/commit/ChangesTab.svelte new file mode 100644 index 00000000..5ee36db4 --- /dev/null +++ b/plugins/git/src/widgets/commit/ChangesTab.svelte @@ -0,0 +1,280 @@ + + + + +
    { + const target = event.target as HTMLElement; + if (!target.closest('li') && !target.closest('.group')) { + panel.selected = []; + panel.selectedGroup = undefined; + } + }} +> + + {#each groups as group (group.id)} + panel.setFolded(group, on)} + onselect={select} + onselectgroup={() => panel.selectGroup(group)} + oncheck={(paths, on) => panel.setChecked(paths, on)} + ondiff={showDiff} + /> + {/each} + +
    + 0} {panel} /> + + diff --git a/plugins/git/src/widgets/commit/CommitMessage.svelte b/plugins/git/src/widgets/commit/CommitMessage.svelte new file mode 100644 index 00000000..4153c2d0 --- /dev/null +++ b/plugins/git/src/widgets/commit/CommitMessage.svelte @@ -0,0 +1,133 @@ + + +
    +
    + + + + {#snippet trigger({ props })} + + {/snippet} + +
    + +
    + + +
    +
    + + diff --git a/plugins/git/src/widgets/commit/CommitView.svelte b/plugins/git/src/widgets/commit/CommitView.svelte new file mode 100644 index 00000000..84740f42 --- /dev/null +++ b/plugins/git/src/widgets/commit/CommitView.svelte @@ -0,0 +1,85 @@ + + +
    + + {#snippet actions()} + + {/snippet} + {#snippet children(status)} + (tab = id)} + items={[ + { id: 'commit', label: 'Commit' }, + { id: 'stash', label: `Stash${$git.stashes.length ? ` (${$git.stashes.length})` : ''}` }, + ]} + /> + {#if tab === 'commit'} + + {:else} + + {/if} + {/snippet} + +
    + + + + diff --git a/plugins/git/src/widgets/commit/StashTab.svelte b/plugins/git/src/widgets/commit/StashTab.svelte new file mode 100644 index 00000000..deff87ea --- /dev/null +++ b/plugins/git/src/widgets/commit/StashTab.svelte @@ -0,0 +1,93 @@ + + + +
      + {#each $git.stashes as entry (entry.index)} +
    • + {entry.message} + + + +
    • + {:else} +
    • No stash.
    • + {/each} +
    + + diff --git a/plugins/git/src/widgets/commit/change-group.ts b/plugins/git/src/widgets/commit/change-group.ts new file mode 100644 index 00000000..3ee5d5db --- /dev/null +++ b/plugins/git/src/widgets/commit/change-group.ts @@ -0,0 +1,16 @@ +import type { FileRow } from '../../model/change-groups'; + +/** A node of the Commit panel's tree. */ +export interface ChangeGroup { + readonly id: 'conflicts' | 'changes' | 'unversioned'; + readonly title: string; + readonly rows: readonly FileRow[]; + readonly checkable: boolean; +} + +/** Rows drawn per group: a folder of dependencies that is not ignored can hold thousands. */ +export const MAX_ROWS = 500; + +/** A renamed file is committed and rolled back with where it was: both halves of the rename. */ +export const pathsOf = (row: FileRow): string[] => + row.file.from ? [row.file.path, row.file.from] : [row.file.path]; diff --git a/plugins/git/src/widgets/commit/commit-panel-state.svelte.ts b/plugins/git/src/widgets/commit/commit-panel-state.svelte.ts new file mode 100644 index 00000000..399505ad --- /dev/null +++ b/plugins/git/src/widgets/commit/commit-panel-state.svelte.ts @@ -0,0 +1,88 @@ +import { type ChangeGroups, nextChecked } from '../../model/change-groups'; +import type { ChangeGroup } from './change-group'; + +type Modifiers = { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }; + +/** + * What the Commit panel keeps while its tabs come and go: the folded groups, the commit message, + * the files checked for the commit and the selection. + */ +export class CommitPanelState { + folded = $state([]); + /** The last commit messages, the newest first (the clock button). */ + messages = $state([]); + message = $state(''); + amend = $state(false); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, never mutated + checked = $state>(new Set()); + selected = $state([]); + /** A group node selected by a click on it (its files are then all selected). */ + selectedGroup = $state(); + cursor = $state(); + private _anchor: string | undefined; + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, never mutated + private _known: ReadonlySet = new Set(); + + constructor(saved: { folded?: readonly string[]; messages?: readonly string[] }) { + this.folded = saved.folded ?? []; + this.messages = saved.messages ?? []; + } + + isFolded(group: ChangeGroup): boolean { + return this.folded.includes(group.id); + } + + setFolded(group: ChangeGroup, on: boolean): void { + this.folded = on + ? // eslint-disable-next-line svelte/prefer-svelte-reactivity -- a throwaway copy + [...new Set([...this.folded, group.id])] + : this.folded.filter((id) => id !== group.id); + } + + /** Follows a new list of changes: see `nextChecked`. */ + syncChecked(lists: ChangeGroups): void { + this.checked = nextChecked(this.checked, this._known, lists); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, never mutated + this._known = new Set([...lists.changes, ...lists.unversioned].map((row) => row.file.path)); + } + + setChecked(paths: readonly string[], on: boolean): void { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, never mutated + const next = new Set(this.checked); + for (const path of paths) { + if (on) next.add(path); + else next.delete(path); + } + this.checked = next; + } + + /** A click on a file: `order` is the visible files, for a Shift range. */ + select(event: Modifiers, path: string, order: readonly string[]): void { + this.selectedGroup = undefined; + this.cursor = path; + if (event.shiftKey && this._anchor) { + const [from, to] = [order.indexOf(this._anchor), order.indexOf(path)].sort((x, y) => x - y); + this.selected = order.slice(Math.max(from!, 0), to! + 1); + return; + } + if (event.ctrlKey || event.metaKey) { + this.selected = this.selected.includes(path) + ? this.selected.filter((other) => other !== path) + : [...this.selected, path]; + } else this.selected = [path]; + this._anchor = path; + } + + /** A click on a group node selects the node, and with it every file of the group. */ + selectGroup(group: ChangeGroup): void { + this.selectedGroup = group.id; + this.selected = group.rows.map((row) => row.file.path); + this._anchor = this.selected[0]; + this.cursor = this.selected[0]; + } + + /** Keeps a commit message in the history. */ + remember(message: string): void { + this.messages = [message, ...this.messages.filter((other) => other !== message)].slice(0, 10); + } +} diff --git a/plugins/git/src/widgets/confirmation.type.ts b/plugins/git/src/widgets/confirmation.type.ts new file mode 100644 index 00000000..87487868 --- /dev/null +++ b/plugins/git/src/widgets/confirmation.type.ts @@ -0,0 +1,8 @@ +/** A destructive action waiting for the user's confirmation. */ +export interface Confirmation { + readonly title: string; + readonly description: string; + /** The label of the button that runs it. */ + readonly confirm: string; + readonly run: () => void; +} diff --git a/plugins/git/src/widgets/log/BranchTree.svelte b/plugins/git/src/widgets/log/BranchTree.svelte new file mode 100644 index 00000000..4f59186c --- /dev/null +++ b/plugins/git/src/widgets/log/BranchTree.svelte @@ -0,0 +1,321 @@ + + +{#snippet branchRow(branch: GitBranch, label: string)} +
  • + +
  • +{/snippet} + +{#snippet node(id: string, title: string)} + +{/snippet} + + + + diff --git a/plugins/git/src/widgets/log/CommitDetails.svelte b/plugins/git/src/widgets/log/CommitDetails.svelte new file mode 100644 index 00000000..ddbfb40f --- /dev/null +++ b/plugins/git/src/widgets/log/CommitDetails.svelte @@ -0,0 +1,166 @@ + + + + + diff --git a/plugins/git/src/widgets/log/CommitTable.svelte b/plugins/git/src/widgets/log/CommitTable.svelte new file mode 100644 index 00000000..5e62796d --- /dev/null +++ b/plugins/git/src/widgets/log/CommitTable.svelte @@ -0,0 +1,257 @@ + + +
    +
    + + Branch: {shownBranch ?? 'detached'}{status.upstream && !$git.logBranch + ? ` ↑${status.ahead} ↓${status.behind}` + : ''} + (range = value)} + /> +
    + +
    + + + {#each commits as commit, index (commit.hash)} + (selectedHash = commit.hash)} + oncontextmenu={() => { + menuCommit = commit; + selectedHash = commit.hash; + }} + onkeydown={(event) => event.key === 'Enter' && (selectedHash = commit.hash)} + > + + + + + + {/each} + +
    +
    + {commit.subject} + {#each commit.refs as ref (ref)} + + {/each} +
    +
    {commit.author}{commitTime(commit.time, now)}
    + {#if !commits.length} +

    {$git.log.length ? 'No commit matches.' : 'No commit yet.'}

    + {/if} + {#if $git.more} + + {/if} +
    +
    +
    + + diff --git a/plugins/git/src/widgets/log/LogView.svelte b/plugins/git/src/widgets/log/LogView.svelte new file mode 100644 index 00000000..8dae1f73 --- /dev/null +++ b/plugins/git/src/widgets/log/LogView.svelte @@ -0,0 +1,48 @@ + + +
    + + {#snippet children(status)} + (confirming = request)} /> + + + {/snippet} + +
    + + + + diff --git a/plugins/git/src/widgets/log/RefLabel.svelte b/plugins/git/src/widgets/log/RefLabel.svelte new file mode 100644 index 00000000..12d2ae57 --- /dev/null +++ b/plugins/git/src/widgets/log/RefLabel.svelte @@ -0,0 +1,33 @@ + + +{ref.replace(/^tag: /, '')} + + diff --git a/plugins/git/src/widgets/log/commit-format.ts b/plugins/git/src/widgets/log/commit-format.ts new file mode 100644 index 00000000..a5560744 --- /dev/null +++ b/plugins/git/src/widgets/log/commit-format.ts @@ -0,0 +1,32 @@ +import type { GitBranch, GitStatus } from '@nanoforge-dev/editor-sdk'; + +export type RefKind = 'head' | 'local' | 'remote' | 'tag'; + +/** `Today 14:32`, `Yesterday 09:10`, else the date and time. */ +export const commitTime = (seconds: number, now: number): string => { + const date = new Date(seconds * 1000); + const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + const today = new Date(now); + const startOfToday = Date.parse( + `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}T00:00:00`, + ); + if (date.getTime() >= startOfToday) return `Today ${time}`; + if (date.getTime() >= startOfToday - 86_400_000) return `Yesterday ${time}`; + return `${date.toLocaleDateString()} ${time}`; +}; + +/** Labels after IntelliJ's: the current branch, local branches, remote branches, tags. */ +export const refKind = (ref: string, status: GitStatus, branches: readonly GitBranch[]): RefKind => + ref.startsWith('tag: ') + ? 'tag' + : ref === status.branch + ? 'head' + : branches.find((branch) => branch.name === ref)?.remote + ? 'remote' + : 'local'; + +/** The file name of a path, and the folder it is in. */ +export const splitPath = (path: string): { name: string; folder: string } => { + const slash = path.lastIndexOf('/'); + return { name: path.slice(slash + 1), folder: slash < 0 ? '' : path.slice(0, slash) }; +}; diff --git a/plugins/git/svelte.config.js b/plugins/git/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/git/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/git/test/fixtures/status.ts b/plugins/git/test/fixtures/status.ts new file mode 100644 index 00000000..8811cf68 --- /dev/null +++ b/plugins/git/test/fixtures/status.ts @@ -0,0 +1,18 @@ +import type { GitStatus } from '@nanoforge-dev/editor-sdk'; + +export const status = (patch: Partial = {}): GitStatus => ({ + repository: true, + branch: 'main', + head: 'abc1234', + ahead: 0, + behind: 0, + merging: false, + files: [ + { path: 'a.txt', staged: 'M', unstaged: 'M', conflicted: false }, + { path: 'src/new.ts', unstaged: 'U', conflicted: false }, + { path: 'src/gone.ts', staged: 'D', conflicted: false }, + { path: 'moved.ts', from: 'old.ts', staged: 'R', conflicted: false }, + { path: 'src/deep/both.ts', conflicted: true }, + ], + ...patch, +}); diff --git a/plugins/git/test/model/branch-groups.spec.ts b/plugins/git/test/model/branch-groups.spec.ts new file mode 100644 index 00000000..6dd36a2b --- /dev/null +++ b/plugins/git/test/model/branch-groups.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { branchGroups } from '../../src/model/branch-groups'; + +describe('branchGroups', () => { + it('groups branches: local with the current one first, then remote', () => { + const groups = branchGroups([ + { name: 'feature', current: false, remote: false }, + { name: 'origin/main', current: false, remote: true }, + { name: 'main', current: true, remote: false }, + ]); + expect(groups.local.map((branch) => branch.name)).toEqual(['main', 'feature']); + expect(groups.remote.map((branch) => branch.name)).toEqual(['origin/main']); + }); +}); diff --git a/plugins/git/test/model/branch-label.spec.ts b/plugins/git/test/model/branch-label.spec.ts new file mode 100644 index 00000000..b0073fab --- /dev/null +++ b/plugins/git/test/model/branch-label.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { branchLabel } from '../../src/model/branch-label'; +import { status } from '../fixtures/status'; + +describe('branchLabel', () => { + it('labels the branch with what is ahead and behind', () => { + expect(branchLabel(status())).toBe('main'); + expect(branchLabel(status({ ahead: 2, behind: 1 }))).toBe('main ↑2 ↓1'); + expect(branchLabel(status({ behind: 3 }))).toBe('main ↓3'); + expect(branchLabel(status({ branch: undefined }))).toBe('detached at abc1234'); + expect(branchLabel({ ...status(), repository: false })).toBe(''); + expect(branchLabel(undefined)).toBe(''); + }); +}); diff --git a/plugins/git/test/model/change-groups.spec.ts b/plugins/git/test/model/change-groups.spec.ts new file mode 100644 index 00000000..2d2b1466 --- /dev/null +++ b/plugins/git/test/model/change-groups.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { changeGroups, nextChecked } from '../../src/model/change-groups'; +import { status } from '../fixtures/status'; + +describe('changeGroups', () => { + it('lists conflicts, changes and unversioned files, with name and folder', () => { + const groups = changeGroups(status()); + const names = (rows: { letter: string; file: { path: string } }[]) => + rows.map((row) => `${row.letter} ${row.file.path}`); + expect(names(groups.conflicts)).toEqual(['C src/deep/both.ts']); + expect(names(groups.changes)).toEqual(['M a.txt', 'D src/gone.ts', 'R moved.ts']); + expect(names(groups.unversioned)).toEqual(['U src/new.ts']); + expect(groups.changes[2]!.title).toBe('Renamed (was old.ts)'); + expect(groups.changes[1]).toMatchObject({ name: 'gone.ts', folder: 'src' }); + expect(groups.changes[0]).toMatchObject({ name: 'a.txt', folder: '' }); + const folder = changeGroups({ + files: [{ path: 'deps/lib/', unstaged: 'U', conflicted: false }], + }); + expect(folder.unversioned[0]).toMatchObject({ name: 'lib/', folder: 'deps' }); + }); +}); + +describe('nextChecked', () => { + it('checks new changes for the commit, not new unversioned files, and keeps choices', () => { + const groups = changeGroups(status()); + const first = nextChecked(new Set(), new Set(), groups); + expect([...first].sort()).toEqual(['a.txt', 'moved.ts', 'src/gone.ts']); + const known = new Set(['a.txt', 'moved.ts', 'src/gone.ts', 'src/new.ts']); + const chosen = new Set(['moved.ts', 'src/gone.ts', 'src/new.ts']); + expect([...nextChecked(chosen, known, groups)].sort()).toEqual([ + 'moved.ts', + 'src/gone.ts', + 'src/new.ts', + ]); + expect(nextChecked(chosen, known, changeGroups({ files: [] })).size).toBe(0); + }); +}); diff --git a/plugins/git/test/model/file-decoration.spec.ts b/plugins/git/test/model/file-decoration.spec.ts new file mode 100644 index 00000000..ba88cd7a --- /dev/null +++ b/plugins/git/test/model/file-decoration.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { decorationOf } from '../../src/model/file-decoration'; +import { status } from '../fixtures/status'; + +describe('decorationOf', () => { + it('marks files and the folders that hold changes', () => { + const value = status(); + expect(decorationOf(value, 'a.txt', 'file')).toEqual({ + badge: 'M', + tone: 'modified', + tooltip: 'Modified', + }); + expect(decorationOf(value, 'src/new.ts', 'file')).toMatchObject({ badge: 'U', tone: 'added' }); + expect(decorationOf(value, 'src/gone.ts', 'file')).toMatchObject({ + badge: 'D', + tone: 'deleted', + }); + expect(decorationOf(value, 'src/deep/both.ts', 'file')).toMatchObject({ badge: 'C' }); + expect(decorationOf(value, 'clean.txt', 'file')).toBeUndefined(); + expect(decorationOf(value, 'src', 'directory')).toEqual({ + badge: '•', + tone: 'conflict', + tooltip: '3 changed files inside', + }); + expect(decorationOf(value, 'sr', 'directory')).toBeUndefined(); + const untracked = status({ files: [{ path: 'deps/', unstaged: 'U', conflicted: false }] }); + expect(decorationOf(untracked, 'deps', 'directory')).toMatchObject({ badge: '•' }); + expect(decorationOf(untracked, 'deps/lib/index.js', 'file')).toMatchObject({ badge: 'U' }); + expect(decorationOf(untracked, 'depsy.js', 'file')).toBeUndefined(); + expect(decorationOf(undefined, 'a.txt', 'file')).toBeUndefined(); + }); +}); diff --git a/plugins/git/test/model/relative-time.spec.ts b/plugins/git/test/model/relative-time.spec.ts new file mode 100644 index 00000000..197532b2 --- /dev/null +++ b/plugins/git/test/model/relative-time.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { relativeTime } from '../../src/model/relative-time'; + +describe('relativeTime', () => { + it('tells how long ago', () => { + const now = 1_000_000_000; + const ago = (seconds: number) => relativeTime(now / 1000 - seconds, now); + expect(ago(5)).toBe('just now'); + expect(ago(60)).toBe('1 minute ago'); + expect(ago(3 * 3600)).toBe('3 hours ago'); + expect(ago(2 * 86400)).toBe('2 days ago'); + expect(ago(400 * 86400)).toBe('1 year ago'); + }); +}); diff --git a/plugins/git/test/store/git-store.spec.ts b/plugins/git/test/store/git-store.spec.ts new file mode 100644 index 00000000..954f176e --- /dev/null +++ b/plugins/git/test/store/git-store.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import type { GitStatus } from '@nanoforge-dev/editor-sdk'; + +import { GitStore } from '../../src/store/git-store'; +import { status } from '../fixtures/status'; + +describe('GitStore', () => { + const api = (value: GitStatus) => { + const calls: string[] = []; + return { + calls, + api: { + status: async () => (calls.push('status'), value), + branches: async () => ( + calls.push('branches'), + [ + { name: 'main', current: true, remote: false }, + { name: 'feature', current: false, remote: false }, + ] + ), + stashes: async () => (calls.push('stashes'), []), + log: async (limit: number, branch?: string) => ( + calls.push(`log ${limit}${branch ? ` ${branch}` : ''}`), + [] + ), + }, + }; + }; + + it('reads the repository, and nothing more than the status without one', async () => { + const store = new GitStore(); + const repo = api(status()); + store.setApi(repo.api); + await store.refresh(); + expect(store.state.get()).toMatchObject({ + status: { branch: 'main' }, + branches: [{ name: 'main', current: true }, { name: 'feature' }], + busy: false, + }); + const none = api({ ...status(), repository: false, files: [] }); + store.setApi(none.api); + await store.refresh(); + expect(new Set(none.calls)).toEqual(new Set(['status'])); + expect(store.state.get().status?.repository).toBe(false); + }); + + it('refreshes after an operation and reports a failure', async () => { + const errors: string[] = []; + const store = new GitStore((title, error) => errors.push(`${title}: ${String(error)}`)); + const repo = api(status()); + store.setApi(repo.api); + await store.refresh(); + repo.calls.length = 0; + expect(await store.run('Commit', async () => undefined)).toBe(true); + expect(repo.calls).toContain('status'); + expect( + await store.run('Push failed', async () => { + throw new Error('rejected'); + }), + ).toBe(false); + expect(errors).toEqual(['Push failed: Error: rejected']); + expect(store.state.get().busy).toBe(false); + }); + + it('says so in a hosted editor, and asks for more history', async () => { + const store = new GitStore(); + store.setApi({ + ...api(status()).api, + status: async () => { + throw Object.assign(new Error('no'), { code: 'FORBIDDEN' }); + }, + }); + await store.refresh(); + expect(store.state.get().unavailable).toMatch(/local editors/); + const repo = api(status()); + store.setApi(repo.api); + await store.refresh(); + await store.showMore(); + expect(repo.calls).toContain('log 100'); + await store.showBranch('feature'); + expect(repo.calls).toContain('log 50 feature'); + await store.showBranch('gone'); + expect(store.state.get().logBranch).toBeUndefined(); + }); +}); diff --git a/plugins/git/tsconfig.json b/plugins/git/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/git/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/git/vite.config.ts b/plugins/git/vite.config.ts new file mode 100644 index 00000000..8dbac00d --- /dev/null +++ b/plugins/git/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'git' }, + }, +}); diff --git a/plugins/history-panel/.prettierignore b/plugins/history-panel/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/history-panel/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/history-panel/LICENSE b/plugins/history-panel/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/history-panel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/history-panel/README.md b/plugins/history-panel/README.md new file mode 100644 index 00000000..03ec2791 --- /dev/null +++ b/plugins/history-panel/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-history-panel` is an internal package of the NanoForge Editor. + +Built-in history panel. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/history-panel +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/history-panel/nanoforge.manifest.json b/plugins/history-panel/nanoforge.manifest.json new file mode 100644 index 00000000..7113e4c2 --- /dev/null +++ b/plugins/history-panel/nanoforge.manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/history-panel", + "version": "0.1.0", + "displayName": "History", + "description": "Every change you can undo: jump to a step, previews, search.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onWidget:history-panel.history"], + "contributes": { + "widgets": [ + { + "id": "history-panel.history", + "title": "History", + "icon": "history", + "defaultSlot": "rightTop", + "order": 100 + } + ] + } +} diff --git a/plugins/history-panel/package.json b/plugins/history-panel/package.json new file mode 100644 index 00000000..158ad92c --- /dev/null +++ b/plugins/history-panel/package.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-history-panel", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in history panel", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/history-panel" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/history-panel/src/index.ts b/plugins/history-panel/src/index.ts new file mode 100644 index 00000000..b20a4018 --- /dev/null +++ b/plugins/history-panel/src/index.ts @@ -0,0 +1,21 @@ +import type { Component } from 'svelte'; + +import { definePlugin } from '@nanoforge-dev/editor-sdk'; +import { StyleServiceToken, WIDGET_VIEWS, type WidgetInstance } from '@nanoforge-dev/editor-sdk/ui'; + +import HistoryView from './widgets/history/HistoryView.svelte'; + +export default definePlugin({ + async activate(context) { + const css = await fetch(context.resolveAsset('history-panel.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(context.services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id: 'history-panel.history', + component: HistoryView as Component<{ instance: WidgetInstance }>, + }), + ); + }, +}); diff --git a/plugins/history-panel/src/widgets/history/ContextSection.svelte b/plugins/history-panel/src/widgets/history/ContextSection.svelte new file mode 100644 index 00000000..d1a72fcb --- /dev/null +++ b/plugins/history-panel/src/widgets/history/ContextSection.svelte @@ -0,0 +1,280 @@ + + +
    +
    + {#if collapsible} + + {:else} +

    {context.label}

    + {/if} + +
    + + {#if !collapsed} +
      + {#if !query.trim()} +
    1. + +
    2. + {/if} + {#each [...past.map( (entry) => ({ entry, undone: false }) ), ...future.map( (entry) => ({ entry, undone: true }) )] as { entry, undone } (entry.id)} + {@const origin = originOf(entry)} + {@const children = entry.command.children ?? []} +
    3. (previewed = entry.id)} + onmouseleave={() => previewed === entry.id && (previewed = undefined)} + > +
      + {#if children.length} + toggle(entry.id)} + /> + {/if} + +
      + {#if expanded.has(entry.id)} +
        + {#each children as child, index (index)}
      1. {child.label}
      2. {/each} +
      + {/if} + {#if previewed === entry.id && entry.command.preview} +
      + {#if entry.command.preview.title}

      + {entry.command.preview.title} +

      {/if} +
      {entry.command.preview.before}
      +
      {entry.command.preview.after}
      +
      + {/if} +
    4. + {:else} + {#if query.trim()}
    5. No matching change
    6. {/if} + {/each} +
    + {/if} +
    + + diff --git a/plugins/history-panel/src/widgets/history/HistoryView.svelte b/plugins/history-panel/src/widgets/history/HistoryView.svelte new file mode 100644 index 00000000..2ea75f3e --- /dev/null +++ b/plugins/history-panel/src/widgets/history/HistoryView.svelte @@ -0,0 +1,134 @@ + + +
    +
    + + + + + +
    +
    + {#each shown as context (context.id)} + void history.goTo(context.id, entryId)} + onclear={() => (clearing = context.id)} + /> + {:else} + + {/each} +
    +
    + + (clearing = undefined)} +> + {#snippet footer()} + + + {/snippet} + + + diff --git a/plugins/history-panel/src/widgets/history/relative-time.ts b/plugins/history-panel/src/widgets/history/relative-time.ts new file mode 100644 index 00000000..9b159953 --- /dev/null +++ b/plugins/history-panel/src/widgets/history/relative-time.ts @@ -0,0 +1,8 @@ +/** "just now", "2 min ago", "3 h ago", then the date. */ +export const relativeTime = (time: number, now = Date.now()): string => { + const seconds = Math.max(0, Math.round((now - time) / 1000)); + if (seconds < 30) return 'just now'; + if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))} min ago`; + if (seconds < 86_400) return `${Math.round(seconds / 3600)} h ago`; + return new Date(time).toLocaleDateString(); +}; diff --git a/plugins/history-panel/svelte.config.js b/plugins/history-panel/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/history-panel/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/history-panel/tsconfig.json b/plugins/history-panel/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/history-panel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/history-panel/vite.config.ts b/plugins/history-panel/vite.config.ts new file mode 100644 index 00000000..0e305a86 --- /dev/null +++ b/plugins/history-panel/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'history-panel' }, + }, +}); diff --git a/plugins/inspectors/.prettierignore b/plugins/inspectors/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/inspectors/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/inspectors/LICENSE b/plugins/inspectors/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/inspectors/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/inspectors/README.md b/plugins/inspectors/README.md new file mode 100644 index 00000000..28a59214 --- /dev/null +++ b/plugins/inspectors/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-inspectors` is an internal package of the NanoForge Editor. + +Built-in runtime inspectors: profiler, network trace, world. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/inspectors +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/inspectors/nanoforge.manifest.json b/plugins/inspectors/nanoforge.manifest.json new file mode 100644 index 00000000..b2e10f64 --- /dev/null +++ b/plugins/inspectors/nanoforge.manifest.json @@ -0,0 +1,53 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/inspectors", + "version": "0.1.0", + "displayName": "Inspectors", + "description": "The running game from the inside: tick times and systems, network packets, and the ECS world.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": [ + "onWidget:inspectors.profiler", + "onWidget:inspectors.network", + "onWidget:inspectors.world" + ], + "contributes": { + "widgets": [ + { + "id": "inspectors.profiler", + "title": "Profiler", + "icon": "activity", + "defaultSlot": "bottom", + "order": 120 + }, + { + "id": "inspectors.network", + "title": "Network", + "icon": "network", + "defaultSlot": "bottom", + "order": 130 + }, + { + "id": "inspectors.world", + "title": "World", + "icon": "globe", + "defaultSlot": "bottom", + "order": 140 + } + ], + "settings": [ + { + "key": "tickBudgetMs", + "type": "number", + "minimum": 1, + "maximum": 1000, + "default": 16.7, + "title": "Tick time budget (ms)", + "description": "The Profiler draws this line and marks the windows whose slowest tick went over it. 16.7 ms is 60 ticks per second.", + "category": "Editor/Inspectors", + "order": 0 + } + ] + } +} diff --git a/plugins/inspectors/package.json b/plugins/inspectors/package.json new file mode 100644 index 00000000..35ee4887 --- /dev/null +++ b/plugins/inspectors/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-inspectors", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in runtime inspectors: profiler, network trace, world", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/inspectors" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/inspectors/src/icons.ts b/plugins/inspectors/src/icons.ts new file mode 100644 index 00000000..4afbd326 --- /dev/null +++ b/plugins/inspectors/src/icons.ts @@ -0,0 +1,14 @@ +import Activity from '@lucide/svelte/icons/activity'; +import Globe from '@lucide/svelte/icons/globe'; +import Network from '@lucide/svelte/icons/network'; +import Pause from '@lucide/svelte/icons/pause'; +import Play from '@lucide/svelte/icons/play'; + +/** Icons of the inspectors (registering one the editor already has is harmless). */ +export const ICONS = { + activity: Activity, + globe: Globe, + network: Network, + pause: Pause, + play: Play, +}; diff --git a/plugins/inspectors/src/index.ts b/plugins/inspectors/src/index.ts new file mode 100644 index 00000000..2a5a4782 --- /dev/null +++ b/plugins/inspectors/src/index.ts @@ -0,0 +1,68 @@ +import type { Component } from 'svelte'; + +import { + MutableDisposable, + ProjectServiceToken, + RuntimeServiceToken, + definePlugin, +} from '@nanoforge-dev/editor-sdk'; +import { + StyleServiceToken, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { ICONS } from './icons'; +import { Recorder } from './recorder/recorder'; +import { setRecorder } from './session/recorder-session'; +import NetworkView from './widgets/network/NetworkView.svelte'; +import ProfilerView from './widgets/profiler/ProfilerView.svelte'; +import WorldView from './widgets/world/WorldView.svelte'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('inspectors.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + + const recorder = new Recorder(); + setRecorder(recorder); + context.subscriptions.add(recorder); + context.subscriptions.add({ dispose: () => setRecorder(undefined) }); + + const session = context.subscriptions.add(new MutableDisposable()); + const followRuntime = () => { + const runtime = services.tryGet(RuntimeServiceToken); + recorder.reset(); + recorder.setRuntime(runtime); + let previous = runtime?.session.get().state; + const unsubscribe = runtime?.session.subscribe(({ state }) => { + if (state === 'building' && previous !== 'building') recorder.reset(); + previous = state; + }); + session.value = { dispose: () => unsubscribe?.() }; + }; + context.subscriptions.add({ + dispose: services + .get(ProjectServiceToken) + .current.subscribe(() => queueMicrotask(followRuntime)), + }); + + for (const [id, component] of [ + ['inspectors.profiler', ProfilerView], + ['inspectors.network', NetworkView], + ['inspectors.world', WorldView], + ] as const) { + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id, + component: component as Component<{ instance: WidgetInstance }>, + }), + ); + } + }, +}); diff --git a/plugins/inspectors/src/network/packets.ts b/plugins/inspectors/src/network/packets.ts new file mode 100644 index 00000000..228ecbd3 --- /dev/null +++ b/plugins/inspectors/src/network/packets.ts @@ -0,0 +1,75 @@ +/** Bytes of a hex string (`0a1b…`); a broken tail is dropped. */ +export const hexToBytes = (hex: string): Uint8Array => { + const bytes = new Uint8Array(Math.floor(hex.length / 2)); + for (let index = 0; index < bytes.length; index++) { + const value = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + if (Number.isNaN(value)) return bytes.subarray(0, index); + bytes[index] = value; + } + return bytes; +}; + +export type Decoded = + | { readonly kind: 'json'; readonly text: string } + | { readonly kind: 'text'; readonly text: string } + | { readonly kind: 'binary' }; + +/** + * A payload as people read it: formatted JSON when it parses, text when it is valid UTF-8 + * without control characters, else binary. `complete` false means only the start of the packet + * is known: JSON that was cut is shown as text. + */ +export const decodePayload = (bytes: Uint8Array, complete = true): Decoded => { + if (!bytes.length) return { kind: 'binary' }; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + if (complete) return { kind: 'binary' }; + text = new TextDecoder('utf-8').decode(bytes).replace(/�+$/, ''); + if (text.includes('�')) return { kind: 'binary' }; + } + // eslint-disable-next-line no-control-regex -- control characters mean binary data + if (/[\x00-\x08\x0e-\x1f]/.test(text)) return { kind: 'binary' }; + if (complete) { + try { + const value: unknown = JSON.parse(text); + if (value !== null && typeof value === 'object') + return { kind: 'json', text: JSON.stringify(value, null, 2) }; + } catch {} + } + return { kind: 'text', text }; +}; + +export interface DumpRow { + /** Offset of the row, hex. */ + readonly offset: string; + /** The bytes, two hex digits each. */ + readonly hex: readonly string[]; + /** Printable characters, `.` for the rest. */ + readonly ascii: string; +} + +/** A classic hex dump, 16 bytes per row. */ +export const hexDump = (bytes: Uint8Array, width = 16): DumpRow[] => { + const rows: DumpRow[] = []; + for (let offset = 0; offset < bytes.length; offset += width) { + const slice = bytes.subarray(offset, offset + width); + rows.push({ + offset: offset.toString(16).padStart(4, '0'), + hex: Array.from(slice, (byte) => byte.toString(16).padStart(2, '0')), + ascii: Array.from(slice, (byte) => + byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.', + ).join(''), + }); + } + return rows; +}; + +/** `512 B`, `1.5 kB`, `2.0 MB`. */ +export const formatBytes = (bytes: number): string => + bytes < 1000 + ? `${Math.round(bytes)} B` + : bytes < 1_000_000 + ? `${(bytes / 1000).toFixed(1)} kB` + : `${(bytes / 1_000_000).toFixed(1)} MB`; diff --git a/plugins/inspectors/src/recorder/recorder.ts b/plugins/inspectors/src/recorder/recorder.ts new file mode 100644 index 00000000..fbb1628d --- /dev/null +++ b/plugins/inspectors/src/recorder/recorder.ts @@ -0,0 +1,240 @@ +import { + type Disposable, + type EngineFeatures, + type EngineFrameStats, + type EngineNetworkStats, + type EngineNetworkTrace, + type EngineSystemStats, + type EngineWorld, + type GameEvent, + ObservableValue, +} from '@nanoforge-dev/editor-sdk'; + +export type GameSource = 'client' | 'server'; + +/** One sampling window of the profiler. */ +export interface FrameSample { + /** When it arrived (ms since epoch). */ + readonly time: number; + readonly stats: EngineFrameStats; + /** The latest system timings when the window ended, when the engine sends them. */ + readonly systems?: EngineSystemStats; +} + +export interface Packet extends EngineNetworkTrace { + readonly id: number; +} + +/** What one game (the client or the server) reported during the run. */ +export interface SourceRecord { + frames: FrameSample[]; + packets: Packet[]; + network: EngineNetworkStats[]; + /** Packets the engine left out of the list (rate limit, sampling). */ + untraced: number; + world: EngineWorld | undefined; +} + +export const LIMITS = { + /** Two minutes of 250 ms windows. */ + frames: 480, + packets: 2000, + /** Two minutes of one-second totals. */ + network: 120, +} as const; + +/** What each inspector asks the engine for, while it is shown. */ +export const FEATURES = { + profiler: { frameStats: { intervalMs: 250 }, ecsSystemStats: { intervalMs: 250 } }, + network: { networkTrace: { maxPerSecond: 200, maxBytes: 512 } }, + world: { ecsWorld: { intervalMs: 250 } }, +} as const satisfies Record; + +export type Inspector = keyof typeof FEATURES; + +const emptyRecord = (): SourceRecord => ({ + frames: [], + packets: [], + network: [], + untraced: 0, + world: undefined, +}); + +/** A new list with the item, the oldest dropped over the limit (views compare lists by identity). */ +const push = (list: readonly T[], item: T, limit: number): T[] => [ + ...list.slice(Math.max(list.length + 1 - limit, 0)), + item, +]; + +/** The part of the runtime service the recorder uses. */ +export interface RecorderRuntime { + onEvent(listener: (event: GameEvent) => void): Disposable; + useFeatures(features: EngineFeatures): Disposable; + /** The worlds already sent in this run (the engine sends one again only when it changes). */ + lastWorlds?(): readonly GameEvent[]; +} + +/** + * Keeps what the running games report to the inspectors: frame windows, system timings, + * packets, network totals and the world, per game. Features are asked for while an inspector + * needs them (`acquire`), and a new run starts from nothing (`reset`). + */ +export class Recorder implements Disposable { + private _records = new Map(); + private readonly _revision = new ObservableValue(0); + private readonly _uses = new Map(); + private _runtime: RecorderRuntime | undefined; + private _listener: Disposable | undefined; + private _systems = new Map(); + private _nextPacket = 0; + private _now: () => number; + + /** Bumped when anything recorded changes. */ + readonly revision = this._revision.readonly(); + + constructor(now: () => number = Date.now) { + this._now = now; + } + + /** Games that reported something, the client first. */ + get sources(): GameSource[] { + return (['client', 'server'] as const).filter((source) => this._records.has(source)); + } + + record(source: GameSource): SourceRecord | undefined { + return this._records.get(source); + } + + /** Follows the runtime of the open project (undefined: none). */ + setRuntime(runtime: RecorderRuntime | undefined): void { + this._listener?.dispose(); + for (const use of this._uses.values()) { + use.request?.dispose(); + delete use.request; + } + this._runtime = runtime; + this._listener = runtime?.onEvent((event) => this.handle(event)); + for (const event of runtime?.lastWorlds?.() ?? []) this.handle(event); + for (const [inspector, use] of this._uses) { + if (runtime && use.count > 0) use.request = runtime.useFeatures(FEATURES[inspector]); + } + } + + /** Asks the engine for an inspector's data until disposed (counted: several views can ask). */ + acquire(inspector: Inspector): Disposable { + const use = this._uses.get(inspector) ?? { count: 0 }; + this._uses.set(inspector, use); + if (use.count++ === 0 && this._runtime) + use.request = this._runtime.useFeatures(FEATURES[inspector]); + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + if (--use.count > 0) return; + use.request?.dispose(); + delete use.request; + }, + }; + } + + /** Forgets the last run (a new one starts). */ + reset(): void { + this._records = new Map(); + this._systems.clear(); + this._bump(); + } + + handle({ source, event, args }: GameEvent): void { + const payload = args[0]; + if (!payload || typeof payload !== 'object') return; + const record = () => { + let found = this._records.get(source); + if (!found) this._records.set(source, (found = emptyRecord())); + return found; + }; + switch (event) { + case 'frame-stats': { + if (!this._uses.get('profiler')?.count) return; + const systems = this._systems.get(source); + const target = record(); + target.frames = push( + target.frames, + { time: this._now(), stats: payload as EngineFrameStats, ...(systems && { systems }) }, + LIMITS.frames, + ); + break; + } + case 'ecs-system-stats': { + this._systems.set(source, payload as EngineSystemStats); + return; + } + case 'network-trace': { + const target = record(); + target.packets = push( + target.packets, + { ...(payload as EngineNetworkTrace), id: this._nextPacket++ }, + LIMITS.packets, + ); + break; + } + case 'network-stats': { + const stats = payload as EngineNetworkStats; + const target = record(); + target.network = push(target.network, stats, LIMITS.network); + target.untraced += stats.untraced; + break; + } + case 'ecs-world': + record().world = payload as EngineWorld; + break; + default: + return; + } + this._bump(); + } + + dispose(): void { + this.setRuntime(undefined); + this._uses.clear(); + } + + private _bump(): void { + this._revision.set(this._revision.get() + 1); + } +} + +/** Windows whose slowest tick went over the budget. */ +export const isSpike = (sample: FrameSample, budgetMs: number): boolean => + sample.stats.tick.max > budgetMs; + +export interface LibraryRow { + readonly name: string; + readonly avg: number; + readonly max: number; + /** Share of the average tick, 0 to 1. */ + readonly share: number; +} + +/** Libraries of a window, the most expensive first. */ +export const libraryRows = (stats: EngineFrameStats): LibraryRow[] => + Object.entries(stats.libraries) + .map(([name, timing]) => ({ + name, + avg: timing.avg, + max: timing.max, + share: stats.tick.avg > 0 ? Math.min(timing.avg / stats.tick.avg, 1) : 0, + })) + .sort((a, b) => b.avg - a.avg || a.name.localeCompare(b.name)); + +/** Bytes and packets per second of a totals window, in and out, both transports together. */ +export const throughput = ( + stats: EngineNetworkStats, +): Record<'in' | 'out', { bytes: number; packets: number }> => { + const seconds = Math.max(stats.windowMs, 1) / 1000; + const sum = (direction: 'in' | 'out') => ({ + bytes: (stats.tcp[direction].bytes + stats.udp[direction].bytes) / seconds, + packets: (stats.tcp[direction].packets + stats.udp[direction].packets) / seconds, + }); + return { in: sum('in'), out: sum('out') }; +}; diff --git a/plugins/inspectors/src/session/recorder-session.ts b/plugins/inspectors/src/session/recorder-session.ts new file mode 100644 index 00000000..ef5bd52c --- /dev/null +++ b/plugins/inspectors/src/session/recorder-session.ts @@ -0,0 +1,13 @@ +import type { Recorder } from '../recorder/recorder'; + +/** Set by the plugin on activation, read by its widgets. */ +let recorder: Recorder | undefined; + +export const setRecorder = (value: Recorder | undefined): void => { + recorder = value; +}; + +export const getRecorder = (): Recorder => { + if (!recorder) throw new Error('The inspectors plugin is not active'); + return recorder; +}; diff --git a/plugins/inspectors/src/widgets/JsonTree.svelte b/plugins/inspectors/src/widgets/JsonTree.svelte new file mode 100644 index 00000000..7c8f3c9e --- /dev/null +++ b/plugins/inspectors/src/widgets/JsonTree.svelte @@ -0,0 +1,97 @@ + + +{#if entries.length} +
    + + {#if open} +
    + {#each entries as [key, child] (key)} + + {/each} +
    + {/if} +
    +{:else} +
    + {name} + {summary} +
    +{/if} + + diff --git a/plugins/inspectors/src/widgets/SourcePicker.svelte b/plugins/inspectors/src/widgets/SourcePicker.svelte new file mode 100644 index 00000000..11dad0ff --- /dev/null +++ b/plugins/inspectors/src/widgets/SourcePicker.svelte @@ -0,0 +1,55 @@ + + +{#if sources.length > 1} +
    + {#each sources as source (source)} + + {/each} +
    +{:else if sources.length} + {LABELS[sources[0]!]} +{/if} + + diff --git a/plugins/inspectors/src/widgets/chart/LineChart.svelte b/plugins/inspectors/src/widgets/chart/LineChart.svelte new file mode 100644 index 00000000..19ea9034 --- /dev/null +++ b/plugins/inspectors/src/widgets/chart/LineChart.svelte @@ -0,0 +1,289 @@ + + +
    +
    + {label} + {#if series.length > 1} + {#each series as entry (entry.label)} + {entry.label} + {/each} + {/if} + {#if budget}{budget.label}{/if} +
    +
    + {#if width > 0} + + + {#each ticks as tick (tick)} + + {format(tick)} + {/each} + {#if budget} + + {/if} + {#each series as entry (entry.label)} + + {/each} + {#each marks ?? [] as marked, index (index)} + {#if marked} + + {marksLabel} + + {/if} + {/each} + {#if focus !== undefined && focus < count} + + {#each series as entry (entry.label)} + + {/each} + {/if} + `${entry.label} ${format(entry.values[focus] ?? 0)}`) + .join(', ')}` + : 'Latest'} + onpointermove={move} + onpointerleave={leave} + onclick={() => hovered !== undefined && onpick?.(hovered)} + {onkeydown} + /> + + + {#if focus !== undefined && focus < count} +
    plotWidth / 2} + style:left={`${MARGIN.left + x(focus)}px`} + > + {time(times[focus]!)} + {#each series as entry (entry.label)} + {entry.label} + {format(entry.values[focus] ?? 0)} + {/each} + {#if marks?.[focus]}{marksLabel}{/if} +
    + {/if} + {/if} +
    +
    + + diff --git a/plugins/inspectors/src/widgets/chart/chart-scale.ts b/plugins/inspectors/src/widgets/chart/chart-scale.ts new file mode 100644 index 00000000..3bcd8600 --- /dev/null +++ b/plugins/inspectors/src/widgets/chart/chart-scale.ts @@ -0,0 +1,41 @@ +/** A round number at or above a value, for the top of an axis: 1, 2, 5, 10, 20, 50… */ +export const niceMax = (value: number): number => { + if (!(value > 0)) return 1; + const power = 10 ** Math.floor(Math.log10(value)); + const scaled = value / power; + const step = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10; + return step * power; +}; + +/** SVG path of a line through values spread evenly over a width (y grows down). */ +export const linePath = ( + values: readonly number[], + width: number, + height: number, + max: number, + slots = values.length, +): string => { + if (!values.length) return ''; + const step = slots > 1 ? width / (slots - 1) : 0; + const offset = (slots - values.length) * step; + return values + .map((value, index) => { + const x = offset + index * step; + const y = height - (Math.min(Math.max(value, 0), max) / max) * height; + return `${index ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(''); +}; + +/** Index of the value under an x position, or undefined outside of the data. */ +export const indexAt = ( + x: number, + width: number, + count: number, + slots = count, +): number | undefined => { + if (!count || width <= 0) return undefined; + const step = slots > 1 ? width / (slots - 1) : width; + const index = Math.round(x / step) - (slots - count); + return index < 0 || index >= count ? undefined : index; +}; diff --git a/plugins/inspectors/src/widgets/follow.svelte.ts b/plugins/inspectors/src/widgets/follow.svelte.ts new file mode 100644 index 00000000..7caf9788 --- /dev/null +++ b/plugins/inspectors/src/widgets/follow.svelte.ts @@ -0,0 +1,62 @@ +import type { Disposable, Observable } from '@nanoforge-dev/editor-sdk'; + +import type { GameSource, Inspector, Recorder } from '../recorder/recorder'; + +/** + * What every inspector view needs: the engine feature while the view is on screen, a revision + * that follows the recorder (one update per frame at most), and the game being looked at. + */ +export const follow = (recorder: Recorder, inspector: Inspector, visible: Observable) => { + let revision = $state(0); + let picked = $state(); + + $effect(() => { + let request: Disposable | undefined; + const stopWatching = visible.subscribe((shown) => { + if (shown) request ??= recorder.acquire(inspector); + else { + request?.dispose(); + request = undefined; + } + }); + let frame = 0; + const unsubscribe = recorder.revision.subscribe(() => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + revision++; + }); + }); + return () => { + cancelAnimationFrame(frame); + unsubscribe(); + stopWatching(); + request?.dispose(); + }; + }); + + return { + get revision() { + return revision; + }, + get sources() { + void revision; + return recorder.sources; + }, + /** The picked game, or the first one that reported something. */ + get source(): GameSource | undefined { + void revision; + const sources = recorder.sources; + return picked && sources.includes(picked) ? picked : sources[0]; + }, + set source(value: GameSource | undefined) { + picked = value; + }, + get record() { + void revision; + const source = this.source; + const record = source ? recorder.record(source) : undefined; + return record && { ...record }; + }, + }; +}; diff --git a/plugins/inspectors/src/widgets/network/NetworkView.svelte b/plugins/inspectors/src/widgets/network/NetworkView.svelte new file mode 100644 index 00000000..970a50b3 --- /dev/null +++ b/plugins/inspectors/src/widgets/network/NetworkView.svelte @@ -0,0 +1,282 @@ + + +{#snippet choice( + label: string, + value: string, + options: readonly (readonly [string, string])[], + set: (value: string) => void, +)} +
    + {#each options as [id, text] (id)} + + {/each} +
    +{/snippet} + +
    + {#if !record || (!packets.length && !totals.length)} + + {:else} +
    + { + view.source = source; + selectedId = undefined; + }} + /> + {@render choice( + 'Direction', + direction, + [ + ['all', 'All'], + ['in', 'In'], + ['out', 'Out'], + ], + (value) => (direction = value as typeof direction), + )} + {@render choice( + 'Transport', + transport, + [ + ['all', 'All'], + ['tcp', 'TCP'], + ['udp', 'UDP'], + ], + (value) => (transport = value as typeof transport), + )} + + + {shown.length} of {packets.length} packets{record.untraced + ? `, ${record.untraced} more not listed` + : ''} + +
    + +
    +
    + + + + + + + + + {#each shown as packet (packet.id)} + (selectedId = packet.id)} + onkeydown={(event) => event.key === 'Enter' && (selectedId = packet.id)} + > + + + + + + + + {/each} + +
    TimeDirectionTransportClientSizePayload
    {time(packet.time)}{packet.direction === 'in' ? '← in' : '→ out'}{packet.transport.toUpperCase()}{packet.clientId ?? ''}{formatBytes(packet.size)}{preview(packet)}
    +
    + +
    + {/if} +
    + + diff --git a/plugins/inspectors/src/widgets/network/PacketDetail.svelte b/plugins/inspectors/src/widgets/network/PacketDetail.svelte new file mode 100644 index 00000000..b66f0a99 --- /dev/null +++ b/plugins/inspectors/src/widgets/network/PacketDetail.svelte @@ -0,0 +1,92 @@ + + + + + diff --git a/plugins/inspectors/src/widgets/network/ThroughputCharts.svelte b/plugins/inspectors/src/widgets/network/ThroughputCharts.svelte new file mode 100644 index 00000000..360cd324 --- /dev/null +++ b/plugins/inspectors/src/widgets/network/ThroughputCharts.svelte @@ -0,0 +1,55 @@ + + +
    + {#if totals.length} + rate.in.bytes) }, + { label: 'Out', slot: 2, values: rates.map((rate) => rate.out.bytes) }, + ]} + /> + String(Math.round(value))} + series={[ + { label: 'In', slot: 1, values: rates.map((rate) => rate.in.packets) }, + { label: 'Out', slot: 2, values: rates.map((rate) => rate.out.packets) }, + ]} + /> + {:else} +

    This game's engine doesn't report network totals: no throughput charts.

    + {/if} +
    + + diff --git a/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte b/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte new file mode 100644 index 00000000..5fc5e052 --- /dev/null +++ b/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte @@ -0,0 +1,249 @@ + + +
    + {#if !frames.length} + + {:else} +
    + { + view.source = source; + resume(); + }} + /> + {Math.round(latest?.stats.tps ?? 0)} ticks/s + {ms(latest?.stats.tick.avg ?? 0)} average tick + 0}> + {#if spikeCount}{/if} + {spikeCount} + {spikeCount === 1 ? 'spike' : 'spikes'} over {ms(budget)} + + + {#if frozen} + Paused{pinned ? `, window of ${time(pinned.time)}` : ''} + + {:else} + + {/if} +
    +
    + sample.time)} + slots={LIMITS.frames} + format={(value) => `${value >= 10 ? Math.round(value) : value.toFixed(1)} ms`} + series={[ + { label: 'Average', slot: 1, values: frames.map((sample) => sample.stats.tick.avg) }, + { label: 'Slowest', slot: 2, values: frames.map((sample) => sample.stats.tick.max) }, + ]} + budget={{ value: budget, label: `Budget ${ms(budget)}` }} + marks={spikes} + marksLabel="Over the budget" + selected={pinnedIndex >= 0 ? pinnedIndex : undefined} + onhover={(index) => (hovered = index)} + onpick={pin} + /> + sample.time)} + slots={LIMITS.frames} + format={(value) => String(Math.round(value))} + series={[{ label: 'Ticks per second', slot: 1, values: frames.map((s) => s.stats.tps) }]} + selected={pinnedIndex >= 0 ? pinnedIndex : undefined} + onhover={(index) => (hovered = index)} + onpick={pin} + /> +
    + {#if shown} +
    + + + + + + + {#each libraries as row (row.name)} + + + + + + + {/each} + +
    Libraries, window of {time(shown.time)}
    LibraryAverageSlowestShare of the tick
    {row.name}{ms(row.avg)}{ms(row.max)} + + {Math.round(row.share * 100)}% +
    + + + + + + + {#each shown.systems?.systems ?? [] as system (system.index)} + + + + + + + {:else} + + + + {/each} + +
    Systems, in run order
    SystemAverageSlowestCalls
    {system.name}{ms(system.avg)}{ms(system.max)}{system.calls}
    + This game's engine doesn't report system timings (it needs an ECS library that + does). +
    +
    + {/if} + {/if} +
    + + diff --git a/plugins/inspectors/src/widgets/world/WorldView.svelte b/plugins/inspectors/src/widgets/world/WorldView.svelte new file mode 100644 index 00000000..81bf2b15 --- /dev/null +++ b/plugins/inspectors/src/widgets/world/WorldView.svelte @@ -0,0 +1,144 @@ + + +
    + {#if !world} + + {:else} +
    + (view.source = source)} + /> + + {entities.length} of {world.entities.length} + {world.entities.length === 1 ? 'entity' : 'entities'} +
    +
      + {#each entities as entity (entity.id)} + {@const expanded = open.includes(entity.id)} +
    • + + {#if expanded} +
      + {#each entity.components as component (component.name)} + + {/each} +
      + {/if} +
    • + {:else} +
    • No entity matches the query.
    • + {/each} +
    + {/if} +
    + + diff --git a/plugins/inspectors/src/world/world-query.ts b/plugins/inspectors/src/world/world-query.ts new file mode 100644 index 00000000..77ce1844 --- /dev/null +++ b/plugins/inspectors/src/world/world-query.ts @@ -0,0 +1,111 @@ +import type { EngineWorld, EngineWorldValue } from '@nanoforge-dev/editor-sdk'; + +export type WorldEntity = EngineWorld['entities'][number]; + +type Operator = '=' | '!=' | '>' | '>=' | '<' | '<='; + +export type QueryTerm = + | { readonly kind: 'id'; readonly id: number } + | { readonly kind: 'text'; readonly text: string } + | { + readonly kind: 'field'; + /** Undefined: a field of any component. */ + readonly component?: string; + readonly field?: string; + readonly compare?: { readonly operator: Operator; readonly value: string }; + }; + +const TERM = + /#(\d+)|"([^"]*)"|([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?(?:\s*(!=|>=|<=|=|>|<)\s*("[^"]*"|[^\s"]+))?/g; + +/** + * Terms of a world query, all of which an entity must match: + * `#12`, `"text"`, `Position`, `Position.x`, `Position.x > 100`, `x > 100`. + * A single name is a component name, or a field of any component. + */ +export const parseQuery = (query: string): QueryTerm[] => { + const terms: QueryTerm[] = []; + for (const match of query.matchAll(TERM)) { + const [, id, text, first, second, operator, raw] = match; + if (id !== undefined) terms.push({ kind: 'id', id: Number(id) }); + else if (text !== undefined) { + if (text) terms.push({ kind: 'text', text: text.toLowerCase() }); + } else if (first) { + const compare = operator + ? { operator: operator as Operator, value: raw!.replace(/^"|"$/g, '') } + : undefined; + terms.push({ + kind: 'field', + ...(second ? { component: first, field: second } : { field: first }), + ...(compare && { compare }), + }); + } + } + return terms; +}; + +const compareValues = (actual: EngineWorldValue, operator: Operator, expected: string): boolean => { + if (actual === null || typeof actual === 'object') return false; + const number = Number(expected); + const numeric = typeof actual === 'number' && expected.trim() !== '' && !Number.isNaN(number); + const [a, b] = numeric ? [actual, number] : [String(actual), expected]; + switch (operator) { + case '=': + return a === b; + case '!=': + return a !== b; + case '>': + return a > b; + case '>=': + return a >= b; + case '<': + return a < b; + case '<=': + return a <= b; + } +}; + +const contains = (value: EngineWorldValue, text: string): boolean => { + if (value === null) return false; + if (typeof value !== 'object') return String(value).toLowerCase().includes(text); + if (Array.isArray(value)) return value.some((item) => contains(item, text)); + return Object.entries(value).some( + ([key, item]) => key.toLowerCase().includes(text) || contains(item, text), + ); +}; + +const matchesTerm = (entity: WorldEntity, term: QueryTerm): boolean => { + switch (term.kind) { + case 'id': + return entity.id === term.id; + case 'text': + return entity.components.some( + (component) => + component.name.toLowerCase().includes(term.text) || contains(component.value, term.text), + ); + case 'field': { + const { component, field, compare } = term; + const lower = (text: string) => text.toLowerCase(); + if (component === undefined && field !== undefined && !compare) { + return entity.components.some( + (candidate) => lower(candidate.name).includes(lower(field)) || field in candidate.value, + ); + } + return entity.components.some((candidate) => { + if (component !== undefined && lower(candidate.name) !== lower(component)) return false; + if (field === undefined || !(field in candidate.value)) return false; + return compare + ? compareValues(candidate.value[field]!, compare.operator, compare.value) + : true; + }); + } + } +}; + +/** Entities matching every term of the query (all of them when it is empty). */ +export const filterEntities = (entities: readonly WorldEntity[], query: string): WorldEntity[] => { + const terms = parseQuery(query); + return terms.length + ? entities.filter((entity) => terms.every((term) => matchesTerm(entity, term))) + : [...entities]; +}; diff --git a/plugins/inspectors/svelte.config.js b/plugins/inspectors/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/inspectors/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/inspectors/test/network/packets.spec.ts b/plugins/inspectors/test/network/packets.spec.ts new file mode 100644 index 00000000..d72ccb66 --- /dev/null +++ b/plugins/inspectors/test/network/packets.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { decodePayload, formatBytes, hexDump, hexToBytes } from '../../src/network/packets'; + +const bytesOf = (text: string) => new TextEncoder().encode(text); + +describe('packets', () => { + it('reads hex', () => { + expect([...hexToBytes('00ff10')]).toEqual([0, 255, 16]); + expect([...hexToBytes('0a1')]).toEqual([10]); + expect([...hexToBytes('0azz')]).toEqual([10]); + }); + + it('decodes JSON, text and binary payloads', () => { + expect(decodePayload(bytesOf('{"type":"play","n":1}'))).toEqual({ + kind: 'json', + text: '{\n "type": "play",\n "n": 1\n}', + }); + expect(decodePayload(bytesOf('hello é'))).toEqual({ kind: 'text', text: 'hello é' }); + expect(decodePayload(bytesOf('42'))).toEqual({ kind: 'text', text: '42' }); + expect(decodePayload(new Uint8Array([0, 1, 2, 255]))).toEqual({ kind: 'binary' }); + expect(decodePayload(new Uint8Array([0xff, 0xfe]))).toEqual({ kind: 'binary' }); + expect(decodePayload(new Uint8Array())).toEqual({ kind: 'binary' }); + }); + + it('shows a cut payload as text, even cut inside a character', () => { + expect(decodePayload(bytesOf('{"type":"pl'), false)).toEqual({ + kind: 'text', + text: '{"type":"pl', + }); + const cut = bytesOf('abé').subarray(0, 3); + expect(decodePayload(cut, false)).toEqual({ kind: 'text', text: 'ab' }); + expect(decodePayload(cut, true)).toEqual({ kind: 'binary' }); + }); + + it('dumps bytes in rows with offsets and printable characters', () => { + const rows = hexDump(bytesOf('Hello, NanoForge!\n')); + expect(rows).toEqual([ + { + offset: '0000', + hex: '48 65 6c 6c 6f 2c 20 4e 61 6e 6f 46 6f 72 67 65'.split(' '), + ascii: 'Hello, NanoForge', + }, + { offset: '0010', hex: ['21', '0a'], ascii: '!.' }, + ]); + }); + + it('formats sizes', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1500)).toBe('1.5 kB'); + expect(formatBytes(2_000_000)).toBe('2.0 MB'); + }); +}); diff --git a/plugins/inspectors/test/recorder/recorder.spec.ts b/plugins/inspectors/test/recorder/recorder.spec.ts new file mode 100644 index 00000000..1b7cadf6 --- /dev/null +++ b/plugins/inspectors/test/recorder/recorder.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import type { EngineFeatures, GameEvent } from '@nanoforge-dev/editor-sdk'; + +import { + FEATURES, + LIMITS, + Recorder, + isSpike, + libraryRows, + throughput, +} from '../../src/recorder/recorder'; + +const frame = (avg: number, max = avg) => ({ + windowMs: 250, + ticks: 15, + tps: 60, + tick: { avg, max }, + libraries: { ecs: { avg: avg / 2, max }, graphics: { avg: avg / 4, max: avg } }, +}); +const event = (source: 'client' | 'server', name: string, payload: unknown): GameEvent => ({ + source, + app: `apps/${source}`, + event: name, + args: [payload], +}); +const totals = (packets: number, bytes: number) => ({ packets, bytes }); + +const fakeRuntime = () => { + const requests = new Set(); + let listener: ((event: GameEvent) => void) | undefined; + return { + requests, + emit: (value: GameEvent) => listener?.(value), + runtime: { + onEvent: (next: (event: GameEvent) => void) => { + listener = next; + return { dispose: () => (listener = undefined) }; + }, + useFeatures: (features: EngineFeatures) => { + requests.add(features); + return { dispose: () => void requests.delete(features) }; + }, + }, + }; +}; + +describe('Recorder', () => { + it('keeps frame windows per game, with the system timings of the window', () => { + let now = 1000; + const recorder = new Recorder(() => now); + recorder.acquire('profiler'); + const systems = { + windowMs: 250, + systems: [{ index: 0, name: 'move', calls: 15, avg: 1, max: 2 }], + }; + recorder.handle(event('client', 'ecs-system-stats', systems)); + recorder.handle(event('client', 'frame-stats', frame(4))); + now = 1250; + recorder.handle(event('client', 'frame-stats', frame(5))); + recorder.handle(event('server', 'frame-stats', frame(1))); + expect(recorder.sources).toEqual(['client', 'server']); + expect(recorder.record('client')!.frames).toEqual([ + { time: 1000, stats: frame(4), systems }, + { time: 1250, stats: frame(5), systems }, + ]); + expect(recorder.record('server')!.frames[0]!.systems).toBeUndefined(); + }); + + it('keeps the last windows and packets only', () => { + const recorder = new Recorder(); + recorder.handle(event('client', 'frame-stats', frame(99))); + expect(recorder.record('client')).toBeUndefined(); + const shown = recorder.acquire('profiler'); + for (let index = 0; index < LIMITS.frames + 5; index++) + recorder.handle(event('client', 'frame-stats', frame(index))); + const frames = recorder.record('client')!.frames; + expect(frames).toHaveLength(LIMITS.frames); + expect(frames[0]!.stats.tick.avg).toBe(5); + shown.dispose(); + recorder.handle(event('client', 'frame-stats', frame(99))); + expect(recorder.record('client')!.frames).toHaveLength(LIMITS.frames); + }); + + it('records packets, totals and what the engine left out, and the world', () => { + const recorder = new Recorder(); + let revisions = 0; + recorder.revision.subscribe(() => revisions++); + const packet = { direction: 'in', transport: 'tcp', size: 3, head: '010203', time: 5 }; + recorder.handle(event('server', 'network-trace', packet)); + recorder.handle(event('server', 'network-trace', packet)); + const stats = { + windowMs: 1000, + time: 9, + tcp: { in: totals(10, 300), out: totals(0, 0) }, + udp: { in: totals(5, 200), out: totals(20, 1000) }, + untraced: 13, + }; + recorder.handle(event('server', 'network-stats', stats)); + recorder.handle(event('server', 'network-stats', stats)); + recorder.handle(event('server', 'ecs-world', { entities: [], systems: [] })); + recorder.handle(event('server', 'state', 'running')); + const record = recorder.record('server')!; + expect(record.packets.map((entry) => entry.id)).toEqual([0, 1]); + expect(record.untraced).toBe(26); + expect(record.world).toEqual({ entities: [], systems: [] }); + expect(throughput(stats)).toEqual({ + in: { bytes: 500, packets: 15 }, + out: { bytes: 1000, packets: 20 }, + }); + expect(revisions).toBe(6); + recorder.reset(); + expect(recorder.sources).toEqual([]); + }); + + it('starts from the world a paused game already sent', () => { + const { runtime } = fakeRuntime(); + const world = { entities: [{ id: 1, components: [] }], systems: [] }; + const recorder = new Recorder(); + recorder.setRuntime({ ...runtime, lastWorlds: () => [event('server', 'ecs-world', world)] }); + expect(recorder.sources).toEqual(['server']); + expect(recorder.record('server')!.world).toEqual(world); + }); + + it('asks the engine for an inspector only while something shows it', () => { + const { runtime, requests, emit } = fakeRuntime(); + const recorder = new Recorder(); + const first = recorder.acquire('profiler'); + expect(requests.size).toBe(0); + recorder.setRuntime(runtime); + expect([...requests]).toEqual([FEATURES.profiler]); + const second = recorder.acquire('profiler'); + const network = recorder.acquire('network'); + expect(requests.size).toBe(2); + first.dispose(); + first.dispose(); + expect(requests.size).toBe(2); + second.dispose(); + expect([...requests]).toEqual([FEATURES.network]); + emit( + event('client', 'network-trace', { + direction: 'in', + transport: 'tcp', + size: 1, + head: '01', + time: 1, + }), + ); + expect(recorder.record('client')!.packets).toHaveLength(1); + const other = fakeRuntime(); + recorder.setRuntime(other.runtime); + expect(requests.size).toBe(0); + expect([...other.requests]).toEqual([FEATURES.network]); + network.dispose(); + expect(other.requests.size).toBe(0); + }); +}); + +describe('profiler numbers', () => { + it('marks windows over the budget and ranks libraries', () => { + const sample = { time: 0, stats: frame(8, 20) }; + expect(isSpike(sample, 16.7)).toBe(true); + expect(isSpike(sample, 25)).toBe(false); + expect(libraryRows(sample.stats)).toEqual([ + { name: 'ecs', avg: 4, max: 20, share: 0.5 }, + { name: 'graphics', avg: 2, max: 8, share: 0.25 }, + ]); + expect(libraryRows(frame(0))[0]!.share).toBe(0); + }); +}); diff --git a/plugins/inspectors/test/widgets/chart/chart-scale.spec.ts b/plugins/inspectors/test/widgets/chart/chart-scale.spec.ts new file mode 100644 index 00000000..9976b503 --- /dev/null +++ b/plugins/inspectors/test/widgets/chart/chart-scale.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { indexAt, linePath, niceMax } from '../../../src/widgets/chart/chart-scale'; + +describe('chart math', () => { + it('rounds the top of an axis up', () => { + expect([0, 0.3, 1, 1.2, 4, 16.7, 60, 730].map(niceMax)).toEqual([ + 1, 0.5, 1, 2, 5, 20, 100, 1000, + ]); + }); + + it('draws a line right-aligned while the history fills up', () => { + expect(linePath([0, 5, 10], 100, 50, 10)).toBe('M0.0,50.0L50.0,25.0L100.0,0.0'); + expect(linePath([10], 100, 50, 10, 5)).toBe('M100.0,0.0'); + expect(linePath([0, 20], 100, 50, 10, 5)).toBe('M75.0,50.0L100.0,0.0'); + expect(linePath([], 100, 50, 10)).toBe(''); + }); + + it('finds the value under the pointer', () => { + expect(indexAt(0, 100, 3)).toBe(0); + expect(indexAt(60, 100, 3)).toBe(1); + expect(indexAt(100, 100, 3)).toBe(2); + expect(indexAt(100, 100, 2, 5)).toBe(1); + expect(indexAt(10, 100, 2, 5)).toBeUndefined(); + expect(indexAt(10, 100, 0)).toBeUndefined(); + }); +}); diff --git a/plugins/inspectors/test/world/world-query.spec.ts b/plugins/inspectors/test/world/world-query.spec.ts new file mode 100644 index 00000000..268823a0 --- /dev/null +++ b/plugins/inspectors/test/world/world-query.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { filterEntities, parseQuery } from '../../src/world/world-query'; + +const WORLD = [ + { + id: 1, + components: [ + { name: 'Position', value: { x: 120, y: 4 } }, + { name: 'Controller', value: { side: 'left' } }, + ], + }, + { + id: 2, + components: [ + { name: 'Position', value: { x: 10, y: 4 } }, + { name: 'Velocity', value: { x: 3, y: 0 } }, + { name: 'Shape', value: { shape: { $class: 'Circle', radius: 8 } } }, + ], + }, + { id: 12, components: [] }, +]; +const ids = (query: string) => filterEntities(WORLD, query).map((entity) => entity.id); + +describe('world query', () => { + it('parses terms', () => { + expect(parseQuery('#12 Position.x >= 100 "left" speed')).toEqual([ + { kind: 'id', id: 12 }, + { + kind: 'field', + component: 'Position', + field: 'x', + compare: { operator: '>=', value: '100' }, + }, + { kind: 'text', text: 'left' }, + { kind: 'field', field: 'speed' }, + ]); + expect(parseQuery('side = "left"')).toEqual([ + { kind: 'field', field: 'side', compare: { operator: '=', value: 'left' } }, + ]); + expect(parseQuery(' ')).toEqual([]); + }); + + it('matches components, fields and ids', () => { + expect(ids('')).toEqual([1, 2, 12]); + expect(ids('Velocity')).toEqual([2]); + expect(ids('veloc')).toEqual([2]); + expect(ids('Position.x')).toEqual([1, 2]); + expect(ids('Position.z')).toEqual([]); + expect(ids('side')).toEqual([1]); + expect(ids('#12')).toEqual([12]); + }); + + it('compares values, numbers as numbers', () => { + expect(ids('Position.x > 100')).toEqual([1]); + expect(ids('Position.x<=10')).toEqual([2]); + expect(ids('x = 3')).toEqual([2]); + expect(ids('x > 5')).toEqual([1, 2]); + expect(ids('side = left')).toEqual([1]); + expect(ids('side != left')).toEqual([]); + expect(ids('Shape.shape = Circle')).toEqual([]); + }); + + it('needs every term, and searches quoted text everywhere', () => { + expect(ids('Position Velocity')).toEqual([2]); + expect(ids('Position.x > 5 Controller')).toEqual([1]); + expect(ids('"circle"')).toEqual([2]); + expect(ids('"radius"')).toEqual([2]); + expect(ids('"120"')).toEqual([1]); + }); +}); diff --git a/plugins/inspectors/tsconfig.json b/plugins/inspectors/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/inspectors/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/inspectors/vite.config.ts b/plugins/inspectors/vite.config.ts new file mode 100644 index 00000000..f7899c29 --- /dev/null +++ b/plugins/inspectors/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'inspectors' }, + }, +}); diff --git a/plugins/packages/.prettierignore b/plugins/packages/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/packages/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/packages/LICENSE b/plugins/packages/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/packages/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/packages/README.md b/plugins/packages/README.md new file mode 100644 index 00000000..3b992042 --- /dev/null +++ b/plugins/packages/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-packages` is an internal package of the NanoForge Editor. + +Built-in Packages dialog: browse the registry, install and update the project's packages. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/packages +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/packages/nanoforge.manifest.json b/plugins/packages/nanoforge.manifest.json new file mode 100644 index 00000000..937ab37c --- /dev/null +++ b/plugins/packages/nanoforge.manifest.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/packages", + "version": "0.1.0", + "displayName": "Packages", + "description": "The Packages dialog: browse the registry, install, update and remove the packages of the project.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onCommand:packages.open"], + "contributes": { + "commands": [{ "id": "packages.open", "title": "Packages…", "category": "File" }], + "menus": [{ "menu": "file", "command": "packages.open", "group": "z-settings" }] + } +} diff --git a/plugins/packages/package.json b/plugins/packages/package.json new file mode 100644 index 00000000..f0329e4c --- /dev/null +++ b/plugins/packages/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-packages", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in Packages dialog: browse the registry, install and update the project's packages", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/packages" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/packages/src/dialogs/packages/BrowseTab.svelte b/plugins/packages/src/dialogs/packages/BrowseTab.svelte new file mode 100644 index 00000000..498c3a25 --- /dev/null +++ b/plugins/packages/src/dialogs/packages/BrowseTab.svelte @@ -0,0 +1,185 @@ + + +
    +
    + + {#if packages.unavailable} + + {#snippet actions()} + + {/snippet} + + {:else if !packages.results.length} + + {:else} +
      + {#each packages.results as item (item.name)} + {@const mine = packages.installedOf(item.name)} +
    • + +
    • + {/each} +
    + {#if packages.total > packages.results.length} +

    {packages.results.length} of {packages.total} shown: search to narrow.

    + {/if} + {/if} +
    +
    + {#if packages.details && packages.details.name === packages.selected} + {@const details = packages.details} + {@const mine = packages.installedOf(details.name)} + {@const dependencies = Object.entries(details.versions[0]?.dependencies ?? {})} +
    +
    + {details.name} + + {details.author || 'Unknown author'} · latest {details.version} + +
    + {#if packages.busy === details.name} + + {:else if mine} + {mine.version} installed + {:else} + + {/if} +
    + {#if details.description}

    {details.description}

    {/if} +

    Depends on

    + {#if dependencies.length} +
      + {#each dependencies as [name, range] (name)} +
    • {name} {range}
    • + {/each} +
    +

    Installed next to it in nf_modules.

    + {:else} +

    No other package.

    + {/if} + {#if details.readme}
    {details.readme}
    {/if} + {:else if !packages.unavailable && packages.results.length} + + {/if} +
    +
    + + diff --git a/plugins/packages/src/dialogs/packages/InstalledTab.svelte b/plugins/packages/src/dialogs/packages/InstalledTab.svelte new file mode 100644 index 00000000..26c013ea --- /dev/null +++ b/plugins/packages/src/dialogs/packages/InstalledTab.svelte @@ -0,0 +1,99 @@ + + +
    + {#if !packages.installed.length} + + {#snippet actions()} + + {/snippet} + + {:else} +
      + {#each packages.installed as entry (entry.name)} +
    • +
      + {entry.name} + + {entry.version} · {reasonOf(entry)}{entry.latest + ? ` · latest ${entry.latest}` + : ''}{entry.present ? '' : ' · not in nf_modules'} + +
      + {#if packages.busy === entry.name} + + {:else} + {#each updateChoices(entry) as choice (choice.version)} + + {/each} + + {/if} +
    • + {/each} +
    + {/if} +
    + + diff --git a/plugins/packages/src/dialogs/packages/PackagesDialog.svelte b/plugins/packages/src/dialogs/packages/PackagesDialog.svelte new file mode 100644 index 00000000..d9502993 --- /dev/null +++ b/plugins/packages/src/dialogs/packages/PackagesDialog.svelte @@ -0,0 +1,128 @@ + + + +
    +
    + (tab = id as typeof tab)} + /> +
    + {#if !packages.project} +

    Open a project to install packages in it.

    + {:else if packages.readOnly} +

    {packages.readOnly}

    + {/if} + {#if packages.absent.length} +

    + {plural(packages.absent.length, 'package')} of this project {packages.absent.length === 1 + ? 'is' + : 'are'} not in nf_modules. + +

    + {/if} + {#if packages.failure} + + {/if} + + {#if tab === 'browse'} + + {:else} + (tab = 'browse')} /> + {/if} +
    + {#snippet footer()} + + {/snippet} +
    + + diff --git a/plugins/packages/src/dialogs/packages/open-packages-dialog.ts b/plugins/packages/src/dialogs/packages/open-packages-dialog.ts new file mode 100644 index 00000000..4058cac5 --- /dev/null +++ b/plugins/packages/src/dialogs/packages/open-packages-dialog.ts @@ -0,0 +1,33 @@ +import { mount, unmount } from 'svelte'; + +import type { ServiceAccessor } from '@nanoforge-dev/editor-sdk'; + +import PackagesDialog from './PackagesDialog.svelte'; + +let current: { component: ReturnType; host: HTMLElement } | undefined; + +/** Closes the Packages dialog, when it is open. */ +export const closePackagesDialog = (): void => { + if (!current) return; + void unmount(current.component); + current.host.remove(); + current = undefined; +}; + +/** Shows the Packages dialog (File › Packages…), on a tab when one is given. */ +export const openPackagesDialog = (services: ServiceAccessor, tab?: unknown): void => { + closePackagesDialog(); + const host = document.createElement('div'); + document.body.append(host); + current = { + host, + component: mount(PackagesDialog, { + target: host, + props: { + services, + ...((tab === 'browse' || tab === 'installed') && { tab }), + onclose: closePackagesDialog, + }, + }), + }; +}; diff --git a/plugins/packages/src/dialogs/packages/packages-dialog-state.svelte.ts b/plugins/packages/src/dialogs/packages/packages-dialog-state.svelte.ts new file mode 100644 index 00000000..c2577860 --- /dev/null +++ b/plugins/packages/src/dialogs/packages/packages-dialog-state.svelte.ts @@ -0,0 +1,137 @@ +import { + type InstalledPackage, + RegistryContract, + type RegistryItem, + type RegistrySummary, + type RpcClient, +} from '@nanoforge-dev/editor-sdk'; + +import { missing } from '../../model/installed-package'; + +export const registryApi = (rpc: RpcClient) => rpc.api(RegistryContract); +export type RegistryApi = ReturnType; + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const codeOf = (error: unknown) => (error as { code?: string }).code; + +/** + * What the Packages dialog keeps while its tabs come and go: the search, the selected package, + * the project's packages and the change in progress. + */ +export class PackagesDialogState { + query = $state(''); + results = $state([]); + total = $state(0); + searching = $state(false); + /** Why the registry can't be shown, when it can't. */ + unavailable = $state(); + selected = $state(); + details = $state(); + installed = $state([]); + /** Why the project's packages can't be read or changed here (a hosted editor). */ + readOnly = $state(); + /** The package being changed, or `*` for all of them. */ + busy = $state(); + failure = $state(); + readonly absent = $derived(missing(this.installed)); + private _generation = 0; + + constructor( + private readonly _registry: RegistryApi, + readonly project: string | undefined, + private readonly _catalog?: { refresh(): unknown }, + ) {} + + installedOf(name: string): InstalledPackage | undefined { + return this.installed.find((entry) => entry.name === name); + } + + async loadInstalled(): Promise { + if (!this.project) return; + try { + this.installed = await this._registry.packages({ project: this.project }); + this.readOnly = undefined; + } catch (error) { + if (codeOf(error) === 'FORBIDDEN') + this.readOnly = 'Packages can only be installed in a local editor.'; + else this.failure = message(error); + } + } + + async search(text: string): Promise { + const mine = ++this._generation; + this.searching = true; + try { + const found = await this._registry.search({ type: 'package', q: text }); + if (mine !== this._generation) return; + this.results = found.items; + this.total = found.total; + this.unavailable = undefined; + if (!found.items.some((item) => item.name === this.selected)) + this.selected = found.items[0]?.name; + } catch (error) { + if (mine !== this._generation) return; + this.results = []; + this.total = 0; + this.unavailable = + codeOf(error) === 'UNAVAILABLE' + ? 'The package registry cannot be reached right now. Installed packages keep working.' + : message(error); + } finally { + if (mine === this._generation) this.searching = false; + } + } + + /** Reads the details of a package; the returned function drops a late answer. */ + loadDetails(name: string): () => void { + let stale = false; + this._registry.details({ name }).then( + (item) => { + if (!stale) this.details = item; + }, + () => undefined, + ); + return () => { + stale = true; + }; + } + + install(name: string) { + const project = this.project; + return project && this._change(name, () => this._registry.installPackage({ project, name })); + } + + uninstall(name: string) { + const project = this.project; + return project && this._change(name, () => this._registry.uninstallPackage({ project, name })); + } + + update(name: string, latest: boolean) { + const project = this.project; + return ( + project && this._change(name, () => this._registry.updatePackage({ project, name, latest })) + ); + } + + restore() { + const project = this.project; + return project && this._change('*', () => this._registry.restorePackages({ project })); + } + + /** Runs a change of the project's packages, then lets the editor see the new items. */ + private async _change(name: string, action: () => Promise): Promise { + this.busy = name; + this.failure = undefined; + try { + this.installed = await action(); + void this._catalog?.refresh(); + } catch (error) { + this.failure = + codeOf(error) === 'FORBIDDEN' + ? 'Packages can only be installed in a local editor.' + : message(error); + } finally { + this.busy = undefined; + } + } +} diff --git a/plugins/packages/src/index.ts b/plugins/packages/src/index.ts new file mode 100644 index 00000000..07aa4e2f --- /dev/null +++ b/plugins/packages/src/index.ts @@ -0,0 +1,21 @@ +import { definePlugin } from '@nanoforge-dev/editor-sdk'; +import { StyleServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { closePackagesDialog, openPackagesDialog } from './dialogs/packages/open-packages-dialog'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('packages.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + + context.subscriptions.add({ dispose: closePackagesDialog }); + context.subscriptions.add( + context.registerCommand('packages.open', (_services, tab?: unknown) => + openPackagesDialog(services, tab), + ), + ); + }, +}); diff --git a/plugins/packages/src/model/installed-package.ts b/plugins/packages/src/model/installed-package.ts new file mode 100644 index 00000000..faab49a4 --- /dev/null +++ b/plugins/packages/src/model/installed-package.ts @@ -0,0 +1,45 @@ +import type { InstalledPackage } from '@nanoforge-dev/editor-sdk'; + +export interface UpdateChoice { + readonly version: string; + /** Past the project's range: the range moves to `^version`. */ + readonly latest: boolean; + readonly label: string; +} + +/** + * The updates to offer for an installed package: the newest version in its range, and the + * newest of all when that is another one. A package only others need follows them: it has no + * range of its own to move. + */ +export const updateChoices = (installed: InstalledPackage): UpdateChoice[] => { + const choices: UpdateChoice[] = []; + if (installed.wanted) + choices.push({ + version: installed.wanted, + latest: false, + label: `Update to ${installed.wanted}`, + }); + if (installed.latest && installed.latest !== installed.wanted && installed.range !== undefined) + choices.push({ + version: installed.latest, + latest: true, + label: `Update to ${installed.latest}`, + }); + return choices; +}; + +/** Why a package is in the project: asked for with a range, or brought by others. */ +export const reasonOf = (installed: InstalledPackage): string => + installed.range !== undefined + ? `asked for as ${installed.range}` + : installed.dependents.length + ? `needed by ${installed.dependents.join(', ')}` + : 'not needed any more'; + +/** The lock lists them, `nf_modules` lacks them (a fresh clone). */ +export const missing = (installed: readonly InstalledPackage[]): InstalledPackage[] => + installed.filter((entry) => !entry.present); + +export const plural = (count: number, noun: string): string => + `${count} ${noun}${count === 1 ? '' : 's'}`; diff --git a/plugins/packages/svelte.config.js b/plugins/packages/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/packages/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/packages/test/model/installed-package.spec.ts b/plugins/packages/test/model/installed-package.spec.ts new file mode 100644 index 00000000..756b37e2 --- /dev/null +++ b/plugins/packages/test/model/installed-package.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { missing, plural, reasonOf, updateChoices } from '../../src/model/installed-package'; + +const base = { name: '@acme/shapes', version: '1.0.0', dependents: [], present: true }; + +describe('packages model', () => { + it('offers the update in the range, and the latest when it is another one', () => { + expect(updateChoices({ ...base, range: '^1.0.0' })).toEqual([]); + expect(updateChoices({ ...base, range: '^1.0.0', wanted: '1.1.0', latest: '1.1.0' })).toEqual([ + { version: '1.1.0', latest: false, label: 'Update to 1.1.0' }, + ]); + expect(updateChoices({ ...base, range: '^1.0.0', wanted: '1.1.0', latest: '2.0.0' })).toEqual([ + { version: '1.1.0', latest: false, label: 'Update to 1.1.0' }, + { version: '2.0.0', latest: true, label: 'Update to 2.0.0' }, + ]); + expect(updateChoices({ ...base, dependents: ['@acme/render'], latest: '2.0.0' })).toEqual([]); + }); + + it('says why a package is there, and which are missing', () => { + expect(reasonOf({ ...base, range: '^1.0.0' })).toBe('asked for as ^1.0.0'); + expect(reasonOf({ ...base, dependents: ['@acme/render', '@acme/ui'] })).toBe( + 'needed by @acme/render, @acme/ui', + ); + expect(missing([base, { ...base, name: '@acme/gone', present: false }])).toHaveLength(1); + expect([plural(1, 'package'), plural(2, 'package')]).toEqual(['1 package', '2 packages']); + }); +}); diff --git a/plugins/packages/tsconfig.json b/plugins/packages/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/packages/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/packages/vite.config.ts b/plugins/packages/vite.config.ts new file mode 100644 index 00000000..ffb52172 --- /dev/null +++ b/plugins/packages/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'packages' }, + }, +}); diff --git a/plugins/scene/.prettierignore b/plugins/scene/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/scene/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/scene/LICENSE b/plugins/scene/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/scene/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/scene/README.md b/plugins/scene/README.md new file mode 100644 index 00000000..0c443610 --- /dev/null +++ b/plugins/scene/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-scene` is an internal package of the NanoForge Editor. + +Built-in scene plugin: the scene tree, scene vars and the loaded scenes. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/scene +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/scene/nanoforge.manifest.json b/plugins/scene/nanoforge.manifest.json new file mode 100644 index 00000000..ca054a61 --- /dev/null +++ b/plugins/scene/nanoforge.manifest.json @@ -0,0 +1,60 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/scene", + "version": "0.1.0", + "displayName": "Scenes", + "description": "The scenes of each app: their tree, their entities and systems (through the ECS widgets), the scene vars, and the loaded scenes while playing.", + "engines": { + "editor": ">=0.1.0" + }, + "dependencies": ["@nanoforge/ecs"], + "engineLibs": { + "required": { + "@nanoforge-dev/scene": "*" + } + }, + "entry": { + "client": "index.js", + "worker": "worker.js" + }, + "activation": [ + "onWidget:scene.scenes", + "onWidget:scene.vars", + "onWidget:ecs.hierarchy", + "onWidget:ecs.inspector", + "onWidget:ecs.systems", + "onWidget:viewport.scene" + ], + "contributes": { + "widgets": [ + { + "id": "scene.scenes", + "title": "Scenes", + "icon": "layers", + "defaultSlot": "leftTop", + "order": 190 + }, + { + "id": "scene.vars", + "title": "Vars", + "icon": "variable", + "defaultSlot": "rightBottom", + "order": 200 + } + ], + "commands": [ + { + "id": "scene.newScene", + "title": "New scene…", + "category": "Scenes" + }, + { + "id": "scene.edit", + "title": "Edit scene", + "category": "Scenes", + "palette": false + } + ] + } +} diff --git a/plugins/scene/package.json b/plugins/scene/package.json new file mode 100644 index 00000000..f65e8dba --- /dev/null +++ b/plugins/scene/package.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-scene", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in scene plugin: the scene tree, scene vars and the loaded scenes", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/scene" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-code": "workspace:*", + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "ts-morph": "catalog:code", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/scene/src/extension/ecs-source.extension-point.ts b/plugins/scene/src/extension/ecs-source.extension-point.ts new file mode 100644 index 00000000..6803c9f1 --- /dev/null +++ b/plugins/scene/src/extension/ecs-source.extension-point.ts @@ -0,0 +1,33 @@ +/** + * The ECS plugin's `ecs.sources` extension point, as this plugin contributes to it (plugins + * import only the SDK: the point is defined again with the same id). + */ +import { type AppModel, type Observable, defineExtensionPoint } from '@nanoforge-dev/editor-sdk'; + +export type EntryScope = + | { readonly kind: 'main' } + | { readonly kind: 'method'; readonly class: string; readonly method: string }; + +export interface EcsLocation { + readonly path: string; + readonly scope?: EntryScope; + readonly label?: string; + readonly scene?: string; +} + +export interface InheritedLocation extends EcsLocation { + readonly label: string; +} + +export interface EcsSource { + readonly id: string; + readonly priority: number; + readonly emptyMessage?: string; + appliesTo(app: AppModel): boolean; + current(app: AppModel): Observable; + inherited(app: AppModel): Observable; + edit?(app: AppModel, location: InheritedLocation): void; + locationOf?(app: AppModel, scene: string): EcsLocation | undefined; +} + +export const ECS_SOURCES = defineExtensionPoint('ecs.sources'); diff --git a/plugins/scene/src/icons.ts b/plugins/scene/src/icons.ts new file mode 100644 index 00000000..5fe05bb5 --- /dev/null +++ b/plugins/scene/src/icons.ts @@ -0,0 +1,6 @@ +import Flag from '@lucide/svelte/icons/flag'; +import Layers from '@lucide/svelte/icons/layers'; +import Variable from '@lucide/svelte/icons/variable'; + +/** Icons of the scene plugin (registering one the editor already has is harmless). */ +export const ICONS = { flag: Flag, layers: Layers, variable: Variable }; diff --git a/plugins/scene/src/index.ts b/plugins/scene/src/index.ts new file mode 100644 index 00000000..59548cf8 --- /dev/null +++ b/plugins/scene/src/index.ts @@ -0,0 +1,76 @@ +import type { Component } from 'svelte'; + +import { definePlugin } from '@nanoforge-dev/editor-sdk'; +import { + PromptServiceToken, + StyleServiceToken, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { ECS_SOURCES, type EcsSource } from './extension/ecs-source.extension-point'; +import { ICONS } from './icons'; +import { SceneLiveService, SceneLiveServiceToken } from './live/scene-live-service'; +import { SceneService, SceneServiceToken, sceneApps } from './service/scene-service'; +import { validateSceneName } from './template/scene-template'; +import Scenes from './widgets/scenes/Scenes.svelte'; +import Vars from './widgets/vars/Vars.svelte'; + +type WidgetComponent = Component<{ instance: WidgetInstance }>; + +export default definePlugin({ + async activate(context) { + const css = await fetch(context.resolveAsset('scene.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(context.services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + + const scenes = context.subscriptions.add(new SceneService(context)); + context.provide(SceneServiceToken, scenes); + const live = context.subscriptions.add(new SceneLiveService(context)); + context.provide(SceneLiveServiceToken, live); + + context.contribute(WIDGET_VIEWS, { id: 'scene.scenes', component: Scenes as WidgetComponent }); + context.contribute(WIDGET_VIEWS, { id: 'scene.vars', component: Vars as WidgetComponent }); + + const source: EcsSource = { + id: 'scene.scenes', + priority: 10, + appliesTo: (app) => sceneApps({ apps: [app] } as never).length > 0, + current: (app) => scenes.current(app), + inherited: (app) => scenes.inherited(app), + edit: (_app, location) => + scenes.select(location.scope?.kind === 'method' ? location.scope.class : undefined), + locationOf: (app, id) => scenes.sceneLocation(app, id), + }; + context.contribute(ECS_SOURCES, source); + + context.registerCommand( + 'scene.newScene', + async () => { + const prompts = context.services.tryGet(PromptServiceToken); + const taken = (scenes.model.get()?.scenes ?? []).map((scene) => scene.className); + const name = await prompts?.ask({ + title: 'New scene', + label: 'Name', + value: 'Level', + confirm: 'Create', + validate: (value) => validateSceneName(value, taken), + }); + return name ? scenes.newScene(name) : false; + }, + { title: 'New scene…', category: 'Scenes' }, + ); + context.registerCommand( + 'scene.edit', + (className: unknown) => { + if (typeof className !== 'string') return false; + scenes.select(className); + return true; + }, + { title: 'Edit scene', category: 'Scenes', palette: false }, + ); + }, +}); diff --git a/plugins/scene/src/live/scene-live-service.ts b/plugins/scene/src/live/scene-live-service.ts new file mode 100644 index 00000000..2aaaead6 --- /dev/null +++ b/plugins/scene/src/live/scene-live-service.ts @@ -0,0 +1,139 @@ +/** + * Live mode (spec: "Live mode"): the loaded scenes and the vars of the running games, and the + * commands that change them (the running game only). + */ +import { + type Disposable, + DisposableStore, + type EngineScenes, + type Observable, + ObservableValue, + type PluginContext, + RuntimeServiceToken, + createToken, + isPlaying, +} from '@nanoforge-dev/editor-sdk'; + +const INTERVAL_MS = 200; + +/** The scenes of one running game. */ +export interface LiveScenes extends EngineScenes { + readonly source: 'client' | 'server'; + /** App id. */ + readonly app: string; +} + +export class SceneLiveService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _session = new DisposableStore(); + private readonly _playing = new ObservableValue(false); + private readonly _games = new ObservableValue([]); + private _watchers = 0; + private _features: Disposable | undefined; + + constructor(private readonly _context: PluginContext) { + this._store.add(this._session); + const runtime = _context.services.tryGet(RuntimeServiceToken); + if (!runtime) return; + this._store.add({ + dispose: runtime.session.subscribe((session) => { + const playing = isPlaying(session.state); + if (playing === this._playing.get()) return; + this._playing.set(playing); + if (playing) this._start(); + else this._stop(); + }), + }); + } + + get playing(): Observable { + return this._playing.readonly(); + } + + /** The scenes of each running game that uses the scene library. */ + get games(): Observable { + return this._games.readonly(); + } + + /** The running game of an app, if it sent its scenes. */ + game(app: string | undefined): LiveScenes | undefined { + return this._games.get().find((game) => game.app === app); + } + + /** + * Asks the games for their scenes while a view shows them (a panel on screen): the engine + * sends them only when asked. + */ + watch(): Disposable { + this._watchers++; + this._updateFeatures(); + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + this._watchers--; + this._updateFeatures(); + }, + }; + } + + /** Moves a running game to a scene (its id), with params and a parent (an id, or "current"). */ + load(game: LiveScenes, id: string, params?: unknown, parent?: string): void { + this._send(game, 'scenes-load', [id, params ?? null, ...(parent ? [parent] : [])]); + } + + unload(game: LiveScenes, id: string): void { + this._send(game, 'scenes-unload', [id]); + } + + setVar(game: LiveScenes, key: string, value: unknown): void { + this._send(game, 'scenes-set-var', [key, value]); + } + + dispose(): void { + this._features?.dispose(); + this._store.dispose(); + } + + private _send(game: LiveScenes, event: string, args: readonly unknown[]): void { + this._context.services.tryGet(RuntimeServiceToken)?.send(event, args, game.source); + } + + private _updateFeatures(): void { + const runtime = this._context.services.tryGet(RuntimeServiceToken); + const wanted = this._watchers > 0 && this._playing.get(); + if (wanted && !this._features && runtime) + this._features = runtime.useFeatures({ scenes: { intervalMs: INTERVAL_MS } }); + else if (!wanted && this._features) { + this._features.dispose(); + this._features = undefined; + } + } + + private _start(): void { + const runtime = this._context.services.tryGet(RuntimeServiceToken); + if (!runtime) return; + this._updateFeatures(); + this._session.add( + runtime.onEvent((event) => { + if (event.event !== 'scenes') return; + const scenes = event.args[0] as EngineScenes; + const others = this._games.get().filter((game) => game.source !== event.source); + this._games.set( + [...others, { ...scenes, source: event.source, app: event.app }].sort((a, b) => + a.source.localeCompare(b.source), + ), + ); + }), + ); + } + + private _stop(): void { + this._session.clear(); + this._updateFeatures(); + this._games.set([]); + } +} + +export const SceneLiveServiceToken = createToken('scene.live'); diff --git a/plugins/scene/src/model/scene-model.type.ts b/plugins/scene/src/model/scene-model.type.ts new file mode 100644 index 00000000..fbb77e36 --- /dev/null +++ b/plugins/scene/src/model/scene-model.type.ts @@ -0,0 +1,91 @@ +/** + * Types shared by the plugin's worker entry (analyzers, transformers) and its widgets. + * Type-only: nothing here runs, except the ids. + */ + +/** A param of a scene (`Scene<{ seed: number }>`), from its type argument. */ +export interface SceneParam { + readonly name: string; + /** The type as written (`number`, `'easy' | 'hard'`). */ + readonly type: string; + readonly optional: boolean; + readonly description?: string; +} + +/** A scene class of an app. */ +export interface SceneModel { + /** Its id: its key in `SceneLibrary`'s `scenes`, else its class name. */ + readonly id: string; + readonly className: string; + /** Project path of its file. */ + readonly path: string; + /** Line of the class. */ + readonly line: number; + /** Class name of its parent (`static parent`, its own or a base class's). */ + readonly parent?: string; + /** Extends `EcsScene` (directly or through a base class). */ + readonly ecs: boolean; + /** Has a `setup` method of its own (the ECS widgets edit it). */ + readonly ownSetup: boolean; + /** The params type as written, if it has one (`{ seed: number }`). */ + readonly paramsType?: string; + /** The params' fields, when the type is a type literal. */ + readonly params: readonly SceneParam[]; + /** The class's doc summary. */ + readonly description?: string; + /** Tagged `@scene` (else found by its base class only). */ + readonly tagged: boolean; + readonly side?: string; + /** `@vars score, lives`. */ + readonly vars: readonly string[]; +} + +/** `new SceneLibrary({ initial, scenes })` in the entry file. */ +export interface LibraryModel { + /** Class name given as `initial`. */ + readonly initial?: string; + /** The `scenes` map: id → class name. */ + readonly scenes: Readonly>; + /** `scenes` is written (an object literal the editor can edit). */ + readonly hasScenes: boolean; + readonly line: number; +} + +export interface AppScenesModel { + readonly scenes: readonly SceneModel[]; + /** `undefined` when the entry file creates no `SceneLibrary`. */ + readonly library?: LibraryModel; + readonly problems: readonly string[]; +} + +export interface ScenesAnalyzeArgs { + /** The app's folder: scenes are its classes (and those tagged `@scene`). */ + readonly root: string; +} + +/** What a change of the entry file's `SceneLibrary` imports: a project path or a package. */ +export type LibraryOp = + | { readonly kind: 'addScene'; readonly className: string; readonly from: string } + | { readonly kind: 'removeScene'; readonly className: string } + | { readonly kind: 'setInitial'; readonly className: string; readonly from: string }; + +/** A change of a scene's file. `parent.from` is the path of the parent's file. */ +export type SceneFileOp = + | { + readonly kind: 'setParent'; + readonly className: string; + readonly parent?: { readonly className: string; readonly from: string }; + } + | { readonly kind: 'removeClass'; readonly className: string }; + +/** Ranges of a file replaced by a text. */ +export interface TextReplace { + readonly ranges: readonly { readonly start: number; readonly end: number }[]; + readonly text: string; +} + +/** The references of a class in one file. */ +export interface ReferenceRange { + readonly path: string; + readonly ranges: readonly { readonly start: number; readonly end: number }[]; +} diff --git a/plugins/scene/src/model/scene-params.ts b/plugins/scene/src/model/scene-params.ts new file mode 100644 index 00000000..738ed216 --- /dev/null +++ b/plugins/scene/src/model/scene-params.ts @@ -0,0 +1,22 @@ +import type { SceneParam } from './scene-model.type'; + +/** A param's value from a prompt's text: by its type, else JSON, else the text itself. */ +export const parseParam = (param: Pick, text: string): unknown => { + const type = param.type.trim(); + if (type === 'number') return Number(text); + if (type === 'boolean') return text.trim() === 'true'; + if (type === 'string') return text; + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +/** A live value as one line (the engine's JSON view: class instances carry `$class`). */ +export const formatValue = (value: unknown): string => { + if (value === null || value === undefined) return '—'; + if (typeof value === 'string') return JSON.stringify(value); + const text = JSON.stringify(value); + return text.length > 60 ? `${text.slice(0, 57)}…` : text; +}; diff --git a/plugins/scene/src/model/scene.const.ts b/plugins/scene/src/model/scene.const.ts new file mode 100644 index 00000000..130b6f31 --- /dev/null +++ b/plugins/scene/src/model/scene.const.ts @@ -0,0 +1,13 @@ +export const SCENES_ANALYZER = 'scene.app'; + +export const LIBRARY_TRANSFORMER = 'scene.library'; +export const SCENE_FILE_TRANSFORMER = 'scene.file'; +export const REPLACE_TRANSFORMER = 'scene.replace'; +export const REFERENCES_ANALYZER = 'scene.references'; +export const CLASSES_ANALYZER = 'scene.classes'; + +export const VARS_ANALYZER = 'scene.vars'; +export const VARS_TRANSFORMER = 'scene.vars-file'; + +/** The file a new app's vars are declared in. */ +export const VARS_FILE = 'scene-vars.ts'; diff --git a/plugins/scene/src/model/vars-model.type.ts b/plugins/scene/src/model/vars-model.type.ts new file mode 100644 index 00000000..72a83738 --- /dev/null +++ b/plugins/scene/src/model/vars-model.type.ts @@ -0,0 +1,56 @@ +/** A scene var declared in `SceneVars` (`src/scene-vars.ts`). */ +export interface VarModel { + readonly name: string; + /** As written: `number`, `'easy' | 'hard'`. */ + readonly type: string; + readonly optional: boolean; + readonly description?: string; + /** `@default` as written. */ + readonly default?: string; + readonly line: number; +} + +/** A `vars.init/set/get/has/remove("key", …)` call with a string-literal key. */ +export interface VarUse { + readonly key: string; + readonly kind: 'init' | 'set' | 'get' | 'has' | 'remove'; + readonly path: string; + readonly line: number; + /** The key's text, quotes excluded (what a rename replaces). */ + readonly start: number; + readonly end: number; + /** The class the call is in (a scene's `setup`). */ + readonly className?: string; + /** Type of the value given to `init` / `set`, when it is a literal. */ + readonly valueType?: string; +} + +export interface VarsModel { + /** The file declaring `SceneVars`, if any. */ + readonly file?: string; + readonly vars: readonly VarModel[]; + readonly uses: readonly VarUse[]; +} + +export interface VarsAnalyzeArgs { + readonly root: string; +} + +/** A change of `SceneVars`' declaration. */ +export type VarsOp = + | { + readonly kind: 'addVar'; + readonly name: string; + readonly type: string; + readonly description?: string; + readonly default?: string; + } + | { + readonly kind: 'updateVar'; + readonly name: string; + readonly rename?: string; + readonly type?: string; + readonly description?: string; + readonly default?: string; + } + | { readonly kind: 'removeVar'; readonly name: string }; diff --git a/plugins/scene/src/service/saved-command.ts b/plugins/scene/src/service/saved-command.ts new file mode 100644 index 00000000..0f28f22b --- /dev/null +++ b/plugins/scene/src/service/saved-command.ts @@ -0,0 +1,14 @@ +import type { HistoryCommand } from '@nanoforge-dev/editor-sdk'; + +/** A command whose file is saved after it is done or undone. */ +export const saved = (command: HistoryCommand, save: () => Promise): HistoryCommand => ({ + ...command, + do: async () => { + await command.do(); + await save(); + }, + undo: async () => { + await command.undo(); + await save(); + }, +}); diff --git a/plugins/scene/src/service/scene-history.const.ts b/plugins/scene/src/service/scene-history.const.ts new file mode 100644 index 00000000..60ff5fa0 --- /dev/null +++ b/plugins/scene/src/service/scene-history.const.ts @@ -0,0 +1,2 @@ +/** Undo context of the scene tree's changes (new, rename, delete, parents, initial). */ +export const SCENES_HISTORY = 'scene:tree'; diff --git a/plugins/scene/src/service/scene-service.ts b/plugins/scene/src/service/scene-service.ts new file mode 100644 index 00000000..806115fe --- /dev/null +++ b/plugins/scene/src/service/scene-service.ts @@ -0,0 +1,576 @@ +import { + type AppModel, + CodeServiceToken, + type Disposable, + DisposableStore, + DocumentServiceToken, + type HistoryCommand, + type Observable, + ObservableValue, + type PluginContext, + type ProjectModel, + ProjectServiceToken, + createToken, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import type { EcsLocation, InheritedLocation } from '../extension/ecs-source.extension-point'; +import type { + AppScenesModel, + LibraryOp, + ReferenceRange, + SceneFileOp, + SceneModel, +} from '../model/scene-model.type'; +import { + CLASSES_ANALYZER, + LIBRARY_TRANSFORMER, + REFERENCES_ANALYZER, + REPLACE_TRANSFORMER, + SCENES_ANALYZER, + SCENE_FILE_TRANSFORMER, + VARS_ANALYZER, + VARS_FILE, + VARS_TRANSFORMER, +} from '../model/scene.const'; +import type { VarsModel, VarsOp } from '../model/vars-model.type'; +import { kebab, scenePath, sceneTemplate } from '../template/scene-template'; +import { varsTemplate } from '../template/vars-template'; +import { saved } from './saved-command'; +import type { Step } from './scene-step.type'; +import { SceneSteps } from './scene-steps'; + +/** The ECS plugin's analyzer and transformer of imports (moving a file). */ +const IMPORTERS_ANALYZER = 'ecs.importers'; +const REWRITE_IMPORTS_TRANSFORMER = 'ecs.rewrite-imports'; + +/** The ECS plugin's selected app (a global context key). */ +const ACTIVE_APP_KEY = 'activeApp'; +const REFRESH_DELAY_MS = 150; + +/** Apps whose scenes the plugin edits: an entry file and the scene engine library. */ +export const sceneApps = (model: ProjectModel | undefined): AppModel[] => + (model?.apps ?? []).filter( + (app) => app.type !== 'lib' && !!app.entryFile && '@nanoforge-dev/scene' in app.engineLibs, + ); + +/** Where a scene's entities are: its class's `setup`. */ +export const locationOf = (scene: SceneModel): EcsLocation => ({ + path: scene.path, + scope: { kind: 'method', class: scene.className, method: 'setup' }, + label: scene.id, + scene: scene.id, +}); + +/** The label of the entry file's `main` in the scene tree and the ECS widgets. */ +export const MAIN_LABEL = 'main.ts'; + +/** Where the app's own entities are: its entry file's `main`. */ +export const mainLocation = (app: AppModel): EcsLocation | undefined => + app.entryFile ? { path: app.entryFile, label: MAIN_LABEL } : undefined; + +/** A scene's parents, outermost first (stops at a cycle or an unknown parent). */ +export const parentsOf = (scenes: readonly SceneModel[], scene: SceneModel): SceneModel[] => { + const chain: SceneModel[] = []; + let parent = scenes.find((candidate) => candidate.className === scene.parent); + while (parent && !chain.includes(parent) && parent !== scene) { + chain.unshift(parent); + const next = parent.parent; + parent = scenes.find((candidate) => candidate.className === next); + } + return chain; +}; + +/** + * The scenes of the app the ECS widgets edit (their analysis, the selected one), and what the + * ECS widgets edit for it (the `ecs.sources` contribution). + */ +export class SceneService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _project = new DisposableStore(); + private readonly _projectModel = new ObservableValue(undefined); + private readonly _appId = new ObservableValue(undefined); + private readonly _model = new ObservableValue(undefined); + private readonly _vars = new ObservableValue(undefined); + /** The selected scene (class name) of each app. */ + private readonly _selected = new ObservableValue>(new Map()); + private _timer: ReturnType | undefined; + private _generation = 0; + + private readonly _steps: SceneSteps; + + constructor(private readonly _context: PluginContext) { + this._steps = new SceneSteps(_context, () => this._refresh(0)); + this._store.add(this._project); + this._store.add({ + dispose: _context.scope.observe(CodeServiceToken).subscribe(() => this._bindProject()), + }); + this._store.add( + _context.contextKeys.onDidChange(({ keys }) => { + if (keys.has(ACTIVE_APP_KEY)) this._pickApp(); + }), + ); + } + + /** Apps with the scene library. */ + get apps(): AppModel[] { + return sceneApps(this._projectModel.get()); + } + get appId(): Observable { + return this._appId.readonly(); + } + get app(): AppModel | undefined { + return this.apps.find((app) => app.id === this._appId.get()); + } + /** The scenes of the app. */ + get model(): Observable { + return this._model.readonly(); + } + /** The scene vars of the app: their declaration and uses. */ + get vars(): Observable { + return this._vars.readonly(); + } + /** The selected scene's class name, per app. */ + get selected(): Observable> { + return this._selected.readonly(); + } + + /** The selected scene of an app. */ + selectedScene(app: AppModel | undefined): SceneModel | undefined { + const className = app ? this._selected.get().get(app.id) : undefined; + return app?.id === this._appId.get() + ? this._model.get()?.scenes.find((scene) => scene.className === className) + : undefined; + } + + /** Makes a scene the one the ECS widgets edit. */ + select(className: string | undefined): void { + const app = this._appId.get(); + if (!app) return; + const next = new Map(this._selected.get()); + if (className) next.set(app, className); + else next.delete(app); + this._selected.set(next); + } + + /** What the ECS widgets edit for an app: its selected scene's `setup`, else its `main`. */ + current(app: AppModel): Observable { + const compute = () => { + const scene = this.selectedScene(app); + return scene ? locationOf(scene) : mainLocation(app); + }; + return this._derived(compute); + } + + /** `main` and the selected scene's parents, outermost first, read-only in the ECS widgets. */ + inherited(app: AppModel): Observable { + const compute = (): InheritedLocation[] => { + const scene = this.selectedScene(app); + const scenes = this._model.get()?.scenes ?? []; + if (!scene) return []; + const main = mainLocation(app); + return [ + ...(main ? [{ ...main, label: MAIN_LABEL }] : []), + ...parentsOf(scenes, scene).map((parent) => ({ ...locationOf(parent), label: parent.id })), + ]; + }; + return this._derived(compute); + } + + /** A scene's location, by id (live entities carry it). */ + sceneLocation(app: AppModel, id: string): EcsLocation | undefined { + if (app.id !== this._appId.get()) return undefined; + const scene = this._model.get()?.scenes.find((candidate) => candidate.id === id); + return scene ? locationOf(scene) : undefined; + } + + /** Creates a scene (its file, and its entry in `scenes`), under a parent or as a root. */ + async newScene(name: string, parent?: SceneModel): Promise { + const app = this.app; + const project = this._context.services.get(ProjectServiceToken).current.get(); + if (!app?.entryFile || !project) return false; + const path = scenePath(app, name); + if (project.fs.entry(path)) + return this._steps.fail(`New scene ${name}`, `${path} already exists.`); + const text = sceneTemplate( + app, + name, + parent && { className: parent.className, path: parent.path }, + ); + const entry = app.entryFile; + const ok = await this._steps.push(`New scene ${name}`, [ + async () => ({ + label: `Create ${path}`, + do: async () => void (await project.fs.write(path, text, { expectedHash: null })), + undo: async () => void (await project.fs.delete(path)), + }), + () => this._libraryStep(entry, { kind: 'addScene', className: name, from: path }), + ]); + if (!ok) return false; + this.select(name); + await this.openInCode(path, parent ? 11 : 10); + return true; + } + + /** Writes `initial` in the entry file. */ + async setInitial(scene: SceneModel): Promise { + const entry = this.app?.entryFile; + if (!entry) return false; + return this._steps.push(`Set ${scene.id} as the initial scene`, [ + () => + this._steps.edit(entry, LIBRARY_TRANSFORMER, { + kind: 'setInitial', + className: scene.className, + from: scene.path, + } satisfies LibraryOp), + ]); + } + + /** Writes a scene's `static parent` (none: a root scene). */ + async setParent(scene: SceneModel, parent: SceneModel | undefined): Promise { + const scenes = this._model.get()?.scenes ?? []; + if (parent && (parent === scene || parentsOf(scenes, parent).includes(scene))) + return this._steps.fail(`Move ${scene.id}`, `${parent.id} is inside ${scene.id}.`); + return this._steps.push( + parent ? `Move ${scene.id} under ${parent.id}` : `Move ${scene.id} to the root`, + [ + () => + this._steps.edit(scene.path, SCENE_FILE_TRANSFORMER, { + kind: 'setParent', + className: scene.className, + ...(parent && { parent: { className: parent.className, from: parent.path } }), + } satisfies SceneFileOp), + ], + ); + } + + /** + * Renames a scene class everywhere it is used, and its file when it is named after it and holds + * nothing else (the imports follow). + */ + async rename(scene: SceneModel, name: string): Promise { + const code = this._context.services.tryGet(CodeServiceToken); + const project = this._context.services.get(ProjectServiceToken).current.get(); + if (!code || !project || name === scene.className) return false; + const fileName = scene.path + .split('/') + .at(-1)! + .replace(/\.[cm]?[jt]sx?$/, ''); + const classes = await code + .analyze(scene.path, CLASSES_ANALYZER) + .catch(() => [] as string[]); + const to = `${scene.path.split('/').slice(0, -1).join('/')}/${kebab(name)}.ts`; + const moveFile = + fileName === kebab(scene.className) && classes.length === 1 && !project.fs.entry(to); + const documents = this._context.services.tryGet(DocumentServiceToken); + if (!documents) return false; + const references = await code.analyze(scene.path, REFERENCES_ANALYZER, { + className: scene.className, + }); + const importers = moveFile + ? ( + await code.analyze(scene.path, IMPORTERS_ANALYZER).catch(() => [] as string[]) + ).filter((importer) => importer !== scene.path) + : []; + const steps: Step[] = [ + async () => { + const edits: HistoryCommand[] = []; + for (const { path, ranges } of references) { + const command = await code.edit( + path, + REPLACE_TRANSFORMER, + { ranges, text: name }, + { label: `Rename ${scene.className}` }, + ); + if (command) edits.push(saved(command, () => documents.save(path))); + } + return this._steps.group(`Rename ${scene.className} to ${name}`, edits); + }, + ]; + if (moveFile) { + steps.push( + async () => { + const edits: HistoryCommand[] = []; + for (const importer of importers) { + const command = await code + .edit( + importer, + REWRITE_IMPORTS_TRANSFORMER, + { from: scene.path, to }, + { label: 'Imports' }, + ) + .catch(() => undefined); + if (command) edits.push(saved(command, () => documents.save(importer))); + } + return this._steps.group('Imports', edits); + }, + async () => ({ + label: `Rename ${scene.path}`, + do: async () => project.fs.rename(scene.path, to), + undo: async () => project.fs.rename(to, scene.path), + }), + ); + } + const ok = await this._steps.push(`Rename ${scene.id} to ${name}`, steps); + if (ok && this._selected.get().get(this._appId.get() ?? '') === scene.className) + this.select(name); + return ok; + } + + /** + * Deletes a scene: its entry in `scenes`, the `static parent` of its children (they become + * roots), and its class (its file, when it holds nothing else). + */ + async delete(scene: SceneModel): Promise { + const code = this._context.services.tryGet(CodeServiceToken); + const project = this._context.services.get(ProjectServiceToken).current.get(); + const app = this.app; + const model = this._model.get(); + if (!code || !project || !app?.entryFile || !model) return false; + if (model.library?.initial === scene.className) + return this._steps.fail( + `Delete ${scene.id}`, + `${scene.id} is the initial scene: set another one first.`, + ); + const children = model.scenes.filter((candidate) => candidate.parent === scene.className); + const classes = await code + .analyze(scene.path, CLASSES_ANALYZER) + .catch(() => [] as string[]); + const entry = app.entryFile; + const steps: Step[] = [ + ...(model.library?.scenes && Object.values(model.library.scenes).includes(scene.className) + ? [() => this._libraryStep(entry, { kind: 'removeScene', className: scene.className })] + : []), + ...children.map( + (child): Step => + () => + this._steps.edit(child.path, SCENE_FILE_TRANSFORMER, { + kind: 'setParent', + className: child.className, + } satisfies SceneFileOp), + ), + classes.length === 1 + ? async () => { + let trash = ''; + return { + label: `Delete ${scene.path}`, + do: async () => { + trash = (await project.fs.delete(scene.path)).trashPath; + }, + undo: async () => project.fs.restore(trash, scene.path), + }; + } + : () => + this._steps.edit(scene.path, SCENE_FILE_TRANSFORMER, { + kind: 'removeClass', + className: scene.className, + } satisfies SceneFileOp), + ]; + const ok = await this._steps.push(`Delete ${scene.id}`, steps); + if (ok) { + if (this._selected.get().get(app.id) === scene.className) this.select(undefined); + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('info', `Deleted ${scene.id}`, { + detail: children.length + ? `${children.map((child) => child.id).join(', ')} no longer ${children.length > 1 ? 'have' : 'has'} a parent. Code that still uses ${scene.className} must be changed.` + : `Code that still uses ${scene.className} must be changed.`, + actions: [{ title: 'Undo', run: () => void this._steps.history()?.stack.undo() }], + }); + } + return ok; + } + + /** + * Declares a var in `SceneVars` (`scene-vars.ts` next to the entry file, created on the first + * var). + */ + async addVar(name: string, type: string, description?: string, fallback?: string) { + const app = this.app; + const project = this._context.services.get(ProjectServiceToken).current.get(); + if (!app?.entryFile || !project) return false; + const op: VarsOp = { + kind: 'addVar', + name, + type, + ...(description && { description }), + ...(fallback && { default: fallback }), + }; + const file = this._vars.get()?.file; + if (file) + return this._steps.push(`Add var ${name}`, [ + () => this._steps.edit(file, VARS_TRANSFORMER, op), + ]); + const path = `${app.entryFile.split('/').slice(0, -1).join('/')}/${VARS_FILE}`; + if (project.fs.entry(path)) + return this._steps.fail(`Add var ${name}`, `${path} declares no SceneVars.`); + const text = varsTemplate(name, type, description, fallback); + return this._steps.push(`Add var ${name}`, [ + async () => ({ + label: `Create ${path}`, + do: async () => void (await project.fs.write(path, text, { expectedHash: null })), + undo: async () => void (await project.fs.delete(path)), + }), + ]); + } + + /** Changes a var's type, default or description, or renames it with its string-key uses. */ + async updateVar( + name: string, + changes: { rename?: string; type?: string; description?: string; default?: string }, + ): Promise { + const vars = this._vars.get(); + const file = vars?.file; + const code = this._context.services.tryGet(CodeServiceToken); + const documents = this._context.services.tryGet(DocumentServiceToken); + if (!file || !code || !documents) return false; + const rename = changes.rename && changes.rename !== name ? changes.rename : undefined; + const steps: Step[] = [ + () => + this._steps.edit(file, VARS_TRANSFORMER, { + kind: 'updateVar', + name, + ...changes, + } satisfies VarsOp), + ]; + if (rename) { + const byPath = new Map(); + for (const use of vars.uses.filter((candidate) => candidate.key === name)) + byPath.set(use.path, [...(byPath.get(use.path) ?? []), { start: use.start, end: use.end }]); + for (const [path, ranges] of byPath) + steps.push(() => this._steps.edit(path, REPLACE_TRANSFORMER, { ranges, text: rename })); + } + return this._steps.push(rename ? `Rename var ${name} to ${rename}` : `Edit var ${name}`, steps); + } + + /** Removes a var's declaration (its uses stay: they become undeclared keys). */ + async removeVar(name: string): Promise { + const file = this._vars.get()?.file; + if (!file) return false; + return this._steps.push(`Remove var ${name}`, [ + () => this._steps.edit(file, VARS_TRANSFORMER, { kind: 'removeVar', name } satisfies VarsOp), + ]); + } + + /** Opens a file in the code editor, at a line. */ + async openInCode(path: string, line?: number): Promise { + await this._context.executeCommand('codeEditor.open', path, line ? { line } : {}); + } + + dispose(): void { + clearTimeout(this._timer); + this._store.dispose(); + } + + /** A change of the entry file's `SceneLibrary`, skipped when the app has none. */ + private async _libraryStep(entry: string, op: LibraryOp) { + if (!this._model.get()?.library?.hasScenes && op.kind !== 'setInitial') return undefined; + return this._steps.edit(entry, LIBRARY_TRANSFORMER, op); + } + + /** An observable of a value computed from the model and the selection. */ + private _derived(compute: () => T): Observable { + return { + get: compute, + subscribe: (run) => { + let last = JSON.stringify(compute()); + const update = () => { + const value = compute(); + const json = JSON.stringify(value); + if (json === last) return; + last = json; + run(value); + }; + const model = this._model.subscribe(update); + const selected = this._selected.subscribe(update); + return () => { + model(); + selected(); + }; + }, + }; + } + + /** The app the ECS widgets edit, when it has scenes; else the first app with scenes. */ + private _pickApp(): void { + const apps = this.apps; + const active = this._context.contextKeys.get(ACTIVE_APP_KEY); + const app = + apps.find((candidate) => candidate.id === active) ?? + apps.find((candidate) => candidate.id === this._appId.get()) ?? + apps.find((candidate) => candidate.type === 'client') ?? + apps[0]; + if (app?.id === this._appId.get()) return; + this._appId.set(app?.id); + this._model.set(undefined); + this._vars.set(undefined); + this._refresh(0); + } + + private _bindProject(): void { + this._project.clear(); + const services = this._context.services; + const project = services.get(ProjectServiceToken).current.get(); + const documents = services.tryGet(DocumentServiceToken); + if (!project || !documents) { + this._projectModel.set(undefined); + this._model.set(undefined); + return; + } + this._project.add({ + dispose: project.model.subscribe((model) => { + this._projectModel.set(model); + this._pickApp(); + this._refresh(0); + }), + }); + this._project.add( + documents.onDidChange(({ uri }) => { + if (/\.[cm]?[jt]sx?$/.test(uri)) this._refresh(); + }), + ); + this._project.add( + project.fs.onDidChange((changes) => { + if (changes.some((change) => /\.[cm]?[jt]sx?$/.test(change.path))) this._refresh(); + }), + ); + } + + /** Analyzes the app's scenes again (debounced). */ + private _refresh(delay = REFRESH_DELAY_MS): void { + clearTimeout(this._timer); + this._timer = setTimeout(() => void this._analyze(), delay); + } + + private async _analyze(): Promise { + const generation = ++this._generation; + const app = this.app; + const code = this._context.services.tryGet(CodeServiceToken); + if (!app?.entryFile || !code) { + this._model.set(undefined); + return; + } + try { + const model = await code.analyze(app.entryFile, SCENES_ANALYZER, { + root: app.root, + }); + const vars = await code.analyze(app.entryFile, VARS_ANALYZER, { root: app.root }); + if (generation !== this._generation) return; + this._model.set(model); + this._vars.set(vars); + } catch (error) { + if (generation !== this._generation) return; + if (error instanceof Error && error.message.includes('Unknown analyzer')) { + this._refresh(300); + return; + } + this._context.logger.warn(`Could not read the scenes of ${app.name}`, error); + this._model.set({ + scenes: [], + problems: [error instanceof Error ? error.message : String(error)], + }); + } + } +} + +export const SceneServiceToken = createToken('scene.service'); diff --git a/plugins/scene/src/service/scene-step.type.ts b/plugins/scene/src/service/scene-step.type.ts new file mode 100644 index 00000000..544c6d3e --- /dev/null +++ b/plugins/scene/src/service/scene-step.type.ts @@ -0,0 +1,4 @@ +import type { HistoryCommand } from '@nanoforge-dev/editor-sdk'; + +/** One step of a command made of steps run one after the other (each computed on the result of the previous ones). */ +export type Step = () => Promise; diff --git a/plugins/scene/src/service/scene-steps.ts b/plugins/scene/src/service/scene-steps.ts new file mode 100644 index 00000000..ea6a69a4 --- /dev/null +++ b/plugins/scene/src/service/scene-steps.ts @@ -0,0 +1,104 @@ +import { + CodeServiceToken, + DocumentServiceToken, + type HistoryCommand, + HistoryServiceToken, + type PluginContext, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { saved } from './saved-command'; +import { SCENES_HISTORY } from './scene-history.const'; +import type { Step } from './scene-step.type'; + +/** + * Runs the Scenes edits: document edits saved after they run, and undo steps made of steps + * computed one after the other. + */ +export class SceneSteps { + constructor( + private readonly _context: PluginContext, + /** Called after a step is pushed: the scenes are read again. */ + private readonly _ondone: () => void, + ) {} + + /** + * A document edit command (`undefined` when nothing changes). The file is saved after it runs: + * a structural change does not wait in an open editor. + */ + async edit(path: string, transformer: string, op: unknown) { + const code = this._context.services.tryGet(CodeServiceToken); + const documents = this._context.services.tryGet(DocumentServiceToken); + if (!code || !documents) throw new Error('No code service'); + const command = await code.edit(path, transformer, op, { label: 'Scenes' }); + return command ? saved(command, () => documents.save(path)) : undefined; + } + /** Commands run in order, undone in reverse. */ + group(label: string, children: readonly HistoryCommand[]): HistoryCommand | undefined { + if (!children.length) return undefined; + return { + label, + children, + do: async () => { + for (const child of children) await child.do(); + }, + undo: async () => { + for (const child of [...children].reverse()) await child.undo(); + }, + }; + } + /** + * Pushes one undo step in the Scenes history, made of steps computed and run one after the + * other (each on the files the previous ones changed). Notifies and returns false when refused. + */ + async push(label: string, steps: readonly Step[]): Promise { + const done: HistoryCommand[] = []; + const command: HistoryCommand = { + label, + do: async () => { + if (done.length) { + for (const step of done) await step.do(); + return; + } + for (const step of steps) { + const next = await step(); + if (!next) continue; + await next.do(); + done.push(next); + } + }, + undo: async () => { + for (const step of [...done].reverse()) await step.undo(); + }, + }; + try { + const history = this.history(); + if (history) await history.stack.push(command); + else await command.do(); + this._ondone(); + return true; + } catch (error) { + for (const step of [...done].reverse()) + await Promise.resolve(step.undo()).catch(() => undefined); + return this.fail(label, error instanceof Error ? error.message : String(error)); + } + } + fail(title: string, detail: string): false { + this._context.services + .tryGet(NotificationServiceToken) + ?.notify('warning', `${title}: not possible`, { detail }); + return false; + } + /** The Scenes undo context (registered on first use). */ + history() { + const history = this._context.services.tryGet(HistoryServiceToken); + return history + ? (history.get(SCENES_HISTORY) ?? + history.registerContext({ + id: SCENES_HISTORY, + label: 'Scenes', + owner: this._context.name, + })) + : undefined; + } +} diff --git a/plugins/scene/src/template/scene-template.ts b/plugins/scene/src/template/scene-template.ts new file mode 100644 index 00000000..f50b9909 --- /dev/null +++ b/plugins/scene/src/template/scene-template.ts @@ -0,0 +1,63 @@ +/** A new scene's file (an `EcsScene` with the TSDoc tags), and its path. */ +import type { AppModel } from '@nanoforge-dev/editor-sdk'; + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +export const kebab = (name: string): string => + name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^A-Za-z0-9]+/g, '-') + .toLowerCase() + .replace(/^-|-$/g, ''); + +/** An error message for a scene class name, or `undefined` when it is fine. */ +export const validateSceneName = (name: string, taken: readonly string[]): string | undefined => { + if (!IDENTIFIER.test(name)) return 'Use letters, digits, _ or $, not starting with a digit.'; + if (!/^[A-Z]/.test(name)) return 'Scene names start with a capital letter.'; + if (taken.includes(name)) return `${name} already exists.`; + return undefined; +}; + +/** Where a new scene's file goes: the app's scenes folder. */ +export const scenePath = (app: AppModel, name: string): string => + `${app.dirs.scenes}/${kebab(name)}.ts`; + +/** The import of a file from another one in the same app (relative, no extension). */ +export const relativeImport = (from: string, to: string): string => { + const source = from.split('/').slice(0, -1); + const target = to.replace(/\.[cm]?[jt]sx?$/, '').split('/'); + let common = 0; + while (common < source.length && source[common] === target[common]) common++; + const up = source.length - common; + const rest = target.slice(common).join('/'); + return up ? `${'../'.repeat(up)}${rest}` : `./${rest}`; +}; + +export const sceneTemplate = ( + app: AppModel, + name: string, + parent?: { className: string; path: string }, +): string => { + const side = app.type === 'server' ? 'server' : 'client'; + const path = scenePath(app, name); + return [ + 'import type { Context } from "@nanoforge-dev/common";', + `import type { Registry } from "@nanoforge-dev/ecs/${side}";`, + 'import { EcsScene } from "@nanoforge-dev/ecs/scene";', + ...(parent + ? ['', `import { ${parent.className} } from "${relativeImport(path, parent.path)}";`] + : []), + '', + '/**', + ` * ${name}: what it shows.`, + ' *', + ' * @scene', + ` * @side ${side}`, + ' */', + `export class ${name} extends EcsScene {`, + ...(parent ? [` static override parent = ${parent.className};`, ''] : []), + ' override setup(registry: Registry, ctx: Context) {}', + '}', + '', + ].join('\n'); +}; diff --git a/plugins/scene/src/template/vars-template.ts b/plugins/scene/src/template/vars-template.ts new file mode 100644 index 00000000..460df811 --- /dev/null +++ b/plugins/scene/src/template/vars-template.ts @@ -0,0 +1,26 @@ +/** A new `scene-vars.ts`, with its first var. */ +export const varsTemplate = ( + name: string, + type: string, + description: string | undefined, + fallback: string | undefined, +): string => { + const doc = [description?.trim(), fallback?.trim() ? `@default ${fallback.trim()}` : ''] + .filter(Boolean) + .join(' '); + return [ + '/**', + ' * The scene vars of the game. A var belongs to the scene that made it, and goes away when that', + ' * scene is unloaded, unless it is persistent.', + ' */', + 'declare module "@nanoforge-dev/scene" {', + ' interface SceneVars {', + ...(doc ? [` /** ${doc} */`] : []), + ` ${name}: ${type};`, + ' }', + '}', + '', + 'export {};', + '', + ].join('\n'); +}; diff --git a/plugins/scene/src/widgets/scenes/Scenes.svelte b/plugins/scene/src/widgets/scenes/Scenes.svelte new file mode 100644 index 00000000..d38d4117 --- /dev/null +++ b/plugins/scene/src/widgets/scenes/Scenes.svelte @@ -0,0 +1,285 @@ + + +
    +
    + {scenes.app?.name ?? ''} + void create()} /> +
    + {#if !scenes.apps.length} + + {:else if !$model} + + {:else} + + + + {#if !$model.scenes.length} + + {/if} + {/if} + {#if $model?.problems.length} +
      + {#each $model.problems as problem (problem)} +
    • {problem}
    • + {/each} +
    + {/if} +
    + + diff --git a/plugins/scene/src/widgets/scenes/scene-prompts.ts b/plugins/scene/src/widgets/scenes/scene-prompts.ts new file mode 100644 index 00000000..4db51773 --- /dev/null +++ b/plugins/scene/src/widgets/scenes/scene-prompts.ts @@ -0,0 +1,35 @@ +import type { PromptService } from '@nanoforge-dev/editor-sdk/ui'; + +import type { SceneModel } from '../../model/scene-model.type'; +import { parseParam } from '../../model/scene-params'; + +/** Asks for a scene's params, one prompt per field. `undefined` when cancelled. */ +export const askSceneParams = async ( + prompts: PromptService | undefined, + scene: SceneModel, +): Promise<{ value: unknown } | undefined> => { + if (!scene.paramsType) return { value: undefined }; + if (!prompts) return undefined; + if (!scene.params.length) { + const text = await prompts.ask({ + title: `Load ${scene.id}`, + label: `Params (${scene.paramsType}, JSON)`, + value: '{}', + confirm: 'Load', + }); + return text === undefined ? undefined : { value: parseParam({ type: 'json' }, text) }; + } + const value: Record = {}; + for (const [index, param] of scene.params.entries()) { + const text = await prompts.ask({ + title: `Load ${scene.id}: ${param.name}`, + label: `${param.name} (${param.type})`, + value: param.type === 'number' ? '0' : '', + confirm: index === scene.params.length - 1 ? 'Load' : 'Next', + ...(param.optional && { optional: true }), + }); + if (text === undefined) return undefined; + if (text !== '' || !param.optional) value[param.name] = parseParam(param, text); + } + return { value }; +}; diff --git a/plugins/scene/src/widgets/scenes/scene-tree.ts b/plugins/scene/src/widgets/scenes/scene-tree.ts new file mode 100644 index 00000000..ec5e893f --- /dev/null +++ b/plugins/scene/src/widgets/scenes/scene-tree.ts @@ -0,0 +1,63 @@ +import type { TreeNode } from '@nanoforge-dev/editor-sdk/ui'; + +import type { AppScenesModel, SceneModel } from '../../model/scene-model.type'; + +/** The row of the entry file's `main` (not a scene). */ +export const MAIN_ID = '#main'; + +/** What a scene's row says of it: initial, its params, what it lacks. */ +export const sceneDetail = (model: AppScenesModel | undefined, scene: SceneModel): string => + [ + scene.className === model?.library?.initial ? 'initial' : '', + scene.paramsType ?? '', + scene.ecs ? '' : 'not an EcsScene', + scene.tagged ? '' : 'untagged', + ] + .filter(Boolean) + .join(' · '); + +/** The scenes as a tree, by `static parent`, under the entry file's `main`. */ +export const sceneTree = ( + model: AppScenesModel | undefined, + loadedIds: readonly string[], + mainLabel: string, +): TreeNode[] => { + const all = model?.scenes ?? []; + const known = new Set(all.map((scene) => scene.className)); + const children = (parent: string | undefined, seen: Set): TreeNode[] => + all + .filter((scene) => + parent === undefined ? !scene.parent || !known.has(scene.parent) : scene.parent === parent, + ) + .filter((scene) => !seen.has(scene.className)) + .map((scene) => { + const inner = children(scene.className, new Set([...seen, scene.className])); + return { + id: scene.className, + label: scene.id, + icon: scene.className === model?.library?.initial ? 'flag' : 'layers', + detail: [ + loadedIds.at(-1) === scene.id + ? 'current' + : loadedIds.includes(scene.id) + ? 'loaded' + : '', + sceneDetail(model, scene), + ] + .filter(Boolean) + .join(' · '), + ...(loadedIds.includes(scene.id) && { tone: 'added' as const }), + ...(inner.length && { children: inner }), + droppable: true, + }; + }); + const main: TreeNode = { + id: MAIN_ID, + label: mainLabel, + icon: 'file-code', + detail: 'app: in every scene', + draggable: false, + droppable: false, + }; + return [main, ...children(undefined, new Set())]; +}; diff --git a/plugins/scene/src/widgets/vars/VarRow.svelte b/plugins/scene/src/widgets/vars/VarRow.svelte new file mode 100644 index 00000000..91518805 --- /dev/null +++ b/plugins/scene/src/widgets/vars/VarRow.svelte @@ -0,0 +1,130 @@ + + +
  • + + {#if playing} +

    + {#if value} + {formatValue(value.value)} + {value.persistent ? 'persistent' : value.owner ? `of ${value.owner}` : ''} + {:else} + not set in the game + {/if} +

    + {/if} + {#if field.description}

    {field.description}

    {/if} + {#if open} +
      + {#each uses as use (`${use.path}:${use.start}`)} +
    • + +
    • + {/each} +
    + {/if} +
  • + + diff --git a/plugins/scene/src/widgets/vars/Vars.svelte b/plugins/scene/src/widgets/vars/Vars.svelte new file mode 100644 index 00000000..7aae2cc8 --- /dev/null +++ b/plugins/scene/src/widgets/vars/Vars.svelte @@ -0,0 +1,306 @@ + + +
    +
    + {$vars?.file ?? 'Scene vars'} + void add()} /> +
    + {#if !scenes.apps.length} + + {:else if !$vars} + + {:else} + + + + {/if} +
    + + diff --git a/plugins/scene/src/widgets/vars/var-prompts.ts b/plugins/scene/src/widgets/vars/var-prompts.ts new file mode 100644 index 00000000..f5a1b813 --- /dev/null +++ b/plugins/scene/src/widgets/vars/var-prompts.ts @@ -0,0 +1,92 @@ +import type { PromptService } from '@nanoforge-dev/editor-sdk/ui'; + +import type { VarModel } from '../../model/vars-model.type'; + +export interface NewVar { + readonly name: string; + readonly type: string; + readonly fallback: string; + readonly description: string; +} + +export type VarField = 'rename' | 'type' | 'default' | 'description'; + +/** Asks for a new var (or the type of one used but not declared): name, type, default, description. */ +export const askNewVar = async ( + prompts: PromptService, + nameProblem: (value: string) => string | undefined, + key?: string, + type?: string, +): Promise => { + const name = + key ?? + (await prompts.ask({ + title: 'Add var', + label: 'Name', + value: 'score', + confirm: 'Next', + validate: (value) => nameProblem(value), + })); + if (!name) return undefined; + const chosenType = await prompts.ask({ + title: `Type of ${name}`, + label: 'Type', + value: type ?? 'number', + confirm: 'Next', + validate: (value) => (value.trim() ? undefined : 'Give a type: number, string, boolean…'), + }); + if (!chosenType) return undefined; + const fallback = await prompts.ask({ + title: `Default of ${name}`, + label: 'Default (optional)', + value: chosenType === 'number' ? '0' : '', + confirm: 'Next', + optional: true, + }); + if (fallback === undefined) return undefined; + const description = await prompts.ask({ + title: `Description of ${name}`, + label: 'Description (optional)', + value: '', + confirm: key ? 'Declare' : 'Add', + optional: true, + }); + if (description === undefined) return undefined; + return { + name, + type: chosenType.trim(), + description: description.trim(), + fallback: fallback.trim(), + }; +}; + +const LABELS = { + rename: 'Name', + type: 'Type', + default: 'Default', + description: 'Description', +} as const; + +/** Asks for a new value of one of a var's fields; undefined when cancelled or unchanged. */ +export const askVarField = async ( + prompts: PromptService, + field: VarModel, + what: VarField, + nameProblem: (value: string, current?: string) => string | undefined, +): Promise => { + const current = { + rename: field.name, + type: field.type, + default: field.default ?? '', + description: field.description ?? '', + }[what]; + const value = await prompts.ask({ + title: what === 'rename' ? `Rename ${field.name}` : `${LABELS[what]} of ${field.name}`, + label: LABELS[what], + value: current, + confirm: what === 'rename' ? 'Rename' : 'Save', + optional: what === 'default' || what === 'description', + ...(what === 'rename' && { validate: (next: string) => nameProblem(next, field.name) }), + }); + return value === undefined || value === current ? undefined : value.trim(); +}; diff --git a/plugins/scene/src/worker/edits.ts b/plugins/scene/src/worker/edits.ts new file mode 100644 index 00000000..50d5a915 --- /dev/null +++ b/plugins/scene/src/worker/edits.ts @@ -0,0 +1,227 @@ +import { type ClassDeclaration, Node, type SourceFile, SyntaxKind } from 'ts-morph'; + +import { + type AnalyzeContext, + CodeError, + type TransformContext, +} from '@nanoforge-dev/editor-sdk/worker'; + +import type { + LibraryOp, + ReferenceRange, + SceneFileOp, + TextReplace, +} from '../model/scene-model.type'; +import { findLibrary, projectPath } from './scenes'; + +const fail = (message: string): never => { + throw new CodeError(message); +}; + +/** Adds `import { name } from ` unless the file already has the name. */ +const ensureImport = (context: TransformContext, name: string, from: string): void => { + const file = context.file; + if (file.getClass(name)) return; + const imported = file + .getImportDeclarations() + .some((declaration) => + declaration + .getNamedImports() + .some((specifier) => (specifier.getAliasNode()?.getText() ?? specifier.getName()) === name), + ); + if (imported) return; + const specifier = from.startsWith('@') ? from : context.moduleSpecifier(from); + const existing = file + .getImportDeclarations() + .find( + (declaration) => + declaration.getModuleSpecifierValue() === specifier && !declaration.isTypeOnly(), + ); + const named = existing?.getNamedImports().at(-1); + if (named) { + context.edit.insertAfter(named, `, ${name}`); + return; + } + const quote = context.literals.quoteStyle(context.file); + const line = `import { ${name} } from ${quote}${specifier}${quote};\n`; + const last = file.getImportDeclarations().at(-1); + if (last) context.edit.insertAfter(last, `\n${line.trimEnd()}`); + else context.edit.insertAt(0, `${line}\n`); +}; + +/** Removes the import of a name (the whole declaration when it is the only one). */ +const removeImport = (context: TransformContext, name: string): void => { + for (const declaration of context.file.getImportDeclarations()) { + const named = declaration.getNamedImports(); + const specifier = named.find( + (candidate) => (candidate.getAliasNode()?.getText() ?? candidate.getName()) === name, + ); + if (!specifier) continue; + if (named.length === 1 && !declaration.getDefaultImport()) + context.edit.removeLines(declaration); + else { + const index = named.indexOf(specifier); + const next = named[index + 1]; + const previous = named[index - 1]; + if (next) context.edit.replace({ start: specifier.getStart(), end: next.getStart() }, ''); + else if (previous) + context.edit.replace({ start: previous.getEnd(), end: specifier.getEnd() }, ''); + } + return; + } +}; + +/** Whether a name is still used in the file other than by its import and the given nodes. */ +const usedElsewhere = (file: SourceFile, name: string, except: readonly Node[]): boolean => + file + .getDescendantsOfKind(SyntaxKind.Identifier) + .some( + (identifier) => + identifier.getText() === name && + !identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) && + !except.some((node) => node === identifier || identifier.getAncestors().includes(node)), + ); + +/** `new SceneLibrary({ … })` of the entry file: its `scenes` map and `initial` (see `LibraryOp`). */ +export const transformLibrary = (context: TransformContext, rawOp: unknown): void => { + const op = rawOp as LibraryOp; + const library = findLibrary(context.file) ?? fail('No `new SceneLibrary(…)` in this file.'); + const options = library.options ?? fail('SceneLibrary has no options object to edit.'); + const scenes = options.getProperty('scenes'); + const map = scenes && Node.isPropertyAssignment(scenes) ? scenes.getInitializer() : undefined; + + switch (op.kind) { + case 'addScene': { + ensureImport(context, op.className, op.from); + if (!map || !Node.isObjectLiteralExpression(map)) { + const last = options.getProperties().at(-1); + const text = `scenes: { ${op.className} }`; + if (last) context.edit.insertAfter(last, `, ${text}`); + else context.edit.replace(options, `{ ${text} }`); + return; + } + const entries = map.getProperties(); + const last = entries.at(-1); + if (last) context.edit.insertAfter(last, `, ${op.className}`); + else context.edit.replace(map, `{ ${op.className} }`); + return; + } + case 'removeScene': { + const removed: Node[] = []; + if (map && Node.isObjectLiteralExpression(map)) { + const entries = map.getProperties(); + const index = entries.findIndex((entry) => + Node.isShorthandPropertyAssignment(entry) + ? entry.getName() === op.className + : Node.isPropertyAssignment(entry) && + entry.getInitializer()?.getText() === op.className, + ); + const entry = entries[index]; + if (entry) { + removed.push(entry); + const next = entries[index + 1]; + const previous = entries[index - 1]; + if (next) context.edit.replace({ start: entry.getStart(), end: next.getStart() }, ''); + else if (previous) + context.edit.replace({ start: previous.getEnd(), end: entry.getEnd() }, ''); + else context.edit.replace(map, '{}'); + } + } + const initial = options.getProperty('initial'); + if (initial && initial.getText().replace(/^initial:\s*/, '') === op.className) + fail(`${op.className} is the initial scene: choose another initial scene first.`); + if (!usedElsewhere(context.file, op.className, removed)) removeImport(context, op.className); + return; + } + case 'setInitial': { + ensureImport(context, op.className, op.from); + const initial = options.getProperty('initial'); + if (initial && Node.isPropertyAssignment(initial)) { + const value = initial.getInitializerOrThrow(); + context.edit.replace(value, op.className); + } else if (initial && Node.isShorthandPropertyAssignment(initial)) { + context.edit.replace(initial, `initial: ${op.className}`); + } else { + const first = options.getProperties()[0]; + if (first) context.edit.insertBefore(first, `initial: ${op.className}, `); + else context.edit.replace(options, `{ initial: ${op.className} }`); + } + return; + } + } +}; + +const classOf = (context: TransformContext, name: string): ClassDeclaration => + context.file.getClass(name) ?? fail(`No class ${name} in this file.`); + +/** A scene's own file: its `static parent`, or the class itself (see `SceneFileOp`). */ +export const transformSceneFile = (context: TransformContext, rawOp: unknown): void => { + const op = rawOp as SceneFileOp; + switch (op.kind) { + case 'setParent': { + const declaration = classOf(context, op.className); + const own = declaration.getStaticProperty('parent'); + const oldParent = + own && Node.isPropertyDeclaration(own) ? own.getInitializer()?.getText() : undefined; + const inherits = !!declaration.getBaseClass()?.getStaticProperty('parent'); + const value = op.parent?.className ?? (inherits ? 'undefined' : undefined); + if (own) { + if (value) context.edit.replace(own, `static override parent = ${value};`); + else context.edit.removeLines(own); + } else if (value) { + const indent = `${context.edit.indentationOf(declaration)} `; + const brace = declaration.getFirstChildByKindOrThrow(SyntaxKind.OpenBraceToken); + const members = declaration.getMembers(); + const text = `\n${indent}static override parent = ${value};${members.length ? '\n' : ''}`; + context.edit.insertAfter(brace, text); + } + if (op.parent) ensureImport(context, op.parent.className, op.parent.from); + if (oldParent && oldParent !== op.parent?.className && own) { + if (!usedElsewhere(context.file, oldParent, [own])) removeImport(context, oldParent); + } + return; + } + case 'removeClass': { + const declaration = classOf(context, op.className); + const docs = declaration.getJsDocs(); + const start = docs[0]?.getStart() ?? declaration.getStart(); + context.edit.removeLines({ start, end: declaration.getEnd() }); + return; + } + } +}; + +/** Replaces ranges of the file with a text (a rename's references in this file). */ +export const transformReplace = (context: TransformContext, rawOp: unknown): void => { + const { ranges, text } = rawOp as TextReplace; + for (const range of ranges) context.edit.replace(range, text); +}; + +/** + * Every reference to a class of the analyzed file, in the whole project (its declaration's name + * included), by file: what a rename changes. + */ +export const analyzeReferences = (context: AnalyzeContext, rawArgs: unknown): ReferenceRange[] => { + const { className } = rawArgs as { className: string }; + const declaration = context.file.getClass(className) ?? fail(`No class ${className} here.`); + const name = declaration.getNameNode() ?? fail(`${className} has no name.`); + const byPath = new Map(); + for (const node of name.findReferencesAsNodes()) { + const path = projectPath(node.getSourceFile()); + if (path.includes('/node_modules/')) continue; + const ranges = byPath.get(path) ?? []; + ranges.push({ start: node.getStart(), end: node.getEnd() }); + byPath.set(path, ranges); + } + const own = byPath.get(context.path) ?? []; + if (!own.some((range) => range.start === name.getStart())) + byPath.set(context.path, [...own, { start: name.getStart(), end: name.getEnd() }]); + return [...byPath].map(([path, ranges]) => ({ path, ranges })); +}; + +/** Exported classes of the analyzed file (to know whether a scene is alone in it). */ +export const analyzeClasses = (context: AnalyzeContext): string[] => + context.file + .getClasses() + .map((declaration) => declaration.getName()) + .filter((name): name is string => !!name); diff --git a/plugins/scene/src/worker/index.ts b/plugins/scene/src/worker/index.ts new file mode 100644 index 00000000..b2c2f1b9 --- /dev/null +++ b/plugins/scene/src/worker/index.ts @@ -0,0 +1,38 @@ +/** + * Code worker entry (`entry.worker`): the analyzers and transformers of scenes, `main.ts`'s + * `SceneLibrary` and the scene vars. `ts-morph` and the worker SDK come from the editor's worker. + */ +import { defineWorkerPlugin } from '@nanoforge-dev/editor-sdk/worker'; + +import { + CLASSES_ANALYZER, + LIBRARY_TRANSFORMER, + REFERENCES_ANALYZER, + REPLACE_TRANSFORMER, + SCENES_ANALYZER, + SCENE_FILE_TRANSFORMER, + VARS_ANALYZER, + VARS_TRANSFORMER, +} from '../model/scene.const'; +import { + analyzeClasses, + analyzeReferences, + transformLibrary, + transformReplace, + transformSceneFile, +} from './edits'; +import { analyzeScenes } from './scenes'; +import { analyzeVars, transformVars } from './vars'; + +export default defineWorkerPlugin({ + activate(sdk) { + sdk.registerAnalyzer(SCENES_ANALYZER, analyzeScenes); + sdk.registerAnalyzer(REFERENCES_ANALYZER, analyzeReferences); + sdk.registerAnalyzer(CLASSES_ANALYZER, analyzeClasses); + sdk.registerTransformer(LIBRARY_TRANSFORMER, transformLibrary); + sdk.registerTransformer(SCENE_FILE_TRANSFORMER, transformSceneFile); + sdk.registerTransformer(REPLACE_TRANSFORMER, transformReplace); + sdk.registerAnalyzer(VARS_ANALYZER, analyzeVars); + sdk.registerTransformer(VARS_TRANSFORMER, transformVars); + }, +}); diff --git a/plugins/scene/src/worker/scenes.ts b/plugins/scene/src/worker/scenes.ts new file mode 100644 index 00000000..8c01fca7 --- /dev/null +++ b/plugins/scene/src/worker/scenes.ts @@ -0,0 +1,250 @@ +import { + type ClassDeclaration, + type JSDocTag, + Node, + type ObjectLiteralExpression, + type SourceFile, + SyntaxKind, + type TypeNode, +} from 'ts-morph'; + +import type { AnalyzeContext } from '@nanoforge-dev/editor-sdk/worker'; + +import type { + AppScenesModel, + LibraryModel, + SceneModel, + SceneParam, + ScenesAnalyzeArgs, +} from '../model/scene-model.type'; + +/** The engine's scene base classes, by module. */ +const BASES: Record = { + '@nanoforge-dev/scene': { name: 'Scene', ecs: false }, + '@nanoforge-dev/ecs/scene': { name: 'EcsScene', ecs: true }, +}; + +/** Project path of a file (the worker's files live under `/project/`). */ +export const projectPath = (file: SourceFile): string => + file.getFilePath().replace(/^\/project\//, ''); + +interface ImportedName { + readonly module: string; + /** The exported name (`Scene` of `import { Scene as Base }`). */ + readonly name: string; + readonly file?: SourceFile; +} + +/** Where a name used in a file comes from, when it is imported. */ +const importOf = (file: SourceFile, local: string): ImportedName | undefined => { + for (const declaration of file.getImportDeclarations()) { + const named = declaration + .getNamedImports() + .find((specifier) => (specifier.getAliasNode()?.getText() ?? specifier.getName()) === local); + if (named) + return { + module: declaration.getModuleSpecifierValue(), + name: named.getName(), + file: declaration.getModuleSpecifierSourceFile(), + }; + } + return undefined; +}; + +/** A class declaration a name refers to: in the same file, or imported from a project file. */ +export const classNamed = (file: SourceFile, local: string): ClassDeclaration | undefined => { + const own = file.getClass(local); + if (own) return own; + const imported = importOf(file, local); + return imported?.file?.getClass(imported.name); +}; + +export interface SceneBase { + readonly ecs: boolean; + /** The type argument given to `Scene` / `EcsScene`, if any. */ + readonly params?: TypeNode; + /** The class and its project base classes, the class first. */ + readonly chain: readonly ClassDeclaration[]; +} + +/** Whether a class is a scene: it extends `Scene` or `EcsScene`, through project classes. */ +export const sceneBase = ( + declaration: ClassDeclaration, + seen = new Set(), +): SceneBase | undefined => { + if (seen.has(declaration)) return undefined; + seen.add(declaration); + const heritage = declaration.getExtends(); + const expression = heritage?.getExpression(); + if (!heritage || !expression || !Node.isIdentifier(expression)) return undefined; + const file = declaration.getSourceFile(); + const local = expression.getText(); + const imported = importOf(file, local); + const engine = imported ? BASES[imported.module] : undefined; + if (engine && imported?.name === engine.name) { + const [params] = heritage.getTypeArguments(); + return { ecs: engine.ecs, ...(params && { params }), chain: [declaration] }; + } + const base = classNamed(file, local); + const inner = base ? sceneBase(base, seen) : undefined; + return inner && { ...inner, chain: [declaration, ...inner.chain] }; +}; + +/** The class a `static parent = X` names, its own or a base class's. */ +const parentOf = (chain: readonly ClassDeclaration[]): string | undefined => { + for (const declaration of chain) { + const initializer = declaration.getStaticProperty('parent'); + const value = + initializer && Node.isPropertyDeclaration(initializer) + ? initializer.getInitializer() + : undefined; + if (value && Node.isIdentifier(value)) { + const local = value.getText(); + return importOf(declaration.getSourceFile(), local)?.name ?? local; + } + } + return undefined; +}; + +const docOf = (declaration: ClassDeclaration) => { + const doc = declaration.getJsDocs().at(-1); + const tags = doc?.getTags() ?? []; + const tagText = (tag: JSDocTag | undefined) => + (tag?.getCommentText() ?? '').replace(/\s+/g, ' ').trim(); + const find = (name: string) => tags.find((tag) => tag.getTagName() === name); + const description = doc + ?.getDescription() + .trim() + .split(/\n\s*\n/)[0] + ?.replace(/\s+/g, ' '); + const vars = tagText(find('vars')) + .split(/[\s,]+/) + .filter(Boolean); + return { + ...(description && { description }), + tagged: !!find('scene'), + ...(find('side') && { side: tagText(find('side')) }), + vars, + }; +}; + +const paramsOf = (type: TypeNode | undefined): SceneParam[] => { + if (!type || !Node.isTypeLiteral(type)) return []; + return type.getProperties().map((property) => { + const description = property.getJsDocs().at(-1)?.getDescription().trim().replace(/\s+/g, ' '); + return { + name: property.getName(), + type: property.getTypeNode()?.getText() ?? 'unknown', + optional: property.hasQuestionToken(), + ...(description && { description }), + }; + }); +}; + +/** `new SceneLibrary({ … })` in a file. */ +export const findLibrary = ( + file: SourceFile, +): { options?: ObjectLiteralExpression; line: number } | undefined => { + const created = file + .getDescendantsOfKind(SyntaxKind.NewExpression) + .find((expression) => expression.getExpression().getText() === 'SceneLibrary'); + if (!created) return undefined; + const [first] = created.getArguments(); + return { + ...(first && Node.isObjectLiteralExpression(first) && { options: first }), + line: created.getStartLineNumber(), + }; +}; + +const libraryOf = (file: SourceFile): LibraryModel | undefined => { + const found = findLibrary(file); + if (!found) return undefined; + const { options } = found; + const property = (name: string) => options?.getProperty(name); + const initialProperty = property('initial'); + const initial = + initialProperty && Node.isShorthandPropertyAssignment(initialProperty) + ? initialProperty.getName() + : initialProperty && Node.isPropertyAssignment(initialProperty) + ? initialProperty.getInitializer()?.getText() + : undefined; + const scenesProperty = property('scenes'); + const map = + scenesProperty && Node.isPropertyAssignment(scenesProperty) + ? scenesProperty.getInitializer() + : undefined; + const scenes: Record = {}; + if (map && Node.isObjectLiteralExpression(map)) { + for (const entry of map.getProperties()) { + if (Node.isShorthandPropertyAssignment(entry)) scenes[entry.getName()] = entry.getName(); + else if (Node.isPropertyAssignment(entry)) { + const value = entry.getInitializer(); + if (value && Node.isIdentifier(value)) + scenes[entry.getName().replace(/^['"]|['"]$/g, '')] = value.getText(); + } + } + } + return { + ...(initial && { initial }), + scenes, + hasScenes: !!map && Node.isObjectLiteralExpression(map), + line: found.line, + }; +}; + +/** + * The scenes of an app (classes of its folder extending `Scene` or `EcsScene`, or tagged + * `@scene`) and the `SceneLibrary` of its entry file (the analyzed file). + */ +export const analyzeScenes = (context: AnalyzeContext, rawArgs: unknown): AppScenesModel => { + const { root } = rawArgs as ScenesAnalyzeArgs; + const prefix = root ? `${root}/` : ''; + const library = libraryOf(context.file); + const idOf = (className: string) => + Object.entries(library?.scenes ?? {}).find(([, value]) => value === className)?.[0] ?? + className; + + const scenes: SceneModel[] = []; + for (const file of context.project.getSourceFiles()) { + const path = projectPath(file); + if (!path.startsWith(prefix) || path.includes('/node_modules/') || path.endsWith('.d.ts')) + continue; + for (const declaration of file.getClasses()) { + const className = declaration.getName(); + if (!className || !declaration.isExported() || declaration.isAbstract()) continue; + const base = sceneBase(declaration); + const doc = docOf(declaration); + if (!base && !doc.tagged) continue; + const params = base?.params; + const parent = base ? parentOf(base.chain) : undefined; + scenes.push({ + id: idOf(className), + className, + path, + line: declaration.getStartLineNumber(), + ...(parent && { parent }), + ecs: base?.ecs ?? false, + ownSetup: !!declaration.getMethod('setup'), + ...(params && { paramsType: params.getText() }), + params: paramsOf(params), + ...doc, + }); + } + } + scenes.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line); + + const problems: string[] = []; + if (library?.hasScenes) { + const listed = new Set(Object.values(library.scenes)); + for (const scene of scenes) + if (!listed.has(scene.className)) + problems.push(`${scene.className} is not in the scenes of SceneLibrary.`); + for (const [id, className] of Object.entries(library.scenes)) + if (!scenes.some((scene) => scene.className === className)) + problems.push(`The scene "${id}" of SceneLibrary is not a scene class of this app.`); + } + for (const scene of scenes) + if (scene.parent && !scenes.some((candidate) => candidate.className === scene.parent)) + problems.push(`${scene.className}'s parent ${scene.parent} is not a scene of this app.`); + return { scenes, ...(library && { library }), problems }; +}; diff --git a/plugins/scene/src/worker/vars.ts b/plugins/scene/src/worker/vars.ts new file mode 100644 index 00000000..de2c257c --- /dev/null +++ b/plugins/scene/src/worker/vars.ts @@ -0,0 +1,178 @@ +import { + type InterfaceDeclaration, + Node, + type PropertySignature, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; + +import { + type AnalyzeContext, + CodeError, + type TransformContext, +} from '@nanoforge-dev/editor-sdk/worker'; + +import type { + VarModel, + VarUse, + VarsAnalyzeArgs, + VarsModel, + VarsOp, +} from '../model/vars-model.type'; +import { projectPath } from './scenes'; + +const METHODS = new Set(['init', 'set', 'get', 'has', 'remove']); +const MODULE = '@nanoforge-dev/scene'; + +/** `interface SceneVars` of a `declare module "@nanoforge-dev/scene"` block of a file. */ +export const sceneVarsOf = (file: SourceFile): InterfaceDeclaration | undefined => { + for (const module of file.getModules()) { + const name = module.getName().replace(/^['"]|['"]$/g, ''); + if (name === MODULE) return module.getInterface('SceneVars'); + } + return undefined; +}; + +const docOf = (property: PropertySignature) => { + const doc = property.getJsDocs().at(-1); + const description = doc?.getDescription().trim().replace(/\s+/g, ' '); + const fallback = doc?.getTags().find((tag) => tag.getTagName() === 'default'); + return { + ...(description && { description }), + ...(fallback && { default: (fallback.getCommentText() ?? '').trim() }), + }; +}; + +const literalType = (node: Node | undefined): string | undefined => { + if (!node) return undefined; + if (Node.isNumericLiteral(node) || Node.isPrefixUnaryExpression(node)) return 'number'; + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return 'string'; + if (node.getKind() === SyntaxKind.TrueKeyword || node.getKind() === SyntaxKind.FalseKeyword) + return 'boolean'; + return undefined; +}; + +/** + * The app's scene vars: their declaration in `SceneVars` (its file, its fields and docs) and + * their uses (`….vars.init("key", …)` and the like, in the app's files). + */ +export const analyzeVars = (context: AnalyzeContext, rawArgs: unknown): VarsModel => { + const { root } = rawArgs as VarsAnalyzeArgs; + const prefix = root ? `${root}/` : ''; + let file: string | undefined; + const vars: VarModel[] = []; + const uses: VarUse[] = []; + for (const source of context.project.getSourceFiles()) { + const path = projectPath(source); + if (!path.startsWith(prefix) || path.includes('/node_modules/') || path.endsWith('.d.ts')) + continue; + const declaration = sceneVarsOf(source); + if (declaration && !file) { + file = path; + for (const property of declaration.getProperties()) + vars.push({ + name: property.getName(), + type: property.getTypeNode()?.getText() ?? 'unknown', + optional: property.hasQuestionToken(), + ...docOf(property), + line: property.getStartLineNumber(), + }); + } + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !METHODS.has(callee.getName())) continue; + const target = callee.getExpression(); + const isVars = Node.isPropertyAccessExpression(target) + ? target.getName() === 'vars' + : Node.isIdentifier(target) && target.getText() === 'vars'; + if (!isVars) continue; + const [key, value] = call.getArguments(); + if (!key || !(Node.isStringLiteral(key) || Node.isNoSubstitutionTemplateLiteral(key))) + continue; + const className = call.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)?.getName(); + const valueType = literalType(value); + uses.push({ + key: key.getLiteralText(), + kind: callee.getName() as VarUse['kind'], + path, + line: call.getStartLineNumber(), + start: key.getStart() + 1, + end: key.getEnd() - 1, + ...(className && { className }), + ...(valueType && { valueType }), + }); + } + } + return { ...(file && { file }), vars, uses }; +}; + +const fail = (message: string): never => { + throw new CodeError(message); +}; + +/** The doc and line of a var's field. */ +const fieldText = ( + indent: string, + name: string, + type: string, + description: string | undefined, + fallback: string | undefined, +) => { + const doc = [description?.trim(), fallback?.trim() ? `@default ${fallback.trim()}` : ''] + .filter(Boolean) + .join(' '); + return `${doc ? `${indent}/** ${doc} */\n` : ''}${indent}${name}: ${type};`; +}; + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +/** Edits the `SceneVars` declaration of the file (see `VarsOp`). */ +export const transformVars = (context: TransformContext, rawOp: unknown): void => { + const op = rawOp as VarsOp; + const declaration = sceneVarsOf(context.file) ?? fail('No `interface SceneVars` in this file.'); + const property = (name: string) => + declaration.getProperty(name) ?? fail(`No var "${name}" in SceneVars.`); + const indentOf = (node: Node) => context.edit.indentationOf(node); + + switch (op.kind) { + case 'addVar': { + if (!IDENTIFIER.test(op.name)) fail(`"${op.name}" isn't a valid var name.`); + if (declaration.getProperty(op.name)) fail(`The var "${op.name}" already exists.`); + const last = declaration.getProperties().at(-1); + const indent = last ? indentOf(last) : `${indentOf(declaration)} `; + const text = fieldText(indent, op.name, op.type, op.description, op.default); + if (last) context.edit.insertAfter(last, `\n${text}`); + else { + const brace = declaration.getFirstChildByKindOrThrow(SyntaxKind.OpenBraceToken); + context.edit.insertAfter(brace, `\n${text}`); + } + return; + } + case 'updateVar': { + const field = property(op.name); + const name = op.rename ?? op.name; + if (op.rename && !IDENTIFIER.test(op.rename)) fail(`"${op.rename}" isn't a valid var name.`); + if (op.rename && op.rename !== op.name && declaration.getProperty(op.rename)) + fail(`The var "${op.rename}" already exists.`); + const current = docOf(field); + const docs = field.getJsDocs(); + const start = docs[0]?.getStart() ?? field.getStart(); + const indent = indentOf(field); + const text = fieldText( + indent, + `${name}${field.hasQuestionToken() ? '?' : ''}`, + op.type ?? field.getTypeNode()?.getText() ?? 'unknown', + op.description ?? current.description, + op.default ?? current.default, + ); + context.edit.replace({ start, end: field.getEnd() }, text.slice(indent.length)); + return; + } + case 'removeVar': { + const field = property(op.name); + const start = field.getJsDocs()[0]?.getStart() ?? field.getStart(); + context.edit.removeLines({ start, end: field.getEnd() }); + return; + } + } +}; diff --git a/plugins/scene/svelte.config.js b/plugins/scene/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/scene/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/scene/test/fixtures/breakout.ts b/plugins/scene/test/fixtures/breakout.ts new file mode 100644 index 00000000..a4a6ae2d --- /dev/null +++ b/plugins/scene/test/fixtures/breakout.ts @@ -0,0 +1,31 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { CODE_FILE, CodeEngine } from '@nanoforge-dev/editor-code/engine'; + +import { SCENES_ANALYZER } from '../../src/model/scene.const'; +import { analyzeScenes } from '../../src/worker/scenes'; + +/** The breakout example: every scene of it is an `EcsScene`. */ +export const BREAKOUT = join(import.meta.dirname, '../../../../examples/breakout'); +export const MAIN = 'apps/client/src/main.ts'; + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (name === 'node_modules' || name === 'dist') return []; + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +export const breakoutEngine = () => { + const engine = new CodeEngine(); + engine.setFiles( + walk(join(BREAKOUT, 'apps')).map((file) => ({ + path: relative(BREAKOUT, file).split('\\').join('/'), + text: readFileSync(file, 'utf8'), + })), + ); + engine.registerAnalyzer(SCENES_ANALYZER, analyzeScenes); + return engine; +}; diff --git a/plugins/scene/test/model/scene-params.spec.ts b/plugins/scene/test/model/scene-params.spec.ts new file mode 100644 index 00000000..6be71e45 --- /dev/null +++ b/plugins/scene/test/model/scene-params.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { formatValue, parseParam } from '../../src/model/scene-params'; + +describe('scene params from prompts', () => { + it('reads a value by the param type', () => { + expect(parseParam({ type: 'number' }, '42')).toBe(42); + expect(parseParam({ type: 'boolean' }, 'true')).toBe(true); + expect(parseParam({ type: 'string' }, 'easy')).toBe('easy'); + expect(parseParam({ type: '{ x: number }' }, '{"x": 1}')).toEqual({ x: 1 }); + expect(parseParam({ type: "'a' | 'b'" }, 'a')).toBe('a'); + }); + + it('shows a live value on one line', () => { + expect(formatValue(3)).toBe('3'); + expect(formatValue('Run')).toBe('"Run"'); + expect(formatValue(null)).toBe('—'); + expect(formatValue({ list: Array.from({ length: 40 }, (_, i) => i) }).endsWith('…')).toBe(true); + }); +}); diff --git a/plugins/scene/test/worker/edits.spec.ts b/plugins/scene/test/worker/edits.spec.ts new file mode 100644 index 00000000..c036fcc5 --- /dev/null +++ b/plugins/scene/test/worker/edits.spec.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { describe, expect, it } from 'vitest'; + +import type { CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import type { LibraryOp, ReferenceRange, SceneFileOp } from '../../src/model/scene-model.type'; +import { + LIBRARY_TRANSFORMER, + REFERENCES_ANALYZER, + REPLACE_TRANSFORMER, + SCENE_FILE_TRANSFORMER, +} from '../../src/model/scene.const'; +import { + analyzeReferences, + transformLibrary, + transformReplace, + transformSceneFile, +} from '../../src/worker/edits'; +import { MAIN, breakoutEngine } from '../fixtures/breakout'; + +const LEVELS = 'apps/client/src/scenes/levels.ts'; +const PAUSE = 'apps/client/src/scenes/pause.ts'; + +const engine = () => { + const code = breakoutEngine(); + code.registerTransformer(LIBRARY_TRANSFORMER, transformLibrary); + code.registerTransformer(SCENE_FILE_TRANSFORMER, transformSceneFile); + code.registerTransformer(REPLACE_TRANSFORMER, transformReplace); + code.registerAnalyzer(REFERENCES_ANALYZER, analyzeReferences); + return code; +}; + +const apply = (code: CodeEngine, path: string, transformer: string, op: unknown) => { + const before = code.text(path)!; + const { text } = applyTextEdits(before, code.transform(path, transformer, op)); + code.setFiles([{ path, text }]); + const a = before.split('\n'); + const b = text.split('\n'); + return { + removed: a.filter((line) => !b.includes(line)), + added: b.filter((line) => !a.includes(line)), + }; +}; + +describe('SceneLibrary transformer', () => { + it('adds a scene to scenes, with its import, and removes it', () => { + const code = engine(); + const added = apply(code, MAIN, LIBRARY_TRANSFORMER, { + kind: 'addScene', + className: 'Bonus', + from: 'apps/client/src/scenes/bonus.ts', + } satisfies LibraryOp); + expect(added.added).toEqual([ + 'import { Bonus } from "./scenes/bonus";', + ' scenes: { Menu, Run, Level1, Level2, Level3, Pause, GameOver, Victory, Bonus },', + ]); + const removed = apply(code, MAIN, LIBRARY_TRANSFORMER, { + kind: 'removeScene', + className: 'Bonus', + } satisfies LibraryOp); + expect(removed.removed).toEqual(added.added); + }); + + it('sets the initial scene', () => { + expect( + apply(engine(), MAIN, LIBRARY_TRANSFORMER, { + kind: 'setInitial', + className: 'Level1', + from: LEVELS, + } satisfies LibraryOp), + ).toEqual({ removed: [' initial: Menu,'], added: [' initial: Level1,'] }); + }); + + it('refuses to remove the initial scene', () => { + expect(() => + engine().transform(MAIN, LIBRARY_TRANSFORMER, { kind: 'removeScene', className: 'Menu' }), + ).toThrow('Menu is the initial scene'); + }); +}); + +describe('scene file transformer', () => { + it('gives a scene a parent, with its import, and takes it back', () => { + const code = engine(); + const set = apply(code, PAUSE, SCENE_FILE_TRANSFORMER, { + kind: 'setParent', + className: 'Pause', + parent: { className: 'Run', from: 'apps/client/src/scenes/run.ts' }, + } satisfies SceneFileOp); + expect(set.added).toEqual(['import { Run } from "./run";', ' static override parent = Run;']); + const unset = apply(code, PAUSE, SCENE_FILE_TRANSFORMER, { + kind: 'setParent', + className: 'Pause', + } satisfies SceneFileOp); + expect(unset.removed).toEqual(set.added); + }); + + it('writes undefined over a parent a base class gives', () => { + expect( + apply(engine(), LEVELS, SCENE_FILE_TRANSFORMER, { + kind: 'setParent', + className: 'Level2', + } satisfies SceneFileOp).added, + ).toEqual([' static override parent = undefined;']); + }); + + it('removes a class with its doc', () => { + const { removed } = apply(engine(), LEVELS, SCENE_FILE_TRANSFORMER, { + kind: 'removeClass', + className: 'Level3', + } satisfies SceneFileOp); + expect(removed).toContain( + ' * Level 3: bricks that take two hits, faster still. The last level.', + ); + expect(removed).toContain('export class Level3 extends Level {'); + }); +}); + +describe('rename references', () => { + it('finds every reference of a scene class in the project', () => { + const code = engine(); + const references = code.analyze(PAUSE, REFERENCES_ANALYZER, { + className: 'Pause', + }) as ReferenceRange[]; + expect(references.map((reference) => reference.path).sort()).toEqual([ + 'apps/client/src/main.ts', + 'apps/client/src/scenes/levels.ts', + PAUSE, + ]); + for (const { path, ranges } of references) + apply(code, path, REPLACE_TRANSFORMER, { ranges, text: 'Halt' }); + expect(code.text(MAIN)).toContain('import { Halt } from "./scenes/pause";'); + expect(code.text(MAIN)).toContain('Level3, Halt, GameOver'); + expect(code.text(PAUSE)).toContain('export class Halt extends EcsScene'); + expect(code.text(PAUSE)).toContain('ctx.scenes.unload(ctx, Halt)'); + }); +}); diff --git a/plugins/scene/test/worker/scenes.spec.ts b/plugins/scene/test/worker/scenes.spec.ts new file mode 100644 index 00000000..b0a0b65c --- /dev/null +++ b/plugins/scene/test/worker/scenes.spec.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { describe, expect, it } from 'vitest'; + +import type { CodeEngine } from '@nanoforge-dev/editor-code/engine'; + +import type { AppScenesModel } from '../../src/model/scene-model.type'; +import { SCENES_ANALYZER } from '../../src/model/scene.const'; +import { MAIN, breakoutEngine } from '../fixtures/breakout'; + +const analyze = (engine: CodeEngine) => + engine.analyze(MAIN, SCENES_ANALYZER, { root: 'apps/client' }) as AppScenesModel; + +describe('scene analyzer (breakout)', () => { + it('finds the scenes, their parents, params and tags', () => { + const model = analyze(breakoutEngine()); + expect( + model.scenes.map((scene) => [scene.id, scene.parent ?? null, scene.ecs, scene.ownSetup]), + ).toEqual([ + ['GameOver', null, true, true], + ['Level1', 'Run', true, true], + ['Level2', 'Run', true, true], + ['Level3', 'Run', true, true], + ['Menu', null, true, true], + ['Pause', null, true, true], + ['Run', null, true, true], + ['Victory', null, true, true], + ]); + const gameOver = model.scenes.find((scene) => scene.id === 'GameOver')!; + expect(gameOver.params).toEqual([ + { + name: 'score', + type: 'number', + optional: false, + description: 'The score of the game that ended.', + }, + ]); + const level = model.scenes.find((scene) => scene.id === 'Level1')!; + expect(level).toMatchObject({ + path: 'apps/client/src/scenes/levels.ts', + tagged: true, + side: 'client', + vars: ['level', 'bricksLeft', 'ballSpeed', 'served'], + description: 'Level 1: three rows that break in one hit.', + }); + expect(model.problems).toEqual([]); + }); + + it("reads SceneLibrary's initial scene and scenes", () => { + expect(analyze(breakoutEngine()).library).toMatchObject({ + initial: 'Menu', + hasScenes: true, + scenes: { + Menu: 'Menu', + Run: 'Run', + Level1: 'Level1', + Level2: 'Level2', + Level3: 'Level3', + Pause: 'Pause', + GameOver: 'GameOver', + Victory: 'Victory', + }, + }); + }); + + it('names a scene by its key in scenes, and reports one missing from it', () => { + const engine = breakoutEngine(); + const main = engine.text(MAIN)!; + engine.setFiles([ + { + path: MAIN, + text: main.replace('scenes: { Menu, Run,', 'scenes: { Title: Menu,').replace(' Run,', ''), + }, + ]); + const model = analyze(engine); + expect(model.scenes.find((scene) => scene.className === 'Menu')?.id).toBe('Title'); + expect(model.problems).toContain('Run is not in the scenes of SceneLibrary.'); + }); +}); diff --git a/plugins/scene/test/worker/vars.spec.ts b/plugins/scene/test/worker/vars.spec.ts new file mode 100644 index 00000000..ca75c828 --- /dev/null +++ b/plugins/scene/test/worker/vars.spec.ts @@ -0,0 +1,96 @@ +/* eslint-disable no-restricted-imports -- tests run the worker code against the real code engine */ +import { describe, expect, it } from 'vitest'; + +import type { CodeEngine } from '@nanoforge-dev/editor-code/engine'; +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import { VARS_ANALYZER, VARS_TRANSFORMER } from '../../src/model/scene.const'; +import type { VarsModel, VarsOp } from '../../src/model/vars-model.type'; +import { analyzeVars, transformVars } from '../../src/worker/vars'; +import { MAIN, breakoutEngine } from '../fixtures/breakout'; + +const VARS = 'apps/client/src/scene-vars.ts'; + +const engine = () => { + const code = breakoutEngine(); + code.registerAnalyzer(VARS_ANALYZER, analyzeVars); + code.registerTransformer(VARS_TRANSFORMER, transformVars); + return code; +}; + +const analyze = (code: CodeEngine) => + code.analyze(MAIN, VARS_ANALYZER, { root: 'apps/client' }) as VarsModel; + +const apply = (code: CodeEngine, op: VarsOp) => { + const before = code.text(VARS)!; + const { text } = applyTextEdits(before, code.transform(VARS, VARS_TRANSFORMER, op)); + code.setFiles([{ path: VARS, text }]); + const a = before.split('\n'); + const b = text.split('\n'); + return { + removed: a.filter((line) => !b.includes(line)), + added: b.filter((line) => !a.includes(line)), + }; +}; + +describe('vars analyzer (breakout)', () => { + it('reads SceneVars with their docs and defaults', () => { + const model = analyze(engine()); + expect(model.file).toBe(VARS); + expect(model.vars.map((field) => field.name)).toEqual([ + 'best', + 'score', + 'lives', + 'level', + 'bricksLeft', + 'ballSpeed', + 'served', + 'paused', + ]); + expect(model.vars.find((field) => field.name === 'lives')).toMatchObject({ + type: 'number', + default: '3', + description: 'Balls left in the current game (made by `Run`).', + }); + }); + + it('finds the uses of each key, with the scene they are in', () => { + const { uses } = analyze(engine()); + const lives = uses.filter((use) => use.key === 'lives'); + expect( + lives.map((use) => [use.kind, use.className ?? null, use.path.split('/').at(-1)]), + ).toEqual( + expect.arrayContaining([ + ['init', 'Run', 'run.ts'], + ['get', null, 'levels.ts'], + ['set', null, 'lose-ball.ts'], + ]), + ); + const init = lives.find((use) => use.kind === 'init')!; + expect(init.valueType).toBe('number'); + expect(engine().text(init.path)!.slice(init.start, init.end)).toBe('lives'); + }); +}); + +describe('vars transformer', () => { + it('adds, updates, renames and removes a var', () => { + const code = engine(); + expect( + apply(code, { + kind: 'addVar', + name: 'combo', + type: 'number', + description: 'Bricks in a row.', + default: '0', + }).added, + ).toEqual([' /** Bricks in a row. @default 0 */', ' combo: number;']); + expect( + apply(code, { kind: 'updateVar', name: 'combo', rename: 'streak', default: '1' }).added, + ).toEqual([' /** Bricks in a row. @default 1 */', ' streak: number;']); + expect(apply(code, { kind: 'removeVar', name: 'streak' }).removed).toEqual([ + ' /** Bricks in a row. @default 1 */', + ' streak: number;', + ]); + expect(analyze(code).vars).toHaveLength(8); + }); +}); diff --git a/plugins/scene/tsconfig.json b/plugins/scene/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/scene/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/scene/vite.config.ts b/plugins/scene/vite.config.ts new file mode 100644 index 00000000..b268884c --- /dev/null +++ b/plugins/scene/vite.config.ts @@ -0,0 +1,15 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { + entry: { index: 'src/index.ts', worker: 'src/worker/index.ts' }, + formats: ['es'], + cssFileName: 'scene', + }, + }, +}); diff --git a/plugins/settings-ui/.prettierignore b/plugins/settings-ui/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/settings-ui/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/settings-ui/LICENSE b/plugins/settings-ui/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/settings-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/settings-ui/README.md b/plugins/settings-ui/README.md new file mode 100644 index 00000000..e31dbf71 --- /dev/null +++ b/plugins/settings-ui/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-settings-ui` is an internal package of the NanoForge Editor. + +Built-in settings dialog. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/settings-ui +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/settings-ui/nanoforge.manifest.json b/plugins/settings-ui/nanoforge.manifest.json new file mode 100644 index 00000000..38c338f1 --- /dev/null +++ b/plugins/settings-ui/nanoforge.manifest.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/settings-ui", + "version": "0.1.0", + "displayName": "Settings", + "description": "The settings dialog: categories, search, scopes, plugins, import and export.", + "engines": { "editor": ">=0.1.0" }, + "entry": { "client": "index.js" }, + "activation": ["onStartup"], + "contributes": { + "commands": [{ "id": "settings.open", "title": "Settings…", "category": "File" }], + "keybindings": [ + { "key": "Mod+Alt+S", "command": "settings.open" }, + { "key": "Mod+,", "command": "settings.open" } + ], + "menus": [{ "menu": "file", "command": "settings.open", "group": "z-settings" }] + } +} diff --git a/plugins/settings-ui/package.json b/plugins/settings-ui/package.json new file mode 100644 index 00000000..05bc0d0b --- /dev/null +++ b/plugins/settings-ui/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-settings-ui", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in settings dialog", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/settings-ui" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/settings-ui/src/dialogs/settings/ConflictsPage.svelte b/plugins/settings-ui/src/dialogs/settings/ConflictsPage.svelte new file mode 100644 index 00000000..246a0c6d --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/ConflictsPage.svelte @@ -0,0 +1,58 @@ + + +
    +

    Sync conflicts

    +

    + These settings changed on this device while offline and in your account meanwhile. Pick the + value to keep. +

    + {#each conflicts as conflict (conflict.key)} +
    + {conflict.key} +
    + + +
    +
    + {:else} +

    No conflicts.

    + {/each} +
    + + diff --git a/plugins/settings-ui/src/dialogs/settings/ImportExportPage.svelte b/plugins/settings-ui/src/dialogs/settings/ImportExportPage.svelte new file mode 100644 index 00000000..06924165 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/ImportExportPage.svelte @@ -0,0 +1,124 @@ + + +
    +

    Import & export

    +
    + ({ value: option, label: option }))} + onchange={(next) => update(next)} + /> + {:else if control.kind === 'number'} + final && update(next)} + /> + {:else if control.kind === 'string' && choices?.length} + update((event.currentTarget as HTMLInputElement).value)} + /> + {:else if control.kind === 'lines'} + + {:else} + + {/if} + {#if error}{/if} +
    + + {#if inspecting} + + + {#each inspected as scope (scope)} + {@const scoped = scope === 'default' ? definition.default : inspection.scopes[scope]} + + + + + + {/each} + +
    {SCOPE_LABELS[scope]}{format(scoped)} + {#if scope !== 'default' && scoped !== undefined} + + {:else if inspection.effectiveScope === scope} + in use + {/if} +
    + {/if} +

    {definition.key}

    + + + diff --git a/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte b/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte new file mode 100644 index 00000000..a8ed28db --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte @@ -0,0 +1,318 @@ + + + +
    +
    + + {#if $status} + {kept && $status === 'idle' ? 'Saved on this editor' : SYNC_STATUS[$status]} + {/if} + {#if editJson().length} + + {#snippet trigger({ props: triggerProps })} + + {/snippet} + + {/if} +
    +
    + +
    + {#if query.trim()} + {#each shown as definition (definition.key)} + + {:else} + + {/each} + {:else if page === 'plugins' && host && pluginsDisabled} + { + if (await apply()) location.reload(); + }} + /> + {:else if contributedPage} + {@const Page = contributedPage.component} + + {:else if page === 'import'} + + {:else if page === 'conflicts'} + void account?.resolveConflict(key, value)} + /> + {:else} + {#each shown as definition (definition.key)} + + {:else} + + {/each} + {/if} +
    +
    +
    + {#snippet footer()} + + + + {/snippet} +
    + + diff --git a/plugins/settings-ui/src/dialogs/settings/open-settings-dialog.ts b/plugins/settings-ui/src/dialogs/settings/open-settings-dialog.ts new file mode 100644 index 00000000..51fe40b4 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/open-settings-dialog.ts @@ -0,0 +1,33 @@ +import { mount, unmount } from 'svelte'; + +import type { ServiceAccessor } from '@nanoforge-dev/editor-sdk'; + +import SettingsDialog from './SettingsDialog.svelte'; + +let current: { component: ReturnType; host: HTMLElement } | undefined; + +/** Closes the Settings dialog, when it is open. */ +export const closeSettingsDialog = (): void => { + if (!current) return; + void unmount(current.component); + current.host.remove(); + current = undefined; +}; + +/** Shows the Settings dialog, on a page when one is given (a category id, `plugins`…). */ +export const openSettingsDialog = (services: ServiceAccessor, page?: unknown): void => { + closeSettingsDialog(); + const host = document.createElement('div'); + document.body.append(host); + current = { + host, + component: mount(SettingsDialog, { + target: host, + props: { + services, + ...(typeof page === 'string' && { page }), + onclose: closeSettingsDialog, + }, + }), + }; +}; diff --git a/plugins/settings-ui/src/dialogs/settings/plugins/InstalledPluginsTab.svelte b/plugins/settings-ui/src/dialogs/settings/plugins/InstalledPluginsTab.svelte new file mode 100644 index 00000000..b049c68f --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/plugins/InstalledPluginsTab.svelte @@ -0,0 +1,141 @@ + + +

    + Enabling, disabling, installing or removing a plugin takes effect after a reload of the editor. +

    +
      + {#each plugins as plugin (`${plugin.descriptor.source}:${plugin.name}`)} + {@const enabled = !disabled.has(plugin.name)} + {@const scope = scopeOf(plugin.descriptor.source)} + {@const change = page.pending.get(plugin.name)} + {@const label = plugin.descriptor.manifest.displayName ?? plugin.name} + {@const update = page.newest.get(plugin.name)} +
    • +
      + {label} + + {plugin.name} · {plugin.descriptor.manifest.version} · + {SOURCE[plugin.descriptor.source] ?? plugin.descriptor.source} · + {change === 'removed' + ? 'removed, reload to finish' + : change + ? `${change.version} installed, reload to finish` + : plugin.status.kind === 'ok' + ? plugin.state + : (STATUS[plugin.status.kind] ?? plugin.status.kind)} + + {#if plugin.descriptor.manifest.description} + {plugin.descriptor.manifest.description} + {/if} +
      + {#if scope && !change} + {#if page.busy === plugin.name} + + {:else} + {#if update && update !== plugin.descriptor.manifest.version} + + {/if} + + {/if} + {/if} + ontoggle(plugin.name, checked)} + /> +
    • + {/each} + {#each [...page.pending].filter(([name, change]) => change !== 'removed' && !known.some((plugin) => plugin.name === name && scopeOf(plugin.source))) as [name, change] (name)} +
    • +
      + {name} + + {name} · {change !== 'removed' ? change.version : ''} · installed, reload to finish + +
      +
    • + {/each} +
    + + diff --git a/plugins/settings-ui/src/dialogs/settings/plugins/MarketplaceTab.svelte b/plugins/settings-ui/src/dialogs/settings/plugins/MarketplaceTab.svelte new file mode 100644 index 00000000..88a6dd28 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/plugins/MarketplaceTab.svelte @@ -0,0 +1,232 @@ + + +{#snippet marketButton(name: string, compatible: string | undefined)} + {@const copy = page.copyOf(name)} + {@const action = marketAction(copy, compatible)} + {#if page.busy === name} + + {:else if action === 'install' && page.project} + + {#snippet trigger({ props: triggerProps })} + + {/snippet} + + {:else if action === 'install'} + + {:else if action === 'update' && copy?.scope} + + {:else if action === 'installed'} + Installed + {:else if action === 'builtin'} + {copy?.version ? 'Part of this editor' : ''} + {:else} + Needs another editor + {/if} +{/snippet} + +
    +
    + + {#if page.unavailable} + + {#snippet actions()} + + {/snippet} + + {:else if !page.results.length} + + {:else} +
      + {#each page.results as item (item.name)} +
    • + +
    • + {/each} +
    + {#if page.total > page.results.length} +

    {page.results.length} of {page.total} shown: search to narrow.

    + {/if} + {/if} +
    +
    + {#if page.details && page.details.name === page.selected} + {@const details = page.details} + {@const copy = page.copyOf(details.name)} +
    +
    + {details.name} + + {details.author || 'Unknown author'} · latest {details.version} + {#if copy} + · {copy.scope ? `${copy.version} installed ${SCOPE_LABEL[copy.scope]}` : 'built in'} + {/if} + +
    + {@render marketButton(details.name, details.compatible)} +
    + {#if details.description}

    {details.description}

    {/if} + {#if details.readme}
    {details.readme}
    {/if} +

    Versions

    +
      + {#each details.versions as version (version.version)} +
    • + {version.version} + + {date(version.publishedAt)}{version.engines.editor + ? ` · editor ${version.engines.editor}` + : ''} + +
    • + {/each} +
    + {:else if !page.unavailable && page.results.length} + + {/if} +
    +
    + + diff --git a/plugins/settings-ui/src/dialogs/settings/plugins/PluginsPage.svelte b/plugins/settings-ui/src/dialogs/settings/plugins/PluginsPage.svelte new file mode 100644 index 00000000..66e48caf --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/plugins/PluginsPage.svelte @@ -0,0 +1,152 @@ + + +
    +
    +

    Plugins

    + (tab = id as typeof tab)} + /> +
    + {#if changed || page.pending.size} +

    + {page.pending.size + ? 'Plugins were installed or removed: reload the editor to finish.' + : 'Apply your changes, then reload.'} + +

    + {/if} + {#if page.failure} + + {/if} + + {#if tab === 'marketplace'} + + {:else} + + {/if} +
    + + diff --git a/plugins/settings-ui/src/dialogs/settings/plugins/plugins-page-state.svelte.ts b/plugins/settings-ui/src/dialogs/settings/plugins/plugins-page-state.svelte.ts new file mode 100644 index 00000000..8baa310f --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/plugins/plugins-page-state.svelte.ts @@ -0,0 +1,162 @@ +import { SvelteMap } from 'svelte/reactivity'; + +import { + type PluginScope, + RegistryContract, + type RegistryItem, + type RegistrySummary, + type RpcClient, + type SettingsService, +} from '@nanoforge-dev/editor-sdk'; + +import { + ACCOUNT_PLUGINS, + type AccountPlugins, + type InstalledCopy, + type KnownPlugin, + type PendingChange, + installedCopy, + scopeOf, + withAccountPlugin, + withoutAccountPlugins, +} from '../../../marketplace/marketplace'; + +export const registryApi = (rpc: RpcClient) => rpc.api(RegistryContract); +export type RegistryApi = ReturnType; +export type PluginDetails = RegistryItem & { compatible?: string | undefined }; + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const codeOf = (error: unknown) => (error as { code?: string }).code; + +/** + * What the Plugins page keeps while its tabs come and go: the marketplace search, the selected + * plugin, and what the page installed or removed (the editor follows after a reload). + */ +export class PluginsPageState { + query = $state(''); + results = $state([]); + total = $state(0); + searching = $state(false); + /** Why the marketplace can't be shown, when it can't. */ + unavailable = $state(); + selected = $state(); + details = $state(); + /** The plugin being installed, updated or removed. */ + busy = $state(); + failure = $state(); + /** What this page changed on disk: the editor follows after a reload. */ + readonly pending = new SvelteMap(); + /** The newest version this editor can run, per installed plugin the registry knows. */ + readonly newest = new SvelteMap(); + private _generation = 0; + private _lookedUp = false; + + constructor( + private readonly _registry: RegistryApi, + readonly project: string | undefined, + private readonly _settings: SettingsService, + /** The plugins the host knows. */ + private readonly _known: () => readonly KnownPlugin[], + ) {} + + copyOf(name: string): InstalledCopy | undefined { + return installedCopy(name, this._known(), this.pending); + } + + async search(text: string): Promise { + const mine = ++this._generation; + this.searching = true; + try { + const found = await this._registry.search({ type: 'plugin', q: text }); + if (mine !== this._generation) return; + this.results = found.items; + this.total = found.total; + this.unavailable = undefined; + if (!found.items.some((item) => item.name === this.selected)) + this.selected = found.items[0]?.name; + } catch (error) { + if (mine !== this._generation) return; + this.results = []; + this.total = 0; + this.unavailable = + codeOf(error) === 'UNAVAILABLE' + ? 'The plugin registry cannot be reached right now. Installed plugins keep working.' + : message(error); + } finally { + if (mine === this._generation) this.searching = false; + } + } + + /** Reads the details of a plugin; the returned function drops a late answer. */ + loadDetails(name: string): () => void { + let stale = false; + this._registry.details({ name }).then( + (item) => { + if (stale) return; + this.details = item; + if (item.compatible) this.newest.set(name, item.compatible); + }, + () => undefined, + ); + return () => { + stale = true; + }; + } + + /** Asks the registry, once, for the newest version of each installed plugin. */ + lookUpNewest(known: readonly KnownPlugin[]): void { + if (this._lookedUp) return; + this._lookedUp = true; + for (const plugin of known) { + if (!scopeOf(plugin.source)) continue; + this._registry.details({ name: plugin.name }).then( + (item) => item.compatible && this.newest.set(plugin.name, item.compatible), + () => undefined, + ); + } + } + + install(name: string, scope: PluginScope): Promise { + return this._run(name, async () => { + const { version } = await this._registry.installPlugin(this._target(name, scope)); + if (scope === 'user') this._remember(withAccountPlugin(this._accountList(), name, version)); + return { version, scope }; + }); + } + + uninstall(name: string, scope: PluginScope): Promise { + return this._run(name, async () => { + await this._registry.uninstallPlugin(this._target(name, scope)); + if (scope === 'user') this._remember(withoutAccountPlugins(this._accountList(), [name])); + return 'removed'; + }); + } + + private async _run(name: string, action: () => Promise): Promise { + this.busy = name; + this.failure = undefined; + try { + this.pending.set(name, await action()); + } catch (error) { + this.failure = + codeOf(error) === 'FORBIDDEN' + ? 'Plugins can only be installed in a local editor.' + : `${name}: ${message(error)}`; + } finally { + this.busy = undefined; + } + } + + private _accountList(): AccountPlugins { + return (this._settings.get(ACCOUNT_PLUGINS) ?? {}) as AccountPlugins; + } + + private _remember(list: AccountPlugins): void { + if (this._settings.hasStore('account')) + void this._settings.set(ACCOUNT_PLUGINS, list, 'account'); + } + + private _target(name: string, scope: PluginScope) { + return { name, scope, ...(scope === 'project' && this.project && { project: this.project }) }; + } +} diff --git a/plugins/settings-ui/src/dialogs/settings/settings-page-api.ts b/plugins/settings-ui/src/dialogs/settings/settings-page-api.ts new file mode 100644 index 00000000..305ef8df --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/settings-page-api.ts @@ -0,0 +1,26 @@ +import type { Observable, SettingsRegistry } from '@nanoforge-dev/editor-sdk'; +import type { SettingsPageApi } from '@nanoforge-dev/editor-sdk/ui'; + +import type { SettingsDraft } from '../../settings/settings-draft'; + +/** What a contributed settings page reads and writes: the dialog's draft. */ +export const settingsPageApi = ( + registry: SettingsRegistry, + draft: SettingsDraft, + revision: Observable, +): SettingsPageApi => ({ + value: (key) => { + const definition = registry.get(key); + return definition ? draft.valueOf(definition) : undefined; + }, + scopeValue: (key, scope) => { + const definition = registry.get(key); + return definition ? draft.scopeValue(definition, scope) : undefined; + }, + set: (key, value, scope) => { + const definition = registry.get(key); + if (definition) draft.setIn(definition, value, scope); + }, + isPending: (key) => draft.changes.get().has(key), + revision, +}); diff --git a/plugins/settings-ui/src/dialogs/settings/settings-tree.ts b/plugins/settings-ui/src/dialogs/settings/settings-tree.ts new file mode 100644 index 00000000..4fb082c4 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/settings-tree.ts @@ -0,0 +1,29 @@ +import type { SettingsPage, TreeNode } from '@nanoforge-dev/editor-sdk/ui'; + +import type { CategoryNode } from '../../settings/setting-categories'; + +const toNodes = (list: readonly CategoryNode[]): TreeNode[] => + list.map((node) => ({ + id: node.id, + label: node.label, + ...(node.children.length && { children: toNodes(node.children) }), + })); + +/** The tree of the dialog: categories, contributed pages, then the editor's own pages. */ +export const settingsTree = ( + categories: readonly CategoryNode[], + pages: readonly SettingsPage[], + options: { plugins: boolean; conflicts: boolean }, +): TreeNode[] => [ + ...toNodes(categories), + ...pages.map((contributed) => ({ + id: contributed.id, + label: contributed.title, + ...(contributed.icon && { icon: contributed.icon }), + })), + ...(options.plugins ? [{ id: 'plugins', label: 'Plugins', icon: 'plug' }] : []), + { id: 'import', label: 'Import & export', icon: 'download' }, + ...(options.conflicts + ? [{ id: 'conflicts', label: 'Sync conflicts', icon: 'triangle-alert' }] + : []), +]; diff --git a/plugins/settings-ui/src/dialogs/settings/sync-status.const.ts b/plugins/settings-ui/src/dialogs/settings/sync-status.const.ts new file mode 100644 index 00000000..d1464ce6 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/sync-status.const.ts @@ -0,0 +1,11 @@ +import type { SyncStatus } from '@nanoforge-dev/editor-sdk'; + +/** What the dialog says of the account sync. */ +export const SYNC_STATUS: Record = { + idle: 'Account synced', + syncing: 'Syncing…', + pending: 'Changes waiting to sync', + offline: 'Offline: changes sync later', + conflict: 'Sync conflicts to resolve', + error: 'Sync failed', +}; diff --git a/plugins/settings-ui/src/index.ts b/plugins/settings-ui/src/index.ts new file mode 100644 index 00000000..9a7fe7ea --- /dev/null +++ b/plugins/settings-ui/src/index.ts @@ -0,0 +1,84 @@ +import { + AccountSyncToken, + JSON_SCHEMAS, + PluginHostToken, + RpcClientToken, + SettingsRegistryToken, + SettingsServiceToken, + definePlugin, + jsonSchemaOf, +} from '@nanoforge-dev/editor-sdk'; +import { NotificationServiceToken, StyleServiceToken } from '@nanoforge-dev/editor-sdk/ui'; + +import { closeSettingsDialog, openSettingsDialog } from './dialogs/settings/open-settings-dialog'; +import { followAccountPlugins } from './marketplace/account-plugins'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('settings-ui.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + + context.subscriptions.add({ dispose: closeSettingsDialog }); + context.subscriptions.add( + context.registerCommand('settings.open', (_services, page?: unknown) => + openSettingsDialog(services, page), + ), + ); + + const registry = services.get(SettingsRegistryToken); + context.subscriptions.add( + context.contribute(JSON_SCHEMAS, { + fileMatch: ['.nanoforge/editor/settings.json', '.nanoforge/editor/local.json'], + schema: () => ({ + type: 'object', + properties: Object.fromEntries( + registry.getAll().map((definition) => [ + definition.key, + { + ...(jsonSchemaOf(definition) as object), + ...((definition.title ?? definition.description) && { + description: [definition.title, definition.description] + .filter(Boolean) + .join(': '), + }), + }, + ]), + ), + }), + }), + ); + + const host = services.tryGet(PluginHostToken); + const notifications = services.tryGet(NotificationServiceToken); + if (host && notifications) { + context.subscriptions.add( + followAccountPlugins({ + rpc: services.get(RpcClientToken), + settings: services.get(SettingsServiceToken), + host, + notifications, + }), + ); + } + + const account = services.tryGet(AccountSyncToken); + if (account) { + let known = 0; + context.subscriptions.add({ + dispose: account.conflicts.subscribe((conflicts) => { + if (conflicts.length > known) { + services + .tryGet(NotificationServiceToken) + ?.notify('warning', 'Some settings conflict with your account', { + detail: 'Open Settings › Sync conflicts to choose which values to keep.', + }); + } + known = conflicts.length; + }), + }); + } + }, +}); diff --git a/plugins/settings-ui/src/marketplace/account-plugins.ts b/plugins/settings-ui/src/marketplace/account-plugins.ts new file mode 100644 index 00000000..dc9eb03c --- /dev/null +++ b/plugins/settings-ui/src/marketplace/account-plugins.ts @@ -0,0 +1,113 @@ +import { + type Disposable, + type PluginHost, + RegistryContract, + type RpcClient, + SessionContract, + type SettingsService, +} from '@nanoforge-dev/editor-sdk'; +import type { NotificationService } from '@nanoforge-dev/editor-sdk/ui'; + +import { + ACCOUNT_PLUGINS, + type AccountPlugins, + missingAccountPlugins, + withoutAccountPlugins, +} from './marketplace'; + +export interface AccountPluginsOptions { + readonly rpc: RpcClient; + readonly settings: SettingsService; + readonly host: PluginHost; + readonly notifications: NotificationService; + /** Wait before the first look, so the account settings and the plugin list are there. */ + readonly delay?: number; +} + +/** + * Offers to install the plugins of the account that this editor lacks (installed "for me" on + * another machine). Local editors only: a hosted editor does not install plugins. + */ +export const followAccountPlugins = (options: AccountPluginsOptions): Disposable => { + const { rpc, settings, host, notifications } = options; + const registry = rpc.api(RegistryContract); + /** Names already offered in this session: one notification each, not one per sync. */ + const offered = new Set(); + let disposed = false; + + const list = () => (settings.get(ACCOUNT_PLUGINS) ?? {}) as AccountPlugins; + const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); + + const install = async (names: readonly string[]) => { + const failed: string[] = []; + for (const name of names) { + try { + await registry.installPlugin({ name, scope: 'user' }); + } catch (error) { + failed.push(`${name}: ${message(error)}`); + } + } + if (failed.length) { + notifications.notify('error', 'Some plugins could not be installed', { + detail: failed.join('. '), + }); + } + if (failed.length < names.length) { + notifications.notify('info', 'Plugins installed: reload the editor to finish', { + actions: [{ title: 'Reload now', run: () => location.reload() }], + timeout: 0, + }); + } + }; + + const check = () => { + if (disposed) return; + const known = host.plugins.get().map((plugin) => ({ + name: plugin.name, + version: plugin.descriptor.manifest.version, + source: plugin.descriptor.source, + })); + const missing = missingAccountPlugins(list(), known).filter((name) => !offered.has(name)); + if (!missing.length) return; + for (const name of missing) offered.add(name); + const count = missing.length > 1 ? `${missing.length} plugins` : 'A plugin'; + notifications.notify( + 'info', + `${count} of your account ${missing.length > 1 ? 'are' : 'is'} not installed here`, + { + detail: missing.join(', '), + actions: [ + { title: 'Install', run: () => install(missing) }, + { + title: 'Remove from my account', + run: () => + settings.set(ACCOUNT_PLUGINS, withoutAccountPlugins(list(), missing), 'account'), + }, + ], + timeout: 0, + }, + ); + }; + + let timer: ReturnType | undefined; + let subscription: Disposable | undefined; + void rpc + .api(SessionContract) + .info(null) + .then((session) => { + if (disposed || session.mode !== 'OFFLINE') return; + timer = setTimeout(check, options.delay ?? 3000); + subscription = settings.onDidChange((event) => { + if (event.keys.has(ACCOUNT_PLUGINS)) check(); + }); + }) + .catch(() => undefined); + + return { + dispose: () => { + disposed = true; + clearTimeout(timer); + subscription?.dispose(); + }, + }; +}; diff --git a/plugins/settings-ui/src/marketplace/marketplace.ts b/plugins/settings-ui/src/marketplace/marketplace.ts new file mode 100644 index 00000000..8541fc55 --- /dev/null +++ b/plugins/settings-ui/src/marketplace/marketplace.ts @@ -0,0 +1,98 @@ +import type { PluginScope } from '@nanoforge-dev/editor-sdk'; + +/** A plugin the host knows, reduced to what the marketplace needs. */ +export interface KnownPlugin { + readonly name: string; + readonly version: string; + readonly source: string; +} + +/** A change made from the page, which the editor only follows after a reload. */ +export type PendingChange = { version: string; scope: PluginScope } | 'removed'; + +export interface InstalledCopy { + readonly version: string; + /** Where it was installed from the marketplace; absent for built-in and dev plugins. */ + readonly scope?: PluginScope; +} + +const SCOPES: Record = { installed: 'user', project: 'project' }; + +/** The scope a plugin source stands for, when it is one the marketplace installs into. */ +export const scopeOf = (source: string): PluginScope | undefined => SCOPES[source]; + +/** + * The copy of a plugin the marketplace should speak of: what this page just did when it did + * something, else the copy installed from the marketplace, else a built-in or dev one. + */ +export const installedCopy = ( + name: string, + plugins: readonly KnownPlugin[], + pending: ReadonlyMap, +): InstalledCopy | undefined => { + const change = pending.get(name); + if (change === 'removed') + return plugins.find((plugin) => plugin.name === name && !scopeOf(plugin.source)); + if (change) return change; + const copies = plugins.filter((plugin) => plugin.name === name); + const own = + copies.find((plugin) => plugin.source === 'project') ?? + copies.find((plugin) => plugin.source === 'installed'); + if (own) return { version: own.version, scope: scopeOf(own.source)! }; + return copies[0] && { version: copies[0].version }; +}; + +export type MarketAction = 'install' | 'update' | 'installed' | 'builtin' | 'unsupported'; + +/** What the marketplace offers for an item, given the newest version this editor can run. */ +export const marketAction = ( + copy: InstalledCopy | undefined, + compatible: string | undefined, +): MarketAction => { + if (copy && !copy.scope) return 'builtin'; + if (!compatible) return copy ? 'installed' : 'unsupported'; + if (!copy) return 'install'; + return copy.version === compatible ? 'installed' : 'update'; +}; + +export const SCOPE_LABEL: Record = { + user: 'for me', + project: 'for this project', +}; + +/** `1.2k`, `3.4M`: download counts in a list. */ +export const compactCount = (value: number): string => + value >= 1_000_000 + ? `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + : value >= 1000 + ? `${(value / 1000).toFixed(1).replace(/\.0$/, '')}k` + : String(value); + +/** `plugins.account`: the plugins installed for me, with version ranges. */ +export const ACCOUNT_PLUGINS = 'plugins.account'; +export type AccountPlugins = Readonly>; + +/** The list with a plugin installed for me (its range follows the installed version). */ +export const withAccountPlugin = ( + list: AccountPlugins, + name: string, + version: string, +): AccountPlugins => + Object.fromEntries( + Object.entries({ ...list, [name]: `^${version}` }).sort(([a], [b]) => a.localeCompare(b)), + ); + +export const withoutAccountPlugins = ( + list: AccountPlugins, + names: readonly string[], +): AccountPlugins => + Object.fromEntries(Object.entries(list).filter(([name]) => !names.includes(name))); + +/** The plugins of the account this editor does not have, from any source. */ +export const missingAccountPlugins = ( + list: AccountPlugins, + plugins: readonly KnownPlugin[], +): string[] => + Object.keys(list) + .filter((name) => !plugins.some((plugin) => plugin.name === name)) + .sort(); diff --git a/plugins/settings-ui/src/settings/setting-categories.ts b/plugins/settings-ui/src/settings/setting-categories.ts new file mode 100644 index 00000000..97c11111 --- /dev/null +++ b/plugins/settings-ui/src/settings/setting-categories.ts @@ -0,0 +1,80 @@ +import type { SettingDefinition, SettingScope } from '@nanoforge-dev/editor-sdk'; + +export const SCOPE_LABELS: Record = { + default: 'Default', + account: 'Account', + machine: 'This machine', + project: 'Project', + projectLocal: 'Project (only me)', +}; + +export const OTHER_CATEGORY = 'Other'; + +/** A node of the category tree: `Editor`, `Editor/Code editor`… */ +export interface CategoryNode { + readonly id: string; + readonly label: string; + readonly children: CategoryNode[]; +} + +export const categoryOf = (definition: SettingDefinition): string => + definition.category ?? OTHER_CATEGORY; + +/** Category tree of every setting (paths split on `/`). */ +export const categoryTree = (definitions: readonly SettingDefinition[]): CategoryNode[] => { + const roots: CategoryNode[] = []; + const index = new Map(); + for (const category of [...new Set(definitions.map(categoryOf))].sort()) { + let parent: CategoryNode[] = roots; + let path = ''; + for (const part of category.split('/')) { + path = path ? `${path}/${part}` : part; + let node = index.get(path); + if (!node) { + node = { id: path, label: part, children: [] }; + index.set(path, node); + parent.push(node); + } + parent = node.children; + } + } + return roots; +}; + +/** Settings of a category and its subcategories, ordered. */ +export const settingsIn = ( + definitions: readonly SettingDefinition[], + category: string, +): SettingDefinition[] => + definitions + .filter((definition) => { + const own = categoryOf(definition); + return own === category || own.startsWith(`${category}/`); + }) + .sort( + (a, b) => + categoryOf(a).localeCompare(categoryOf(b)) || + (a.order ?? 0) - (b.order ?? 0) || + a.key.localeCompare(b.key), + ); + +/** Settings whose title, description, key or tags contain every word of the query. */ +export const searchSettings = ( + definitions: readonly SettingDefinition[], + query: string, +): SettingDefinition[] => { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return []; + return definitions.filter((definition) => { + const text = [ + definition.title, + definition.description, + definition.key, + categoryOf(definition), + ...(definition.tags ?? []), + ] + .join(' ') + .toLowerCase(); + return words.every((word) => text.includes(word)); + }); +}; diff --git a/plugins/settings-ui/src/settings/setting-control.ts b/plugins/settings-ui/src/settings/setting-control.ts new file mode 100644 index 00000000..068f5b96 --- /dev/null +++ b/plugins/settings-ui/src/settings/setting-control.ts @@ -0,0 +1,63 @@ +import { + type SettingDefinition, + type SettingsService, + type WritableScope, + jsonSchemaOf, +} from '@nanoforge-dev/editor-sdk'; + +export type Control = + | { kind: 'boolean' } + | { kind: 'enum'; options: string[] } + | { kind: 'number'; min?: number; max?: number; integer: boolean } + | { kind: 'string' } + | { kind: 'lines' } + | { kind: 'json' }; + +/** The control a setting's schema calls for. */ +export const controlOf = (definition: SettingDefinition): Control => { + const schema = jsonSchemaOf(definition) as { + type?: string; + enum?: unknown[]; + minimum?: number; + maximum?: number; + items?: { type?: string }; + }; + if (schema.enum?.every((value) => typeof value === 'string')) { + return { kind: 'enum', options: schema.enum as string[] }; + } + switch (schema.type) { + case 'boolean': + return { kind: 'boolean' }; + case 'number': + case 'integer': + return { + kind: 'number', + integer: schema.type === 'integer', + ...(schema.minimum !== undefined && { min: schema.minimum }), + ...(schema.maximum !== undefined && { max: schema.maximum }), + }; + case 'string': + return { kind: 'string' }; + case 'array': + return schema.items?.type === 'string' ? { kind: 'lines' } : { kind: 'json' }; + default: + return { kind: 'json' }; + } +}; + +/** + * Where a change goes by default: the scope the value comes from, else the account (when + * signed in) or this machine, else the first scope the setting allows. + */ +export const defaultScope = ( + settings: SettingsService, + definition: SettingDefinition, +): WritableScope => { + const { effectiveScope } = settings.inspect(definition); + const allowed = definition.scopes.filter((scope) => settings.hasStore(scope)); + if (effectiveScope !== 'default' && allowed.includes(effectiveScope)) return effectiveScope; + for (const scope of ['account', 'machine', 'project', 'projectLocal'] as const) { + if (allowed.includes(scope)) return scope; + } + return definition.scopes[0] ?? 'machine'; +}; diff --git a/plugins/settings-ui/src/settings/settings-draft.ts b/plugins/settings-ui/src/settings/settings-draft.ts new file mode 100644 index 00000000..5cdbc6ea --- /dev/null +++ b/plugins/settings-ui/src/settings/settings-draft.ts @@ -0,0 +1,98 @@ +import { + ObservableValue, + type SettingDefinition, + type SettingsService, + type WritableScope, +} from '@nanoforge-dev/editor-sdk'; + +import { defaultScope } from './setting-control'; + +export type PendingChange = + | { readonly kind: 'set'; readonly scope: WritableScope; readonly value: unknown } + | { readonly kind: 'reset'; readonly scope: WritableScope }; + +/** Changes made in the dialog, written on Apply. */ +export class SettingsDraft { + private readonly _changes = new ObservableValue>(new Map()); + /** Target scope chosen per setting (defaults to `defaultScope`). */ + private readonly _scopes = new ObservableValue>(new Map()); + + constructor(private readonly _settings: SettingsService) {} + + get changes() { + return this._changes.readonly(); + } + + get scopes() { + return this._scopes.readonly(); + } + + scopeOf(definition: SettingDefinition): WritableScope { + return this._scopes.get().get(definition.key) ?? defaultScope(this._settings, definition); + } + + setScope(definition: SettingDefinition, scope: WritableScope): void { + this._scopes.set(new Map(this._scopes.get()).set(definition.key, scope)); + const change = this._changes.get().get(definition.key); + if (change?.kind === 'set') this._put(definition.key, { ...change, scope }); + } + + /** The value shown for a setting: its pending value, else the effective one. */ + valueOf(definition: SettingDefinition): unknown { + const change = this._changes.get().get(definition.key); + if (change?.kind === 'set') return change.value; + if (change?.kind === 'reset') { + const { scopes } = this._settings.inspect(definition); + const winner = (['projectLocal', 'project', 'machine', 'account'] as const).find( + (scope) => scope !== change.scope && scopes[scope] !== undefined, + ); + return winner ? scopes[winner] : definition.default; + } + return this._settings.get(definition); + } + + set(definition: SettingDefinition, value: unknown): void { + this._put(definition.key, { kind: 'set', scope: this.scopeOf(definition), value }); + } + + /** Sets the value of a setting in a given scope (pages that edit one scope at a time). */ + setIn(definition: SettingDefinition, value: unknown, scope: WritableScope): void { + this._put(definition.key, { kind: 'set', scope, value }); + } + + /** What a scope holds for a setting, pending change included; undefined when not set there. */ + scopeValue(definition: SettingDefinition, scope: WritableScope): unknown { + const change = this._changes.get().get(definition.key); + if (change?.scope === scope) return change.kind === 'set' ? change.value : undefined; + return this._settings.inspect(definition).scopes[scope]; + } + + reset(definition: SettingDefinition, scope: WritableScope): void { + this._put(definition.key, { kind: 'reset', scope }); + } + + discard(key: string): void { + const next = new Map(this._changes.get()); + next.delete(key); + this._changes.set(next); + } + + /** Writes every pending change; resolves with the ones that failed. */ + async apply(): Promise<{ key: string; error: unknown }[]> { + const failed: { key: string; error: unknown }[] = []; + for (const [key, change] of this._changes.get()) { + try { + if (change.kind === 'set') await this._settings.set(key, change.value, change.scope); + else await this._settings.reset(key, change.scope); + } catch (error) { + failed.push({ key, error }); + } + } + this._changes.set(new Map(failed.map(({ key }) => [key, this._changes.get().get(key)!]))); + return failed; + } + + private _put(key: string, change: PendingChange): void { + this._changes.set(new Map(this._changes.get()).set(key, change)); + } +} diff --git a/plugins/settings-ui/svelte.config.js b/plugins/settings-ui/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/settings-ui/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/settings-ui/test/marketplace/marketplace.spec.ts b/plugins/settings-ui/test/marketplace/marketplace.spec.ts new file mode 100644 index 00000000..922d5a8b --- /dev/null +++ b/plugins/settings-ui/test/marketplace/marketplace.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { + type PendingChange, + compactCount, + installedCopy, + marketAction, + missingAccountPlugins, + withAccountPlugin, + withoutAccountPlugins, +} from '../../src/marketplace/marketplace'; + +const plugins = [ + { name: '@nanoforge/git', version: '0.1.0', source: 'bundled' }, + { name: '@acme/tools', version: '1.0.0', source: 'installed' }, + { name: '@acme/both', version: '1.0.0', source: 'installed' }, + { name: '@acme/both', version: '1.1.0', source: 'project' }, + { name: '@acme/hello', version: '0.0.1', source: 'dev' }, +]; +const none = new Map(); + +describe('marketplace', () => { + it('finds the copy of a plugin to speak of', () => { + expect(installedCopy('@acme/none', plugins, none)).toBeUndefined(); + expect(installedCopy('@acme/tools', plugins, none)).toEqual({ + version: '1.0.0', + scope: 'user', + }); + expect(installedCopy('@acme/both', plugins, none)).toEqual({ + version: '1.1.0', + scope: 'project', + }); + expect(installedCopy('@nanoforge/git', plugins, none)).toEqual({ version: '0.1.0' }); + const pending = new Map([ + ['@acme/tools', 'removed'], + ['@acme/new', { version: '2.0.0', scope: 'user' }], + ]); + expect(installedCopy('@acme/tools', plugins, pending)).toBeUndefined(); + expect(installedCopy('@acme/new', plugins, pending)).toEqual({ + version: '2.0.0', + scope: 'user', + }); + }); + + it('offers install, update, or nothing', () => { + expect(marketAction(undefined, '1.0.0')).toBe('install'); + expect(marketAction(undefined, undefined)).toBe('unsupported'); + expect(marketAction({ version: '1.0.0', scope: 'user' }, '1.0.0')).toBe('installed'); + expect(marketAction({ version: '1.0.0', scope: 'user' }, '1.2.0')).toBe('update'); + expect(marketAction({ version: '1.0.0', scope: 'user' }, undefined)).toBe('installed'); + expect(marketAction({ version: '0.1.0' }, '9.0.0')).toBe('builtin'); + }); + + it('keeps the list of plugins installed for me, and finds the ones missing here', () => { + const list = withAccountPlugin({ '@b/two': '^1.0.0' }, '@a/one', '2.1.0'); + expect(Object.entries(list)).toEqual([ + ['@a/one', '^2.1.0'], + ['@b/two', '^1.0.0'], + ]); + expect(withoutAccountPlugins(list, ['@b/two'])).toEqual({ '@a/one': '^2.1.0' }); + expect(missingAccountPlugins({ '@acme/hello': '*', '@acme/gone': '*' }, plugins)).toEqual([ + '@acme/gone', + ]); + }); + + it('shortens download counts', () => { + expect([12, 1000, 1250, 3_400_000].map(compactCount)).toEqual(['12', '1k', '1.3k', '3.4M']); + }); +}); diff --git a/plugins/settings-ui/tsconfig.json b/plugins/settings-ui/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/settings-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/settings-ui/vite.config.ts b/plugins/settings-ui/vite.config.ts new file mode 100644 index 00000000..8e6c03ac --- /dev/null +++ b/plugins/settings-ui/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'settings-ui' }, + }, +}); diff --git a/plugins/viewport/.prettierignore b/plugins/viewport/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/plugins/viewport/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/plugins/viewport/LICENSE b/plugins/viewport/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/plugins/viewport/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/viewport/README.md b/plugins/viewport/README.md new file mode 100644 index 00000000..11665558 --- /dev/null +++ b/plugins/viewport/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-plugin-viewport` is an internal package of the NanoForge Editor. + +Built-in viewport (Game and Scene screens). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/plugins/viewport +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/plugins/viewport/nanoforge.manifest.json b/plugins/viewport/nanoforge.manifest.json new file mode 100644 index 00000000..8fa067b1 --- /dev/null +++ b/plugins/viewport/nanoforge.manifest.json @@ -0,0 +1,190 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@nanoforge/viewport", + "version": "0.1.0", + "displayName": "Viewport", + "description": "The Game and Scene screens: resolutions, zoom, stats, screenshots, pop out.", + "engines": { + "editor": ">=0.1.0" + }, + "entry": { + "client": "index.js" + }, + "activation": ["onStartup"], + "contributes": { + "widgets": [ + { + "id": "viewport.scene", + "title": "Scene", + "icon": "box", + "kind": "screen", + "order": 150 + }, + { + "id": "viewport.game", + "title": "Game", + "icon": "gamepad-2", + "kind": "screen", + "order": 300 + } + ], + "commands": [ + { + "id": "viewport.setResolution", + "title": "Set game resolution", + "category": "Game", + "palette": false + }, + { + "id": "viewport.customResolution", + "title": "Custom game resolution\u2026", + "category": "Game" + }, + { + "id": "viewport.rotate", + "title": "Rotate the game view", + "category": "Game" + }, + { + "id": "viewport.zoomIn", + "title": "Zoom in the game", + "category": "Game" + }, + { + "id": "viewport.zoomOut", + "title": "Zoom out the game", + "category": "Game" + }, + { + "id": "viewport.resetZoom", + "title": "Reset the game zoom", + "category": "Game" + }, + { + "id": "viewport.togglePixelPerfect", + "title": "Pixel perfect", + "category": "Game" + }, + { + "id": "viewport.toggleStats", + "title": "Show or hide game stats", + "category": "Game" + }, + { + "id": "viewport.toggleMute", + "title": "Mute or unmute the game", + "category": "Game" + }, + { + "id": "viewport.toggleMaximizeOnPlay", + "title": "Maximize on play", + "category": "Game" + }, + { + "id": "viewport.screenshot", + "title": "Take a screenshot of the game", + "category": "Game" + }, + { + "id": "viewport.popOut", + "title": "Pop out the game", + "category": "Game" + }, + { + "id": "viewport.bringBack", + "title": "Bring the game back", + "category": "Game" + } + ], + "menus": [ + { + "menu": "run", + "command": "viewport.toggleMaximizeOnPlay", + "group": "y-viewport", + "order": 0 + }, + { + "menu": "run", + "command": "viewport.screenshot", + "group": "y-viewport", + "order": 1, + "when": "runtime.active" + }, + { + "menu": "run", + "command": "viewport.popOut", + "group": "y-viewport", + "order": 2, + "when": "runtime.active" + } + ], + "settings": [ + { + "key": "resolution", + "type": "string", + "pattern": "^(fit|1080p|720p|4:3|phone|tablet|\\d{2,5}x\\d{2,5})$", + "default": "fit", + "title": "Game resolution", + "description": "fit, a preset, or WIDTHxHEIGHT.", + "category": "Editor/Game view", + "order": 0 + }, + { + "key": "orientation", + "type": "string", + "enum": ["landscape", "portrait"], + "default": "landscape", + "title": "Device orientation", + "category": "Editor/Game view", + "order": 1 + }, + { + "key": "zoom", + "type": "number", + "minimum": 0.25, + "maximum": 4, + "default": 1, + "title": "Game zoom", + "category": "Editor/Game view", + "order": 2 + }, + { + "key": "pixelPerfect", + "type": "boolean", + "default": false, + "title": "Pixel perfect", + "description": "Integer zoom and pixelated rendering.", + "category": "Editor/Game view", + "order": 3 + }, + { + "key": "stats", + "type": "boolean", + "default": false, + "title": "Show game stats", + "category": "Editor/Game view", + "order": 4, + "scopes": ["account", "machine"] + }, + { + "key": "muted", + "type": "boolean", + "default": false, + "title": "Mute the game", + "category": "Editor/Game view", + "order": 5, + "scopes": ["account", "machine"] + }, + { + "key": "maximizeOnPlay", + "type": "boolean", + "default": false, + "title": "Maximize on play", + "description": "Hide the docks while playing.", + "category": "Editor/Game view", + "order": 6 + } + ] + } +} diff --git a/plugins/viewport/package.json b/plugins/viewport/package.json new file mode 100644 index 00000000..c8245d31 --- /dev/null +++ b/plugins/viewport/package.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-plugin-viewport", + "version": "0.0.0", + "description": "NanoForge Editor - Built-in viewport (Game and Scene screens)", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "plugins/viewport" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "prettier --check . && eslint --format=pretty src", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@lucide/svelte": "catalog:components", + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:core", + "svelte": "catalog:core", + "svelte-check": "catalog:core", + "typescript": "catalog:build", + "vite": "catalog:core" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.{ts,svelte}": [ + "eslint --fix" + ] + } +} diff --git a/plugins/viewport/src/game/popout.ts b/plugins/viewport/src/game/popout.ts new file mode 100644 index 00000000..3c512558 --- /dev/null +++ b/plugins/viewport/src/game/popout.ts @@ -0,0 +1,54 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; +import type { RuntimeService } from '@nanoforge-dev/editor-sdk'; + +/** The window the game was moved to, if any. */ +export const popout = new ObservableValue(undefined); + +const FORWARDED = ['keydown', 'keyup'] as const; + +/** + * Moves the running game into a new browser window (same page realm: the game keeps running). + * Keys typed there are forwarded to the editor window, where the engine listens. Closing the + * window brings the game back (the Game screen re-attaches it). + */ +export const popOut = (runtime: RuntimeService, title: string): boolean => { + if (popout.get()) { + popout.get()!.focus(); + return true; + } + const opened = window.open('', 'nanoforge-game', 'popup,width=1280,height=760'); + if (!opened) return false; + const doc = opened.document; + doc.title = title; + doc.body.style.cssText = 'margin:0;background:#000;overflow:hidden'; + const host = doc.createElement('div'); + host.style.cssText = 'position:fixed;inset:0'; + doc.body.append(host); + for (const type of FORWARDED) { + opened.addEventListener(type, (event) => { + window.dispatchEvent(new KeyboardEvent(type, event)); + }); + } + const view = runtime.attach(host); + popout.set(opened); + const back = () => { + if (popout.get() !== opened) return; + view.dispose(); + popout.set(undefined); + }; + opened.addEventListener('pagehide', back); + const timer = setInterval(() => { + if (opened.closed) { + clearInterval(timer); + back(); + } + }, 500); + return true; +}; + +/** Brings a popped out game back (closes its window). */ +export const bringBack = (): void => { + const opened = popout.get(); + popout.set(undefined); + opened?.close(); +}; diff --git a/plugins/viewport/src/game/resolutions.ts b/plugins/viewport/src/game/resolutions.ts new file mode 100644 index 00000000..25b6d643 --- /dev/null +++ b/plugins/viewport/src/game/resolutions.ts @@ -0,0 +1,45 @@ +export interface Resolution { + readonly id: string; + readonly label: string; + readonly width: number; + readonly height: number; + /** Devices can be turned (portrait/landscape). */ + readonly device?: boolean; +} + +export const RESOLUTIONS: readonly Resolution[] = [ + { id: '1080p', label: '1920 × 1080 (16:9)', width: 1920, height: 1080 }, + { id: '720p', label: '1280 × 720 (16:9)', width: 1280, height: 720 }, + { id: '4:3', label: '1024 × 768 (4:3)', width: 1024, height: 768 }, + { id: 'phone', label: 'Phone (390 × 844)', width: 844, height: 390, device: true }, + { id: 'tablet', label: 'Tablet (820 × 1180)', width: 1180, height: 820, device: true }, +]; + +export const ZOOMS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4] as const; + +/** + * Size of the game container: undefined to fill the screen (`fit`), else the preset or custom + * `WxH`, turned for devices in portrait, times the zoom. + */ +export const frameSize = ( + resolution: string, + orientation: 'landscape' | 'portrait', + zoom: number, +): { width: number; height: number } | undefined => { + if (resolution === 'fit') return undefined; + const preset = RESOLUTIONS.find((candidate) => candidate.id === resolution); + const custom = /^(\d{2,5})x(\d{2,5})$/.exec(resolution); + let width = preset?.width ?? Number(custom?.[1] ?? 0); + let height = preset?.height ?? Number(custom?.[2] ?? 0); + if (!width || !height) return undefined; + if (preset?.device && orientation === 'portrait') [width, height] = [height, width]; + return { width: Math.round(width * zoom), height: Math.round(height * zoom) }; +}; + +/** Next zoom step (pixel perfect: integers only). */ +export const stepZoom = (zoom: number, direction: 1 | -1, pixelPerfect: boolean): number => { + const steps = pixelPerfect ? ZOOMS.filter((value) => Number.isInteger(value)) : ZOOMS; + const index = steps.findIndex((value) => value >= zoom - 1e-9); + const current = index < 0 ? steps.length - 1 : index; + return steps[Math.min(steps.length - 1, Math.max(0, current + direction))]!; +}; diff --git a/plugins/viewport/src/game/screenshot.ts b/plugins/viewport/src/game/screenshot.ts new file mode 100644 index 00000000..87dee12c --- /dev/null +++ b/plugins/viewport/src/game/screenshot.ts @@ -0,0 +1,30 @@ +/** Composes the canvases of the game (layers) into one PNG. */ +export const captureGame = async (stage: HTMLElement): Promise => { + const canvases = [...stage.querySelectorAll('canvas')]; + if (!canvases.length) return undefined; + const bounds = stage.getBoundingClientRect(); + const scale = window.devicePixelRatio || 1; + const output = document.createElement('canvas'); + output.width = Math.round(bounds.width * scale); + output.height = Math.round(bounds.height * scale); + const context = output.getContext('2d'); + if (!context) return undefined; + for (const canvas of canvases) { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) continue; + context.drawImage( + canvas, + (rect.left - bounds.left) * scale, + (rect.top - bounds.top) * scale, + rect.width * scale, + rect.height * scale, + ); + } + return new Promise((resolve) => output.toBlob((blob) => resolve(blob ?? undefined), 'image/png')); +}; + +/** `screenshots/2026-09-27_14-03-05.png` */ +export const screenshotPath = (date = new Date()): string => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `screenshots/${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}.png`; +}; diff --git a/plugins/viewport/src/icons.ts b/plugins/viewport/src/icons.ts new file mode 100644 index 00000000..f059b51f --- /dev/null +++ b/plugins/viewport/src/icons.ts @@ -0,0 +1,24 @@ +import Activity from '@lucide/svelte/icons/activity'; +import Camera from '@lucide/svelte/icons/camera'; +import ExternalLink from '@lucide/svelte/icons/external-link'; +import Grid3x3 from '@lucide/svelte/icons/grid-3x3'; +import Maximize from '@lucide/svelte/icons/maximize'; +import Minus from '@lucide/svelte/icons/minus'; +import Monitor from '@lucide/svelte/icons/monitor'; +import RotateCw from '@lucide/svelte/icons/rotate-cw'; +import Volume2 from '@lucide/svelte/icons/volume-2'; +import VolumeX from '@lucide/svelte/icons/volume-x'; + +/** Icons the viewport uses beyond the editor's own set. */ +export const ICONS = { + activity: Activity, + camera: Camera, + 'external-link': ExternalLink, + 'grid-3x3': Grid3x3, + maximize: Maximize, + minus: Minus, + monitor: Monitor, + 'rotate-cw': RotateCw, + 'volume-2': Volume2, + 'volume-x': VolumeX, +}; diff --git a/plugins/viewport/src/index.ts b/plugins/viewport/src/index.ts new file mode 100644 index 00000000..0e13818b --- /dev/null +++ b/plugins/viewport/src/index.ts @@ -0,0 +1,217 @@ +import type { Component } from 'svelte'; + +import { + DisposableStore, + EditorServices, + MutableDisposable, + ProjectServiceToken, + RuntimeServiceToken, + SettingsServiceToken, + definePlugin, + observe, +} from '@nanoforge-dev/editor-sdk'; +import { + LayoutControllerToken, + NotificationServiceToken, + PromptServiceToken, + StyleServiceToken, + VIEWPORT_TOOLS, + WIDGET_VIEWS, + type WidgetInstance, + registerIcons, +} from '@nanoforge-dev/editor-sdk/ui'; + +import { bringBack, popOut, popout } from './game/popout'; +import { frameSize, stepZoom } from './game/resolutions'; +import { captureGame, screenshotPath } from './game/screenshot'; +import { ICONS } from './icons'; +import { GAME_SCREEN, SCENE_SCREEN, SETTING } from './settings/viewport-settings.const'; +import { TOGGLES, TOOLS } from './toolbar/viewport-tools.const'; +import GameScreen from './widgets/game/GameScreen.svelte'; +import SceneScreen from './widgets/scene/SceneScreen.svelte'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + const css = await fetch(context.resolveAsset('viewport.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + context.subscriptions.add(registerIcons(ICONS)); + for (const [id, component] of [ + [GAME_SCREEN, GameScreen], + [SCENE_SCREEN, SceneScreen], + ] as const) { + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id, + component: component as Component<{ instance: WidgetInstance }>, + }), + ); + } + for (const tool of TOOLS) { + context.subscriptions.add(context.contribute(VIEWPORT_TOOLS, { screen: 'game', ...tool })); + } + + const settings = services.get(SettingsServiceToken); + const contextKeys = services.get(EditorServices.ContextKeys); + for (const [key, setting] of Object.entries(TOGGLES)) { + context.subscriptions.add( + observe(settings.observe(setting), (value) => contextKeys.set(key, value)), + ); + } + const write = (key: string, value: unknown) => + settings.set(key, value, settings.hasStore('machine') ? 'machine' : 'account'); + const toggle = (key: string) => write(key, !settings.get(key)); + const runtime = () => services.tryGet(RuntimeServiceToken); + const notify = (kind: 'info' | 'error', title: string, detail?: string) => + services.tryGet(NotificationServiceToken)?.notify(kind, title, detail ? { detail } : {}); + + const playing = context.subscriptions.add(new MutableDisposable()); + context.subscriptions.add({ + dispose: services.get(ProjectServiceToken).current.subscribe(() => { + playing.clear(); + const service = runtime(); + if (!service) return; + const store = new DisposableStore(); + let previousScreen: string | undefined; + let previousState = service.session.get().state; + store.add({ + dispose: service.session.subscribe(({ state, mode }) => { + const layout = services.tryGet(LayoutControllerToken); + const was = previousState; + previousState = state; + if (state === 'building' && (was === 'idle' || was === 'crashed')) { + const active = layout?.current.activeScreen; + if (mode !== 'server') { + previousScreen = active && active !== GAME_SCREEN ? active : undefined; + layout?.showScreen(GAME_SCREEN); + } + if (settings.get(SETTING.maximizeOnPlay)) layout?.setDocksHidden(true); + } + if (state === 'running' && was === 'starting') { + service.send('mute', [settings.get(SETTING.muted)]); + } + if (state === 'crashed') layout?.setDocksHidden(false); + if (state === 'idle' && was !== 'idle') { + layout?.setDocksHidden(false); + bringBack(); + if (previousScreen && layout?.current.activeScreen === GAME_SCREEN) { + layout.showScreen(previousScreen); + } + previousScreen = undefined; + } + }), + }); + store.add( + observe(settings.observe(SETTING.muted), (muted) => { + if (['running', 'paused'].includes(service.session.get().state)) { + service.send('mute', [muted]); + } + }), + ); + playing.value = store; + }), + }); + + const stageOf = () => + popout.get()?.document.querySelector('[data-nf-game-stage]') ?? + document.querySelector('[data-nf-game-stage]'); + + const commands: [string, (...args: unknown[]) => unknown][] = [ + ['viewport.setResolution', (id) => typeof id === 'string' && write(SETTING.resolution, id)], + [ + 'viewport.customResolution', + async () => { + const value = await services.tryGet(PromptServiceToken)?.ask({ + title: 'Custom resolution', + label: 'Width × height (e.g. 1600x900)', + value: '1600x900', + confirm: 'Apply', + validate: (text) => + frameSize( + text.replace(/\s|×/g, (char) => (char === '×' ? 'x' : '')), + 'landscape', + 1, + ) + ? undefined + : 'Use WIDTHxHEIGHT, e.g. 1600x900', + }); + if (value) await write(SETTING.resolution, value.replace(/\s/g, '').replace('×', 'x')); + }, + ], + [ + 'viewport.rotate', + () => + write( + SETTING.orientation, + settings.get(SETTING.orientation) === 'portrait' ? 'landscape' : 'portrait', + ), + ], + [ + 'viewport.zoomIn', + () => + write( + SETTING.zoom, + stepZoom(settings.get(SETTING.zoom), 1, settings.get(SETTING.pixelPerfect)), + ), + ], + [ + 'viewport.zoomOut', + () => + write( + SETTING.zoom, + stepZoom(settings.get(SETTING.zoom), -1, settings.get(SETTING.pixelPerfect)), + ), + ], + ['viewport.resetZoom', () => write(SETTING.zoom, 1)], + [ + 'viewport.togglePixelPerfect', + async () => { + await toggle(SETTING.pixelPerfect); + const zoom = settings.get(SETTING.zoom); + if (settings.get(SETTING.pixelPerfect) && !Number.isInteger(zoom)) { + await write(SETTING.zoom, Math.max(1, Math.round(zoom))); + } + }, + ], + ['viewport.toggleStats', () => toggle(SETTING.stats)], + ['viewport.toggleMute', () => toggle(SETTING.muted)], + ['viewport.toggleMaximizeOnPlay', () => toggle(SETTING.maximizeOnPlay)], + [ + 'viewport.screenshot', + async () => { + const stage = stageOf(); + const project = services.get(ProjectServiceToken).current.get(); + const image = stage && (await captureGame(stage)); + if (!image || !project) { + notify('info', 'Nothing to capture: play the game first'); + return; + } + const path = screenshotPath(); + await project.fs.write(path, new Uint8Array(await image.arrayBuffer())); + await navigator.clipboard + ?.write([new ClipboardItem({ 'image/png': image })]) + .catch(() => undefined); + notify('info', 'Screenshot saved', path); + }, + ], + [ + 'viewport.popOut', + () => { + const service = runtime(); + if (service && !popOut(service, 'NanoForge game')) { + notify('error', 'The browser blocked the new window', 'Allow pop-ups for the editor.'); + } + }, + ], + ['viewport.bringBack', () => bringBack()], + ]; + for (const [id, run] of commands) { + context.subscriptions.add( + context.registerCommand(id, (_services, ...args: unknown[]) => run(...args)), + ); + } + context.subscriptions.add({ dispose: () => bringBack() }); + }, +}); diff --git a/plugins/viewport/src/settings/viewport-settings.const.ts b/plugins/viewport/src/settings/viewport-settings.const.ts new file mode 100644 index 00000000..633d2729 --- /dev/null +++ b/plugins/viewport/src/settings/viewport-settings.const.ts @@ -0,0 +1,13 @@ +/** Settings of the plugin (declared in its manifest, prefixed with its name). */ +export const SETTING = { + resolution: '@nanoforge/viewport.resolution', + orientation: '@nanoforge/viewport.orientation', + zoom: '@nanoforge/viewport.zoom', + pixelPerfect: '@nanoforge/viewport.pixelPerfect', + stats: '@nanoforge/viewport.stats', + muted: '@nanoforge/viewport.muted', + maximizeOnPlay: '@nanoforge/viewport.maximizeOnPlay', +} as const; + +export const GAME_SCREEN = 'viewport.game'; +export const SCENE_SCREEN = 'viewport.scene'; diff --git a/plugins/viewport/src/toolbar/viewport-tools.const.ts b/plugins/viewport/src/toolbar/viewport-tools.const.ts new file mode 100644 index 00000000..61af030a --- /dev/null +++ b/plugins/viewport/src/toolbar/viewport-tools.const.ts @@ -0,0 +1,69 @@ +import { SETTING } from '../settings/viewport-settings.const'; + +/** Settings mirrored as context keys (`toggled` states of the tools). */ +export const TOGGLES = { + 'viewport.stats': SETTING.stats, + 'viewport.muted': SETTING.muted, + 'viewport.pixelPerfect': SETTING.pixelPerfect, + 'viewport.maximizeOnPlay': SETTING.maximizeOnPlay, +} as const; + +export const TOOLS = [ + { + id: 'viewport.pixelPerfect', + icon: 'grid-3x3', + title: 'Pixel perfect', + command: 'viewport.togglePixelPerfect', + order: 10, + toggled: 'viewport.pixelPerfect', + }, + { + id: 'viewport.stats', + icon: 'activity', + title: 'Stats', + command: 'viewport.toggleStats', + order: 20, + toggled: 'viewport.stats', + }, + { + id: 'viewport.mute', + icon: 'volume-2', + title: 'Mute the game', + command: 'viewport.toggleMute', + order: 30, + when: '!viewport.muted', + }, + { + id: 'viewport.unmute', + icon: 'volume-x', + title: 'Unmute the game', + command: 'viewport.toggleMute', + order: 30, + when: 'viewport.muted', + toggled: 'viewport.muted', + }, + { + id: 'viewport.screenshot', + icon: 'camera', + title: 'Screenshot', + command: 'viewport.screenshot', + order: 40, + when: 'runtime.active', + }, + { + id: 'viewport.popOut', + icon: 'external-link', + title: 'Pop out', + command: 'viewport.popOut', + order: 50, + when: 'runtime.active', + }, + { + id: 'viewport.maximizeOnPlay', + icon: 'maximize', + title: 'Maximize on play', + command: 'viewport.toggleMaximizeOnPlay', + order: 60, + toggled: 'viewport.maximizeOnPlay', + }, +] as const; diff --git a/plugins/viewport/src/widgets/ViewportToolbar.svelte b/plugins/viewport/src/widgets/ViewportToolbar.svelte new file mode 100644 index 00000000..1b211eb5 --- /dev/null +++ b/plugins/viewport/src/widgets/ViewportToolbar.svelte @@ -0,0 +1,67 @@ + + + + + diff --git a/plugins/viewport/src/widgets/game/GameScreen.svelte b/plugins/viewport/src/widgets/game/GameScreen.svelte new file mode 100644 index 00000000..f83639fc --- /dev/null +++ b/plugins/viewport/src/widgets/game/GameScreen.svelte @@ -0,0 +1,417 @@ + + +
    + + {#snippet start()} + + {#snippet trigger({ props })} + + {/snippet} + + {#if $resolution !== 'fit'} + run('viewport.rotate')} + /> + run('viewport.zoomOut')} /> + + run('viewport.zoomIn')} /> + {/if} + {/snippet} + + +
    + +
    { + if (event.key === 'Escape') (event.currentTarget as HTMLElement).blur(); + }} + > +
    + {#if $session?.state === 'paused'} + +

    Paused

    + {/if} + {#if playing && !$popout} + {#each $overlays.filter((overlay) => overlay.value.screen === 'game') as overlay (overlay.value.id)} + {@const Overlay = overlay.value.component} + + {/each} + {#if $stats && runtime}{/if} + {/if} +
    + + {#if $popout && playing} +
    + + {#snippet actions()} + + + {/snippet} + +
    + {:else if !runtime} +
    + +
    + {:else if $session?.state === 'idle'} +
    + + {#snippet actions()} + + {/snippet} + +
    + {:else if $session && PROGRESS[$session.state]} +
    +

    {PROGRESS[$session.state]}

    +
    + {:else if $session?.state === 'crashed'} +
    + + {#snippet actions()} + {#if $session.diagnostics?.length} +
      + {#each $session.diagnostics.slice(0, 5) as diagnostic, index (index)} +
    • + {#if diagnostic.path} + {diagnostic.path}{diagnostic.line ? `:${diagnostic.line}` : ''} + {/if} + {diagnostic.message} +
    • + {/each} +
    + {/if} +
    + + {#if commands.has('console.show')} + + {/if} + +
    + {/snippet} +
    +
    + {:else if $session?.mode === 'server'} +
    + +
    + {/if} + + {#if warnings.length && !dismissed && playing} + + {/if} +
    +
    + + diff --git a/plugins/viewport/src/widgets/game/StatsOverlay.svelte b/plugins/viewport/src/widgets/game/StatsOverlay.svelte new file mode 100644 index 00000000..6d23e196 --- /dev/null +++ b/plugins/viewport/src/widgets/game/StatsOverlay.svelte @@ -0,0 +1,78 @@ + + +
    + {#each [...$stats].sort(([a], [b]) => a.localeCompare(b)) as [source, frame] (source)} + {@const slowest = Object.entries(frame.libraries) + .sort(([, a], [, b]) => b.avg - a.avg) + .slice(0, 3)} +
    +

    {source}

    +

    + {Math.round(frame.tps)} tps · tick {ms(frame.tick.avg)} (max {ms( + frame.tick.max, + )}) +

    + {#each slowest as [library, timing] (library)} +

    {library} {ms(timing.avg)}

    + {/each} +
    + {:else} +

    Waiting for frame stats…

    + {/each} + {#if heap}

    JS heap {(heap / 1024 / 1024).toFixed(0)} MB

    {/if} +
    + + diff --git a/plugins/viewport/src/widgets/scene/SceneScreen.svelte b/plugins/viewport/src/widgets/scene/SceneScreen.svelte new file mode 100644 index 00000000..314f8765 --- /dev/null +++ b/plugins/viewport/src/widgets/scene/SceneScreen.svelte @@ -0,0 +1,92 @@ + + +
    + +
    + {#if editor?.component} + {@const Editor = editor.component} + + {:else if editor?.mount} +
    + {:else} + + {/if} + {#if editor} + {#each $overlays.filter((overlay) => overlay.value.screen === 'scene') as overlay (overlay.value.id)} + {@const Overlay = overlay.value.component} + + {/each} + {/if} +
    +
    + + diff --git a/plugins/viewport/svelte.config.js b/plugins/viewport/svelte.config.js new file mode 100644 index 00000000..e4c62a9c --- /dev/null +++ b/plugins/viewport/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode (the host's runtime flags). */ +export default { compilerOptions: { runes: true } }; diff --git a/plugins/viewport/tsconfig.json b/plugins/viewport/tsconfig.json new file mode 100644 index 00000000..a10595a5 --- /dev/null +++ b/plugins/viewport/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/plugins/viewport/vite.config.ts b/plugins/viewport/vite.config.ts new file mode 100644 index 00000000..1876b044 --- /dev/null +++ b/plugins/viewport/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'viewport' }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 141014af..1d8b8d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -5,23 +106,30 @@ settings: excludeLinksFromLockfile: false catalogs: + adapter: + '@sveltejs/adapter-static': + specifier: ^3.0.10 + version: 3.0.10 build: - '@tsconfig/svelte': - specifier: ^5.0.8 - version: 5.0.8 - svelte-adapter-bun: - specifier: ^1.0.1 - version: 1.0.1 + tsdown: + specifier: ^0.22.14 + version: 0.22.14 + turbo: + specifier: ^2.10.12 + version: 2.11.4 typescript: specifier: ^6.0.3 version: 6.0.3 + unrun: + specifier: ^0.3.0 + version: 0.3.1 ci: '@commitlint/cli': specifier: ^21.2.0 - version: 21.2.0 + version: 21.2.3 '@commitlint/config-conventional': specifier: ^21.2.0 - version: 21.2.0 + version: 21.2.3 '@favware/cliff-jumper': specifier: ^6.1.0 version: 6.1.0 @@ -33,131 +141,72 @@ catalogs: version: 9.1.7 lint-staged: specifier: ^17.0.8 - version: 17.0.8 + version: 17.5.1 + code: + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 components: + '@fontsource/instrument-sans': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/jetbrains-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@lucide/svelte': + specifier: ^1.48.0 + version: 1.48.0 bits-ui: - specifier: ^2.18.1 - version: 2.18.1 + specifier: ^2.19.3 + version: 2.19.3 monaco-editor: specifier: ^0.55.1 version: 0.55.1 + config: + '@nanoforge-dev/config': + specifier: ^2.3.0 + version: 2.3.0 core: - '@nanoforge-dev/ecs-lib': - specifier: ^1.4.0 - version: 1.4.0 '@sveltejs/kit': specifier: ^2.65.0 - version: 2.65.0 + version: 2.70.3 '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2 + version: 7.3.1 '@types/bun': specifier: ^1.3.14 - version: 1.3.14 - bun: - specifier: ^1.3.14 - version: 1.3.14 + version: 1.4.2 + '@types/node': + specifier: ^26.4.0 + version: 26.6.2 svelte: specifier: ^5.56.3 - version: 5.56.3 + version: 5.57.1 svelte-check: specifier: ^4.6.0 - version: 4.6.0 - svelte-kit-sessions: - specifier: ^0.4.0 - version: 0.4.0 + version: 4.7.6 vite: specifier: ^8.0.16 - version: 8.0.16 - css: - '@alexanderniebuhr/prettier-plugin-unocss': - specifier: ^0.0.4 - version: 0.0.4 - '@unocss/extractor-svelte': - specifier: ^66.7.4 - version: 66.7.4 - '@unocss/preset-icons': - specifier: ^66.7.4 - version: 66.7.4 - '@unocss/preset-web-fonts': - specifier: ^66.7.4 - version: 66.7.4 - '@unocss/preset-wind4': - specifier: ^66.7.4 - version: 66.7.4 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - tailwind-merge: - specifier: ^3.6.0 - version: 3.6.0 - tailwind-variants: - specifier: ^3.2.2 - version: 3.2.2 - tailwindcss: - specifier: ^4.3.2 - version: 4.3.2 - unocss: - specifier: ^66.7.4 - version: 66.7.4 - i18n: - '@inlang/paraglide-js': - specifier: ^2.20.2 - version: 2.20.2 - icons: - '@iconify-json/clarity': - specifier: ^1.2.4 - version: 1.2.4 - '@iconify-json/ic': - specifier: ^1.2.4 - version: 1.2.4 - '@iconify-json/material-icon-theme': - specifier: ^1.2.68 - version: 1.2.68 - '@iconify-json/solar': - specifier: ^1.2.5 - version: 1.2.5 - '@lucide/svelte': - specifier: ^1.22.0 - version: 1.22.0 - libs-back: - class-transformer: - specifier: ^0.5.1 - version: 0.5.1 - class-validator: - specifier: ^0.15.1 - version: 0.15.1 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - micromatch: - specifier: ^4.0.8 - version: 4.0.8 - tar: - specifier: ^7.5.19 - version: 7.5.19 - libs-front: - '@internationalized/date': - specifier: ^3.12.2 - version: 3.12.2 - '@tanstack/svelte-query': - specifier: ^6.1.36 - version: 6.1.36 - formsnap: - specifier: ^2.0.1 - version: 2.0.1 - mode-watcher: - specifier: ^1.1.0 - version: 1.1.0 - svelte-sonner: - specifier: ^1.1.1 - version: 1.1.1 - sveltekit-superforms: - specifier: ^2.30.1 - version: 2.30.1 + version: 8.3.1 + libs: + '@jridgewell/trace-mapping': + specifier: ^0.3.31 + version: 0.3.31 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + ignore: + specifier: ^7.0.5 + version: 7.0.10 + semver: + specifier: ^7.7.3 + version: 7.8.5 zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.5 lint: '@nanoforge-dev/utils-eslint-config': specifier: ^1.0.2 @@ -170,242 +219,1084 @@ catalogs: version: 6.0.2 eslint: specifier: ^10.6.0 - version: 10.6.0 + version: 10.11.0 eslint-plugin-svelte: specifier: ^3.20.0 - version: 3.20.0 + version: 3.23.0 globals: specifier: ^17.7.0 - version: 17.7.0 + version: 17.12.0 prettier: specifier: ^3.9.4 - version: 3.9.4 + version: 3.9.9 prettier-plugin-svelte: specifier: ^4.1.1 version: 4.1.1 typescript-eslint: specifier: ^8.62.1 - version: 8.62.1 + version: 8.70.1 + server: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + prettier: + specifier: ^3.9.4 + version: 3.9.9 + yaml: + specifier: ^2.9.1 + version: 2.9.1 test: '@playwright/test': specifier: ^1.61.1 - version: 1.61.1 + version: 1.63.0 '@vitest/browser-playwright': specifier: ^4.1.9 - version: 4.1.9 + version: 4.1.11 '@vitest/coverage-v8': specifier: ^4.1.9 - version: 4.1.9 + version: 4.1.11 playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.63.0 + version: 1.63.0 vitest: specifier: ^4.1.9 - version: 4.1.9 + version: 4.1.11 vitest-browser-svelte: specifier: ^2.2.0 - version: 2.2.0 + version: 2.2.1 + tooling: + es-module-lexer: + specifier: ^2.0.0 + version: 2.3.2 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:ci + version: 21.2.3(@types/node@26.6.2)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) + '@commitlint/config-conventional': + specifier: catalog:ci + version: 21.2.3 + '@nanoforge-dev/actions': + specifier: catalog:ci + version: 2.1.5 + '@nanoforge-dev/utils-eslint-config': + specifier: catalog:lint + version: 1.0.2(@types/eslint@9.6.1)(eslint@10.11.0(jiti@2.6.1))(prettier@3.9.9)(typescript@6.0.3) + '@nanoforge-dev/utils-prettier-config': + specifier: catalog:lint + version: 1.0.2 + '@trivago/prettier-plugin-sort-imports': + specifier: catalog:lint + version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)))(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@types/node': + specifier: catalog:core + version: 26.6.2 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + eslint: + specifier: catalog:lint + version: 10.11.0(jiti@2.6.1) + eslint-plugin-svelte: + specifier: catalog:lint + version: 3.23.0(eslint@10.11.0(jiti@2.6.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + globals: + specifier: catalog:lint + version: 17.12.0 + husky: + specifier: catalog:ci + version: 9.1.7 + lint-staged: + specifier: catalog:ci + version: 17.5.1 + prettier: + specifier: catalog:lint + version: 3.9.9 + prettier-plugin-svelte: + specifier: catalog:lint + version: 4.1.1(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + turbo: + specifier: catalog:build + version: 2.11.4 + typescript: + specifier: catalog:build + version: 6.0.3 + typescript-eslint: + specifier: catalog:lint + version: 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + apps/editor: + dependencies: + chokidar: + specifier: catalog:server + version: 4.0.3 + prettier: + specifier: catalog:server + version: 3.9.9 + typescript: + specifier: catalog:build + version: 6.0.3 + unrun: + specifier: catalog:build + version: 0.3.1(synckit@0.11.13) + yaml: + specifier: catalog:server + version: 2.9.1 + devDependencies: + '@favware/cliff-jumper': + specifier: catalog:ci + version: 6.1.0 + '@nanoforge-dev/editor-code': + specifier: workspace:* + version: link:../../packages/code + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../../packages/history + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../../packages/kernel + '@nanoforge-dev/editor-layout': + specifier: workspace:* + version: link:../../packages/layout + '@nanoforge-dev/editor-plugin-code-editor': + specifier: workspace:* + version: link:../../plugins/code-editor + '@nanoforge-dev/editor-plugin-command-palette': + specifier: workspace:* + version: link:../../plugins/command-palette + '@nanoforge-dev/editor-plugin-console': + specifier: workspace:* + version: link:../../plugins/console + '@nanoforge-dev/editor-plugin-ecs': + specifier: workspace:* + version: link:../../plugins/ecs + '@nanoforge-dev/editor-plugin-file-manager': + specifier: workspace:* + version: link:../../plugins/file-manager + '@nanoforge-dev/editor-plugin-git': + specifier: workspace:* + version: link:../../plugins/git + '@nanoforge-dev/editor-plugin-history-panel': + specifier: workspace:* + version: link:../../plugins/history-panel + '@nanoforge-dev/editor-plugin-inspectors': + specifier: workspace:* + version: link:../../plugins/inspectors + '@nanoforge-dev/editor-plugin-packages': + specifier: workspace:* + version: link:../../plugins/packages + '@nanoforge-dev/editor-plugin-scene': + specifier: workspace:* + version: link:../../plugins/scene + '@nanoforge-dev/editor-plugin-settings-ui': + specifier: workspace:* + version: link:../../plugins/settings-ui + '@nanoforge-dev/editor-plugin-viewport': + specifier: workspace:* + version: link:../../plugins/viewport + '@nanoforge-dev/editor-project': + specifier: workspace:* + version: link:../../packages/project + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../../packages/protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../../packages/rpc + '@nanoforge-dev/editor-runtime': + specifier: workspace:* + version: link:../../packages/runtime + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../../packages/server-core + '@nanoforge-dev/editor-settings': + specifier: workspace:* + version: link:../../packages/settings + '@nanoforge-dev/editor-ui': + specifier: workspace:* + version: link:../../packages/ui + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@playwright/test': + specifier: catalog:test + version: 1.63.0 + '@sveltejs/adapter-static': + specifier: catalog:adapter + version: 3.0.10(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))) + '@sveltejs/kit': + specifier: catalog:core + version: 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + '@types/bun': + specifier: catalog:core + version: 1.4.2 + playwright: + specifier: catalog:test + version: 1.63.0 + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + zod: + specifier: catalog:libs + version: 4.6.5 + + packages/code: + dependencies: + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../history + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-meta': + specifier: workspace:* + version: link:../meta + '@nanoforge-dev/editor-project': + specifier: workspace:* + version: link:../project + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + ts-morph: + specifier: catalog:code + version: 28.0.0 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../server-core + '@types/node': + specifier: catalog:core + version: 26.6.2 + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/history: + dependencies: + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + devDependencies: + '@nanoforge-dev/editor-project': + specifier: workspace:* + version: link:../project + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../server-core + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/kernel: + dependencies: + semver: + specifier: catalog:libs + version: 7.8.5 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@types/semver': + specifier: catalog:libs + version: 7.8.0 + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/layout: + dependencies: + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/meta: + dependencies: + ts-morph: + specifier: catalog:code + version: 28.0.0 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/project: + dependencies: + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + '@nanoforge-dev/registry': + specifier: workspace:* + version: link:../registry + devDependencies: + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../server-core + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + zod: + specifier: catalog:libs + version: 4.6.5 + + packages/protocol: + dependencies: + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + '@nanoforge-dev/registry': + specifier: workspace:* + version: link:../registry + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/registry: + dependencies: + fflate: + specifier: catalog:libs + version: 0.8.3 + semver: + specifier: catalog:libs + version: 7.8.5 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@types/node': + specifier: catalog:core + version: 26.6.2 + '@types/semver': + specifier: catalog:libs + version: 7.8.0 + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/rpc: + dependencies: + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/runtime: + dependencies: + '@jridgewell/trace-mapping': + specifier: catalog:libs + version: 0.3.31 + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + '@nanoforge-dev/editor-settings': + specifier: workspace:* + version: link:../settings + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../server-core + '@types/node': + specifier: catalog:core + version: 26.6.2 + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/sdk: + dependencies: + '@nanoforge-dev/editor-code': + specifier: workspace:* + version: link:../code + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../history + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-meta': + specifier: workspace:* + version: link:../meta + '@nanoforge-dev/editor-project': + specifier: workspace:* + version: link:../project + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + '@nanoforge-dev/editor-runtime': + specifier: workspace:* + version: link:../runtime + '@nanoforge-dev/editor-settings': + specifier: workspace:* + version: link:../settings + '@nanoforge-dev/editor-ui': + specifier: workspace:* + version: link:../ui + devDependencies: + '@favware/cliff-jumper': + specifier: catalog:ci + version: 6.1.0 + tsdown: + specifier: catalog:build + version: 0.22.14(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)) + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/server-core: + dependencies: + '@nanoforge-dev/config': + specifier: catalog:config + version: 2.3.0(synckit@0.11.13) + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + '@nanoforge-dev/registry': + specifier: workspace:* + version: link:../registry + chokidar: + specifier: catalog:server + version: 4.0.3 + fflate: + specifier: catalog:libs + version: 0.8.3 + ignore: + specifier: catalog:libs + version: 7.0.10 + prettier: + specifier: catalog:server + version: 3.9.9 + semver: + specifier: catalog:libs + version: 7.8.5 + typescript: + specifier: catalog:build + version: 6.0.3 + unrun: + specifier: catalog:build + version: 0.3.1(synckit@0.11.13) + yaml: + specifier: catalog:server + version: 2.9.1 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@types/bun': + specifier: catalog:core + version: 1.4.2 + '@types/node': + specifier: catalog:core + version: 26.6.2 + '@types/semver': + specifier: catalog:libs + version: 7.8.0 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/settings: + dependencies: + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-project': + specifier: workspace:* + version: link:../project + '@nanoforge-dev/editor-protocol': + specifier: workspace:* + version: link:../protocol + '@nanoforge-dev/editor-rpc': + specifier: workspace:* + version: link:../rpc + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@nanoforge-dev/editor-server-core': + specifier: workspace:* + version: link:../server-core + typescript: + specifier: catalog:build + version: 6.0.3 + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/ui: + dependencies: + '@fontsource/instrument-sans': + specifier: catalog:components + version: 5.3.0 + '@fontsource/jetbrains-mono': + specifier: catalog:components + version: 5.3.0 + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../history + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../kernel + '@nanoforge-dev/editor-layout': + specifier: workspace:* + version: link:../layout + '@nanoforge-dev/editor-settings': + specifier: workspace:* + version: link:../settings + bits-ui: + specifier: catalog:components + version: 2.19.3(@internationalized/date@3.12.4)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + '@vitest/browser-playwright': + specifier: catalog:test + version: 4.1.11(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11) + playwright: + specifier: catalog:test + version: 1.63.0 + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + vitest-browser-svelte: + specifier: catalog:test + version: 2.2.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vitest@4.1.11) + + plugins/code-editor: + devDependencies: + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + monaco-editor: + specifier: catalog:components + version: 0.55.1 + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/command-palette: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) -importers: + plugins/console: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) - .: - dependencies: - bun: + plugins/ecs: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-code': + specifier: workspace:* + version: link:../../packages/code + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../../packages/history + '@nanoforge-dev/editor-meta': + specifier: workspace:* + version: link:../../packages/meta + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: specifier: catalog:core - version: 1.3.14 - class-transformer: - specifier: catalog:libs-back - version: 0.5.1 - class-validator: - specifier: catalog:libs-back - version: 0.15.1 - dotenv: - specifier: catalog:libs-back - version: 17.4.2 - micromatch: - specifier: catalog:libs-back - version: 4.0.8 - tar: - specifier: catalog:libs-back - version: 7.5.19 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + ts-morph: + specifier: catalog:code + version: 28.0.0 typescript: specifier: catalog:build version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/file-manager: devDependencies: - '@alexanderniebuhr/prettier-plugin-unocss': - specifier: catalog:css - version: 0.0.4 - '@commitlint/cli': - specifier: catalog:ci - version: 21.2.0(@types/node@25.9.3)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) - '@commitlint/config-conventional': - specifier: catalog:ci - version: 21.2.0 - '@favware/cliff-jumper': - specifier: catalog:ci - version: 6.1.0 - '@iconify-json/clarity': - specifier: catalog:icons - version: 1.2.4 - '@iconify-json/ic': - specifier: catalog:icons - version: 1.2.4 - '@iconify-json/icomoon-free': - specifier: ^1.2.1 - version: 1.2.1 - '@iconify-json/material-icon-theme': - specifier: catalog:icons - version: 1.2.68 - '@iconify-json/solar': - specifier: catalog:icons - version: 1.2.5 - '@inlang/paraglide-js': - specifier: catalog:i18n - version: 2.20.2(typescript@6.0.3) - '@internationalized/date': - specifier: catalog:libs-front - version: 3.12.2 '@lucide/svelte': - specifier: catalog:icons - version: 1.22.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - '@nanoforge-dev/actions': - specifier: catalog:ci - version: 2.1.5 - '@nanoforge-dev/ecs-lib': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': specifier: catalog:core - version: 1.4.0 - '@nanoforge-dev/utils-eslint-config': - specifier: catalog:lint - version: 1.0.2(@types/eslint@9.6.1)(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4)(typescript@6.0.3) - '@nanoforge-dev/utils-prettier-config': - specifier: catalog:lint - version: 1.0.2 - '@playwright/test': + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + ignore: + specifier: catalog:libs + version: 7.0.10 + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + + plugins/git: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: specifier: catalog:test - version: 1.61.1 - '@sveltejs/kit': + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/history-panel: + devDependencies: + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: specifier: catalog:core - version: 2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + + plugins/inspectors: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin '@sveltejs/vite-plugin-svelte': specifier: catalog:core - version: 7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - '@tanstack/svelte-query': - specifier: catalog:libs-front - version: 6.1.36(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - '@trivago/prettier-plugin-sort-imports': - specifier: catalog:lint - version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)))(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - '@tsconfig/svelte': + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: specifier: catalog:build - version: 5.0.8 - '@types/bun': + version: 6.0.3 + vite: specifier: catalog:core - version: 1.3.14 - '@types/micromatch': - specifier: ^4.0.10 - version: 4.0.10 - '@unocss/extractor-svelte': - specifier: catalog:css - version: 66.7.4 - '@unocss/preset-icons': - specifier: catalog:css - version: 66.7.4 - '@unocss/preset-web-fonts': - specifier: catalog:css - version: 66.7.4 - '@unocss/preset-wind4': - specifier: catalog:css - version: 66.7.4 - '@vitest/browser-playwright': + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: specifier: catalog:test - version: 4.1.9(playwright@1.61.1)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/coverage-v8': + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/packages: + devDependencies: + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: specifier: catalog:test - version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) - bits-ui: - specifier: catalog:components - version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - clsx: - specifier: catalog:css - version: 2.1.1 - eslint: - specifier: catalog:lint - version: 10.6.0(jiti@2.7.0) - eslint-plugin-svelte: - specifier: catalog:lint - version: 3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - formsnap: - specifier: catalog:libs-front - version: 2.0.1(svelte@5.56.3(@typescript-eslint/types@8.62.1))(sveltekit-superforms@2.30.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)) - globals: - specifier: catalog:lint - version: 17.7.0 - husky: - specifier: catalog:ci - version: 9.1.7 - lint-staged: - specifier: catalog:ci - version: 17.0.8 - mode-watcher: - specifier: catalog:libs-front - version: 1.1.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - monaco-editor: + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/scene: + devDependencies: + '@lucide/svelte': specifier: catalog:components - version: 0.55.1 - playwright: + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-code': + specifier: workspace:* + version: link:../../packages/code + '@nanoforge-dev/editor-history': + specifier: workspace:* + version: link:../../packages/history + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + ts-morph: + specifier: catalog:code + version: 28.0.0 + typescript: + specifier: catalog:build + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: specifier: catalog:test - version: 1.61.1 - prettier: - specifier: catalog:lint - version: 3.9.4 - prettier-plugin-svelte: - specifier: catalog:lint - version: 4.1.1(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)) + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/settings-ui: + devDependencies: + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) svelte: specifier: catalog:core - version: 5.56.3(@typescript-eslint/types@8.62.1) - svelte-adapter-bun: + version: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-check: + specifier: catalog:core + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: specifier: catalog:build - version: 1.0.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3) + version: 6.0.3 + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: catalog:test + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + + plugins/viewport: + devDependencies: + '@lucide/svelte': + specifier: catalog:components + version: 1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../../tooling/vite-plugin + '@sveltejs/vite-plugin-svelte': + specifier: catalog:core + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) svelte-check: specifier: catalog:core - version: 4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3) - svelte-kit-sessions: + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3) + typescript: + specifier: catalog:build + version: 6.0.3 + vite: specifier: catalog:core - version: 0.4.0(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte-sonner: - specifier: catalog:libs-front - version: 1.1.1(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - sveltekit-superforms: - specifier: catalog:libs-front - version: 2.30.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3) - tailwind-merge: - specifier: catalog:css - version: 3.6.0 - tailwind-variants: - specifier: catalog:css - version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2) - tailwindcss: - specifier: catalog:css - version: 4.3.2 - typescript-eslint: - specifier: catalog:lint - version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - unocss: - specifier: catalog:css - version: 66.7.4(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + + tooling/example-plugin: + devDependencies: + '@nanoforge-dev/editor-sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@nanoforge-dev/editor-vite-plugin': + specifier: workspace:* + version: link:../vite-plugin + svelte: + specifier: catalog:core + version: 5.57.1(@typescript-eslint/types@8.70.1) + vite: + specifier: catalog:core + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + + tooling/vite-plugin: + dependencies: + es-module-lexer: + specifier: catalog:tooling + version: 2.3.2 + semver: + specifier: catalog:libs + version: 7.8.5 + zod: + specifier: catalog:libs + version: 4.6.5 + devDependencies: + '@favware/cliff-jumper': + specifier: catalog:ci + version: 6.1.0 + '@nanoforge-dev/editor-kernel': + specifier: workspace:* + version: link:../../packages/kernel + '@types/node': + specifier: catalog:core + version: 26.6.2 + tsdown: + specifier: catalog:build + version: 0.22.14(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)) + typescript: + specifier: catalog:build + version: 6.0.3 vite: specifier: catalog:core - version: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) vitest: specifier: catalog:test - version: 4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - vitest-browser-svelte: - specifier: catalog:test - version: 2.2.0(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vitest@4.1.9) - zod: - specifier: catalog:libs-front - version: 4.4.3 + version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) packages: @@ -427,24 +1318,12 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - '@alexanderniebuhr/prettier-plugin-unocss@0.0.4': - resolution: {integrity: sha512-uuuNFjp+WNGsP7V5C7F7uot4a8imccsRf9DYXuQquzXzxLEfzWfqYy/cZUNUk4+fOpLbXGlPUxrJvPAcPau0Ow==} - - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - - '@ark/schema@0.56.0': - resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} - - '@ark/util@0.56.0': - resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.29.7': @@ -459,25 +1338,21 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -487,61 +1362,67 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} - '@commitlint/cli@21.2.0': - resolution: {integrity: sha512-4GLVIhUaT3c3GBlQ0GB80/5H3xXdn/Tgw4lrsuoOQVDu2wl4Xw0GuzSar8xZKSMv4H3xaKaQXmvH91GmdyYBZA==} + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@commitlint/cli@21.2.3': + resolution: {integrity: sha512-5yOX6IRjcrIReCxpyAd04hgI/yhUMpVBJ/ouwFV0wk9yx5+fYRDWZWJ5h55Tbedgfol+5m2mU5nqzbB8g3v0vQ==} engines: {node: '>=22.12.0'} hasBin: true - '@commitlint/config-conventional@21.2.0': - resolution: {integrity: sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==} + '@commitlint/config-conventional@21.2.3': + resolution: {integrity: sha512-EP2n5DN2GwRWNVrLX0Jx3mpQlW5PfFi9HnD9OJ8hN/H0xFqpCFyHeGDhg+0P8/21iMMcdkcQgbNqmVklebqYsQ==} engines: {node: '>=22.12.0'} - '@commitlint/config-validator@21.2.0': - resolution: {integrity: sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==} + '@commitlint/config-validator@21.2.3': + resolution: {integrity: sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw==} engines: {node: '>=22.12.0'} - '@commitlint/ensure@21.2.0': - resolution: {integrity: sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==} + '@commitlint/ensure@21.2.3': + resolution: {integrity: sha512-gEjNKgRocbODI5/sMx2Qc9XOf2/zFTNV2aEK1QZ4gKDKxDC/VR7us3QtIX1C5d5xxHxx0I/tEbYbdSP1Am2LwA==} engines: {node: '>=22.12.0'} '@commitlint/execute-rule@21.0.1': resolution: {integrity: sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==} engines: {node: '>=22.12.0'} - '@commitlint/format@21.2.0': - resolution: {integrity: sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==} + '@commitlint/format@21.2.3': + resolution: {integrity: sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ==} engines: {node: '>=22.12.0'} - '@commitlint/is-ignored@21.2.0': - resolution: {integrity: sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==} + '@commitlint/is-ignored@21.2.3': + resolution: {integrity: sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w==} engines: {node: '>=22.12.0'} - '@commitlint/lint@21.2.0': - resolution: {integrity: sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==} + '@commitlint/lint@21.2.3': + resolution: {integrity: sha512-lUZcSuVLLEDkhge9ERk8Rp+FXQtrUhCK+h328BFnzKXrTuXGdqiYMEpTlNWdZaOrjyMhMJaz354ZaWEGJxcLeg==} engines: {node: '>=22.12.0'} - '@commitlint/load@21.2.0': - resolution: {integrity: sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==} + '@commitlint/load@21.2.3': + resolution: {integrity: sha512-cDrL8lOo23Lk7knGzQdjbFcq7z2JgvjwV6PhbESVvEc+Fe21MeLjPKBJi8nKjg7j+jh79CVbVSUbZc104thXRw==} engines: {node: '>=22.12.0'} '@commitlint/message@21.2.0': resolution: {integrity: sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==} engines: {node: '>=22.12.0'} - '@commitlint/parse@21.2.0': - resolution: {integrity: sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==} + '@commitlint/parse@21.2.3': + resolution: {integrity: sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w==} engines: {node: '>=22.12.0'} - '@commitlint/read@21.2.0': - resolution: {integrity: sha512-ELx8Ovh/JoAw5lpvDgxc6Y0We9skf2IPI2RFN+gnYgDGjRdMSF8zeodxhZmcclLWzfUIF7hXLOa8gOlllYcvBA==} + '@commitlint/read@21.2.3': + resolution: {integrity: sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og==} engines: {node: '>=22.12.0'} - '@commitlint/resolve-extends@21.2.0': - resolution: {integrity: sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==} + '@commitlint/resolve-extends@21.2.3': + resolution: {integrity: sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA==} engines: {node: '>=22.12.0'} - '@commitlint/rules@21.2.0': - resolution: {integrity: sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==} + '@commitlint/rules@21.2.3': + resolution: {integrity: sha512-5+YnI/LuTx3JDl9GjqfP2z9x96M8KKDrd+4jOel/T0L1BB3oAmBlSN8xppf63QsudiVEgR8C0gRoAMrPVrms6g==} engines: {node: '>=22.12.0'} '@commitlint/to-lines@21.0.1': @@ -552,8 +1433,8 @@ packages: resolution: {integrity: sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==} engines: {node: '>=22.12.0'} - '@commitlint/types@21.2.0': - resolution: {integrity: sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==} + '@commitlint/types@21.2.3': + resolution: {integrity: sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ==} engines: {node: '>=22.12.0'} '@conventional-changelog/git-client@2.7.0': @@ -568,8 +1449,20 @@ packages: conventional-commits-parser: optional: true - '@conventional-changelog/template@1.2.0': - resolution: {integrity: sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==} + '@conventional-changelog/git-client@3.1.2': + resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} + engines: {node: '>=22'} + peerDependencies: + conventional-commits-filter: ^6.0.1 + conventional-commits-parser: ^7.1.2 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@conventional-changelog/template@1.4.0': + resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==} engines: {node: '>=22'} '@dprint/formatter@0.5.1': @@ -581,26 +1474,8 @@ packages: '@dprint/toml@0.7.0': resolution: {integrity: sha512-eFaQTcfxKHB+YyTh83x7GEv+gDPuj9q5NFOTaoj5rZmQTbj6OgjjMxUicmS1R8zYcx8YAq5oA9J3YFa5U6x2gA==} - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/core@1.11.0': - resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/runtime@1.11.0': - resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -613,29 +1488,26 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exodus/schemasafe@1.3.0': - resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} - '@favware/cliff-jumper@6.1.0': resolution: {integrity: sha512-BTuJ3evIMbkXgcQSk9K9B2vAuENi0bzS6qhtKLx6ZO/LTUIuoJfG05IsLqTngwBsJ6ZGnN3dXYGK/ANVMT8Xrw==} engines: {node: '>=v18'} @@ -645,20 +1517,20 @@ packages: resolution: {integrity: sha512-PPYtcLzhSafdylp8NBOxMCYIcLqTUMNiQc7ciBoAIvxNG2egM+P7e2nNPui5+Svyk89Q+Tnbrp139ZRIIBw3IA==} engines: {node: '>=v16'} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@hapi/hoek@9.3.0': - resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + '@fontsource/instrument-sans@5.3.0': + resolution: {integrity: sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==} - '@hapi/topo@5.1.0': - resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@fontsource/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -678,55 +1550,10 @@ packages: '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@iconify-json/clarity@1.2.4': - resolution: {integrity: sha512-9hQ405dgvK2b2BWQdAIb/+ayH02k5VE4lKw2456wpjtq+57shHXE4Kk8bFa7RwchQHFuTtJBItVhlU4kjejxDw==} - - '@iconify-json/ic@1.2.4': - resolution: {integrity: sha512-pzPMmrZrBQuwT7nmtrYdkttun8KalRGgZPIL1Ny9KpF2zjRGIUPN+npTfuD3lrgO/OnSwAoJWuekQwBpt/Cqrw==} - - '@iconify-json/icomoon-free@1.2.1': - resolution: {integrity: sha512-Me5L0/vH/LlhVr5R85TFah0LcJD7an6mv+5dagvXY/YnXQsOx3eB7GUioXheSoB8tObhvCuZFbvujMYKPKNWfw==} - - '@iconify-json/material-icon-theme@1.2.68': - resolution: {integrity: sha512-08ZX1L3Lt1lKvcflvp57ZTpXwpMBtr1KI2CxTRQ1ryPkiDI3WacoSXVfE7/nO7CeV9RewfegjfgsPbidF1BxLA==} - - '@iconify-json/solar@1.2.5': - resolution: {integrity: sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA==} - - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - - '@iconify/utils@3.1.3': - resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} - - '@inlang/paraglide-js@2.20.2': - resolution: {integrity: sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg==} - hasBin: true - peerDependencies: - typescript: '>=5.6' - peerDependenciesMeta: - typescript: - optional: true - - '@inlang/recommend-sherlock@0.2.1': - resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} - - '@inlang/sdk@2.10.0': - resolution: {integrity: sha512-xLVRHAI9Jw6L/NrlK6s1mXCvbFwzk5INEL4i3By4jW9yjIMchJ+JGSw4WgFpiLlcoBuR/qIMmSn6te/Jozuybg==} - engines: {node: '>=20.0.0'} - - '@internationalized/date@3.12.2': - resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} + engines: {node: '>=18.18'} - '@isaacs/ttlcache@1.4.1': - resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} - engines: {node: '>=12'} + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -738,21 +1565,23 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lix-js/sdk@0.4.10': - resolution: {integrity: sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==} - engines: {node: '>=18'} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 - '@lix-js/server-protocol-schema@0.1.1': - resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@lucide/svelte@1.22.0': - resolution: {integrity: sha512-eaNC3GGu9ma7mviB9vPL6OnawXqxdvRnoAQSq5l15mBlsuwD7kozZ7pzPXSlT6OwSl7hz4qTk+ZU3OEewwi5gQ==} + '@lucide/svelte@1.48.0': + resolution: {integrity: sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==} peerDependencies: svelte: ^5 @@ -760,13 +1589,9 @@ packages: resolution: {integrity: sha512-aXFw1PGS8NmSEGAIJ1g5X+0kAiaWYPOvxkLttmKR1qO95O17mcpkKXfTdoSIyFnqPF2YnZjq9KR7Cvs27fNewg==} engines: {node: '25'} - '@nanoforge-dev/common@1.4.0': - resolution: {integrity: sha512-B26XC1iG8l7VWD+xsNBLPhde+ZH6Rf59cUnLVIYJAvGxtjbOhupe+jLW8SrCaMwm7PrPrrSRKAChOrx5lP0Wcg==} - engines: {node: '25'} - - '@nanoforge-dev/ecs-lib@1.4.0': - resolution: {integrity: sha512-CAei/D0in14J7lw64xwKSl+i1v36BGS54ieySPcHP+k5t7bnpcNG9OPenzZzhaQb2KX7m45W+BCLyOekMvmqSA==} - engines: {node: '25'} + '@nanoforge-dev/config@2.3.0': + resolution: {integrity: sha512-+RKaLm9NnT+ddbA1GVjhNOi3y9qW9/Hc0sU1bozYA/MLPnTP4EwMfuIxl0R3sackGsu2EO9Zqu8TPgt87JWdFg==} + engines: {node: '26'} '@nanoforge-dev/utils-eslint-config@1.0.2': resolution: {integrity: sha512-hIsVGGpVIW3RvPZocWEf15Y5eqmIxTtLfWI66wqx+AO6PHcatUBhEi4sk1i54Qb2yGFueRBS/dSXj0kxG4Rbcw==} @@ -776,31 +1601,31 @@ packages: resolution: {integrity: sha512-dTa2ixPJmWVD+L13Iuxx51JIm3gaX+gnRCe8jA0PFw0xjMjLYv+0ZgbWSSVlNmowp6fqpOzfPdWADkkPNQs8LQ==} engines: {node: '25'} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} - '@octokit/core@7.0.6': - resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + '@octokit/core@7.0.8': + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} engines: {node: '>= 20'} - '@octokit/endpoint@11.0.3': - resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + '@octokit/endpoint@11.0.5': + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} engines: {node: '>= 20'} - '@octokit/graphql@9.0.3': - resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + '@octokit/graphql@9.0.5': + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} engines: {node: '>= 20'} '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@octokit/openapi-types@29.0.1': + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} + '@octokit/plugin-paginate-rest@14.0.0': resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} engines: {node: '>= 20'} @@ -813,238 +1638,31 @@ packages: peerDependencies: '@octokit/core': '>=6' - '@octokit/plugin-retry@8.1.0': - resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} + '@octokit/plugin-retry@8.1.1': + resolution: {integrity: sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==} engines: {node: '>= 20'} peerDependencies: '@octokit/core': '>=7' - '@octokit/request-error@7.1.0': - resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + '@octokit/request-error@7.1.2': + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} engines: {node: '>= 20'} - '@octokit/request@10.0.10': - resolution: {integrity: sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==} + '@octokit/request@10.0.16': + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} engines: {node: '>= 20'} '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@oven/bun-darwin-aarch64@1.3.14': - resolution: {integrity: sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A==} - cpu: [arm64] - os: [darwin] - - '@oven/bun-darwin-x64-baseline@1.3.14': - resolution: {integrity: sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g==} - cpu: [x64] - os: [darwin] - - '@oven/bun-darwin-x64@1.3.14': - resolution: {integrity: sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w==} - cpu: [x64] - os: [darwin] - - '@oven/bun-freebsd-aarch64@1.3.14': - resolution: {integrity: sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw==} - cpu: [arm64] - os: [freebsd] - - '@oven/bun-freebsd-x64@1.3.14': - resolution: {integrity: sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw==} - cpu: [x64] - os: [freebsd] - - '@oven/bun-linux-aarch64-android@1.3.14': - resolution: {integrity: sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA==} - cpu: [arm64] - os: [android] - - '@oven/bun-linux-aarch64-musl@1.3.14': - resolution: {integrity: sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA==} - cpu: [arm64] - os: [linux] - - '@oven/bun-linux-aarch64@1.3.14': - resolution: {integrity: sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw==} - cpu: [arm64] - os: [linux] - - '@oven/bun-linux-x64-android@1.3.14': - resolution: {integrity: sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg==} - cpu: [x64] - os: [android] - - '@oven/bun-linux-x64-baseline@1.3.14': - resolution: {integrity: sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA==} - cpu: [x64] - os: [linux] - - '@oven/bun-linux-x64-musl-baseline@1.3.14': - resolution: {integrity: sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw==} - cpu: [x64] - os: [linux] - - '@oven/bun-linux-x64-musl@1.3.14': - resolution: {integrity: sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg==} - cpu: [x64] - os: [linux] - - '@oven/bun-linux-x64@1.3.14': - resolution: {integrity: sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw==} - cpu: [x64] - os: [linux] - - '@oven/bun-windows-aarch64@1.3.14': - resolution: {integrity: sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA==} - cpu: [arm64] - os: [win32] - - '@oven/bun-windows-x64-baseline@1.3.14': - resolution: {integrity: sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw==} - cpu: [x64] - os: [win32] - - '@oven/bun-windows-x64@1.3.14': - resolution: {integrity: sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg==} - cpu: [x64] - os: [win32] - - '@oxc-parser/binding-android-arm-eabi@0.131.0': - resolution: {integrity: sha512-t2xicr9pfzkSRYx5aPqZqlLaayIwJTqgQ81Jor31Xep2nGyL2Aq3d0K5wOfeR7VevaSdxaS9dzSQP9xDwn8fDg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxc-parser/binding-android-arm64@0.131.0': - resolution: {integrity: sha512-nlGIod6gw75x1aEDgLS+srj+JRGY0HHm9MI9YgzE/B64l6d6+H3MSP9NOgp0+HTg8tp4vV9rVfgQGgd+TfVZcA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-parser/binding-darwin-arm64@0.131.0': - resolution: {integrity: sha512-jukuV6xe5RbQKFo7QD34NDCLDZp4PSOm8rmckhNdH/60ymG5zXbDzGBEyc+nTkuLQNama2aSGCt+CPfpjNTqyw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-parser/binding-darwin-x64@0.131.0': - resolution: {integrity: sha512-g3JOo4khe9rslHm5WYaVDWb0HS/M1MLR3I9S8560MkKIcC96VQY00QjOlsuRyfSj/JDXj8i9T7ryPO2RidiXVg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-parser/binding-freebsd-x64@0.131.0': - resolution: {integrity: sha512-1hziITDTxjMePnX+dR9ocVT+EuZkQ8wm4FPAbmbEiKG+Phbo73J1ZnPAA6Y/aGsWF3McOFnQuZIktAFwalkfJQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-parser/binding-linux-arm-gnueabihf@0.131.0': - resolution: {integrity: sha512-9uRxfXwyKG9+MwmGQBo2ncPNwZH5HTmCETFM2WiuDBNDCW4NC5ttSQkwCAMrTAWgwMzVBH1CP8pM0v7nebCWXQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm-musleabihf@0.131.0': - resolution: {integrity: sha512-mgbLvzRShXOLBdWGInf08Af4q+pfj1xD8hSgLClDZ9of/BXkB6+LIhTH7fihiDUipqB3yoSkKBWaZ3Ejlf5Yag==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm64-gnu@0.131.0': - resolution: {integrity: sha512-OPT8++4aN6j2GJ8+3IZHS/byXoZP4aSBn+FoG6rgBJ2fKwPKXWF3MqrFMNW7NKHM28FLY579xYLxJSfgobEqPA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-arm64-musl@0.131.0': - resolution: {integrity: sha512-vtPiwmfVTAXzaxDKsOXG+LwgRAA7WEnaeHzhS5z0GE89gAK18KSXnly7Z6saXXq6L3dVMyK44uoTI03zKxrpmw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-ppc64-gnu@0.131.0': - resolution: {integrity: sha512-8AW8L7w5cGHSdZPcyZX2yR0+GUODsT15rbRjfdD54rv6DMbtuEB19ysLOpKJlRGfH6UNYNpCHaU1uJWgTWf1/w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-gnu@0.131.0': - resolution: {integrity: sha512-vvpjkjEOUsPcsYf8evE4MO3aGx9+3wodXEBOicGNnOwTuAik8eBONNkgSdhkGsAblQmfVHJyanRnpxglddTXIA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-musl@0.131.0': - resolution: {integrity: sha512-AqmcNC3fClXX+fxQ6VGEN1667xVFiRBkY0CZmDMSiaeFUsv1+UkBPYYi48IUKcA9/ivvoKNRzQl2I4//kT9F/w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-s390x-gnu@0.131.0': - resolution: {integrity: sha512-7d3jOMKy7RSQCcDLIci+ySll2FgsOMl/GiRux4q2JNv0zg4EdhFISa9idvrdN/HEUIQQJNg6dmveUeJl2YErGA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-gnu@0.131.0': - resolution: {integrity: sha512-JHK/h95qVqVQ+ITER837kcTdwBDFpFaNnOTYGCP0zdUSX/mLKC7tXOoyrTb6vG7iRPwGlcgBil3v2IjYw1FqJA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-musl@0.131.0': - resolution: {integrity: sha512-b2BO82O8azXAyf7EUgOPKu145nWypbNyk07HbU09fkzhm9lEA5oPvaN/M8Nlo7tOErVTa2WOgS4QbOnxAPXdDQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-openharmony-arm64@0.131.0': - resolution: {integrity: sha512-GHO9glZaX7LkX/OGfluEPf1yjg+ehiFbUdowbX6uNWOQhmwKWU4m4+nZ9FJkrHNKuxyI1KKertMdGjVKCApKWA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxc-parser/binding-wasm32-wasi@0.131.0': - resolution: {integrity: sha512-3SkikPaEFoih1N83qLVEDLRLeY4nYsf6JT9SnWiMCQ5lGQdKup6bEuKCqkRiG9dD1IIaFeYz9RjlciPmYoFIWA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.131.0': - resolution: {integrity: sha512-Os5bEhryeA2jkH+ZrnZyAC1EP5gs+X4YB1Fjqml7UPD5kU7ecsK1MPEVMfCrdt/GDNpDbavYXiOXOdyJ5b3OPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-parser/binding-win32-ia32-msvc@0.131.0': - resolution: {integrity: sha512-m+jNz9EuF0NXoiptc6B9h5yompZQVW/a5MJeOu5zojfH5yWk82tvF2ccrHkfhgtrS9h9DD5l1Qv8dWlfY7Nz8g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxc-parser/binding-win32-x64-msvc@0.131.0': - resolution: {integrity: sha512-o14Hk8dAyiEUMFEWEgmAwFZvBt1RzAYLM3xeQ+5315JXgVYhoemivgYcbYVRbsFkS71ShMGlAFE0kPnr460rww==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxc-project/types@0.131.0': - resolution: {integrity: sha512-PgnWDfV0h+b16XNKbXU7Daib/BFSt/J2mEzfYIBu6JB/wNdlU+kVYXCkGA1A9fWkTbOgbjh4e6NhPeQOYvFhEA==} + '@octokit/types@17.0.0': + resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@octokit/types@18.0.0': + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} - '@oxc-project/types@0.135.0': - resolution: {integrity: sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} '@oxfmt/binding-android-arm-eabi@0.35.0': resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} @@ -1172,206 +1790,109 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} hasBin: true '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@poppinss/macroable@1.1.2': - resolution: {integrity: sha512-FAVBRzzWhYP5mA3lCwLH1A0fKBqq5anyjGet90Z81aRK5c/+LTGUE1zJhZrErjaenBSOOI9BVUs3WVmotneFQA==} + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} - '@quansync/fs@1.0.0': - resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] + cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.1.1': - resolution: {integrity: sha512-BLf9Wak/gfwVb7NQTQW4wBgL3oAfPy7ArEkhwV543OVw/uY6B47z5xYsqPSZ9PDOorvURPinws6ThaFuNgGLgA==} + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-arm64@1.1.1': - resolution: {integrity: sha512-rRZRPy/Ynb+Mxu0O6tfPldHeDgAn0sRij+IOUy6sFdUlv3hArGW/DloE3GfAxtqpOJuRNgF74Nr5gM4xBeU2jQ==} + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.1.1': - resolution: {integrity: sha512-/MtefPxhKPyWWFM8L45OWiEqRf+eSU2Qv9ZAyTaoZOoGcoPKxbbhjTJO2/U2IThv0uDZ4NWHc3/oTsR6IEOtww==} + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-freebsd-x64@1.1.1': - resolution: {integrity: sha512-202K+cpIi1kx/Zn7AtxBi4LTXSY67Aszb2K9rNsuW7FeBeh0nqoNmYLOSZidV0p88VPBzMmTZcHAdPNo3kRYzQ==} + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm-gnueabihf@1.1.1': - resolution: {integrity: sha512-wl9NfeXNUwrXtUc063tddmZFUI6qiNs1CNOwni0OL4vC7MqVSYugra3ZgtDmtVy8e0DluJTENmzIv2BwqLzT4Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-gnu@1.1.1': - resolution: {integrity: sha512-at2EO4o7D/PJLC4Xik16bU4CcjQE2tSv1LfqMA0TRYQYQihRm3gZeDB8xaX28A9SFedibcAk5DeMCKt4REKG0A==} + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-arm64-musl@1.1.1': - resolution: {integrity: sha512-5PUjZx366h9tkJTPJF5eibxOlK3sGoeRiBJLLjjEB5/kLDuhr6qB3LkhqLz1smXNgsX+pBhnbcJBrPE30HznAA==} + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-ppc64-gnu@1.1.1': - resolution: {integrity: sha512-1WK84XPeio3tjP1sM/TMXiC0G1i1iq1qGZ71KfNQjEFLU1kwD+Cv5T8nGySg/JUFwLbaScu6ve9DmeXlmqpkFA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.1': - resolution: {integrity: sha512-1nS1X5z1uMJ369RU25hTpKCFvUwXZp12dIzlzk4S+UxCTcSVGsAE6tzkOSufv/7jnmAtK0ZlrsJxh2fGmsnVSw==} + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.1': - resolution: {integrity: sha512-NwX/wspnq4vYyMFsqbYvzums3ki/Tk8FZbMzMAovPDp3OfLeYKby/D+9osokadXuYEV3OvpeHlwnr/bG8QMixA==} + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-musl@1.1.1': - resolution: {integrity: sha512-+n46LhDrJFQM+229y4oXtVpj1G50U/+XuHMlpnisFTEXhrg9f/YIjp/HymX+PVJjBEr7XHRs3CFLelV464pqwA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-openharmony-arm64@1.1.1': - resolution: {integrity: sha512-qGwEu47zOWYo7LdRHhCWTNhzwGtxXpdY6CERs8QEOqC0PXGGics/e3vHnyEUKt8xK6YkbZXFUCeklrpB6js8ag==} + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-wasm32-wasi@1.1.1': - resolution: {integrity: sha512-qczfgEH8u0wHGGOXtA7UMAybNKuQjjEXairyQaw4WzjiMztfbgatG1h4OKays/smhtwbWltpKCRGtVhU6h40Sg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-arm64-msvc@1.1.1': - resolution: {integrity: sha512-4psXSh63mSbwJF+mB8/9yfUUEzBiHYcUjxa32EO9ZwKy0Ypwjcg4F10D8SvVXgd+isy2UUUjF9HJJnDu1T/4Gg==} + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.1': - resolution: {integrity: sha512-MUvC/HLXVjzkQkWiExdVTEEWf0py+GfWm8WKSZsekG3ih6a21iy0BHPF07X3JIf3ifoklZXTIaHTLPBgH1C3dw==} + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1390,19 +1911,14 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sideway/address@4.1.5': - resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} - - '@sideway/formula@3.0.1': - resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} - - '@sideway/pinpoint@2.0.0': - resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} + '@simple-libs/child-process-utils@2.0.0': + resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} + engines: {node: '>=22'} + '@simple-libs/stream-utils@1.2.0': resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} @@ -1411,27 +1927,25 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} - '@sinclair/typebox@0.31.28': - resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} - '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@sqlite.org/sqlite-wasm@3.48.0-build4': - resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} - hasBin: true - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@sveltejs/acorn-typescript@1.0.10': - resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: acorn: ^8.9.0 - '@sveltejs/kit@2.65.0': - resolution: {integrity: sha512-nUWJ4dSKNo8mIOh+HTL+XyRj8FX9Dyb1ayBxj4q9+WrTJfn4jfTt21p3WUFTnnmdnt9xAXpBKLQ+H9y41x0X7Q==} + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.70.3': + resolution: {integrity: sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -1446,12 +1960,12 @@ packages: typescript: optional: true - '@sveltejs/load-config@0.1.1': - resolution: {integrity: sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==} + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} engines: {node: '>= 18.0.0'} - '@sveltejs/vite-plugin-svelte@7.1.2': - resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + '@sveltejs/vite-plugin-svelte@7.3.1': + resolution: {integrity: sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==} engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: svelte: ^5.46.4 @@ -1460,14 +1974,6 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tanstack/query-core@5.101.2': - resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} - - '@tanstack/svelte-query@6.1.36': - resolution: {integrity: sha512-AVyvXPzBh5OQnWFB7rk9gskciPouayAhU9Vm24uMai+Imf+ZfBEbjOAEtdM41oexFK/cdXmpAIySDSVaAzy9zw==} - peerDependencies: - svelte: ^5.25.0 - '@testing-library/svelte-core@1.1.3': resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} engines: {node: '>=16'} @@ -1493,17 +1999,41 @@ packages: svelte: optional: true - '@tsconfig/svelte@5.0.8': - resolution: {integrity: sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==} + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + + '@turbo/darwin-64@2.11.4': + resolution: {integrity: sha512-fEmcQVa95k79tZjcay5b2A4cpR/VrlNUki4AN7EbA+32vj5C5rBUE1q3i2yOFxsB2n01sOlzIZ+s2p2zADCTig==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.11.4': + resolution: {integrity: sha512-SgXCVgBC1v+bdE6+KQGV6Kob7nBYo75zxzNFFuA4jLdM0J8lYamdTUoZSHLR0dy+kXVEYZqSC98CKqC17NG3Og==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.11.4': + resolution: {integrity: sha512-MWM7Au6NPJMBmHJAzFzQ/PACYNRpBOqnBkj+7csdR+YhJ+0XBg4Z64wFwLnGFDac0gc+YlasWOZ5t05bGs54aQ==} + cpu: [x64] + os: [android, linux] + + '@turbo/linux-arm64@2.11.4': + resolution: {integrity: sha512-rRVOh0UNlLbKPuf70FokqjahO/hCGgBmgvZcNXUPayn8Wk0zXIsVoNKpr0UQUhhR+KMiibvBKkVAGDViDFQHKA==} + cpu: [arm64] + os: [android, linux] - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@turbo/windows-64@2.11.4': + resolution: {integrity: sha512-COr+++1GwDg09XbJdm3sP1gEQApeV43UknYP9h82qet1tAGJrGQAxKVTMrf2JMsMw8MkJRnQj3gAVuQmV1kIyQ==} + cpu: [x64] + os: [win32] - '@types/braces@3.0.5': - resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + '@turbo/windows-arm64@2.11.4': + resolution: {integrity: sha512-gh5lwDA2d4aBdLvTANhFsFEgxbBqKZ4m4dCyChw9GoX8b+Esk8H3p25Q+rlWCUVJ2o6Hne10ZTL0QSFfWD4gIA==} + cpu: [arm64] + os: [win32] - '@types/bun@1.3.14': - resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/bun@1.4.2': + resolution: {integrity: sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1526,243 +2056,265 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/micromatch@4.0.10': - resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} - '@types/node@25.9.3': - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/validator@13.15.10': - resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - - '@typeschema/class-validator@0.3.0': - resolution: {integrity: sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==} - peerDependencies: - class-validator: ^0.14.1 - peerDependenciesMeta: - class-validator: - optional: true - - '@typeschema/core@0.14.0': - resolution: {integrity: sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==} - peerDependencies: - '@types/json-schema': ^7.0.15 - peerDependenciesMeta: - '@types/json-schema': - optional: true - - '@typescript-eslint/eslint-plugin@8.62.1': - resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.62.1 + '@typescript-eslint/parser': ^8.70.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.62.1': - resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + '@typescript-eslint/parser@8.70.1': + resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.62.1': - resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + '@typescript-eslint/project-service@8.70.1': + resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.62.1': - resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + '@typescript-eslint/scope-manager@8.70.1': + resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.62.1': - resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.62.1': - resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + '@typescript-eslint/type-utils@8.70.1': + resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.1': - resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + '@typescript-eslint/types@8.70.1': + resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.62.1': - resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + '@typescript-eslint/typescript-estree@8.70.1': + resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.62.1': - resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + '@typescript-eslint/utils@8.70.1': + resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.1': - resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + '@typescript-eslint/visitor-keys@8.70.1': + resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unocss/cli@66.7.4': - resolution: {integrity: sha512-ujqzt7An9Y1BUL0xMFQvF219nLwz7hF2lh/E97YhzB8lI8xA/T/VB7+njXXBEADV4Jp/5qGVaAatME9tadCqBw==} - hasBin: true - - '@unocss/config@66.7.4': - resolution: {integrity: sha512-xdIpeZv2l69GlvO2GjAMXXMnJ4LPI0J9OXIEsON6kSHJzZqL9Gbztqvt9L+3OXQsNCeduJF/Dm9JrNPSbpDqHw==} - - '@unocss/core@0.25.1': - resolution: {integrity: sha512-YYX40KA2rhsm6aTKTGpE0bi9E7RoAcuqMo/L+f6DmBSwtTXdflckoa903rxgOa6HOLH9fXgOJP9jHt3O/5ysPA==} - - '@unocss/core@66.7.4': - resolution: {integrity: sha512-OGXh+RRsAgOrecTKRjUd4SepHR7W4v6zIf6pGtKyIIAIMnzcW/tU+afR1cDbhtb4efZMQhzaoAh3ncvkL8eEYA==} - - '@unocss/extractor-arbitrary-variants@66.7.4': - resolution: {integrity: sha512-0yawiWzVDDBRow8kuMeNFhzYJUwYrsKc1xclzNurkisal7tTG24haDmpqneol8j3TT2blrQOIuSQPrEgv6y4oQ==} + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} + peerDependencies: + playwright: '*' + vitest: 4.1.11 - '@unocss/extractor-svelte@66.7.4': - resolution: {integrity: sha512-ei0eVTBtS8TOqc9T79/Hh776oWx3PZis/pSQ4s2uYyJldl5SooKJQlFyfZzqMfQ6yC7goy4wCDkn36Wq4nOF8A==} + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} + peerDependencies: + vitest: 4.1.11 - '@unocss/inspector@66.7.4': - resolution: {integrity: sha512-YxgG9KaXZTe9wVffhUQkV/RY+PPcXha/jAg0rcPUYQr88Z0PEenOUp/Q11ATP8mTwNd+3oUwbKMnkXvv2qsdQA==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true - '@unocss/preset-attributify@66.7.4': - resolution: {integrity: sha512-DqRGDJ4OH9h8IissdbZ+IoiIg4q9gdZwdDfnrPcNbBm1ZC4QV/SEizt9GGF6Z/bgvMscMupMU233btQyTq+h6w==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@unocss/preset-icons@66.7.4': - resolution: {integrity: sha512-E5DZJOBv4dNsKheds5MNjELF/boMvJNGya1I6NKzC3FQJC7BBSs67+7HAWTz7KKavLS9dEBjdh3JUlXhYMSO8w==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@unocss/preset-mini@0.25.1': - resolution: {integrity: sha512-NBfa2/t0TIAT//L8OVskDDQJ3OREjLwDzWMBNO86rvM0VPCIBvCmkKuFR3zxQcWK+aegISeScJA9Zcdk3qJjHA==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@unocss/preset-mini@66.7.4': - resolution: {integrity: sha512-aITPP3k60ya3ZMubWQfwNUjh8SzA6YRsbX37WaAMYyIrP1lilgCClJi9HxYt0/Wya09NmUrVNdyYKZfYhfvprA==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@unocss/preset-tagify@66.7.4': - resolution: {integrity: sha512-4AU4WJfQlk4X+AcsNO532dLcrSxPjEiFTVY7IjDrR4fnFV6bU5yXvEMpM9mToZvk1oDosDjT9Zt7MEpt2d/N1A==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@unocss/preset-typography@66.7.4': - resolution: {integrity: sha512-FNlkAdlfUqsiHrkeKt5gr8rbTH5K6qNBv44n5fK6wWWbrl/6mB+oazvC9yFp+winJWr+M7TBrnrhC7wAeTdpAA==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@unocss/preset-uno@0.25.1': - resolution: {integrity: sha512-e5lvCF5GD52nD/7l/k4TJedw5AgsVGTs/6rKxE9DP0G5oGszKjx+oHLzoYvCP2aQAcallnZ/CxXTbOvqCQCVxQ==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@unocss/preset-uno@66.7.4': - resolution: {integrity: sha512-ov0sGR2mQ3x4OtdFHdpndy3VuK5UkXuhEAb6LwxSzUIKOqWcxSi24nugBchfInS930pI3Ka9Eje7kAsU+QAPEg==} + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] - '@unocss/preset-web-fonts@66.7.4': - resolution: {integrity: sha512-pzpaKZzcbtEVodMf6RvcjyYhgfJ6JrOzsELgo7tzKWSrUuuAuVteTXQTVNIsgjRncJ+adeUhIVTtWg6uHjj1Yg==} + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] - '@unocss/preset-wind3@66.7.4': - resolution: {integrity: sha512-wnYilrLEYyuXzWlC1WfHB8xl+Y8ah3ZHbRBEZbOlYIDAgrGdDHxPT1EOHSHVphfoobCp8+t7eNQvjB1xBG0LaQ==} + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] - '@unocss/preset-wind4@66.7.4': - resolution: {integrity: sha512-X3Tde85s9qMlow0IWy32yo8ivtZ3jvpYIWLeOmmBSJXil7GVvA1Yi3LYSLSMoR4VT8CasbQKg99LyhtXiDhYlQ==} + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] - '@unocss/preset-wind@0.25.1': - resolution: {integrity: sha512-y5gkCCNiGsonqI8OOmtMVXgEMjPDtr+ihxnS3oDptYR4CcBE1cREhLgZocChHHQNEMN9Qx2GsoMN9l6RsoExiA==} + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] - '@unocss/preset-wind@66.7.4': - resolution: {integrity: sha512-OZRsTfjY0iTEu01oqknKFxCxudv+3yXkXTVdllFBrFXws7OKXmm78Wmo+jwX3PhDBaEq71WuHi8s6u76YOiNLQ==} + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] - '@unocss/rule-utils@66.7.4': - resolution: {integrity: sha512-S+q2gqsWpZfW+MJV6LGH42eyKDK8xGdUPse72T8euNDagl5/OZFGSJ3TlIrlNilscbUiWM8nIu0cWeRaAmEMlA==} + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@unocss/transformer-attributify-jsx@66.7.4': - resolution: {integrity: sha512-HvSmnxEaMdvEOBAe4JeGfQHYoemY7eyEkM9tK60KBvuvLS/tpkTu9sQy8Y2PaSVdtdl8wNJYAjBq14mBTJhJQQ==} + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] - '@unocss/transformer-compile-class@66.7.4': - resolution: {integrity: sha512-CqagFT2ybqeEIf5YDPalK+ueKewNiI3h/a0MDH+VqDEmyl4ICgSB38VtQabiMnFG4um0kVGqThHoYKDy6qGKyg==} + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] - '@unocss/transformer-directives@66.7.4': - resolution: {integrity: sha512-mMS+9NWMI7riWW9LI9+XibbAs6kJuhEezqQ+99d0s40k2w+io7vJab2i+LkDDyCmdaPL5tdqcb48PXu9mzvAeQ==} + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] - '@unocss/transformer-variant-group@66.7.4': - resolution: {integrity: sha512-3+fdrcpg7f8wltk7Ph4nK817HkwVfKoKOImPuD9nnb3T6RVUuwDq3N3XZK4vrwlFyEJqNLDBhzHF3rCGymreOg==} + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] - '@unocss/vite@66.7.4': - resolution: {integrity: sha512-U6P3qWuGJIQ3fqMQf9qMB2kkXGrISkcdeW1kHlBKs54QjWKJRMNTxgAAb57ytlbD2K9osEMO/k58w96lbXcTNg==} - peerDependencies: - vite: ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] - '@valibot/to-json-schema@1.7.1': - resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} - peerDependencies: - valibot: ^1.4.0 + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] - '@vinejs/compiler@3.0.0': - resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==} - engines: {node: '>=18.0.0'} + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] - '@vinejs/vine@3.0.1': - resolution: {integrity: sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==} - engines: {node: '>=18.16.0'} + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] - '@vitest/browser-playwright@4.1.9': - resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==} - peerDependencies: - playwright: '*' - vitest: 4.1.9 + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] - '@vitest/browser@4.1.9': - resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} - peerDependencies: - vitest: 4.1.9 + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} - peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 - peerDependenciesMeta: - '@vitest/browser': - optional: true + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -1776,36 +2328,35 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argue-cli@3.2.0: + resolution: {integrity: sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==} + engines: {node: '>=22'} + aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} - arkregex@0.0.5: - resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} - - arktype@2.2.0: - resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} - - array-timsort@1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -1821,8 +2372,8 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - bits-ui@2.18.1: - resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==} + bits-ui@2.19.3: + resolution: {integrity: sha512-04COIqqht1h0YRnVTJVHy3+F8NhzS5x0eSM8eqvavaWmK/GLDTgYJW2h1CA//8hh9vSk4rG7rOvJ3QAIpCuYIw==} engines: {node: '>=20'} peerDependencies: '@internationalized/date': ^3.8.1 @@ -1831,38 +2382,27 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} - bun-types@1.3.14: - resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} - bun@1.3.14: - resolution: {integrity: sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg==} - cpu: [arm64, x64] - os: [darwin, linux, android, freebsd, win32] - hasBin: true + bun-types@1.4.2: + resolution: {integrity: sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==} cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1875,31 +2415,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - class-transformer@0.5.1: - resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - - class-validator@0.14.4: - resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} - - class-validator@0.15.1: - resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -1908,13 +2423,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1923,31 +2437,16 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} - comment-json@4.6.2: - resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} - engines: {node: '>= 6'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.0: - resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} - engines: {node: ^14.18.0 || >=16.10.0} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} - conventional-changelog-angular@9.2.0: - resolution: {integrity: sha512-2EihZvM1KmbBGwuUow8lNXLe+ECRFsyOcV8X0197/WI7Rvvgfi55Oicfw0wxJTXGGIVXSfj+xBoNj+cxLlIiVw==} + conventional-changelog-angular@9.4.0: + resolution: {integrity: sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==} engines: {node: '>=22'} - conventional-changelog-conventionalcommits@10.2.0: - resolution: {integrity: sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==} + conventional-changelog-conventionalcommits@10.4.0: + resolution: {integrity: sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==} engines: {node: '>=22'} conventional-changelog-preset-loader@5.0.0: @@ -1963,8 +2462,8 @@ packages: engines: {node: '>=18'} hasBin: true - conventional-commits-parser@7.0.0: - resolution: {integrity: sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==} + conventional-commits-parser@7.1.2: + resolution: {integrity: sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==} engines: {node: '>=22'} hasBin: true @@ -2001,18 +2500,11 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - dayjs@1.11.21: - resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2022,14 +2514,6 @@ packages: supports-color: optional: true - dedent@1.5.1: - resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2044,35 +2528,32 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.8.1: - resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - - duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - - effect@3.21.3: - resolution: {integrity: sha512-RqwU7WnJ6CqYhyjpOVJA5vh1Sgkn6eVECO6mnD0EjlbWcC2M3LJaPglXXr13Rdo/Y+B+wTEPzGRYFNL2xKxNeQ==} + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2084,11 +2565,11 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-toolkit@1.47.1: - resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -2135,8 +2616,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.20.0: - resolution: {integrity: sha512-AElKLVt7Hjy4d7ljwhrhw9hux60DCxCNkmK8cY/aAXvjs8tpR7PvU4DlyI/SA1PaJww1gh0wPGo2pbyURuEwxQ==} + eslint-plugin-svelte@3.23.0: + resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 @@ -2168,8 +2649,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.6.0: - resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + eslint@10.11.0: + resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2189,17 +2670,12 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - esrap@2.2.11: - resolution: {integrity: sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==} + esrap@2.3.12: + resolution: {integrity: sha512-X+t0J3EQKrrw5lgpgjn4hKjD17cHCZBZIcp2OUOyH7teMjH67gNRR2p+b1rMYavBkRN6VfHPWJOPY7w+VwQf/g==} peerDependencies: '@typescript-eslint/types': ^8.2.0 peerDependenciesMeta: @@ -2221,21 +2697,14 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2248,8 +2717,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -2260,40 +2729,25 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - formsnap@2.0.1: - resolution: {integrity: sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.0.0 - sveltekit-superforms: ^2.19.0 + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -2304,55 +2758,53 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - git-cliff-darwin-arm64@2.13.1: - resolution: {integrity: sha512-3ebPUnUlLmrSZDHknSZQmyZV7DEJVtKse8I25Am0cENET5Py9u9Hg9k8IRXdiDtHtPDs6MYZx7BOi11WtcfqSg==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + git-cliff-darwin-arm64@2.14.2: + resolution: {integrity: sha512-Ylk39hv5flEUrXeoGvH6M3WjHBNERFH7a9DVEcGU2a8Q7a7fPtVasNk1fMuJ7CVbZCRIzUNyQV0ejRpxBdFnag==} cpu: [arm64] os: [darwin] - git-cliff-darwin-x64@2.13.1: - resolution: {integrity: sha512-KZfggGAiw1EvZH3BOUclEU4eGCP2+Z+lH/N2Ni3FH9L2M1U7FYJqqaMhqgO8azTj67betvDshH8WBUkIkSsVxA==} + git-cliff-darwin-x64@2.14.2: + resolution: {integrity: sha512-3FZNm0BGCIim/3G6uHSWAakrCaGmC47lcI+Cob9CZPMBYHKrenYk0i6B8MlObSXr2Lr7Ve1BpGbd/foQg4NOiA==} cpu: [x64] os: [darwin] - git-cliff-linux-arm64@2.13.1: - resolution: {integrity: sha512-clNRcNzdvk4GKyTt3ZhhWqcrjVRghcUcGNSV/Y87YJf3Mc/zl9ajUAhnXPOBSX/KWBr2od5SmkCt0qGYagY07g==} + git-cliff-linux-arm64@2.14.2: + resolution: {integrity: sha512-3oMDpoyfsy2angD9ulcCBtyWCkzZqN3spmmtb0jic8OKAu1p+CALS8oep7J8AdgkDojuLo6bdHiSZIM9PeTxmQ==} cpu: [arm64] os: [linux] - git-cliff-linux-x64@2.13.1: - resolution: {integrity: sha512-gZcIQhIQ1lDUMD8UieUSEZAYoyZN7nPd8O3VQoj1Ddhqll4UAS1Zxms1SU3U8pb0UTdc2m3ia/wtOnyhvbjdjQ==} + git-cliff-linux-x64@2.14.2: + resolution: {integrity: sha512-4EaBYwBiCupECZ2rzC6CelNSGYvkvtJeja4wXLDGtfKnRhCN/WNoqbM+hvrtm32uf5qovfoNoDA906F8itw+oQ==} cpu: [x64] os: [linux] - git-cliff-windows-arm64@2.13.1: - resolution: {integrity: sha512-+XubuQv68DuDwF0u6Af5d889MEf/RD48VBQS7ZmPi4sUSCXAZqRm1/ApCsStLqxMDCf1+7s05B/kNbm9wjV80A==} + git-cliff-windows-arm64@2.14.2: + resolution: {integrity: sha512-0oc0xD/6A+BvInz8imBJulXVOlR9M0nL+d3ibL0lwJvxUVTMQzvoCnsn9OxpsdzXrEisnTvaf+GExoKVJkUcDw==} cpu: [arm64] os: [win32] - git-cliff-windows-x64@2.13.1: - resolution: {integrity: sha512-Bi8ehp1VMkomY7M/356PgovKQ8CBRiuOOkq+aWC2evQuMFfXWjG0GBlPED9QkZoUJ4iQ5ygB5DYdK3BjhaOyPw==} + git-cliff-windows-x64@2.14.2: + resolution: {integrity: sha512-RFmCMiwGMU8O+rF//wxT7L10ssoDtzI+QFxlbjpA8E1BAEafkmLTQkKalT88ipEweD46P+Sfc20v8RXuE7V/UA==} cpu: [x64] os: [win32] - git-cliff@2.13.1: - resolution: {integrity: sha512-2BzXwrom+SMHeNA5Ut+MtzqXejg0wbVwmzj3k7e9w62UQoyCDrM9UIcvtl6hnT3jocEQ1zLRQBaXXx97Gmnk7A==} + git-cliff@2.14.2: + resolution: {integrity: sha512-hlXIq4k2Tdj9C59WsD7FsLE0xRGFDozTUuoeqg9toMyVohaVxXiSiLnhM/IQyCmhOh5M4zuNckr2o8qMeuffbg==} engines: {node: ^18.19 || >=20.6} hasBin: true - git-raw-commits@5.0.1: - resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} - engines: {node: '>=18'} - deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. - hasBin: true - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2365,14 +2817,10 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.7.0: - resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} - gzip-size@6.0.0: - resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} - engines: {node: '>=10'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2381,13 +2829,22 @@ packages: resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} engines: {node: '>=12'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - human-id@4.2.0: - resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} - hasBin: true - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -2401,20 +2858,17 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.4.1: + resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} + engines: {node: ^22.18.0 || >=24.0.0} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -2438,18 +2892,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -2487,24 +2933,14 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - joi@17.13.4: - resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} - - js-sha256@0.11.1: - resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -2512,16 +2948,9 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-ts@3.1.1: - resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} - engines: {node: '>=16'} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2531,16 +2960,11 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-with-bigint@3.5.8: - resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} @@ -2549,89 +2973,82 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - kysely@0.28.17: - resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} - engines: {node: '>=20.0.0'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.13.6: - resolution: {integrity: sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} lilconfig@2.1.0: @@ -2641,15 +3058,11 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@17.0.8: - resolution: {integrity: sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==} + lint-staged@17.5.1: + resolution: {integrity: sha512-7EDuco1xnBMeVpvAbeMq1U5KXwJGLmY8q6l+Ye78r36C4mPc+Vg1Z2SK8gHyRTyvPro90A0q5/FAZ+Au23d6QQ==} engines: {node: '>=22.22.1'} hasBin: true - listr2@10.2.1: - resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} - engines: {node: '>=22.13.0'} - locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -2664,22 +3077,18 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-regexp@0.10.0: - resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -2690,52 +3099,18 @@ packages: engines: {node: '>= 18'} hasBin: true - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - memoize-weak@1.0.2: - resolution: {integrity: sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==} - meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} - meow@14.1.0: - resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} - engines: {node: '>=20'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - - mode-watcher@1.1.0: - resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==} - peerDependencies: - svelte: ^5.27.0 - monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -2750,52 +3125,29 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - normalize-url@8.1.1: - resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} - engines: {node: '>=14.16'} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} - ofetch@1.5.1: - resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - oxc-parser@0.131.0: - resolution: {integrity: sha512-SJ3/7ZPbgie8dr5Z9BI/M51zZbpXba+hRSG0MDzVwMW5CRQg2fjYE0jHGlLX4eeiibGgC/mzoDFKSDHwVZEHRQ==} - engines: {node: ^20.19.0 || >=22.12.0} - - oxc-walker@0.7.0: - resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} - peerDependencies: - oxc-parser: '>=0.98.0' - oxfmt@0.35.0: resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2809,9 +3161,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2830,6 +3179,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2845,31 +3197,21 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@2.1.0: - resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} hasBin: true plur@5.1.0: @@ -2892,8 +3234,8 @@ packages: ts-node: optional: true - postcss-safe-parser@7.0.1: - resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + postcss-safe-parser@7.1.0: + resolution: {integrity: sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==} engines: {node: '>=18.0'} peerDependencies: postcss: ^8.4.31 @@ -2904,12 +3246,12 @@ packages: peerDependencies: postcss: ^8.4.29 - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2927,29 +3269,22 @@ packages: prettier: ^3.0.0 svelte: ^5.0.0 - prettier@2.5.1: - resolution: {integrity: sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==} - engines: {node: '>=10.13.0'} - hasBin: true - - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} - property-expr@2.0.6: - resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2958,14 +3293,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - - regexp-tree@0.1.27: - resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} - hasBin: true - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2978,38 +3305,33 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true - rolldown@1.1.1: - resolution: {integrity: sha512-IN750c0p+s3jqJIsFLRZrQazmbAB1kkQDTtQjSt/gbS2ywLhlv4R5Shazer0FZKmuo/BsO3/w2UoYnUjuOZqHg==} + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - runed@0.23.4: - resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==} - peerDependencies: - svelte: ^5.7.0 - - runed@0.25.0: - resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==} - peerDependencies: - svelte: ^5.7.0 - - runed@0.28.0: - resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==} - peerDependencies: - svelte: ^5.7.0 - runed@0.35.1: resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} peerDependencies: @@ -3023,13 +3345,13 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3050,32 +3372,19 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sqlite-wasm-kysely@0.3.0: - resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} - peerDependencies: - kysely: '*' - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -3085,8 +3394,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} engines: {node: '>=20'} strip-ansi@7.2.0: @@ -3100,10 +3409,6 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - superstruct@2.0.2: - resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} - engines: {node: '>=14.0.0'} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3112,107 +3417,49 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-hyperlinks@4.4.0: - resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} engines: {node: '>=20'} - svelte-adapter-bun@1.0.1: - resolution: {integrity: sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA==} - peerDependencies: - '@sveltejs/kit': ^2.4.0 - typescript: ^5 - - svelte-check@4.6.0: - resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' + typescript: ^5.0.0 || ^6.0.0 - svelte-eslint-parser@1.8.0: - resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1} + svelte-eslint-parser@1.8.1: + resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: optional: true - svelte-kit-sessions@0.4.0: - resolution: {integrity: sha512-cWjHwd+EGIuZ0p8CxSqE5EMOT8EUsoYfAnbE8QB+r6FonroYiMvTLUgv8b9dVLC55Yw3UtTntjUaZ5fKJF3XOA==} - peerDependencies: - '@sveltejs/kit': ^1.0.0 || ^2.0.0 - svelte: ^5.1.13 - - svelte-sonner@1.1.1: - resolution: {integrity: sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==} - peerDependencies: - svelte: ^5.0.0 - svelte-toolbelt@0.10.6: resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} peerDependencies: svelte: ^5.30.2 - svelte-toolbelt@0.5.0: - resolution: {integrity: sha512-t3tenZcnfQoIeRuQf/jBU7bvTeT3TGkcEE+1EUr5orp0lR7NEpprflpuie3x9Dn0W9nOKqs3HwKGJeeN5Ok1sQ==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.0.0-next.126 - - svelte-toolbelt@0.7.1: - resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.0.0 - - svelte@5.56.3: - resolution: {integrity: sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==} + svelte@5.57.1: + resolution: {integrity: sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==} engines: {node: '>=18'} - sveltekit-superforms@2.30.1: - resolution: {integrity: sha512-wBzyqsE0idvEJWuNJ+HCiAtdxa7Z55GZ8jmtlVHJfonrk9bRYC49MoPaloYyFoYuU3QPy6Omna/Qzn1kaIkgew==} - peerDependencies: - '@sveltejs/kit': 1.x || 2.x - svelte: 3.x || 4.x || >=5.0.0-next.51 - synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - - tailwind-variants@3.2.2: - resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} - engines: {node: '>=16.x', pnpm: '>=7.x'} - peerDependencies: - tailwind-merge: '>=3.0.0' - tailwindcss: '*' - peerDependenciesMeta: - tailwind-merge: - optional: true - - tailwindcss@4.3.2: - resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} - - tar@7.5.19: - resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} - engines: {node: '>=18'} - - tiny-case@1.0.3: - resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -3223,23 +3470,17 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toposort@2.0.2: - resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - ts-algebra@2.0.0: - resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} @@ -3247,9 +3488,42 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-deepmerge@7.0.3: - resolution: {integrity: sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==} - engines: {node: '>=14.13.1'} + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3258,22 +3532,16 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + turbo@2.11.4: + resolution: {integrity: sha512-nedZtJ2hjWpU/JBFq7Aj1GQBxQ56si+kIUmNZBUX35O35TIuhSBR6/u3yHHmk81U+RBdBcVjvLQ3/iOFzMwKsA==} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@2.19.0: - resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} - engines: {node: '>=12.20'} - - type-level-regexp@0.1.17: - resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} - - typebox@1.2.8: - resolution: {integrity: sha512-rlsKhsd7L3082m9nxhFIB4Gl8jizLd/6YlFAWaz96hdV8+VJRjwYaYmDlT0jlTRwkb48SxCSSirqUtrg/HilNw==} - - typescript-eslint@8.62.1: - resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + typescript-eslint@8.70.1: + resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3284,20 +3552,14 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} - unconfig@7.5.0: - resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} unicorn-magic@0.3.0: @@ -3307,60 +3569,33 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - unocss@66.7.4: - resolution: {integrity: sha512-a9Aqre6SS036sAO5qo0hrOAfrgWuilNIO3ekwp/tWz0CtsL7SXOA3lAtHi3WP0vtIsWbbYJEEGX4n4VBoQprqA==} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true peerDependencies: - '@unocss/astro': 66.7.4 - '@unocss/postcss': 66.7.4 - '@unocss/webpack': 66.7.4 + synckit: ^0.11.11 peerDependenciesMeta: - '@unocss/astro': + synckit: optional: true - '@unocss/postcss': - optional: true - '@unocss/webpack': - optional: true - - unplugin-utils@0.3.1: - resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} - engines: {node: '>=20.19.0'} - - unplugin@2.3.11: - resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} - engines: {node: '>=18.12.0'} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - urlpattern-polyfill@10.1.0: - resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true - - valibot@1.4.1: - resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - validator@13.15.35: - resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} - engines: {node: '>= 0.10'} + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} - vite@8.0.16: - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 + '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3405,26 +3640,26 @@ packages: vite: optional: true - vitest-browser-svelte@2.2.0: - resolution: {integrity: sha512-gxEvWHTzU66PceLXZI9jcInSuHAumeva2DSeEYtI7T9JiFfBscrxSfQsMMEViIgEgTJmL3AZPCRYiV1bDWXooQ==} + vitest-browser-svelte@2.2.1: + resolution: {integrity: sha512-dVjmXuk58eLSloA41Lvz5ALMqWVzK7Gk5TvY5KWFbI8VoEO1nJF1/M498NwJRbxlnjaEZrASd02h+NDS/T9mBA==} peerDependencies: svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 vitest: ^4.0.0 - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3452,9 +3687,6 @@ packages: jsdom: optional: true - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3469,16 +3701,12 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3493,16 +3721,12 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true @@ -3510,31 +3734,32 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yargs@18.0.0: - resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} - yup@1.7.1: - resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} - zod-v3-to-json-schema@4.0.0: - resolution: {integrity: sha512-KixLrhX/uPmRFnDgsZrzrk4x5SSJA+PmaE5adbfID9+3KPJcdxqRobaHU397EfWBqfQircrjKqvEqZ/mW5QH6w==} - peerDependencies: - zod: ^3.25 || ^4.0.14 + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: @@ -3550,54 +3775,35 @@ snapshots: '@actions/github@9.1.1': dependencies: '@actions/http-client': 3.0.2 - '@octokit/core': 7.0.6 - '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) - '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - '@octokit/request': 10.0.10 - '@octokit/request-error': 7.1.0 - undici: 6.26.0 + '@octokit/core': 7.0.8 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + undici: 6.28.1 '@actions/http-client@3.0.2': dependencies: tunnel: 0.0.6 - undici: 6.26.0 + undici: 6.28.1 '@actions/http-client@4.0.1': dependencies: tunnel: 0.0.6 - undici: 6.26.0 + undici: 6.28.1 '@actions/io@3.0.2': {} - '@alexanderniebuhr/prettier-plugin-unocss@0.0.4': - dependencies: - '@unocss/preset-uno': 0.25.1 - import-fresh: 3.3.0 - prettier: 2.5.1 - - '@antfu/install-pkg@1.1.0': - dependencies: - package-manager-detector: 1.6.0 - tinyexec: 1.2.4 - - '@ark/schema@0.56.0': - dependencies: - '@ark/util': 0.56.0 - optional: true - - '@ark/util@0.56.0': - optional: true - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -3608,32 +3814,29 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 - - '@babel/runtime@7.29.7': - optional: true + '@babel/types': 7.29.8 '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -3642,65 +3845,77 @@ snapshots: '@blazediff/core@1.9.1': {} - '@commitlint/cli@21.2.0(@types/node@25.9.3)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': dependencies: - '@commitlint/config-conventional': 21.2.0 - '@commitlint/format': 21.2.0 - '@commitlint/lint': 21.2.0 - '@commitlint/load': 21.2.0(@types/node@25.9.3)(typescript@6.0.3) - '@commitlint/read': 21.2.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) - '@commitlint/types': 21.2.0 - tinyexec: 1.2.4 - yargs: 18.0.0 + hashery: 1.5.1 + keyv: 5.6.0 + + '@commitlint/cli@21.2.3(@types/node@26.6.2)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + dependencies: + '@commitlint/config-conventional': 21.2.3 + '@commitlint/format': 21.2.3 + '@commitlint/lint': 21.2.3 + '@commitlint/load': 21.2.3(@types/node@26.6.2)(typescript@6.0.3) + '@commitlint/read': 21.2.3(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + '@commitlint/types': 21.2.3 + tinyexec: 1.3.1 + yargs: 18.2.0 transitivePeerDependencies: - '@types/node' - conventional-commits-filter - conventional-commits-parser - typescript - '@commitlint/config-conventional@21.2.0': + '@commitlint/config-conventional@21.2.3': dependencies: - '@commitlint/types': 21.2.0 - conventional-changelog-conventionalcommits: 10.2.0 + '@commitlint/types': 21.2.3 + conventional-changelog-conventionalcommits: 10.4.0 - '@commitlint/config-validator@21.2.0': + '@commitlint/config-validator@21.2.3': dependencies: - '@commitlint/types': 21.2.0 + '@commitlint/types': 21.2.3 ajv: 8.20.0 - '@commitlint/ensure@21.2.0': + '@commitlint/ensure@21.2.3': dependencies: - '@commitlint/types': 21.2.0 - es-toolkit: 1.47.1 + '@commitlint/types': 21.2.3 + es-toolkit: 1.52.0 '@commitlint/execute-rule@21.0.1': {} - '@commitlint/format@21.2.0': + '@commitlint/format@21.2.3': dependencies: - '@commitlint/types': 21.2.0 + '@commitlint/types': 21.2.3 picocolors: 1.1.1 - '@commitlint/is-ignored@21.2.0': + '@commitlint/is-ignored@21.2.3': dependencies: - '@commitlint/types': 21.2.0 - semver: 7.8.4 + '@commitlint/types': 21.2.3 + semver: 7.8.5 - '@commitlint/lint@21.2.0': + '@commitlint/lint@21.2.3': dependencies: - '@commitlint/is-ignored': 21.2.0 - '@commitlint/parse': 21.2.0 - '@commitlint/rules': 21.2.0 - '@commitlint/types': 21.2.0 + '@commitlint/is-ignored': 21.2.3 + '@commitlint/parse': 21.2.3 + '@commitlint/rules': 21.2.3 + '@commitlint/types': 21.2.3 - '@commitlint/load@21.2.0(@types/node@25.9.3)(typescript@6.0.3)': + '@commitlint/load@21.2.3(@types/node@26.6.2)(typescript@6.0.3)': dependencies: - '@commitlint/config-validator': 21.2.0 + '@commitlint/config-validator': 21.2.3 '@commitlint/execute-rule': 21.0.1 - '@commitlint/resolve-extends': 21.2.0 - '@commitlint/types': 21.2.0 + '@commitlint/resolve-extends': 21.2.3 + '@commitlint/types': 21.2.3 cosmiconfig: 9.0.2(typescript@6.0.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.3)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) - es-toolkit: 1.47.1 + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.6.2)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) + es-toolkit: 1.52.0 is-plain-obj: 4.1.0 picocolors: 1.1.1 transitivePeerDependencies: @@ -3709,36 +3924,36 @@ snapshots: '@commitlint/message@21.2.0': {} - '@commitlint/parse@21.2.0': + '@commitlint/parse@21.2.3': dependencies: - '@commitlint/types': 21.2.0 - conventional-changelog-angular: 9.2.0 - conventional-commits-parser: 7.0.0 + '@commitlint/types': 21.2.3 + conventional-changelog-angular: 9.4.0 + conventional-commits-parser: 7.1.2 - '@commitlint/read@21.2.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + '@commitlint/read@21.2.3(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': dependencies: '@commitlint/top-level': 21.2.0 - '@commitlint/types': 21.2.0 - git-raw-commits: 5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) - tinyexec: 1.2.4 + '@commitlint/types': 21.2.3 + '@conventional-changelog/git-client': 3.1.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + tinyexec: 1.3.1 transitivePeerDependencies: - conventional-commits-filter - conventional-commits-parser - '@commitlint/resolve-extends@21.2.0': + '@commitlint/resolve-extends@21.2.3': dependencies: - '@commitlint/config-validator': 21.2.0 - '@commitlint/types': 21.2.0 - es-toolkit: 1.47.1 + '@commitlint/config-validator': 21.2.3 + '@commitlint/types': 21.2.3 + es-toolkit: 1.52.0 global-directory: 5.0.0 resolve-from: 5.0.0 - '@commitlint/rules@21.2.0': + '@commitlint/rules@21.2.3': dependencies: - '@commitlint/ensure': 21.2.0 + '@commitlint/ensure': 21.2.3 '@commitlint/message': 21.2.0 '@commitlint/to-lines': 21.0.1 - '@commitlint/types': 21.2.0 + '@commitlint/types': 21.2.3 '@commitlint/to-lines@21.0.1': {} @@ -3746,21 +3961,30 @@ snapshots: dependencies: escalade: 3.2.0 - '@commitlint/types@21.2.0': + '@commitlint/types@21.2.3': dependencies: - conventional-commits-parser: 7.0.0 + conventional-commits-parser: 7.1.2 picocolors: 1.1.1 '@conventional-changelog/git-client@2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': dependencies: '@simple-libs/child-process-utils': 1.0.2 '@simple-libs/stream-utils': 1.2.0 - semver: 7.8.4 + semver: 7.8.5 + optionalDependencies: + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + + '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 2.0.0 + '@simple-libs/stream-utils': 2.0.0 + semver: 7.8.5 optionalDependencies: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 - '@conventional-changelog/template@1.2.0': {} + '@conventional-changelog/template@1.4.0': {} '@dprint/formatter@0.5.1': {} @@ -3768,41 +3992,9 @@ snapshots: '@dprint/toml@0.7.0': {} - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/core@1.11.0': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.0': + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.6.1))': dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': - dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3811,11 +4003,11 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -3823,57 +4015,50 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.7.2': + '@eslint/plugin-kit@0.7.3': dependencies: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exodus/schemasafe@1.3.0': - optional: true - '@favware/cliff-jumper@6.1.0': dependencies: '@favware/colorette-spinner': 1.0.1 '@octokit/auth-token': 6.0.0 - '@octokit/core': 7.0.6 - '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6) + '@octokit/core': 7.0.8 + '@octokit/plugin-retry': 8.1.1(@octokit/core@7.0.8) '@sapphire/result': 2.8.0 '@sapphire/utilities': 3.18.2 colorette: 2.0.20 commander: 14.0.3 conventional-recommended-bump: 11.2.0 execa: 9.6.1 - git-cliff: 2.13.1 - js-yaml: 4.2.0 - semver: 7.8.4 - smol-toml: 1.6.1 + git-cliff: 2.14.2 + js-yaml: 4.3.2 + semver: 7.8.5 + smol-toml: 1.9.0 '@favware/colorette-spinner@1.0.1': dependencies: colorette: 2.0.20 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} - '@hapi/hoek@9.3.0': - optional: true + '@fontsource/instrument-sans@5.3.0': {} - '@hapi/topo@5.1.0': - dependencies: - '@hapi/hoek': 9.3.0 - optional: true + '@fontsource/jetbrains-mono@5.3.0': {} '@humanfs/core@0.19.2': dependencies: @@ -3891,75 +4076,13 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iconify-json/clarity@1.2.4': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify-json/ic@1.2.4': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify-json/icomoon-free@1.2.1': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify-json/material-icon-theme@1.2.68': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify-json/solar@1.2.5': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify/types@2.0.0': {} - - '@iconify/utils@3.1.3': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@iconify/types': 2.0.0 - import-meta-resolve: 4.2.0 - - '@inlang/paraglide-js@2.20.2(typescript@6.0.3)': - dependencies: - '@inlang/recommend-sherlock': 0.2.1 - '@inlang/sdk': 2.10.0 - commander: 11.1.0 - consola: 3.4.0 - json5: 2.2.3 - unplugin: 2.3.11 - urlpattern-polyfill: 10.1.0 - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - babel-plugin-macros - - '@inlang/recommend-sherlock@0.2.1': - dependencies: - comment-json: 4.6.2 - - '@inlang/sdk@2.10.0': - dependencies: - '@lix-js/sdk': 0.4.10 - '@sinclair/typebox': 0.31.28 - kysely: 0.28.17 - sqlite-wasm-kysely: 0.3.0(kysely@0.28.17) - uuid: 14.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - '@internationalized/date@3.12.2': + '@internationalized/date@3.12.4': dependencies: '@swc/helpers': 0.5.23 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - - '@isaacs/ttlcache@1.4.1': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -3969,253 +4092,130 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@lix-js/sdk@0.4.10': + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: - '@lix-js/server-protocol-schema': 0.1.1 - dedent: 1.5.1 - human-id: 4.2.0 - js-sha256: 0.11.1 - kysely: 0.28.17 - sqlite-wasm-kysely: 0.3.0(kysely@0.28.17) - uuid: 14.0.0 - transitivePeerDependencies: - - babel-plugin-macros + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 - '@lix-js/server-protocol-schema@0.1.1': {} + '@keyv/serialize@1.1.1': {} - '@lucide/svelte@1.22.0(svelte@5.56.3(@typescript-eslint/types@8.62.1))': + '@lucide/svelte@1.48.0(svelte@5.57.1(@typescript-eslint/types@8.70.1))': dependencies: - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) '@nanoforge-dev/actions@2.1.5': dependencies: '@actions/core': 3.0.1 '@actions/github': 9.1.1 commander: 15.0.0 - semver: 7.8.4 + semver: 7.8.5 - '@nanoforge-dev/common@1.4.0': {} - - '@nanoforge-dev/ecs-lib@1.4.0': - dependencies: - '@nanoforge-dev/common': 1.4.0 - - '@nanoforge-dev/utils-eslint-config@1.0.2(@types/eslint@9.6.1)(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4)(typescript@6.0.3)': + '@nanoforge-dev/config@2.3.0(synckit@0.11.13)': dependencies: - '@eslint/js': 9.39.4 - '@favware/cliff-jumper': 6.1.0 - eslint-config-prettier: 10.1.8(eslint@10.6.0(jiti@2.7.0)) - eslint-formatter-pretty: 7.1.0 - eslint-plugin-format: 1.5.0(eslint@10.6.0(jiti@2.7.0)) - eslint-plugin-prettier: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4) - globals: 16.5.0 - typescript-eslint: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + unrun: 0.3.1(synckit@0.11.13) + zod: 4.6.5 transitivePeerDependencies: - - '@types/eslint' - - eslint - - prettier - - supports-color - - typescript - - '@nanoforge-dev/utils-prettier-config@1.0.2': {} - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': - dependencies: - '@emnapi/core': 1.11.0 - '@emnapi/runtime': 1.11.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@octokit/auth-token@6.0.0': {} - - '@octokit/core@7.0.6': - dependencies: - '@octokit/auth-token': 6.0.0 - '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.10 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - before-after-hook: 4.0.0 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@11.0.3': - dependencies: - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@9.0.3': - dependencies: - '@octokit/request': 10.0.10 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@27.0.0': {} - - '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - bottleneck: 2.19.5 - - '@octokit/request-error@7.1.0': - dependencies: - '@octokit/types': 16.0.0 - - '@octokit/request@10.0.10': - dependencies: - '@octokit/endpoint': 11.0.3 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - content-type: 2.0.0 - json-with-bigint: 3.5.8 - universal-user-agent: 7.0.3 - - '@octokit/types@16.0.0': - dependencies: - '@octokit/openapi-types': 27.0.0 - - '@oven/bun-darwin-aarch64@1.3.14': - optional: true - - '@oven/bun-darwin-x64-baseline@1.3.14': - optional: true - - '@oven/bun-darwin-x64@1.3.14': - optional: true - - '@oven/bun-freebsd-aarch64@1.3.14': - optional: true - - '@oven/bun-freebsd-x64@1.3.14': - optional: true - - '@oven/bun-linux-aarch64-android@1.3.14': - optional: true - - '@oven/bun-linux-aarch64-musl@1.3.14': - optional: true - - '@oven/bun-linux-aarch64@1.3.14': - optional: true - - '@oven/bun-linux-x64-android@1.3.14': - optional: true - - '@oven/bun-linux-x64-baseline@1.3.14': - optional: true - - '@oven/bun-linux-x64-musl-baseline@1.3.14': - optional: true - - '@oven/bun-linux-x64-musl@1.3.14': - optional: true - - '@oven/bun-linux-x64@1.3.14': - optional: true - - '@oven/bun-windows-aarch64@1.3.14': - optional: true - - '@oven/bun-windows-x64-baseline@1.3.14': - optional: true - - '@oven/bun-windows-x64@1.3.14': - optional: true - - '@oxc-parser/binding-android-arm-eabi@0.131.0': - optional: true - - '@oxc-parser/binding-android-arm64@0.131.0': - optional: true - - '@oxc-parser/binding-darwin-arm64@0.131.0': - optional: true - - '@oxc-parser/binding-darwin-x64@0.131.0': - optional: true - - '@oxc-parser/binding-freebsd-x64@0.131.0': - optional: true + - synckit - '@oxc-parser/binding-linux-arm-gnueabihf@0.131.0': - optional: true + '@nanoforge-dev/utils-eslint-config@1.0.2(@types/eslint@9.6.1)(eslint@10.11.0(jiti@2.6.1))(prettier@3.9.9)(typescript@6.0.3)': + dependencies: + '@eslint/js': 9.39.5 + '@favware/cliff-jumper': 6.1.0 + eslint-config-prettier: 10.1.8(eslint@10.11.0(jiti@2.6.1)) + eslint-formatter-pretty: 7.1.0 + eslint-plugin-format: 1.5.0(eslint@10.11.0(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.6.1)))(eslint@10.11.0(jiti@2.6.1))(prettier@3.9.9) + globals: 16.5.0 + typescript-eslint: 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + transitivePeerDependencies: + - '@types/eslint' + - eslint + - prettier + - supports-color + - typescript - '@oxc-parser/binding-linux-arm-musleabihf@0.131.0': - optional: true + '@nanoforge-dev/utils-prettier-config@1.0.2': {} - '@oxc-parser/binding-linux-arm64-gnu@0.131.0': - optional: true + '@octokit/auth-token@6.0.0': {} - '@oxc-parser/binding-linux-arm64-musl@0.131.0': - optional: true + '@octokit/core@7.0.8': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.5 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 - '@oxc-parser/binding-linux-ppc64-gnu@0.131.0': - optional: true + '@octokit/endpoint@11.0.5': + dependencies: + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 - '@oxc-parser/binding-linux-riscv64-gnu@0.131.0': - optional: true + '@octokit/graphql@9.0.5': + dependencies: + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 - '@oxc-parser/binding-linux-riscv64-musl@0.131.0': - optional: true + '@octokit/openapi-types@27.0.0': {} - '@oxc-parser/binding-linux-s390x-gnu@0.131.0': - optional: true + '@octokit/openapi-types@28.0.0': {} - '@oxc-parser/binding-linux-x64-gnu@0.131.0': - optional: true + '@octokit/openapi-types@29.0.1': {} - '@oxc-parser/binding-linux-x64-musl@0.131.0': - optional: true + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 - '@oxc-parser/binding-openharmony-arm64@0.131.0': - optional: true + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 - '@oxc-parser/binding-wasm32-wasi@0.131.0': + '@octokit/plugin-retry@8.1.1(@octokit/core@7.0.8)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true + '@octokit/core': 7.0.8 + '@octokit/request-error': 7.1.2 + '@octokit/types': 17.0.0 + bottleneck: 2.19.5 - '@oxc-parser/binding-win32-arm64-msvc@0.131.0': - optional: true + '@octokit/request-error@7.1.2': + dependencies: + '@octokit/types': 18.0.0 - '@oxc-parser/binding-win32-ia32-msvc@0.131.0': - optional: true + '@octokit/request@10.0.16': + dependencies: + '@octokit/endpoint': 11.0.5 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + content-type: 3.1.1 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 - '@oxc-parser/binding-win32-x64-msvc@0.131.0': - optional: true + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 - '@oxc-project/types@0.131.0': {} + '@octokit/types@17.0.0': + dependencies: + '@octokit/openapi-types': 28.0.0 - '@oxc-project/types@0.133.0': {} + '@octokit/types@18.0.0': + dependencies: + '@octokit/openapi-types': 29.0.1 - '@oxc-project/types@0.135.0': {} + '@oxc-project/types@0.151.0': {} '@oxfmt/binding-android-arm-eabi@0.35.0': optional: true @@ -4276,115 +4276,59 @@ snapshots: '@pkgr/core@0.3.6': {} - '@playwright/test@1.61.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.61.1 + playwright: 1.63.0 '@polka/url@1.0.0-next.29': {} - '@poppinss/macroable@1.1.2': - optional: true - - '@quansync/fs@1.0.0': + '@quansync/fs@1.1.0': dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm64@1.0.3': - optional: true - - '@rolldown/binding-android-arm64@1.1.1': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.1': - optional: true - - '@rolldown/binding-darwin-x64@1.0.3': - optional: true - - '@rolldown/binding-darwin-x64@1.1.1': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.3': - optional: true - - '@rolldown/binding-freebsd-x64@1.1.1': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.1.1': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.1.1': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.1.1': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.1.1': + '@rolldown/binding-android-arm-eabi@1.2.11': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.3': + '@rolldown/binding-android-arm64@1.2.11': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.1': + '@rolldown/binding-darwin-arm64@1.2.11': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.3': + '@rolldown/binding-darwin-x64@1.2.11': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.1': + '@rolldown/binding-freebsd-x64@1.2.11': optional: true - '@rolldown/binding-linux-x64-musl@1.0.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': optional: true - '@rolldown/binding-linux-x64-musl@1.1.1': + '@rolldown/binding-linux-arm64-gnu@1.2.11': optional: true - '@rolldown/binding-openharmony-arm64@1.0.3': + '@rolldown/binding-linux-arm64-musl@1.2.11': optional: true - '@rolldown/binding-openharmony-arm64@1.1.1': + '@rolldown/binding-linux-ppc64-gnu@1.2.11': optional: true - '@rolldown/binding-wasm32-wasi@1.0.3': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@rolldown/binding-linux-s390x-gnu@1.2.11': optional: true - '@rolldown/binding-wasm32-wasi@1.1.1': - dependencies: - '@emnapi/core': 1.11.0 - '@emnapi/runtime': 1.11.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + '@rolldown/binding-linux-x64-gnu@1.2.11': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.3': + '@rolldown/binding-linux-x64-musl@1.2.11': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.1': + '@rolldown/binding-openharmony-arm64@1.2.11': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.3': + '@rolldown/binding-win32-arm64-msvc@1.2.11': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.1': + '@rolldown/binding-win32-x64-msvc@1.2.11': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4395,112 +4339,113 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sideway/address@4.1.5': - dependencies: - '@hapi/hoek': 9.3.0 - optional: true - - '@sideway/formula@3.0.1': - optional: true - - '@sideway/pinpoint@2.0.0': - optional: true - '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 + '@simple-libs/child-process-utils@2.0.0': + dependencies: + '@simple-libs/stream-utils': 2.0.0 + '@simple-libs/stream-utils@1.2.0': {} '@simple-libs/stream-utils@2.0.0': {} - '@sinclair/typebox@0.31.28': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@sqlite.org/sqlite-wasm@3.48.0-build4': {} - '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)': + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))': dependencies: - acorn: 8.17.0 + '@sveltejs/kit': 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) - '@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': + '@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) - '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) '@types/cookie': 0.6.0 - acorn: 8.17.0 + acorn: 8.18.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.9.4 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 - set-cookie-parser: 3.1.0 + set-cookie-parser: 3.1.2 sirv: 3.0.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) + vite: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) optionalDependencies: typescript: 6.0.3 - '@sveltejs/load-config@0.1.1': {} + '@sveltejs/load-config@0.2.3': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: deepmerge: 4.3.1 - magic-string: 0.30.21 - obug: 2.1.3 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + magic-string: 1.4.2 + obug: 2.2.1 + svelte: 5.57.1(@typescript-eslint/types@8.70.1) + vite: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 - '@tanstack/query-core@5.101.2': {} - - '@tanstack/svelte-query@6.1.36(svelte@5.56.3(@typescript-eslint/types@8.62.1))': - dependencies: - '@tanstack/query-core': 5.101.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - '@testing-library/svelte-core@1.1.3(svelte@5.56.3(@typescript-eslint/types@8.62.1))': + '@testing-library/svelte-core@1.1.3(svelte@5.57.1(@typescript-eslint/types@8.70.1))': dependencies: - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)))(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1))': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)))(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1))': dependencies: - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 javascript-natural-sort: 0.7.1 lodash-es: 4.18.1 minimatch: 9.0.9 parse-imports-exports: 0.2.4 - prettier: 3.9.4 + prettier: 3.9.9 optionalDependencies: - prettier-plugin-svelte: 4.1.1(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + prettier-plugin-svelte: 4.1.1(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) transitivePeerDependencies: - supports-color - '@tsconfig/svelte@5.0.8': {} - - '@tybys/wasm-util@0.10.2': + '@ts-morph/common@0.29.0': dependencies: - tslib: 2.8.1 + minimatch: 10.2.6 + path-browserify: 1.0.1 + tinyglobby: 0.2.17 + + '@turbo/darwin-64@2.11.4': + optional: true + + '@turbo/darwin-arm64@2.11.4': + optional: true + + '@turbo/linux-64@2.11.4': + optional: true + + '@turbo/linux-arm64@2.11.4': + optional: true + + '@turbo/windows-64@2.11.4': optional: true - '@types/braces@3.0.5': {} + '@turbo/windows-arm64@2.11.4': + optional: true - '@types/bun@1.3.14': + '@types/bun@1.4.2': dependencies: - bun-types: 1.3.14 + bun-types: 1.4.2 '@types/chai@5.2.3': dependencies: @@ -4522,383 +4467,272 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/micromatch@4.0.10': - dependencies: - '@types/braces': 3.0.5 - - '@types/node@25.9.3': + '@types/node@26.6.2': dependencies: - undici-types: 7.24.6 + undici-types: 8.9.0 - '@types/trusted-types@2.0.7': {} - - '@types/validator@13.15.10': {} - - '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4)': - dependencies: - '@typeschema/core': 0.14.0(@types/json-schema@7.0.15) - optionalDependencies: - class-validator: 0.14.4 - transitivePeerDependencies: - - '@types/json-schema' - optional: true + '@types/semver@7.8.0': {} - '@typeschema/core@0.14.0(@types/json-schema@7.0.15)': - optionalDependencies: - '@types/json-schema': 7.0.15 + '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.1 - eslint: 10.6.0(jiti@2.7.0) - ignore: 7.0.5 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 + eslint: 10.11.0(jiti@2.6.1) + ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.62.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.62.1': + '@typescript-eslint/scope-manager@8.70.1': dependencies: - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.62.1': {} + '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.62.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/project-service': 8.70.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.4 + minimatch: 10.2.6 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + eslint: 10.11.0(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.1': + '@typescript-eslint/visitor-keys@8.70.1': dependencies: - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 - '@unocss/cli@66.7.4': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.7.4 - '@unocss/core': 66.7.4 - '@unocss/preset-wind3': 66.7.4 - '@unocss/preset-wind4': 66.7.4 - '@unocss/transformer-directives': 66.7.4 - cac: 7.0.0 - chokidar: 5.0.0 - colorette: 2.0.20 - consola: 3.4.2 - magic-string: 0.30.21 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - tinyglobby: 0.2.17 - unplugin-utils: 0.3.1 - - '@unocss/config@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - colorette: 2.0.20 - consola: 3.4.2 - unconfig: 7.5.0 - - '@unocss/core@0.25.1': {} - - '@unocss/core@66.7.4': {} - - '@unocss/extractor-arbitrary-variants@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - - '@unocss/extractor-svelte@66.7.4': {} - - '@unocss/inspector@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/rule-utils': 66.7.4 - colorette: 2.0.20 - gzip-size: 6.0.0 - sirv: 3.0.2 - - '@unocss/preset-attributify@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - - '@unocss/preset-icons@66.7.4': - dependencies: - '@iconify/utils': 3.1.3 - '@unocss/core': 66.7.4 - ofetch: 1.5.1 - - '@unocss/preset-mini@0.25.1': - dependencies: - '@unocss/core': 0.25.1 - - '@unocss/preset-mini@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/extractor-arbitrary-variants': 66.7.4 - '@unocss/rule-utils': 66.7.4 - - '@unocss/preset-tagify@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - - '@unocss/preset-typography@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/rule-utils': 66.7.4 - - '@unocss/preset-uno@0.25.1': - dependencies: - '@unocss/core': 0.25.1 - '@unocss/preset-mini': 0.25.1 - '@unocss/preset-wind': 0.25.1 - - '@unocss/preset-uno@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/preset-wind3': 66.7.4 - - '@unocss/preset-web-fonts@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - ofetch: 1.5.1 - - '@unocss/preset-wind3@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/preset-mini': 66.7.4 - '@unocss/rule-utils': 66.7.4 - - '@unocss/preset-wind4@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/extractor-arbitrary-variants': 66.7.4 - '@unocss/rule-utils': 66.7.4 - - '@unocss/preset-wind@0.25.1': - dependencies: - '@unocss/core': 0.25.1 - '@unocss/preset-mini': 0.25.1 - - '@unocss/preset-wind@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/preset-wind3': 66.7.4 - - '@unocss/rule-utils@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - magic-string: 0.30.21 - - '@unocss/transformer-attributify-jsx@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - oxc-parser: 0.131.0 - oxc-walker: 0.7.0(oxc-parser@0.131.0) - - '@unocss/transformer-compile-class@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - - '@unocss/transformer-directives@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - '@unocss/rule-utils': 66.7.4 - css-tree: 3.2.1 - - '@unocss/transformer-variant-group@66.7.4': - dependencies: - '@unocss/core': 66.7.4 - - '@unocss/vite@66.7.4(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.7.4 - '@unocss/core': 66.7.4 - '@unocss/inspector': 66.7.4 - chokidar: 5.0.0 - magic-string: 0.30.21 - pathe: 2.0.3 - tinyglobby: 0.2.17 - unplugin-utils: 0.3.1 - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) - - '@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3))': - dependencies: - valibot: 1.4.1(typescript@6.0.3) - optional: true - - '@vinejs/compiler@3.0.0': - optional: true - - '@vinejs/vine@3.0.1': - dependencies: - '@poppinss/macroable': 1.1.2 - '@types/validator': 13.15.10 - '@vinejs/compiler': 3.0.0 - camelcase: 8.0.0 - dayjs: 1.11.21 - dlv: 1.1.3 - normalize-url: 8.1.1 - validator: 13.15.35 - optional: true - - '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser-playwright@4.1.11(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - playwright: 1.61.1 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + playwright: 1.63.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser@4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/utils': 4.1.9 + '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - ws: 8.21.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + ws: 8.21.3 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.4 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.7 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) optionalDependencies: - '@vitest/browser': 4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser': 4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11) - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.11': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 + + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true - acorn-jsx@5.3.2(acorn@8.17.0): + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} ajv@6.15.0: dependencies: @@ -4910,7 +4744,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4918,31 +4752,21 @@ snapshots: dependencies: environment: 1.1.0 - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@6.2.3: {} - argparse@2.0.1: {} - - aria-query@5.3.1: {} + ansis@4.4.0: {} - arkregex@0.0.5: - dependencies: - '@ark/util': 0.56.0 - optional: true + argparse@2.0.1: {} - arktype@2.2.0: - dependencies: - '@ark/schema': 0.56.0 - '@ark/util': 0.56.0 - arkregex: 0.0.5 - optional: true + argue-cli@3.2.0: {} - array-timsort@1.0.3: {} + aria-query@5.3.1: {} assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.4: + ast-v8-to-istanbul@1.0.7: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -4956,62 +4780,44 @@ snapshots: before-after-hook@4.0.0: {} - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + bits-ui@2.19.3(@internationalized/date@3.12.4)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/dom': 1.7.6 - '@internationalized/date': 3.12.2 + '@floating-ui/core': 1.8.0 + '@floating-ui/dom': 1.8.0 + '@internationalized/date': 3.12.4 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - tabbable: 6.4.0 + runed: 0.35.1(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + tabbable: 6.5.0 transitivePeerDependencies: - '@sveltejs/kit' bottleneck@2.19.5: {} - brace-expansion@2.1.1: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - bun-types@1.3.14: + bun-types@1.4.2: dependencies: - '@types/node': 25.9.3 - - bun@1.3.14: - optionalDependencies: - '@oven/bun-darwin-aarch64': 1.3.14 - '@oven/bun-darwin-x64': 1.3.14 - '@oven/bun-darwin-x64-baseline': 1.3.14 - '@oven/bun-freebsd-aarch64': 1.3.14 - '@oven/bun-freebsd-x64': 1.3.14 - '@oven/bun-linux-aarch64': 1.3.14 - '@oven/bun-linux-aarch64-android': 1.3.14 - '@oven/bun-linux-aarch64-musl': 1.3.14 - '@oven/bun-linux-x64': 1.3.14 - '@oven/bun-linux-x64-android': 1.3.14 - '@oven/bun-linux-x64-baseline': 1.3.14 - '@oven/bun-linux-x64-musl': 1.3.14 - '@oven/bun-linux-x64-musl-baseline': 1.3.14 - '@oven/bun-windows-aarch64': 1.3.14 - '@oven/bun-windows-x64': 1.3.14 - '@oven/bun-windows-x64-baseline': 1.3.14 + '@types/node': 26.6.2 cac@7.0.0: {} - callsites@3.1.0: {} + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 - camelcase@8.0.0: - optional: true + callsites@3.1.0: {} chai@6.2.2: {} @@ -5021,36 +4827,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - - chownr@3.0.0: {} - - class-transformer@0.5.1: {} - - class-validator@0.14.4: - dependencies: - '@types/validator': 13.15.10 - libphonenumber-js: 1.13.6 - validator: 13.15.35 - optional: true - - class-validator@0.15.1: - dependencies: - '@types/validator': 13.15.10 - libphonenumber-js: 1.13.6 - validator: 13.15.35 - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.1 - cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -5059,34 +4835,23 @@ snapshots: clsx@2.1.1: {} - colorette@2.0.20: {} + code-block-writer@13.0.3: {} - commander@11.1.0: {} + colorette@2.0.20: {} commander@14.0.3: {} commander@15.0.0: {} - comment-json@4.6.2: - dependencies: - array-timsort: 1.0.3 - esprima: 4.0.1 - - confbox@0.1.8: {} - - consola@3.4.0: {} + content-type@3.1.1: {} - consola@3.4.2: {} - - content-type@2.0.0: {} - - conventional-changelog-angular@9.2.0: + conventional-changelog-angular@9.4.0: dependencies: - '@conventional-changelog/template': 1.2.0 + '@conventional-changelog/template': 1.4.0 - conventional-changelog-conventionalcommits@10.2.0: + conventional-changelog-conventionalcommits@10.4.0: dependencies: - '@conventional-changelog/template': 1.2.0 + '@conventional-changelog/template': 1.4.0 conventional-changelog-preset-loader@5.0.0: {} @@ -5097,10 +4862,10 @@ snapshots: '@simple-libs/stream-utils': 1.2.0 meow: 13.2.0 - conventional-commits-parser@7.0.0: + conventional-commits-parser@7.1.2: dependencies: '@simple-libs/stream-utils': 2.0.0 - meow: 14.1.0 + argue-cli: 3.2.0 conventional-recommended-bump@11.2.0: dependencies: @@ -5114,9 +4879,9 @@ snapshots: cookie@0.6.0: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.3)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@26.6.2)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@types/node': 25.9.3 + '@types/node': 26.6.2 cosmiconfig: 9.0.2(typescript@6.0.3) jiti: 2.6.1 typescript: 6.0.3 @@ -5125,7 +4890,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -5136,22 +4901,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.1 - cssesc@3.0.0: {} - dayjs@1.11.21: - optional: true - debug@4.4.3: dependencies: ms: 2.1.3 - dedent@1.5.1: {} - deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -5160,31 +4915,20 @@ snapshots: dequal@2.0.3: {} - destr@2.0.5: {} - detect-libc@2.1.2: {} - devalue@5.8.1: {} - - dlv@1.1.3: - optional: true + devalue@5.9.4: {} dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 - dotenv@17.4.2: {} - - duplexer@0.1.2: {} - - effect@3.21.3: - dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 - optional: true + dts-resolver@3.0.0: {} emoji-regex@10.6.0: {} + empathic@2.1.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -5193,17 +4937,17 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-module-lexer@2.1.0: {} + es-module-lexer@2.3.2: {} - es-toolkit@1.47.1: {} + es-toolkit@1.52.0: {} escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.6.1)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.6.1) eslint-formatter-pretty@7.1.0: dependencies: @@ -5213,54 +4957,54 @@ snapshots: eslint-rule-docs: 1.1.235 log-symbols: 7.0.1 plur: 5.1.0 - string-width: 8.2.1 - supports-hyperlinks: 4.4.0 + string-width: 8.3.0 + supports-hyperlinks: 4.5.0 - eslint-formatting-reporter@0.0.0(eslint@10.6.0(jiti@2.7.0)): + eslint-formatting-reporter@0.0.0(eslint@10.11.0(jiti@2.6.1)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.11.0(jiti@2.6.1) prettier-linter-helpers: 1.0.1 eslint-parser-plain@0.1.1: {} - eslint-plugin-format@1.5.0(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-format@1.5.0(eslint@10.11.0(jiti@2.6.1)): dependencies: '@dprint/formatter': 0.5.1 '@dprint/markdown': 0.21.1 '@dprint/toml': 0.7.0 - eslint: 10.6.0(jiti@2.7.0) - eslint-formatting-reporter: 0.0.0(eslint@10.6.0(jiti@2.7.0)) + eslint: 10.11.0(jiti@2.6.1) + eslint-formatting-reporter: 0.0.0(eslint@10.11.0(jiti@2.6.1)) eslint-parser-plain: 0.1.1 - ohash: 2.0.11 + ohash: 2.0.12 oxfmt: 0.35.0 - prettier: 3.9.4 + prettier: 3.9.9 synckit: 0.11.13 - eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4): + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.6.1)))(eslint@10.11.0(jiti@2.6.1))(prettier@3.9.9): dependencies: - eslint: 10.6.0(jiti@2.7.0) - prettier: 3.9.4 + eslint: 10.11.0(jiti@2.6.1) + prettier: 3.9.9 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@10.6.0(jiti@2.7.0)) + eslint-config-prettier: 10.1.8(eslint@10.11.0(jiti@2.6.1)) - eslint-plugin-svelte@3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + eslint-plugin-svelte@3.23.0(eslint@10.11.0(jiti@2.6.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) - '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 10.6.0(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.6.0 + eslint: 10.11.0(jiti@2.6.1) esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 - postcss: 8.5.15 - postcss-load-config: 3.1.4(postcss@8.5.15) - postcss-safe-parser: 7.0.1(postcss@8.5.15) - semver: 7.8.4 - svelte-eslint-parser: 1.8.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) + postcss: 8.5.28 + postcss-load-config: 3.1.4(postcss@8.5.28) + postcss-safe-parser: 7.1.0(postcss@8.5.28) + semver: 7.8.5 + svelte-eslint-parser: 1.8.1(svelte@5.57.1(@typescript-eslint/types@8.70.1)) optionalDependencies: - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) transitivePeerDependencies: - ts-node @@ -5284,14 +5028,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.6.0(jiti@2.7.0): + eslint@10.11.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 + '@eslint/plugin-kit': 0.7.3 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -5306,18 +5050,18 @@ snapshots: esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 + file-entry-cache: 11.1.5 find-up: 5.0.0 glob-parent: 6.0.2 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.7.0 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -5325,27 +5069,25 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 - esrap@2.2.11(@typescript-eslint/types@8.62.1): + esrap@2.3.12(@typescript-eslint/types@8.70.1): dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 optionalDependencies: - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/types': 8.70.1 esrecurse@4.3.0: dependencies: @@ -5359,8 +5101,6 @@ snapshots: esutils@2.0.3: {} - eventemitter3@5.0.4: {} - execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -5371,17 +5111,12 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.1.2 + yoctocolors: 2.2.0 - expect-type@1.3.0: {} - - fast-check@3.23.2: - dependencies: - pure-rand: 6.1.0 - optional: true + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} @@ -5391,93 +5126,79 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.2: {} + fast-uri@3.1.8: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 + + fflate@0.8.3: {} figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 - file-entry-cache@8.0.0: + file-entry-cache@11.1.5: dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 + flat-cache: 6.1.23 find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - formsnap@2.0.1(svelte@5.56.3(@typescript-eslint/types@8.62.1))(sveltekit-superforms@2.30.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)): + flat-cache@6.1.23: dependencies: - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.5.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - sveltekit-superforms: 2.30.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3) + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 - fsevents@2.3.2: - optional: true + flatted@3.4.4: {} fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.7.0: {} get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - git-cliff-darwin-arm64@2.13.1: + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + git-cliff-darwin-arm64@2.14.2: optional: true - git-cliff-darwin-x64@2.13.1: + git-cliff-darwin-x64@2.14.2: optional: true - git-cliff-linux-arm64@2.13.1: + git-cliff-linux-arm64@2.14.2: optional: true - git-cliff-linux-x64@2.13.1: + git-cliff-linux-x64@2.14.2: optional: true - git-cliff-windows-arm64@2.13.1: + git-cliff-windows-arm64@2.14.2: optional: true - git-cliff-windows-x64@2.13.1: + git-cliff-windows-x64@2.14.2: optional: true - git-cliff@2.13.1: + git-cliff@2.14.2: dependencies: execa: 9.6.1 optionalDependencies: - git-cliff-darwin-arm64: 2.13.1 - git-cliff-darwin-x64: 2.13.1 - git-cliff-linux-arm64: 2.13.1 - git-cliff-linux-x64: 2.13.1 - git-cliff-windows-arm64: 2.13.1 - git-cliff-windows-x64: 2.13.1 - - git-raw-commits@5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): - dependencies: - '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) - meow: 13.2.0 - transitivePeerDependencies: - - conventional-commits-filter - - conventional-commits-parser + git-cliff-darwin-arm64: 2.14.2 + git-cliff-darwin-x64: 2.14.2 + git-cliff-linux-arm64: 2.14.2 + git-cliff-linux-x64: 2.14.2 + git-cliff-windows-arm64: 2.14.2 + git-cliff-windows-x64: 2.14.2 glob-parent@6.0.2: dependencies: @@ -5489,19 +5210,23 @@ snapshots: globals@16.5.0: {} - globals@17.7.0: {} - - gzip-size@6.0.0: - dependencies: - duplexer: 0.1.2 + globals@17.12.0: {} has-flag@4.0.0: {} has-flag@5.0.1: {} - html-escaper@2.0.2: {} + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hookable@6.1.2: {} - human-id@4.2.0: {} + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-escaper@2.0.2: {} human-signals@8.0.1: {} @@ -5509,19 +5234,14 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} - - import-fresh@3.3.0: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 + ignore@7.0.10: {} import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - import-meta-resolve@4.2.0: {} + import-without-cache@0.4.1: {} imurmurhash@0.1.4: {} @@ -5535,16 +5255,10 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - is-number@7.0.0: {} - is-plain-obj@4.1.0: {} is-reference@3.0.3: @@ -5574,135 +5288,99 @@ snapshots: jiti@2.6.1: {} - jiti@2.7.0: {} - - joi@17.13.4: - dependencies: - '@hapi/hoek': 9.3.0 - '@hapi/topo': 5.1.0 - '@sideway/address': 4.1.5 - '@sideway/formula': 3.0.1 - '@sideway/pinpoint': 2.0.0 - optional: true - - js-sha256@0.11.1: {} - js-tokens@10.0.0: {} js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} - json-schema-to-ts@3.1.1: - dependencies: - '@babel/runtime': 7.29.7 - ts-algebra: 2.0.0 - optional: true - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} json-stable-stringify-without-jsonify@1.0.1: {} - json-with-bigint@3.5.8: {} - - json5@2.2.3: {} + json-with-bigint@3.5.12: {} - keyv@4.5.4: + keyv@5.6.0: dependencies: - json-buffer: 3.0.1 + '@keyv/serialize': 1.1.1 kleur@4.1.5: {} known-css-properties@0.37.0: {} - kysely@0.28.17: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.13.6: {} - - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 lilconfig@2.1.0: {} lines-and-columns@1.2.4: {} - lint-staged@17.0.8: + lint-staged@17.5.1: dependencies: - listr2: 10.2.1 - picomatch: 4.0.4 + picomatch: 4.0.7 string-argv: 0.3.2 - tinyexec: 1.2.4 + tinyexec: 1.3.1 optionalDependencies: - yaml: 2.9.0 - - listr2@10.2.1: - dependencies: - cli-truncate: 5.2.0 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 10.0.0 + yaml: 2.9.1 locate-character@3.0.0: {} @@ -5715,85 +5393,39 @@ snapshots: log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 - yoctocolors: 2.1.2 - - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 + yoctocolors: 2.2.0 lz-string@1.5.0: {} - magic-regexp@0.10.0: + magic-string@0.30.21: dependencies: - estree-walker: 3.0.3 - magic-string: 0.30.21 - mlly: 1.8.2 - regexp-tree: 0.1.27 - type-level-regexp: 0.1.17 - ufo: 1.6.4 - unplugin: 2.3.11 + '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@0.30.21: + magic-string@1.4.2: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magicast@0.5.3: + magicast@0.5.5: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 marked@14.0.0: {} - mdn-data@2.27.1: {} - - memoize-weak@1.0.2: {} - meow@13.2.0: {} - meow@14.1.0: {} - - micromatch@4.0.8: + minimatch@10.2.6: dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mimic-function@5.0.1: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.12 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.1 - - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - - mlly@1.8.2: - dependencies: - acorn: 8.17.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.4 - - mode-watcher@1.1.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - runed: 0.25.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.7.1(svelte@5.56.3(@typescript-eslint/types@8.62.1)) + brace-expansion: 2.1.7 monaco-editor@0.55.1: dependencies: @@ -5806,33 +5438,18 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.19: {} natural-compare@1.4.0: {} - node-fetch-native@1.6.7: {} - - normalize-url@8.1.1: - optional: true - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 unicorn-magic: 0.3.0 - obug@2.1.3: {} - - ofetch@1.5.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.4 - - ohash@2.0.11: {} + obug@2.2.1: {} - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 + ohash@2.0.12: {} optionator@0.9.4: dependencies: @@ -5843,36 +5460,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.131.0: - dependencies: - '@oxc-project/types': 0.131.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.131.0 - '@oxc-parser/binding-android-arm64': 0.131.0 - '@oxc-parser/binding-darwin-arm64': 0.131.0 - '@oxc-parser/binding-darwin-x64': 0.131.0 - '@oxc-parser/binding-freebsd-x64': 0.131.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.131.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.131.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.131.0 - '@oxc-parser/binding-linux-arm64-musl': 0.131.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.131.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.131.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.131.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.131.0 - '@oxc-parser/binding-linux-x64-gnu': 0.131.0 - '@oxc-parser/binding-linux-x64-musl': 0.131.0 - '@oxc-parser/binding-openharmony-arm64': 0.131.0 - '@oxc-parser/binding-wasm32-wasi': 0.131.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.131.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.131.0 - '@oxc-parser/binding-win32-x64-msvc': 0.131.0 - - oxc-walker@0.7.0(oxc-parser@0.131.0): - dependencies: - magic-regexp: 0.10.0 - oxc-parser: 0.131.0 - oxfmt@0.35.0: dependencies: tinypool: 2.1.0 @@ -5905,8 +5492,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-manager-detector@1.6.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5926,6 +5511,8 @@ snapshots: parse-statements@1.0.11: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -5934,27 +5521,15 @@ snapshots: pathe@2.0.3: {} - perfect-debounce@2.1.0: {} - picocolors@1.1.1: {} - picomatch@2.3.2: {} + picomatch@4.0.7: {} - picomatch@4.0.4: {} + playwright-core@1.63.0: {} - pkg-types@1.3.1: + playwright@1.63.0: dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - playwright-core@1.61.1: {} - - playwright@1.61.1: - dependencies: - playwright-core: 1.61.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 plur@5.1.0: dependencies: @@ -5962,29 +5537,29 @@ snapshots: pngjs@7.0.0: {} - postcss-load-config@3.1.4(postcss@8.5.15): + postcss-load-config@3.1.4(postcss@8.5.28): dependencies: lilconfig: 2.1.0 yaml: 1.10.3 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.28 - postcss-safe-parser@7.0.1(postcss@8.5.15): + postcss-safe-parser@7.1.0(postcss@8.5.28): dependencies: - postcss: 8.5.15 + postcss: 8.5.28 - postcss-scss@4.0.9(postcss@8.5.15): + postcss-scss@4.0.9(postcss@8.5.28): dependencies: - postcss: 8.5.15 + postcss: 8.5.28 - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.15: + postcss@8.5.28: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5994,121 +5569,86 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-svelte@4.1.1(prettier@3.9.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + prettier-plugin-svelte@4.1.1(prettier@3.9.9)(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: - prettier: 3.9.4 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - prettier@2.5.1: {} + prettier: 3.9.9 + svelte: 5.57.1(@typescript-eslint/types@8.70.1) - prettier@3.9.4: {} + prettier@3.9.9: {} - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 - property-expr@2.0.6: - optional: true - punycode@2.3.1: {} - pure-rand@6.1.0: - optional: true + qified@0.10.1: + dependencies: + hookified: 2.2.0 quansync@1.0.0: {} readdirp@4.1.2: {} - readdirp@5.0.0: {} - - regexp-tree@0.1.27: {} - require-from-string@2.0.2: {} resolve-from@4.0.0: {} resolve-from@5.0.0: {} - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 + resolve-pkg-maps@1.0.0: {} - rfdc@1.4.1: {} - - rolldown@1.0.3: + rolldown-plugin-dts@0.27.14(rolldown@1.2.11)(typescript@6.0.3): dependencies: - '@oxc-project/types': 0.133.0 - '@rolldown/pluginutils': 1.0.1 + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.2.1 + rolldown: 1.2.11 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 - - rolldown@1.1.1: - dependencies: - '@oxc-project/types': 0.135.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.1 - '@rolldown/binding-darwin-arm64': 1.1.1 - '@rolldown/binding-darwin-x64': 1.1.1 - '@rolldown/binding-freebsd-x64': 1.1.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.1 - '@rolldown/binding-linux-arm64-gnu': 1.1.1 - '@rolldown/binding-linux-arm64-musl': 1.1.1 - '@rolldown/binding-linux-ppc64-gnu': 1.1.1 - '@rolldown/binding-linux-s390x-gnu': 1.1.1 - '@rolldown/binding-linux-x64-gnu': 1.1.1 - '@rolldown/binding-linux-x64-musl': 1.1.1 - '@rolldown/binding-openharmony-arm64': 1.1.1 - '@rolldown/binding-wasm32-wasi': 1.1.1 - '@rolldown/binding-win32-arm64-msvc': 1.1.1 - '@rolldown/binding-win32-x64-msvc': 1.1.1 - - runed@0.23.4(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - esm-env: 1.2.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - runed@0.25.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - esm-env: 1.2.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver - runed@0.28.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + rolldown@1.2.11: dependencies: - esm-env: 1.2.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - runed@0.35.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + runed@0.35.1(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) optionalDependencies: - '@sveltejs/kit': 2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + '@sveltejs/kit': 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) sade@1.8.1: dependencies: mri: 1.2.0 - semver@7.8.4: {} + semver@7.8.5: {} - set-cookie-parser@3.1.0: {} + set-cookie-parser@3.1.2: {} shebang-command@2.0.0: dependencies: @@ -6126,45 +5666,30 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - smol-toml@1.6.1: {} + smol-toml@1.9.0: {} source-map-js@1.2.1: {} - sqlite-wasm-kysely@0.3.0(kysely@0.28.17): - dependencies: - '@sqlite.org/sqlite-wasm': 3.48.0-build4 - kysely: 0.28.17 - stackback@0.0.2: {} - std-env@4.1.0: {} + std-env@4.2.0: {} string-argv@0.3.2: {} string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 - string-width@8.2.1: + string-width@8.3.0: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-final-newline@4.0.0: {} @@ -6172,348 +5697,238 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - superstruct@2.0.2: - optional: true - supports-color@10.2.2: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-hyperlinks@4.4.0: + supports-hyperlinks@4.5.0: dependencies: has-flag: 5.0.1 supports-color: 10.2.2 - svelte-adapter-bun@1.0.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3): - dependencies: - '@sveltejs/kit': 2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - rolldown: 1.1.1 - typescript: 6.0.3 - - svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3): + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@sveltejs/load-config': 0.1.1 + '@sveltejs/load-config': 0.2.3 chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.7) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) typescript: 6.0.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + svelte-eslint-parser@1.8.1(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.15 - postcss-scss: 4.0.9(postcss@8.5.15) - postcss-selector-parser: 7.1.4 - semver: 7.8.4 + postcss: 8.5.28 + postcss-scss: 4.0.9(postcss@8.5.28) + postcss-selector-parser: 7.1.6 + semver: 7.8.5 optionalDependencies: - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - svelte-kit-sessions@0.4.0(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - '@isaacs/ttlcache': 1.4.1 - '@sveltejs/kit': 2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) - svelte-sonner@1.1.1(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - runed: 0.28.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - svelte-toolbelt@0.10.6(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1)) + runed: 0.35.1(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1))(typescript@6.0.3)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)))(svelte@5.57.1(@typescript-eslint/types@8.70.1)) style-to-object: 1.0.14 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) transitivePeerDependencies: - '@sveltejs/kit' - svelte-toolbelt@0.5.0(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - clsx: 2.1.1 - style-to-object: 1.0.14 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - svelte-toolbelt@0.7.1(svelte@5.56.3(@typescript-eslint/types@8.62.1)): - dependencies: - clsx: 2.1.1 - runed: 0.23.4(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - style-to-object: 1.0.14 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - - svelte@5.56.3(@typescript-eslint/types@8.62.1): + svelte@5.57.1(@typescript-eslint/types@8.70.1): dependencies: '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) '@types/estree': 1.0.9 - '@types/trusted-types': 2.0.7 - acorn: 8.17.0 + acorn: 8.18.0 aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.1 + devalue: 5.9.4 esm-env: 1.2.2 - esrap: 2.2.11(@typescript-eslint/types@8.62.1) + esrap: 2.3.12(@typescript-eslint/types@8.70.1) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 - zimmerframe: 1.1.4 + zimmerframe: 1.1.5 transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-superforms@2.30.1(@sveltejs/kit@2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3): - dependencies: - '@sveltejs/kit': 2.65.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - devalue: 5.8.1 - memoize-weak: 1.0.2 - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - ts-deepmerge: 7.0.3 - optionalDependencies: - '@exodus/schemasafe': 1.3.0 - '@standard-schema/spec': 1.1.0 - '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4) - '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@6.0.3)) - '@vinejs/vine': 3.0.1 - arktype: 2.2.0 - class-validator: 0.14.4 - effect: 3.21.3 - joi: 17.13.4 - json-schema-to-ts: 3.1.1 - superstruct: 2.0.2 - typebox: 1.2.8 - valibot: 1.4.1(typescript@6.0.3) - yup: 1.7.1 - zod: 4.4.3 - zod-v3-to-json-schema: 4.0.0(zod@4.4.3) - transitivePeerDependencies: - - '@types/json-schema' - - typescript - synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 - tabbable@6.4.0: {} - - tailwind-merge@3.6.0: {} - - tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2): - dependencies: - tailwindcss: 4.3.2 - optionalDependencies: - tailwind-merge: 3.6.0 - - tailwindcss@4.3.2: {} - - tar@7.5.19: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - - tiny-case@1.0.3: - optional: true + tabbable@6.5.0: {} tinybench@2.9.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.1.0: {} - tinyrainbow@3.1.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toposort@2.0.2: - optional: true + tinyrainbow@3.1.1: {} totalist@3.0.1: {} - ts-algebra@2.0.0: - optional: true + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 - ts-deepmerge@7.0.3: {} + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + + tsdown@0.22.14(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)): + dependencies: + ansis: 4.4.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.11 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.11)(typescript@6.0.3) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 6.0.3 + unrun: 0.3.1(synckit@0.11.13) + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc tslib@2.8.1: {} tunnel@0.0.6: {} + turbo@2.11.4: + optionalDependencies: + '@turbo/darwin-64': 2.11.4 + '@turbo/darwin-arm64': 2.11.4 + '@turbo/linux-64': 2.11.4 + '@turbo/linux-arm64': 2.11.4 + '@turbo/windows-64': 2.11.4 + '@turbo/windows-arm64': 2.11.4 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@2.19.0: - optional: true - - type-level-regexp@0.1.17: {} - - typebox@1.2.8: - optional: true - - typescript-eslint@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.11.0(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color typescript@6.0.3: {} - ufo@1.6.4: {} - unconfig-core@7.5.0: dependencies: - '@quansync/fs': 1.0.0 - quansync: 1.0.0 - - unconfig@7.5.0: - dependencies: - '@quansync/fs': 1.0.0 - defu: 6.1.7 - jiti: 2.7.0 + '@quansync/fs': 1.1.0 quansync: 1.0.0 - unconfig-core: 7.5.0 - undici-types@7.24.6: {} + undici-types@8.9.0: {} - undici@6.26.0: {} + undici@6.28.1: {} unicorn-magic@0.3.0: {} universal-user-agent@7.0.3: {} - unocss@66.7.4(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)): - dependencies: - '@unocss/cli': 66.7.4 - '@unocss/core': 66.7.4 - '@unocss/preset-attributify': 66.7.4 - '@unocss/preset-icons': 66.7.4 - '@unocss/preset-mini': 66.7.4 - '@unocss/preset-tagify': 66.7.4 - '@unocss/preset-typography': 66.7.4 - '@unocss/preset-uno': 66.7.4 - '@unocss/preset-web-fonts': 66.7.4 - '@unocss/preset-wind': 66.7.4 - '@unocss/preset-wind3': 66.7.4 - '@unocss/preset-wind4': 66.7.4 - '@unocss/transformer-attributify-jsx': 66.7.4 - '@unocss/transformer-compile-class': 66.7.4 - '@unocss/transformer-directives': 66.7.4 - '@unocss/transformer-variant-group': 66.7.4 - '@unocss/vite': 66.7.4(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - transitivePeerDependencies: - - vite - - unplugin-utils@0.3.1: - dependencies: - pathe: 2.0.3 - picomatch: 4.0.4 - - unplugin@2.3.11: + unrun@0.3.1(synckit@0.11.13): dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.17.0 - picomatch: 4.0.4 - webpack-virtual-modules: 0.6.2 + rolldown: 1.2.11 + optionalDependencies: + synckit: 0.11.13 uri-js@4.4.1: dependencies: punycode: 2.3.1 - urlpattern-polyfill@10.1.0: {} - util-deprecate@1.0.2: {} - uuid@14.0.0: {} - - valibot@1.4.1(typescript@6.0.3): - optionalDependencies: - typescript: 6.0.3 - optional: true - - validator@13.15.35: {} + verkit@0.3.2: {} - vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0): + vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.6.2 fsevents: 2.3.3 - jiti: 2.7.0 - yaml: 2.9.0 + jiti: 2.6.1 + yaml: 2.9.1 - vitefu@1.1.3(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)): optionalDependencies: - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) - vitest-browser-svelte@2.2.0(svelte@5.56.3(@typescript-eslint/types@8.62.1))(vitest@4.1.9): + vitest-browser-svelte@2.2.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vitest@4.1.11): dependencies: - '@testing-library/svelte-core': 1.1.3(svelte@5.56.3(@typescript-eslint/types@8.62.1)) - svelte: 5.56.3(@typescript-eslint/types@8.62.1) - vitest: 4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + '@testing-library/svelte-core': 1.1.3(svelte@5.57.1(@typescript-eslint/types@8.70.1)) + svelte: 5.57.1(@typescript-eslint/types@8.70.1) + vitest: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) - vitest@4.1.9(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 + picomatch: 4.0.7 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + tinyrainbow: 3.1.1 + vite: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.3 - '@vitest/browser-playwright': 4.1.9(playwright@1.61.1)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + '@types/node': 26.6.2 + '@vitest/browser-playwright': 4.1.11(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) transitivePeerDependencies: - msw - webpack-virtual-modules@0.6.2: {} - which@2.0.2: dependencies: isexe: 2.0.0 @@ -6525,57 +5940,74 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.1 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.0: {} + ws@8.21.3: {} y18n@5.0.8: {} - yallist@5.0.0: {} - yaml@1.10.3: {} - yaml@2.9.0: - optional: true + yaml@2.9.1: {} yargs-parser@22.0.0: {} - yargs@18.0.0: + yargs@18.2.0: dependencies: cliui: 9.0.1 escalade: 3.2.0 get-caller-file: 2.0.5 - string-width: 7.2.0 + string-width: 8.3.0 y18n: 5.0.8 yargs-parser: 22.0.0 yocto-queue@0.1.0: {} - yoctocolors@2.1.2: {} + yoctocolors@2.2.0: {} - yup@1.7.1: + yuku-ast@0.8.7: dependencies: - property-expr: 2.0.6 - tiny-case: 1.0.3 - toposort: 2.0.2 - type-fest: 2.19.0 - optional: true - - zimmerframe@1.1.4: {} + '@yuku-toolchain/types': 0.8.7 - zod-v3-to-json-schema@4.0.0(zod@4.4.3): + yuku-codegen@0.8.7: dependencies: - zod: 4.4.3 - optional: true - - zod@4.4.3: {} + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 + + zimmerframe@1.1.5: {} + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0cd6228b..b9a857c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,11 +1,19 @@ +packages: + - apps/* + - packages/* + - plugins/* + - tooling/* + allowBuilds: bun: true + esbuild: true catalogs: build: - '@tsconfig/svelte': ^5.0.8 - 'svelte-adapter-bun': ^1.0.1 + tsdown: ^0.22.14 + turbo: ^2.10.12 typescript: ^6.0.3 + unrun: ^0.3.0 ci: '@commitlint/cli': ^21.2.0 '@commitlint/config-conventional': ^21.2.0 @@ -14,53 +22,30 @@ catalogs: husky: ^9.1.7 lint-staged: ^17.0.8 components: - bits-ui: ^2.18.1 + '@lucide/svelte': ^1.48.0 + '@fontsource/instrument-sans': ^5.3.0 + '@fontsource/jetbrains-mono': ^5.3.0 + bits-ui: ^2.19.3 + paneforge: ^1.0.2 monaco-editor: ^0.55.1 - svelte-sonner: ^1.1.1 + config: + '@nanoforge-dev/config': ^2.3.0 core: - '@nanoforge-dev/ecs-lib': ^1.4.0 - '@sveltejs/adapter-auto': ^7.0.1 '@sveltejs/kit': ^2.65.0 '@sveltejs/vite-plugin-svelte': ^7.1.2 '@types/bun': ^1.3.14 - bun: ^1.3.14 + '@types/node': ^26.4.0 svelte: ^5.56.3 svelte-check: ^4.6.0 - svelte-kit-sessions: ^0.4.0 vite: ^8.0.16 - css: - '@alexanderniebuhr/prettier-plugin-unocss': ^0.0.4 - '@unocss/extractor-svelte': ^66.7.4 - '@unocss/preset-icons': ^66.7.4 - '@unocss/preset-web-fonts': ^66.7.4 - '@unocss/preset-wind4': ^66.7.4 - clsx: ^2.1.1 - tailwind-merge: ^3.6.0 - tailwind-variants: ^3.2.2 - tailwindcss: ^4.3.2 - unocss: ^66.7.4 - i18n: - '@inlang/paraglide-js': ^2.20.2 - icons: - '@iconify-json/clarity': ^1.2.4 - '@iconify-json/ic': ^1.2.4 - '@iconify-json/icomoon-free': ^1.2.1 - '@iconify-json/material-icon-theme': ^1.2.68 - '@iconify-json/solar': ^1.2.5 - '@lucide/svelte': ^1.22.0 - libs-back: - class-transformer: ^0.5.1 - class-validator: ^0.15.1 - dotenv: ^17.4.2 - micromatch: ^4.0.8 - tar: ^7.5.19 - libs-front: - '@internationalized/date': ^3.12.2 - '@tanstack/svelte-query': ^6.1.36 - formsnap: ^2.0.1 - mode-watcher: ^1.1.0 - svelte-sonner: ^1.1.1 - sveltekit-superforms: ^2.30.1 + adapter: + '@sveltejs/adapter-static': ^3.0.10 + libs: + '@jridgewell/trace-mapping': ^0.3.31 + fflate: ^0.8.2 + ignore: ^7.0.5 + semver: ^7.7.3 + '@types/semver': ^7.7.1 zod: ^4.4.3 lint: '@nanoforge-dev/utils-eslint-config': ^1.0.2 @@ -72,10 +57,18 @@ catalogs: prettier: ^3.9.4 prettier-plugin-svelte: ^4.1.1 typescript-eslint: ^8.62.1 + code: + ts-morph: ^28.0.0 + tooling: + es-module-lexer: ^2.0.0 + server: + chokidar: ^4.0.3 + prettier: ^3.9.4 + yaml: ^2.9.1 test: '@playwright/test': ^1.61.1 '@vitest/browser-playwright': ^4.1.9 '@vitest/coverage-v8': ^4.1.9 - playwright: ^1.61.1 + playwright: ^1.63.0 vitest: ^4.1.9 vitest-browser-svelte: ^2.2.0 diff --git a/prettier.config.js b/prettier.config.js index e2ca49d7..362b4ed4 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -4,14 +4,6 @@ export default { ...prettierConfig, plugins: ['@trivago/prettier-plugin-sort-imports', 'prettier-plugin-svelte'], overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], - importOrder: [ - '^.app/(.*)$', - '^.env/(.*)$', - '^.lib/(.*)$', - '^@utils/(.*)$', - '^@utils-client/(.*)$', - '^@utils-server/(.*)$', - '^[./]', - ], + importOrder: ['^@nanoforge-dev/(.*)$', '^\\$app/(.*)$', '^\\$lib/(.*)$', '^[./]'], singleQuote: true, }; diff --git a/project.inlang/settings.json b/project.inlang/settings.json deleted file mode 100644 index f135c202..00000000 --- a/project.inlang/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/project-settings", - "modules": [ - "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" - ], - "plugin.inlang.messageFormat": { - "pathPattern": "./messages/{locale}.json" - }, - "baseLocale": "en", - "locales": ["en", "fr"] -} diff --git a/scripts/engine-bridge.mjs b/scripts/engine-bridge.mjs new file mode 100644 index 00000000..b3e250ee --- /dev/null +++ b/scripts/engine-bridge.mjs @@ -0,0 +1,81 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; + +const speaks = (file) => + existsSync(file) && readFileSync(file, 'utf8').includes('EDITOR_PROTOCOL_VERSION'); + +/** + * How an engine checkout speaks the editor bridge: + * - `library`: engine 2.0 and later. The bridge is `@nanoforge-dev/editor-lib` + * (`modules/editor`), and a game registers its `EditorLibrary`; + * - `core`: earlier bridge branches, where core handled it for every game; + * - `undefined`: no bridge (or the engine is not built). + */ +export const bridgeOf = (engine) => { + if (speaks(join(engine, 'modules/editor/dist/index.js'))) return 'library'; + if (speaks(join(engine, 'packages/common/dist/index.js'))) return 'core'; + return undefined; +}; + +/** Links `@nanoforge-dev/editor-lib` of the engine into the `node_modules` of a game's app. */ +export const linkEditorLibrary = (engine, app) => { + const link = join(app, 'node_modules/@nanoforge-dev/editor-lib'); + mkdirSync(dirname(link), { recursive: true }); + rmSync(link, { recursive: true, force: true }); + symlinkSync(join(engine, 'modules/editor'), link); +}; + +/** + * Registers `EditorLibrary` in a game's `main.ts`, after its last `app.use(...)`. A game that + * already has it is left as it is. + */ +export const useEditorLibrary = (main) => { + const text = readFileSync(main, 'utf8'); + if (text.includes('EditorLibrary')) return; + const uses = [...text.matchAll(/^([ \t]*)app\.use\(.*\);[ \t]*$/gm)]; + const last = uses.at(-1); + if (!last) throw new Error(`${main} registers no library: cannot add the editor library`); + const end = last.index + last[0].length; + writeFileSync( + main, + `import { EditorLibrary } from "@nanoforge-dev/editor-lib";\n${text.slice(0, end)}\n${last[1]}app.use(new EditorLibrary());${text.slice(end)}`, + ); +}; + +/** + * Gives a game folder the `node_modules` of a folder of the engine's examples: a copy made of + * links (scopes and `.bin` are real folders of links), so the game resolves the engine's builds. + */ +export const linkNodeModules = (from, to) => { + rmSync(to, { recursive: true, force: true }); + const copy = (dir, target) => { + mkdirSync(target, { recursive: true }); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.vite' || entry.name === '.unrun') continue; + const path = join(dir, entry.name); + if (lstatSync(path).isDirectory()) copy(path, join(target, entry.name)); + else symlinkSync(realpathSync(path), join(target, entry.name)); + } + }; + copy(from, to); +}; + +/** Links engine modules (`modules/`) into the `node_modules` of a game's app. */ +export const linkEngineModules = (engine, app, modules) => { + for (const module of modules) { + const target = join(app, 'node_modules/@nanoforge-dev', module); + mkdirSync(dirname(target), { recursive: true }); + rmSync(target, { recursive: true, force: true }); + symlinkSync(join(engine, 'modules', module), target); + } +}; diff --git a/scripts/example.mjs b/scripts/example.mjs new file mode 100644 index 00000000..f871ec2b --- /dev/null +++ b/scripts/example.mjs @@ -0,0 +1,88 @@ +import { spawn } from 'node:child_process'; +import { existsSync, rmSync, symlinkSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { + bridgeOf, + linkEditorLibrary, + linkEngineModules, + linkNodeModules, +} from './engine-bridge.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const examples = join(root, 'examples'); +/** + * The examples. Their `node_modules` are those of the engine's pong example (`apps`: the folders + * linked), plus engine modules it does not use (`modules`, folders of the engine's `modules`). + */ +const GAMES = { + 'pong-game': { apps: ['apps/client', 'apps/server'], modules: [] }, + breakout: { apps: ['apps/client'], modules: ['scene'] }, +}; +const name = process.argv[2] ?? 'pong-game'; +const options = GAMES[name]; +if (!options) { + console.error(`\nNo example "${name}". Examples: ${Object.keys(GAMES).join(', ')}.\n`); + process.exit(1); +} +const game = join(examples, name); +const engine = resolve(process.env.NANOFORGE_ENGINE ?? join(root, '../engine-editor-bridge')); +const source = join(engine, 'example/pong-network'); +const port = process.env.PORT ?? '4800'; + +const fail = (message) => { + console.error(`\n${message}\n`); + process.exit(1); +}; + +const common = join(engine, 'packages/common/dist/index.js'); +const bridge = bridgeOf(engine); +if (!existsSync(join(source, 'node_modules/.bin/nf')) || !existsSync(common)) + fail( + `No built engine at ${engine}.\nSet NANOFORGE_ENGINE to an engine checkout, and run "pnpm install" and "pnpm build" in it.`, + ); +if (bridge !== 'library') + fail( + `The engine at ${engine} has no built editor library (modules/editor), which the example game imports.\nUse an engine 2.0 or later, and run "pnpm build" in it.`, + ); +for (const module of options.modules) { + if (!existsSync(join(engine, 'modules', module, 'dist/index.js'))) + fail( + `The engine at ${engine} has no built modules/${module}, which the ${name} example imports.\nUse an engine with it (the scene module is on the branch feat/scene), and run "pnpm build" in it.`, + ); +} +const server = join(root, 'apps/editor/dist/index.js'); +if (!existsSync(server)) + fail('The editor is not built. Run: pnpm turbo build --filter=@nanoforge-dev/editor...'); + +for (const folder of ['', ...options.apps]) + linkNodeModules(join(source, folder, 'node_modules'), join(game, folder, 'node_modules')); +for (const folder of options.apps) { + linkEditorLibrary(engine, join(game, folder)); + linkEngineModules(engine, join(game, folder), options.modules); +} + +const hoisted = join(examples, 'node_modules'); +rmSync(hoisted, { recursive: true, force: true }); +symlinkSync(join(engine, 'node_modules'), hoisted); + +const cli = process.env.NANOFORGE_CLI && resolve(process.env.NANOFORGE_CLI); +if (cli && existsSync(join(cli, 'dist/command.loader.js'))) { + const local = join(game, 'node_modules/@nanoforge-dev/cli'); + rmSync(local, { recursive: true, force: true }); + symlinkSync(cli, local); +} + +const url = `http://127.0.0.1:${port}/load?path=${name}`; +console.log(`\nExample project: ${game}\nEngine: ${engine}\n\nOpen ${url}\n`); +const child = spawn('bun', [server], { + stdio: 'inherit', + env: { + ...process.env, + PORT: port, + FS_ROOT: examples, + DATA_DIR: join(examples, '.data'), + }, +}); +child.on('exit', (code) => process.exit(code ?? 0)); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal)); diff --git a/src/app.css b/src/app.css deleted file mode 100644 index 99d7ca42..00000000 --- a/src/app.css +++ /dev/null @@ -1,123 +0,0 @@ -@layer base { - /* disable chrome cancel button */ - input[type='search']::-webkit-search-cancel-button { - display: none; - } -} - -/* White theme */ -/*:root {*/ -/* --background: 0.9851 0 0;*/ -/* --foreground: 0.2046 0 0;*/ - -/* --border: 0.9288 0.0126 255.51;*/ -/* --input: 0.9683 0.0069 247.9;*/ -/* --ring: 0.4493 0.1953 294.69;*/ - -/* --card: 1 0 0;*/ -/* --card-foreground: 0.2046 0 0;*/ - -/* --popover: 1 0 0;*/ -/* --popover-foreground: 0.2046 0 0;*/ - -/* --primary: 0.4493 0.1953 294.69;*/ -/* --primary-foreground: 0.9842 0.0034 247.86;*/ - -/* --secondary: 0.9683 0.0069 247.9;*/ -/* --secondary-foreground: 0.2077 0.0398 265.75;*/ - -/* --muted: 0.9683 0.0069 247.9;*/ -/* --muted-foreground: 0.5544 0.0407 257.42;*/ - -/* --accent: 0.4493 0.1953 294.69;*/ -/* --accent-foreground: 0.9842 0.0034 247.86;*/ - -/* --destructive: 0.6368 0.2078 25.33;*/ -/* --destructive-foreground: 0.9842 0.0034 247.86;*/ -/*}*/ - -/* Dark theme */ -/*.dark {*/ -:root { - --background: 0.115 0 89.876; - --foreground: 0.9461 0 0; - - --border: 0.2256 0 0; - --input: 0.244 0 89.876; - --ring: 0.4493 0.1953 294.69; - - --card: 0.1683 0 0; - --card-foreground: 0.9461 0 0; - - --popover: 0.115 0 89.876; - --popover-foreground: 0.9461 0 0; - - --primary: 0.4493 0.1953 294.69; - --primary-foreground: 0.9461 0 0; - - --secondary: 0.244 0 89.876; - --secondary-foreground: 0.9461 0 0; - - --muted: 0.205 0 89.876; - --muted-foreground: 0.7107 0.0351 256.79; - - --accent: 0.4493 0.1953 294.69; - --accent-foreground: 0.9461 0 0; - - --success: 0.4546 0.1348 141.88; - --success-foreground: 0.9461 0 0; - - --destructive: 0.551 0.222 28.915; - --destructive-foreground: 0.9461 0 0; -} - -body { - background: oklch(var(--background)); - color: oklch(var(--foreground)); - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - - line-height: 1.5; - font-weight: 400; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -html { - scrollbar-color: #52525b #18181b; - scrollbar-width: thin; -} - -html::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -html::-webkit-scrollbar-track { - background: #18181b; -} - -html::-webkit-scrollbar-thumb { - background: #52525b; - border-radius: 9999px; -} - -@keyframes enter { - from { - opacity: var(--un-enter-opacity, 1); - transform: translate3d(var(--un-enter-translate-x, 0), var(--un-enter-translate-y, 0), 0) - scale3d(var(--un-enter-scale, 1), var(--un-enter-scale, 1), var(--un-enter-scale, 1)) - rotate(var(--un-enter-rotate, 0)); - } -} - -@keyframes exit { - to { - opacity: var(--un-exit-opacity, 1); - transform: translate3d(var(--un-exit-translate-x, 0), var(--un-exit-translate-y, 0), 0) - scale3d(var(--un-exit-scale, 1), var(--un-exit-scale, 1), var(--un-exit-scale, 1)) - rotate(var(--un-exit-rotate, 0)); - } -} diff --git a/src/app.d.ts b/src/app.d.ts deleted file mode 100644 index 85e7cdf9..00000000 --- a/src/app.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } -} - -declare module 'svelte-kit-sessions' { - interface SessionData { - id: string; - } -} - -export {}; diff --git a/src/demo.spec.ts b/src/demo.spec.ts deleted file mode 100644 index e2efa47c..00000000 --- a/src/demo.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, it } from 'vitest'; - -it('expect 1 + 2 = 3', async () => { - expect(1 + 2).equal(3); -}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts deleted file mode 100644 index 310fe4f5..00000000 --- a/src/hooks.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; -import { sequence } from '@sveltejs/kit/hooks'; - -import { paraglideMiddleware } from '$lib/paraglide/server'; -import { checkAuthorizationHandle, sessionHandle } from '$lib/server/session'; - -const handleParaglide: Handle = ({ event, resolve }) => - paraglideMiddleware(event.request, ({ request, locale }) => { - event.request = request; - - return resolve(event, { - transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale), - }); - }); - -export const handle: Handle = sequence(sessionHandle, handleParaglide, checkAuthorizationHandle); diff --git a/src/hooks.ts b/src/hooks.ts deleted file mode 100644 index e75600b3..00000000 --- a/src/hooks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { deLocalizeUrl } from '$lib/paraglide/runtime'; - -export const reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/src/lib/assets/Tools/play.png b/src/lib/assets/Tools/play.png deleted file mode 100644 index 6df693530797eee1559d2658b553360f311f969a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 961 zcmeAS@N?(olHy`uVBq!ia0vp^DImNS%Lh_0G|-ozDK)sPt`N*PiESm;&P$a_FRX-=?1Th6AVu`CSIRse5T3hbfeMf zCgamhCTD=C*%X9Y%+9o!p9P{;i?gkkXWQ5gq!ry>X??Ce|Mqg$18L@GTH>$G^|;vI z|7e%w(IU4CeL&lqH$^A`DdmzNzu^A_0YjQ*^E9Ax&H|6fVg?4jBOuH;Rhv&5C@5Lt z8c`CQpH@imtTM>b0?2~uGBhv%$u@2Hv+2^WOnzhaLP<~&kw61UUB}oRqRBcV)-Nu(@Jj>xfnK)~ygVD3J&nKmx z#C+pQ6JgX#*&m=Lpl94^Fq4TX=G47&B7$PZ%?#d_hbBh09Mlka;MHg3*6Jdu$`o^Y zV%v&|9LWxUdN>*Lo>YYPbo3i4%N=XHAt}yu=akh^3&8|cZP7<=Iz9{+jTK!^vk1xx zyP5DXGQLX*734BHK0)9?T-dwTwkZ-yMrQ*A6LeqaJG&i|v@$xTqTsB?$|#qm@Y_e~ zQo4}f04#2hT9`KqrybcoKg+46h4Z5jo64r>^)5;+LY?y#XiU&p=PSC>#JP{Wa}=ihYl g*~;zx@~8a?{ujH>s;K_@wFnf!p00i_>zopr00CfDSO5S3 diff --git a/src/lib/assets/defaultProjectCover.png b/src/lib/assets/defaultProjectCover.png deleted file mode 100644 index 96e772fc1a08c0722e401400d48bf83ed219cee3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11596 zcmeHti93{C{P&rWY+15bwn8Z3A(6eZ?^&`hWea8BvNWTGL`Xu`iil*%zE23%gY5e- zNsM*u+ZgW|&+qzO@4xU~*V}bX?)#kY`R?D(Irljy%1~c}p7t~?0DxXgQ`HCnBz!~y z>f`XwMo|AA{Bz9rx|Rtw{1-~?_zb?&cxhVr0&pgW{6h?hRI|dyQ+{gZe#V~8eu1}r zoIqe;pqQ(NyRXA-FDEfiAD6Uch0_4=0WH;QCPC>-6M-o4w+(0AxE&kkfE^Sq24CkGaflv2G z<{LUc#^iR7e?eK>Sg;!}H~4lPWcWT@8Cu^d&3aQa8>(3Ocm2)e)$PFR-yMxA-S4{> zzq)MKTfdI-Ofd28v3n)9H*0jm0SVd$h4S}_cC+x^LrX)TLCiHTXUMGB@3@%h$V3JbQtqe5DMl;7uzSz~cU?wF_HK4^Uw%U4+kTq^0Cu#Ddd&nB z%Yz4gNaaSJikhV*>!#Y z0B~)vXsvAb@*r>bQRGxqRx}y+I-eu~^i+*iZxR$VrpK7z97_4N;&X#aZNn^W8 z-nL|~@3Uwx&Ck9GM&+AaRf}UDxJMA?n1Dj zQkWC4Wzeyv9vPSlvYjL)t!_=)8Xs82kM3*chan&SZu@F;lv2=A3ay*%j0^y+7%VVR z3Wsg#^A4{bxksn>yw>Yj9!>z{xnKGXz&w)AQ23ZFgQ%>m;LzH0fer_`-`5@B(z`12gE;F`?ji z8XF|^rDsC<4WnY^uD9?i^@67`00)SXntA&#f*h$--crZ8Z9YbMG=@gt(VBr@0xjP zVMi7z{<_?tx8GWy8s=h!UcL^(f-+d_BH(3%^3Z11A|eY_l+H&L6+J_+ZG~}f=J~!w zIZ^~dRgG>`3PDwiYTv;C7TvRr_a*~?c;bfrAU~8I+Tq5=m&S$;)N$+bfVmzoR0SPA znS$qVF$PNUEfO%sD5XR^hx5i2%z6lkH&Kd_5yo~|mOuX?Ox7FIVYhR`Nm$WFI5_3z z=M$66DXt$;65AF4sPHr>zY;&a%}jPgMZy)?e@;T%CgO7j0n? zgcJL(U^$$OK$1ZySq+U9t`)kr`eQJou)_FfuOO0vp-t@qTbQzhyLJ*-S`2qr7X<)o zZ*_Z9(vUCAz_Q>!z0mVaa&QPv=}F`6L7?uOTVZKB~)?hM-drZIkZ#pqYyIq zc>hAOo?GCr_Va2fIsiIeiNK&q^M~CjM`G5L0r)EM+X>x6i}B+i=gO|se{TZ+y&22& zQo%@rV^-fZdAUA=n4NC`U;@>NA2Qis^P?B!|3wo`5%y%+_8kQvu~rtwzay(~89`n1 z-{bq2O0t73Xh?AQVn;_2QUbc0@V^qK|Nr=Z`9Vz&P7p(C%`~RToB6r*X3{fZ1;RXr zW_D5fn~8W`&o^BgNy2YVyy0O%$H8GYt|$DJPX8q>#ppVp<6)Y-T~J9L(@-F~G-SS- zsTtu{S!I$J{KKy{Pd6__+kf%ihbxO2MHCs*yL)C}%7GQ|#l6QpDM}Aejh>@I`NNq{mhd;vd*K)36|vsHA=}T5@2>N&0|Eo2FG*;=J%KuKKKQ$7Wnv_I zFF2h}n>KQw((y4ic;M$=J4f+QT%~mp=S3vCLKvWLoWng?w%I7amiaXl{MgftlPb^> zq}ZFw5TDB!@)(e@GcV)__}8>|!w{^+B7y8w?t_qxcUD6mOZN)#HzjY``Rg-cw;dW5 z_B!`Q#gp6?!kzp05gq)rKmuXv6lS>^d)VKcl%1>beI$L4L2+a&>|kwkWN)o$GN1Gg zNhYPB1@YM<{@lSEBwkl-wK)7uuF`uX9_gXMVZe-H&%#@Y;3K%ansMqhV)ggEMV2(`iBx7 zU40BVe*DOnYtXcmXL$t$C0TRO3vEQrbi21D8t>K3CW(CYc%(9A2+$|`6SwiGhK0$? zbww>3!Vml>rN{cMebrHW4OKqAd`uG7aBX*!^98YY-S3C2oq=Ti+7Q+KEl9+q{eYWjK;>UOC4jS~By_bhtxCP3>Dd%~0l|Dcx$v*mA$!HonrDynmLUBb;i(UUF8t1lyco;@6TL|HiL7%MJb{--~>Il$P z=Zlx`AoLk*3~sNms#z7+SU;`r zb}CH}(Rc%a-uw|wwK>V|DXADy95(e=`gsHkSr#QNC>*K`%Kq{ExLzF2{l$Py?RJ5g zZXAM-jun_+I<0?q_q~~3+(~NBx_PHQe$auW1h=e)eG(^0+>&8yuF@v{Z+IwFB7{N7 z=+2y@bhn zOf!S2FsNFjmyaDxiRX7RX+Y2^sJFjiY(4he}RYpB1S zn96hZSC=6G-Pr)6Eqb#qnhpG?FeS#Ouml{D?a&X{grX2DWD*F7a>nnMO+rw_KoOMoI!}?~Gd^cF0*WEr~AMdAMgb z^ykhu{F}9!^K*stA$hR)uF4J=7D#Vb-fhXyO9atSM8)GgH@`oZS1baX zDNM06uwJo44;vF-gnzlr8a+SORR48@v%^rs_{|mUOPjZWmtVV1-+!U~LgnY~m7Fga0Q!Jc@W_vd21CugVzt2tup%pErlKH%pJ9L?wF^ z4GUKaYuX@F6^-@QA(Nx5QF71AR9+Ol&-sasKPH6Hf zOp?6c`1zWawhA8vR~_O(vB$5`iA5&aBwml&>&nC%gN(Avx0|C^3DVw}i-c7!hu^@g$ z@p&GXqH%s+BdYlKxDd`Yf3kO?!4?&2S#xEw_pReI<|#9x$Z>V=_wEUqM66Yf2%#SP z_4>j0vd{CUrfQp#KP1nsdzWEX@v~+~VMJrA!4>-xV~o=C_^k0e!6&AnP9$RF_8cMK ztuNdDgi!9x;T~}nKQrp1qbP`pglfx@92REwaJd(C<8JqpW1(>nJih8WGwL{3ODsLb zHmjq8HF!Zr-BUYbvmS$iL!;YU7*)xfrm$sa#X8ajGGC6Q-}KnTM;M8F%B$^!_G zHz*!6_zljz+Eyxlq&dE!S+u|{+G{*%SJb(0r5pF?pCG&(nLVxG2Q_gn<7Z(Nig9pO z#JLpzJM^;8lsvoU*A{kf$QE4qCF$y9w|a?Z!vJPi@k|wyG<_;t2fMFcHVUi7MD20toZDA<|bK(UloH7 z?q*)sINjLh<=l$Q)ixSmk7GvF;o6$Gcbl(#GQ z&2wgL-xN>Ue!1q^wCAK2=J(kC&@i0jG$Fk=v>AO_!rDHVD`tA`U)uW(BfPk*fS@e_ z5fPi!bAlLmSVk1J`_Y@Pvo7_KS5Nz%w%KFYFUGc{Q`|aFHdm~~hLjtF8fy2nb>kkN z;L0$4_xUH0jpQ0RtD7KTv-@c}Lg?!^$mFrUpXprmVL%nh|G;!_V$8PJw2$!(4_+@J z;oaPsvxSB;)r$V! z4d07w+S+t>?~kRx*?jFS>Z3~2=v6E7@1@uEWEtMR-!=Q?L0z^3rUCK+nQ-}QSr(Ez zxR{!6(>~v-Xhvddg`t-fuV7Z8k%&fuKNbon2O&g`29#)_w#OxwY4SujIT$O0j78n1kxH2+aR-#^PcZ0@@ zLI#0U;o+)|q4)Hun0!UP#(#1A1YWu>7Q&5NVV=%gnIr@=*jgaWe&69Nj*02wIoraUq zlcHR+2$xkE=9+OmAvMtdsyyeER9&&7U2w6^N{ht$fK-Igs@N?c`cKdQ@R-&0xqAVF z3J@<4B!=B--!v|V3n1sy^pqH+KJa?#A_sWE>x+)7~axU4DF$oQh3 zFhI;W@8m(&gHD@ra-W(jd_nF~eZf{U|6QD?cv;Oznjt_6Lj+8M6N#MME68Hx!KUNV zc_PKr&Q6FT)G476C`(C!`wcbM+MNxNOkDECng32ZQ#|kNG#aMjQ_+2L)LrwzHle?y zNq%LPDt%*5TH&_XT%~yas|2IyZ~LhwNG7K9Ey|Zp(YpS;v?-_5Fg|<&qk59GHQZRP zA9)FjOGGjOTWE%(a875{6+`5dPO+WN&RE;u&Q4*VH2Z;Ipc8R@G*suYzHat0jIwQA zlFN@V;f9)#+jQH~@4!w92bnvAYu{&jxt{AZ=Yxg%U4?JrSTQ>2OTmRDNcnost#zUxus%(y4mGr!$lpw&CO$TRk z$H@rX=&f|xTJ+LJ5KbvSMOmKX>dhTHGjNRt9l2PY>C!emWHXHGe{`ho?{=u}lN+N! zR@ri?!U%~tLc4iyS{h};F+&jA+zoyG>`D3Rmb(9CBcy%TCmALZXBNGDx)BKFqyC@H zSats^N$`u5J$N%Bf3yir&~WaRPd{D(IVgx}{zrt1J+BSc=3k%5b2~)xP(?4+m$tYP zv;ifizHUBmGe1_ZGW0Hyt^3^BRP+Ab}kEKqKT-kr_J+ zDW!UYA+MdHyhVf(^Y|Wg!p`nbMBcmz!VCK|I&U5m+RWRf8DZQlNAu&#EGvjB6(RQh z?&;UY;A@%tviVeOZLRYjKT+ml&$-_v0(kB??BspRKKr>Tni;s%*XQU{&5rG-m{ngC z9!oa{3eA5B2GJTVmMF7VUAiBR@db`0_v5D&nt7LLo5}1M`G}KDaS;{E9@@aC43-$f zNm2((lc%pfLL!E62;q~DV=Q?P|I50WcXwu%Eb{nCQg$?3x;Vo4xn3MHPA4Mg{uAXB zwI!W3-DICIHKRgHAC(MFP862C5(gcW!r(X)=}k1-lZseo^hs;# z!>a)bb==t5Dwhnntr0`$wk?z}Mb-)21o-); z!ox)BIwcl!cS5mK&g=$Fi5}!+bE}xYj2gA`cqq&NI>PG|ZlxaA{A|z&S(re3c${01 zh?BB?I7mZ@QG3!95)o}qQ=$e(!9vw=F-_s2?1(Zu8b^a$X?(XX__i*UnaPBs8Fe1V zh|*c{n*YG8g78A%vXpPAo&0R0Pec74R*MfEE^dl2N)AHgNz$&2Nn)+g+q{Bb8^T_i zlB~T+R;T^$9mmDlul7=O3j>C8#LiO}V()gYv1_Y^y>5`HFRu04?Si6A3GA&ODGh(>b4^N<)xq=B(`RJZBq39FUE1a(iV(5%; zlbSme*_`C4O)M)fJVC~vZJs+agdj3 z1SC~R*%PxkTldA5%O*|abKjJ}!W0ZCJC|kii)lU|m%tu`6GrRd# zdfNz@o(SbbnDM{NOhUg(58b#*Id$$yQ-d&~ODhuP;*gKOiB|EM@0n#quT!HoOj++P ztvPFBje!i-9M~>g#QXXHNEI2l3z-Q(iB?)=RLpt3sTgw3tfM;oL0yGH`Y7^GPlr&Lu0m`EMB3{2+_PibS$ z{O*AmpteI{`$~_dq=4LI8ff|wW@+KObJ{9$M1!H%uyiog$F;L9%R-6F_Igmoe{N0; zsO7%~(K-OfxYMFyVd9=#_0cmXFVpfSphn4lsH@dnSauyOc`C@qL+?H@@kDuwU(6Z=JpCPZ3w4Y0aSOmv(z~HF z!h}QKeDmApWCJXkd8scS9+u0+5h7h(C=8Kzp5{*yyYE^DBIH?9wgQt!!Z_LmHJ@5u;ONm;9zo60N%<{c8 zRT@sQ=XaNm&~%65$~rlWxWE!V* ztv(U$w-aavj}oy{Bjk=bxzj((R|5idFoyr&80{T1MPUNp>*@JpOT$M*#C-E5$Naur z>W^}KrMh5q*K0HxFGW3X2Ex-lNPdA#?*FAmH_an3_t=Rf2(J)`!@q-LuU()@jAI%1 z#QOe@olZF2 z84`k5VWxI7uZnXFJ2abfguMR%7g!1JR&_9N`R-1Q0v3|DVCLH2YtKt|U;n)6kOU3f zW+s6zjyYr*&ano?zmlxf#Sj!9Zho#wh8{_)=YY&Qx=K&snF(#}$F+@zh#^Ao0N3Z$ z5GzcgajvwR?+oD%LUSpd3=y`UKj*2XyF*xHG`8M8KuXOJMt!^9zW_B&14OUUi zbRXO$0epPcO;l)jOpOV#Cc^*#*guxgAOs+YnUQ?~&{bD$p|SwfclSW&Q^0!xvE+mR z;LwY#EdV<|pFoU&l!v+>|7$Srf)Ri`n|#Qw27r|0{~yn$!Xut45{}kKJRk9AAMs=o zohCaAo(P^QJ7RIH@8b~*zG`*dZC`0MHJJb8V|(h4C(S_RrB86`p#V(Q{Oo8|S=Jxh zeB>ilt_NK3XqCW0zW2foKlp(C+5i9e|D+$@oupF{ei<_x!UVuneUD^VE0QTnh-!kH z3V=T?ci$-8K~TfP1VH@+_LU=ltt=m%9;^S{cuA*nZe-zqU*TDD@c93Z8l_dKs+)$p z4xyZv`ZO#Mnq1z2T<~C=5f`*J_l%bp|F{R&Di_#JK3ZntjxFZKPiM2D{}}wQTpsQ7 zulO6^&ee?{mCAH&;l=sOip5_fx7zM_(z1@_?hVpsM*f2xrLG&LU=xo+t=kt zhJ(}YN6NSM5~Y0^-f%NQDdzP|Haoic-H!P9sUx$F20UYEYvPHG3w_K7Gr7`BA%D)E zxR4$8us;x?X*5vP=$HGcviisC@6ZVM3kLM*iu)wxLY}2{hsMt0@{pjib$9^*RJ};X zIk2POlXe0#{xd%4l#(+-nDaS`pIJv%{v^CIU@DMg_|=ej#v!aTWgBQ3ZOSzDdk1K* zIto$28vx7s^PF=E>tB`AB?pIM>IOVVYR+cD19^0uW5UEVJ30Y7%#S0ih>IB<{b}Xqh7$H_i+>dFR1n|wzwC2 z)Kk$;CO{ycIQ}cyhZkW z`0uPs3#U(aY(W(<51ednP^eL?c02Fa7Sds$I)ZKn8_F3=3sCSn`LV(Gn}daNt;|9Q z=)KUi$>GvHz;60?;|>54w_oYg2lzJ}_FZj|7hhmQmw#{H$MW9sRe=|=s09;JL+e)# zt~MzRu>mc&ou(`ley4ox+f@j(;7<<8zB|={H^fV;sJQz9wBtwj39Q-pnP|N~oDllt zzcrMa@4orEf8HmCh7E0Vk!PV$eti9pYAno#FUEn@v){>W=^g)^_oLzaytQDb5L#Q5 z#28d!7Nm&0|^7HjIk3#Sc;2 zE0J90da%1mEK#?B{Js)A*AvFT-Cgs`V=qnFD$LLM+ZdNTA1o~a>E#7d&&$JA!%xQT zzK)c9pBKk}g_&|waO?B105E3if5I;@<4o!=o5N(EaqWe?EZYuAi`Q6rdT+OwsvP- z?JXW+e@m9n3YS=ufAMZ{)?U}kkyqHIc`b-H@wtk#jlMf97e~hxA{U@Cr!z@J6nB>G zaz`8FaLnQ$Jd-0fjQG`RT*<%|=99F-!LA+bZ@er{z8@Aj&$v+^v^dR(O^oxc@nMb@XPrEj1#7`U)Z z#Tz!#e?pxOEzlVxiv;yBL zlcmzeFe6m79~w+MbNH}IZ}iE7zo`nT0G>}jEeD_PpX zk;;t_9TanCzb6*cs*Kj>@rd&Xzj(N75{8f5zd%c?*G&OR3|zMfhD)oB#T?t;lqw)S z1*A`ccR$JWaSTbSSqf%08}DFq-7Y-4@9+=O**lP4rB_j;*50qb=QQ?yzOM>2 zXbQ@qaX{mlkK~SaA33lk_pl@xg-H+o5>)FutpbbS-_mib$JQjS(2$#>rJfcy$ zWJxdBp45cEn_0}<`T{z^k^9g0riG&S9MYoqrn?t+|K*>QQ8ZJsFazq6v+D+;F&ZsZ z?X=B9aNCEYmA2WK<)xwbIpzz`HJYEU-u(!_5;cDJc**j7xCqc3YIn5M?nJ2yBt6r( z^Bt$D@DFRO5LL_S9~D1Y?=SYkrTlW~!%R_vnT$XDiBUU;FG*n@d#H;u5ZZv2n!ak$ Ib^AyE2i}l^%m4rY diff --git a/src/lib/assets/logo.png b/src/lib/assets/logo.png deleted file mode 100644 index 3aac5522082985045bfe451494b14d7ee8c72cd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17982 zcmb@tRa9L~6D_)NcXxMpcL?qTHtrDI-GVy=2yO`kcXtmG2<{Nv-QCXm{+D~t`xyfU z8&^Z+J-HFKBB&vq7LwF!It8THZOQ zYjEzw^KEdKnak@sGu4g9!>RpjI?KP9X4`(hUrx~hGd#Aa_e8-NDp$QP!A@WI$suZg}k~|JB2-{ zAV45VaTgKbM+PfAGw7>{xCsOZ8VV8#M2?RR27&s%_CkW-VWDwB;_T$LAW#TIFboJC z`Tyb2-v+86Xq*^I`WR}4WODcjtHaO_XAwGdY4X}$mv7WRW4|3y{d$HkV>KYeDPbRE z@DO{i;58`MiY{_Zl2%Z#EHFpjzJvBR8rnf^B@SH8SmgA*%>QY0pjI=+dE7+$YAn2< z&Nfi{2nPiT9SjpFNm-Q4HP4{iJ_7d9s-)!9PWoQ0ZbMVTU#Uf#JPM!sNeSPwPQIM# zoewp;reRt1-vQyV8_Gl5Dp4g#JeDY1Xz08>Tm^O96#a=Y5Wgt;^;denaEy!Hx&wEwej_&LR@MjPRA; zOrp}*z__WIWNDv&B*uMq79p{ScrifvoHkT`WvCzV7s|1^1gbW)7g6sS-o`3ewBpi4 z&Jy=*mK9Ra8_lz-%G83 zjQzQD2SK8PB~#g@a)s!GS!Hlie#NP14N}uHVC>E%O+ys-j4f~M9bUNJHwoE}4Y%vl zwdP^-6TJFTLnkl^Xf|vT&-PmuhjH^Px?vzINDmd2Zfm@IvvUk^{r2v*j2PN&FQ|0dJiVC0W(ta z=yyCv=MV@8R?|TS7Bo#f_lJ5o-0=dx!NW73CsA`8+>U%+q&pZ#IRG|MYCHN#>_yi4 z>0v-kHAEjBY%t0=w{hccWX~!5h!xR|I^6KE$gQQuxjfOHt=H{r7^r1%z}cL>Xt0Hn>tjPBXuak=H3u+O}C68g3Sb3EgA` zb;?1<4Hs zF=_ZQxC9G!$5%paOfCc1ORVp(uDFbNbC%5RH_`{QCv$tTh9q?NTQ$&xgT zMz+iXtK1)z^wHezZW}v@j{)4uT-?f25VV`tkPyDt_mKtgNl}>5@(R)#*b?f$zzTnM zfBNB%pCFt*dA7QbZLX7j+8obIfeQBEOfx18dJ7;ZS?@JO&+#<2zWME(Z`ZnENpP11 zw^kVHm_2CS;!Rf>m?c^V_lPZ1eur6QR4^@1spM+jXn@BWANN^$kP&lsUcp1|RgQ#r zV^x*U;kh*u*ex0{hM*}^jq>Z=MMGO23~MlG7A#=Y;bs40)r0-lvw>8XN*K`)_xRFK zr>wv8C-pD}BLM=VVra|roXa@>0JUzHvKfVQe_>h1l5X#o*poJRWax!xfwPc|lk>4n zUtIb(zMrGR?RvEB`n0}6#G-6`XpLX89_t7eKM3I!erXxhGg2t$W)x54V&&=L<0?IQ z0RQK`%f)PN=vT>s?zx6wFzv3>OM~nWi-~=T7(j;a)ziuvw@1U zfhTf3b{10(q|Of>{GFtxVRO@RmRk1pqv$MrS-V5{GFIKJBC2dtUc4lpAJyZbWIZdq zQz&O;iBOOy{M(wJRp0#_Tc<`(r@5!q1iGD8N4g!T&QK_>Y>DJC%a&J`72_Jx@wt%t z>3@o}kq~)`IeYl15@{_8;=)&&3w)#_@Kyi$j(9BJIZRTz#k$~O~H*ox2%+biweAK{U zK92Q}8NK-W$x=0b2-oj2kMFe<mj@JAt1`ufLfyC28an+3`GQ=U(;NIt&INkP0BL?n_T?5tKQrj`v{H_|$(#63$=@13? zkn8FaSJwk+XOW~rPPA6Cp00YEp45}*OGxMi2^WTPyhXN6hm@$Kz(u?ZkAN#9yvlNC zz_%b(D|c>YNt-u@oU{>VuHv-1?NzUP@?U|1?+V4bMEveCsTLdfL-q;BtUnI_J>8j* zgMg99GaeO3a{Am+PYWM*yX~=#eW4c>M4qO_?g`{)a$-#+MLt{Q zz1}7H?AJ}Q?@PJq96z5{e-S}tj?E$O-zKG1KFC=0>-0tKwK2@hWL${rTBk?3gx=_N z;?%xg?4~0N&B&32FLIkrNdlkj%KojO=jTjva?w1I_Z6YPYjH6U^cFE(CsCwM(%5Mf z-HuqwkkYgHRV&25+Eh0CKkbk*5?kUTCR5|);&p8w|5hUXE?ETicS1ST&e#sAjARlW zvGS%`q4h4wexfkOG-`$=)Jbffe+f!fJbVd!LmL`(CRET1#gyPuky6k3o> zT*e3EZA72{^_}fI{@_#y(H$z*d511e^2ZY@g!*Mx*Qy6d?-Xwn7 z6xfzLu{~%FIFOUWo7E0CIlagMK$lVF&-DbfOj7J*Yy$hAY`aQF9knJk#B${a_R!m9 zRmFmB{!$_V-ff@f>f@FA&7f*z6|M7smvF|6Q{jrsJ-2E#CwYpViHXpvELoe&1DLUY zWH)lg<@M%u;@0ZS=zihw7p&CR39&AHHty>ireajNI?R?aLs+yfo<_*))pIv;C&UYv zI$;{V=qV?*W%+8(j@Ax*?5xWQ?FwlmUhChZy|0Otzl2_ZS){HHQa=d$7=%0F4-$a7 zH13@g?kQe4yKM+MLaJ6V`zXkO9w_$nFF$UX+^sYCHfeV6f1fyEeNjaa)~#>iTWP-T zw0P%Z5d4UZlPyw;GJPGV`_nfF zB0BVU!Ye5@9BgKbCd-&Jqx$_p|Fqe107gavn=zS1%qjSq?{hYJhul#T z3*3G?qY=8W*S~7L!8~?G(tVKHc=Lf|Z*zw8cd5>-*yPS88;`SI7Y{Gsvwg$b-d@wI zWKFs`Wv_T6*NX<9txst&=uiH)UMIdEuByswM)Y4`z8%?SE_J)8RsX=8;jYAqR-Mqg zc=eq+vErdu$Jj0IP*M?TsitNy9GKYiH+l}X8$J1XPugp>e_A`d!ICx#UWq@mZQk&h zc1+awb~=Ny?X<-*2UhmbF^x>BS_NGI z^svCn=XhTkP?g&>7W}R0{=&h&`?keh2AR&fN;Rn$l8HsfYX>T`(r|+Fxa}gQe(Dqf z6>LRtx9&Gc8k^bmx7}2kmd>dm7m>oDeYmJJ?{$&5C@Y?-UzF`_zNaJ4s4K{#cf5ZO z^jL?KbMZ3yB5lS9_=0}YPC4nc9FouPYqeihdXX4v9iOB;t-ABVk)8r>Ll?gXy{RYC zwb~*;yt@fO;jE6|y$q!rp-XK|GPg{XAEhhLFSS>$i?_G(bOC`jrP_G zinJXa0zOVoG$~BP%*Z&aRh;a?)7GB9E0PLb#6G`qpVmW71U!H0*q9trXK2&#pPcn! zHh@Pkgu&<_N96GiyFJX;&;BX!F1vcIzy!BWmF>Kc z;>u&Df6(5wZxl65sTXwh-On8#Uth>N8^eD+I<&7}Op7nFvKxrw{I|$xVDZBy;^P42 zi4=^t@!g+<>CFj9+Rl6G?Q4l*^dU$I&fNlT^1E}kFH6ClY)_ilnPMB8E->Ej<;*os z2%q(?*mO$joGDgYA0}J0pF2bRI}oAy^3&gMs!~0Xh$10s+PRdgENkjFHSbgq1kP{`aHoGCym zD-jPJ%WY652Z~-Zk3?Wa9qCCSEWf3e1>KA8R`*|I2@Oz2MCWd60WLOnxi}Bu`?&;d z?lRox6*=5_j_p7i_AziGLy562?6Bh0_HbQ4{d3cX3n65wb@}D2y}LggHWa~}fL}p6 zjXAcQiHzuYX9tR2w7T|}>+!wV!MBW6zty2G)qh4XL}%jSfSE_HR@m=&^KHr}cfL&u zU^0*m1Sh(+@9uwZ#l~4sWaqw`JGS+%BY>Y6`T9~eKaae>(Ewl>q?#RbKB;vWZRzi) z+u0h<#+Dy>)5kOF|8yWgk;gOBS+1w~>xxeDTU$aAuZYZT8n=f;GOjS|g~%!$A60Zj zwMLMj%5~oY2(Lnc)KAyIL(ad(Ls)|~y?WjW3?8)WF=oi)uSW|p_pm|>6`-%5^}F6w zTeLUNG1LAFR=hnb0+&p+F}UVVt9SV=LO_nV)r|;hoj)abInB>)tsSpVemTp6#VsN-kf`_7mSCka z6E@?O44-Sy$TokiSxRb!b9cQ3(SxY#KzH>%f`2;bD_VON-MhCN%)&!~j$t}+cI9ur zcXwxEaEUQ~>_w3?a8X;k%5CvFx!@%w2R(1FT6DJT8mW>r{(Ek7X#-4qeIUC@^a?m0 zyU|TGA$STNO8{^t)Ir&;|8w>aRfS6rE}Dt5-re+do4P36@F_-^-2`1O2u*T71+?-u z%-q~EqNE!#wjx)$)hP9PiGhtdP>ZA`;0Z~t=X5rG&FGUf4vhX-j(whhWD<~aTV^SH z9QwF6HhhGI`#M=0_{i2p!^0pI>hx;9!~CBUs>WPhslWT`v1rvPdj#67GR%&=Y^qSR z`Ryf|7ix34vrSv;dJr+$tNU;~PDgl!f(rWRdZHex5x43VE#XJrcpUCkHmQ2W8EVk! z=piT%j|?SmoKK5Cdxzao9x6X+6ObO2Fv;A;xtZvlcm7tK>GckaQv)Jjf79oRucAKs zH%q0AfT$Eo2DKVYgPGLhXN4gDD{aNMf~}bdh%cWDo!L0abHi?VgKhcfe7dv*9m03`>MV!ameEA z%dhfIdlMMMRKSz;Y7>SAFk&Ush1>u~<%X=7pRsZ;+S&mGe*yq7&foQv2IXox&0!Nv zgA(F<75>ba=oy{8nf~UCsZ)jr^;x)&G(5l=#4>wM@YP~~k-A>Wg??~NQpSJq%t;66rvZOVY*Gf{Lk68ZLrrZyqZ*sPc1 zG$H;lXaN+4tz=wKF!+ghK&1~Kyp_aBEb<7{HwHcVr$D_7WlN$V<|iTyQdm~sD9Ah! zhk6jF zWE#?P*uRuyM-#6Tl@uBK|J?fD#oan*A}?YR5ktwSV=`>ZjLQ#PH5Eux7yAZ+MQbuV z|3$tS@JFB!VlZsK%C!F_&Q)Qa+kOGGw7s8kYO1s_5w7GanR zW;>DoCIy=bWa=@CJ!h`5DI&6q>fPV~7Xt(czzT027`#$PFfoUnq0KSD&SS9Xb6v}3IT zZNP{hBMdTvB1ffWY|E|PKh5P~!s&&tTSWS$HE&^(TT9m&co*ZX8}Ye!W@owAjG|VU zx4mbKM|d+zwb$bmB@8v6ZR+0_$5IlKQ@4AM_Isy&r$2w5V_b*_j{B!;45#356Le>| z2P93V0}Hwqnw%cgG;G0P5V*RV7LWeH>J1GgV}eV3b$78phYVG!t=;|O=W!!Uzg?_u zgF8%?24ZA;j@I?8b8GAClAG^fc)NuDLYFc*j!Ye6*T$n15A0keA`3*o60^YB3-O`) z{%c&@_FDdupCWrum0;R`Y**D#v_~l#AGY&E^xWG&?NQL~j{TG&yHm$yss9N{@oxiS z;TAbRKVgI^B!pG%#{B@_8kz9&@SVHL=XqAQDA+VTCJ9k1FQ$rz4=IQB#C6*T0}}c? zJi178<;@cR?yr+XhsU-4UEK}GLMLDN(ny1t2d;2tkHc#ti)ISQ8XBU{3IgI+bBHX~ z%rJN=rhaLe`kW(#jc}fq*N`|m)j4jk&*<;$`3jC~KE`ZCLH?#H=Qb-tzS7Ty5F5Wn zw9^>Jja;1Fx`WVK1bYR z#rx4V2dsAa7g2L6jBhD{?4Doj^8=PTd*%q0I<$tvcVZ)i(beE^Bj7Rzx`@;JTvsz{ zHpmbkGxzX{GYWWWClIx{$GwH$o73vj-?bJsNhH;I5Qgzke8}O+Yg-LXmFw?dn*D{9 zX8$f9&<*}R`fb+9@(wYEA93XkIS_gubKS?baYkg1I1yiNK2YDk?Pekml-^?yx4uG4 z@RPg;vl?~&GdV%3^b2%e)G5?iPpaSj3+?TQPy8i|fJdm=@z|!S$%{wG7xl783Fig2 zJx3+dxJ>e?FF{dV{@3@(*h)Q$(LXZMf4=4096IK~Lwn!C`;^YN+~1*QhA=WGMBJlD zS~s;ajlHxu5YT_*$le*XV#2epjbnXb3J3bDK(W&Vt-gt9RG%mS`kC!gjf^c#Cz9~X zec+YpVnUVSfD~3R$QW@g1Z(>S#7vE8X%MKIT_+#&yVUI*ZwuVUr06P%bs{mY(2LBjEDJdend3| z1a`;$qXEL6?B#Y(ZgD;X52_skmo zz^-A1%7lB$Rqy$3`k5?Fi5?4|jr^rt^{g^DG-yIfBK-%a!UndE>|3&01(U=MM7-lrzdN?u-5x@X369Q+R1Qo97F6_e%?Yyeo_1{|7*wIY zt#R-RghJhW841+Ba4FJvqIt(K<99pSt^@B4s5!NBg}F(}<#V>AE zrHhiTEgGy#+KNUw(W9s;{$qNBfh<8D@PO>V`~#Vp;n*n7F+Cq+1#|s^!nVhQJDn#m zYn{hUW$EZ(n3nov&9q0DFH!Zu1%S9vlf3#fPni}Jco_0sXA?wHk*#bm4-H$xjOztzon9d&=I$nl)eUN zxNY}J5+cbtvC%}~VZ>b~CM7;Rf81%QO3#R|Fn@Mb_x@AR3Qx7Y$kGQm!DsRD5uFRv zUipd?@xu2^z&09N!lHD*+$?iA3qM~StxBi)=DsZVjIS^5Wi+re+FAXQjDClJ$$0&X zjH2S-(2X~9yvttdWjZwkIn$d=u|8lHLRnk91P5jOVP5LnLPJwQ$D^68ZxR>3*r!aX zXI?mQ!RMhJfK&sQN4;ACos?0-#EfvP>}51>Bi?^N+0G=gmYSDa?~++8OGKOZ*=PYxM%LSryd|vXn1jy4rjS23P)vLmLKn zOPK8L19~9Z8M_~x-ZNjol-QG)t#ZNGc@!HxuRQg44H}$i`7$l!c#M*PquYQtpA&1R zhrZlR2FY$0=Cn)BT4&)#+3t^Fx8}ayY%~u{X={si>Mrr3g&575rX$@gXo-=%ZTLdl zV-q|X%vxNbxx=W!pF@Hil&{0w1_Sj9*RzS#{hwiC4NV)M=dy^l-jXNu}!?LLv>1 z6TvV=eJpAmJ2IT*J?pe3aMLPMN1md7qfYu(oYTQ?YvpL+-5P?N=~m7?4jwXuV^qIS zMzrKt;ivMyjB*SgL?00v?mxOLbkWbuZ}B)Pb=Cf^93j}R&heSz8Ku%^S*_^F8V6J+okT<9>>3L;9#E^nrfo#>*Gf9vWH5E`B{;m7 zkK1lJxhzuE>-}p=rIL!jY$jJ2ff_s6J3&P9F`?+mg_Yf9pVfcoQZrg%`avs>EQO4w zZ2#h$0nACXYi;=|+Ff1yZ3DAF#1%H-XU#;%Uc36d_Jp<#Qg=Hh!+Rqdp z6dyA_IrKVUdcHM7IqOg8zY}F+)I_mhWTjrBi`OQ`k4c;K1S{CHPL7E4&HGo?5ZI6@ zKPAKUxPKYGEwFou^A6vu{kg78UN;vFC%fy#Wj-K!6E%B**Big}9+Q+$b_yzyBchcF z)LZ3ExUNC4G})quk1t%L;3tgaVdvex_k9f1k(-S}JGyXSN08NrZ49t@?CS5IRVE}3 zB|q<>JI!10^~Qh`AvWggNUdS2+zPx^9*?8_ax+Y-nBUV8&_8|=;1xmf&#`9^xrAV9 zU`DTzY%dh!doYh+*n5(47{aq<-LSbet$ko#I)yp@& zlP_8KK2Le&kVSk2_P&CD9QQ6rai8@OG==|RhU7a7`g_b%byYb-B6W@6-!d@+UUroQ zNCwpF6n4!+3V;3%lUBHT4F`@@n3OPUV(075NyPmS(oylt*VPjVe@|`-Ll()mX8?)q zAj|tUYa@^ZNG8ik)BdWMPB^-Q9vY|LsE{Q9i=wTqo{RI7OQ0*H@*|!jE>mi1Fvd$$ zNAML$s=F6xlu7zm{%l#>l6oE@-A>$BV|z|P!AQFN@r_0yUXF}4U19T7pFSo=riW9U z6g~X76@X3aB*@rC^GouRNKw03BsHlc*a$bE?CkLaNcf5S&;nJ;uw-PI2B;q2 zze^RSj_2X!>VfhRf7e#lK0lIx&rlAHe@x~QCHN~jb2l<Ti*wvtE=vaHvW|?Vxjg#KWZ7)?zJ2&8eM_dhE0_FDj{Y;gedAB+ zv~R!&3bN)7oGVTK-@!bN_mpfZl$e?bT3T-oYl0l$O*5=Xg(G4xF~t z=~bz1_;U_^VeMp;H^($uxkf2w!%Lt?A40Ud=EhAhl}eG8pAh2mKMw)NYxhP%S#Y}e z7H=i5Zks&3VVgd?#%E8#h-lrtyoI%aR+D-~ibF|mw(yZ~d+Ha%5okxrPgtKldB`BMd#o_*!03j~nNCR+c3GCeTk@Q+)upr^r>eH_ebJ)04u zK*sh-GB-0NHMyembtP`wMCz{8Tk{1Lt1gH8FM7UP%GS)u%$;crx>AKaW6^16?94(ZzuN+5lL20VSlAK0 zp8anQdnp`Xf=VW4W-eBPFOQG&yu?&UxpE4>_eNb04sr8HQ?i{9!~J^A$r&UoQYZh$ zf8U_4W+GjF?VEBI8hsy-pz48EB>6sMYrw>85X>`k;aEyM$tdj~;tBNqc9oQ63rWsu z`(>h|TakSfnrW>QM4g(azi9p0z05i7`OawL*v%kBS8t-?E_0;j6?}q33v%pv(q?!tcS$ zfRZCM(_v(Uv(>P>T4ii~CfK9q*6;N4G@^BP9~*HXZ>l)aZyN3SgB@0T50z^GeZ)!G ziY@y4C_|0V9G@oE&0hLi^7mZ3xF|JAJrX>5OvEB;mH~9El4!-L&R4YtJK6m zzfu>MDNgWGkny6M@CM9<`Y%asp;Q9ILUFX`EPf4wyVz`vZ)i;H-?Zss3*<66e_a%^ zbu-8?4opq{9A4fCFCt#YJ`HlnIDP^caSCD2hBD}sw0^Q~B1jWzl&inNbdvmny zp>Y!?2%sfOo041S)Owhp>Da=8mIq79wFrgTFm|5m$q-)_%vD#Ri<4$u-3`ZEVd3av zOyRSNp~P0}se@iqSuwBdWU%^uJT7BHZFbi^;fj+=OwcMiB#V_uA> zY2vjBZ8YS6Z%6UFsJOH$uwcY8WEwChMQw@`AX%~Xc`%kX+hfm;Xx74MTc-rnuG%Pw zZ~XGQpOK59JX8~TlZAtiFs-e|IRx!^zI>9+4Gp+2@1j!XxsM=I@g66;#C#iHf8QUN z*ni#aR8&gfu2Ac`jLZ0?)%A%f{g1T2ei95y45byDM39lY>u?v=2MHQciMpr1i1VKs z%_Ou6tym;Hf(M5I@5;1ENC?6W!dO%M?D@5S{41*tALl7Q?-`k!;)`z=eO^w6JOf0H z{Hq3l~cxpRmw*A?+6TDTsq*(0^Z#?MU&~{x(CL z{S$GeZvA;s+`8f{lI^c~ZeUgwmZBq3!w!|=vd+RG^3mVrm9|)=Os;rBl(dOxu?;&U z4C@a{)Y*|$J~IXmUwmH3inbxaxs#X}DPzHQ?=p2Vil#U-f1Ix^ucCxcEI&6?(NLE7 z{1@KWRxn=3`B=!iu#|MdODfEMc_uncx0%x@ZfjVPSJR8nh7AzLps?Oy%xo8d5lv9; z+E-9(r$bb~;|Ag&rMsv+9^^;AAHva4V$Jfb@}aW-Y)PR}$I^ZBZPJUd$lSYn%qIFv z_SWTb6Xfd|#IxV*+5@UXfgxm~yHAhj=8H=*6P)=aJ(K(Q07)wi&GGqxoW#u1O4{tY zFCA$+x$fVWo-44F9~6;H{>WY#(Q+%Y{Hyti25~HN2)WUFz}gVnRX2LCs%{BGx*>kH z0GC09<~O>>0#qq<DZ^NH(IO@#}*^~RnD<8Ef0(^f?o37fNJO5NxS0_8vmzvfi%ci@+jlA>_CRR z+)AK(r9ZJ8Y;FMx89K?v`(Mu@;MVrUZ?YNa1syE;d3QeT7{RIzXNM6S7s#kN!+tGf z)U)sXu8llkZxmZ+wqXkNi-H}k9iH%u`*aNR<7|MbdTID}7;)9~MS169ALhn>f<^Kk zhRjhQ#L~{L`eh;-iCGeoAkfBk8@oUUg|T|EJ}>a6TvmxCM4a{7I`!Vs zDNW2t(-j1nV}K-LRn%y$w^vwfM0!1mJe#2^U{(BU<|gjyq^)yn ze^axE)7I}g>lHPaM4SZU_?@R-{nv4Gp6KE<{ex)W*8wo^98MigT+qSpU!PV3c=!c1 zm00T?{oJkkk-f*rK$L+I&&{$89I5XmHv4TaE&)l2!J*|+p=;Ik7m*w&D(buQUL~f4 z#2|>a{c~=A7>oyhKH;T-^{Wbof8XU_UUOCaL)pMq{SRPhki_&-%O(cfp6sXja=q#Gvc#o<`WWks?2s8l4-6<@W{44h;Tf`*N2O`ScA4rO#FG;3XZPSlK z8$CpR2Yu!{{I%VNiw8GmmBWD_H%~IL6b<5X02*lhc=Gq(dFi}uIa#Wh_mU=t({}3E zX$bP%Br>i_5x)MUZYG!$04KpJpm{SwBx9Qky+I@TVr^|ajNmmR6Z`VsezJb-EWqB} zjTKuaL^hz|M+V9xm){0>ok{LEf`sV$l{Y|-B4TUbp{%Pj9ifHn6yRBX5mt_1qcWQf}@@-JKO-l|LfA3j(+3*)y}l(qId z-Em0(cWw@io(m z`GG->{xOIfG$0dW|CnPovWfY_qDR)N{gn7rq4CEdF$#YKz&r3(oi)YzUdFSf#-0aJ z7&G^Mc7_6_MbqrQvF$2aQu?RY#`*FF{k>9;kOC+WhoDfQ5zYEWoeh}zdhu_q`+2wD znLF3cfXHo=CF}6!gAIn3(6S%yzV3V+`rhfI#)1TIwBC6c>(sk0mJwdFi?F3*lIloc zyW-U6i=ZI={yyKotAz4D@`gg%{zDCJ@)BZnW5L~^MUR>k~LP)K?Kfo{Da)GHe+LOLws-;cj_rG*yIyxB_<(FR=ZK}LU16oUXI$Wa!N`nQP_MO@7R z1TW-(OCpUD*()==Pu&4(OZQ zp9SG-J#}BwYTJSofMx?wXHta(s{K5;Uk!~dWa({|*ZnP)CpD4jdi8fiO(%?GFd>+Z=pw2Iro|&Nw}0 zYx|>BcIihh`;_}m6uGQ8cfLS^a=0BbES70pm<>kuF4N6VMiZcGpzQkht+%2Eb}Ob6 zJB{FpF26?p;yg}TjIHfn7vAf#&d$Z^EFE>`7G87DpHbsEf8b~vG{xSR>Kgho_{vrI zEJzY6!di(3Y4UuHCoTM{K!K#M10nWyaiG7@H6B}_Gu->oONqbTyY3P>F6I}F@lbRN zyA05bWg~^F;Mkj|;Y_k7Iykq2@sMsYIB~1*p7V(w^R@kZXmYLx{S0546Kmb7RzHNf z5VEvE)ilKwP66E6xprr(qSt#pkKRhR2Voe%$^MV!XkiYOS1@AyDi|J)1mTRF{x2uQ zkH{i<6~{YJnFR??Qj$HGhxe4>C9epsT5%$FA{&Y33&`4P9L?BObSSVilJUjyi#@*i zmQtg4Pozgw9&js|3M^gY?r`YTvD++U;zT7^JbQ;G>QTSSo5CL5Jy`yGRZ9c+51I8l zUcH~K6N)wtWYW6&9Dk}laE6?@cAu=yrR!e~I#a`KI9O*IeHJ7>H9!q(U9y-5L>glg ztS`GMeSWNKvl`}>2d;AP`)SS^it%><6G^utXPx|IEYj@`mR}RGc9!TDoS41+f1JV# z;aTR&Mu0zv0ezLBxspTgVMpfr(fF}7dbTWm+;th$A5^F6>Ji1aliiTAGf88YSa9L|yW*1+#koRCYUlalwe_FkQa z0IZ!x?u0=1IXyu;yr`}AKBxv$<#hKQo58Jjft=&($us;Z_c>#_0JN7=UyUB?O9vqM>|KD&L(e5va2sk zRjH(W71lzU4Nbr%z(p7hKI-U70DIN^gL!DJ@rm8B>DfDv^=j}}R|*SI z^e(--a8)Cds>WV>l18<+3Eshs{v$*-X)0DlTftQqq^=Y~SLR+dvH2`@`dbdG_A#}-B z9|llM>AKzP|0h746TyMj{_%K%D8{B=kVo9-)J`@ku(i?NZTu6r#KLAtI9e7xOp-?Q zW_#(ce}HVt8YiNIZl8oaL6+Zpve&rCkpj=^)AIoHx;h; z_a@3p!@|TN)0?-Ww9%Doj(au~ZyGO^#|vX7EjyZ1V?lHi(k>h0K~Jui^dXi~ZLsFE zVD42}`7dPcd}7t)QEBVSn9rrsO@2dPvffUh&i_X&D&BYpqaqE`CVqK|2v*%dH=>C+ zg3kjQNdGZui`&{fO6ae*i8S-x+yncOzgS_oCeO^X`SX5kXM@W}-rcyz{~MR0*g(#& z15SzD93IaTC?U`dd@)t<{`Gol{2Q=B*K1;sGbNYt!{6Z>6j~S^EED|wR9V#1m*65? z%~Pb6UP^dC7N^}dOPpI87o+LQ36Ye+sB1NI_#0px*vqkdy!S;@%2QGqd-csn^ND;P ze}8}e{^s{7zq`VBvZeE!CnzSwZT5|Q@n1^dlLN61ibGjb_oK5RQR5dB!7{EUFVQrh zHq#|=Qy1{4aMF`!x8;e-48I~$Db@90fAcOvoIWQvHZSs#pS-My{7(hy$LG%9kFP-r zfQA8BMGD~8)6~L;ct2!XMV!okCu%~Q0LU#eIm$?fJs{uChfJk9#~-%SY! zP!t{Pak-yLhS4u)fxHNy^2nlN$7gm=Ay&!*+N6(5-=7(4GmWyd(_eyaqG2U;s!%Y1 zaLy-^k(%?T&t^CO=H*Dtpoa}wjQ6K2a9`e8ty|TH0af!Xt zaH0z13+s0l6Fgoe4aA&)x*<@j8o{P`zZDxd^NXrOe`^@qa@v)+D%Z<8)yCuJBoC)n&Y zMeD-&Gg=O!=d#=b|Fn0R=YKS~J+w$E(X)Q&%Bb(YHc;spWq;TFJKUYJw}B?+>jtyfut^Gy z%dyP*Iyi|pl1iLkCXm%fdH_g!9K3s055oURs!|pKRM&n$J>Ysbapclw7z~IJ<`=g% z0sRirG;phyh5`Tx37pQ;vV&Sx3N{PxK6d0{YNGPeW-EgNNAxJ5egX&jKnCUY5)TO< zz7L?hNFLYDbMzvf1Od9tm;C%x#}W*hTU54IZZ~N3Zs;`@*10 z!+zwAt?C>5e9?Z%#flS2Xe9dwOz<5Rwfx^KG8cdX&+N4)qHd)6!?na`qdBP=syTsH zC~VPPZcod#n3qRizwO`xpS zAM;hT-7NBHKu0h8VSs9o3P9(ESK0q*+j4qTxs-F2?_X*dv3&J?hyjtF(tm!~dtwHM z!$8%d9pIz@OOp`6c?NOqx{5#eZx3sKD)6!X#3IV)d#^!7M(OmOv?EPcnETrc* z)z@CZw3`}*)}x=y(OURDX>>j!!7*q1XvU5XC|hR1HUcYSK?WLEuiY2y0AYV03&r?N z2z#R~lNP>mM{aaV_}!5s$cQqVHi9 zwsC`hO8^25P~3{21|*rnj7FJx71>FJI19eEF|+4vZ&sIuIxEi|IR=yA7v#0Yid8s( z+nv~UD{RYyhXjtoY7!hU=nZ^n#t-gb4oQ8=3>h7nwZ;p##0%dWN5cCeOk(vi6r4_0 z9lGN&3mxoX23Yt0#IRe)P6a0ey2t)FYlMKdmbonoL4~+;b z;1*Q&vqPNalBF+Vc^_>-Z3HX~-${o-L!N|HwTm7V!!@)5+E7xjAIFSq?aKm)(#g_c@}vY*d_(jFX4nF5{cZ;lZbqT_`Fx~KkFWW;29uH|i|1>$`vab=*zv?4v-Gu=3wC{ zc}3BU?w~$){ok6~;^hBNb34~pim6DSN$DP>ZKP^)uo(Iu&3MVwQz-mxDwGKwTw;5E zl+^R#M{!`t0-A(*{hRMpMGxR1<=th9#sS7x&HJ}YL%j6ng+4ou%~HLSN-skLL>rG| zlkjj@nWO^)(x5v-oA%3ZOW_c+79=zADie$B`?G5b>Kl+MGB#&FI1nwrouO;JOCUia zrT@wPO++^GJ@V46PHo2%X5F_Q!x3~9jQOXP9eG;A&Y`AdT!cykd`H^*2Pl%|>lt>P zjX}`2MWR9D8oe1##FO0vVq_kDq&l3+9ISWM4f`Nl51zUIv#0~Sd7P1pcqhk{IDcC8 zda~lSrq1);_22sYO7qKa99w9mA+4|hSop88d3@-`o}YriEshJPyk@n3{{7-v(`&#A zasU54>(?`99ooRo)GaNw_sJe@VZFVouIofnde=^PTJ*!da&PTn_PYTEbA>mwALU&? zd);m8xShqoG0^q>4VU>3UAUQ3|7P2%x6%uaOQk2Y9^DHJht=!+Pw96)C~n>paLm95 zxG}FZzcl2XNwMwp0QSbC;-L1?ymNCWE`AK$zNj9Rwf6k_m2Vr4*Z*N?JgS_;IdkUv z`8nTaS_RLZv#?P~;0-s+jB{$$!2S4Z?_C4#$6vGUM{U7|F3@It;J(4~&xdc${8_b5 zT}Vr!En|fguxe~umbGHldcyt z9%pJ){4*z0P~}fLrAS8brf3TzsL*%FP36BrLZm;hukOT7T}J1-rAB-(=Ijz#`7E zW`XG31;BDI{mJW1T!(@-Z#`JMq?^wm_v^N{RUemSWhZYq{4yX1`NB z{&?AL;7;1{Mc%XRI@u@#I)Wt74Sg{8d4jH!@DW-|)NYmdrE8?%g*BDMnQAtse%XoH;al{@J&)Nsp@pozbg0VzmW5mI|8cLTV(%)Z+-W`-fL@w znz-lFDL#6WPV&Fs@vxBH=3T>e-|9Pzz*XjlUd`U{D@Sa8?Hy~Q7jm*Cw{?NXgdEM@ z{^Nm%h=o~1V&mfki9=@ZHuwJiwd`~J<{WU2`yaGX$R;A0VU=u%{=Q)AF% s{m`)K5k1a5!Frm?( - path: string, - body?: I, - options?: RequestOptions, - ): Promise { - return this.runRequestBody(path, body, options); - } - - private async runRequestBody(path: string, body?: I, options?: RequestOptions): Promise { - const rawResult = await this._client.post(path, this._parseBodyToFormData(body), options); - const result = deserialize(await rawResult.text()); - - if (result.type === 'redirect') - throw new Error(`Redirect (${result.status}) - ${result.location}`); - if (result.type === 'error') { - if (result.error?.error && result.error?.message && result.status) - throw new Exception(result.error.error, result.error.message, result.status); - throw new Error(`Error (${result.status}) - ${result.error}`); - } - - if (result.type === 'failure') { - if (result.data?.error && result.data?.message && result.status) - throw new Exception( - result.data.error as string, - result.data.message as string, - result.status, - ); - throw new Error(`Failure (${result.status}) - ${result.data}`); - } - - return result.data as R; - } - - private _parseBodyToFormData(body?: any): FormData { - const formData = new FormData(); - if (body) { - for (const [key, baseValue] of Object.entries(body)) { - let value: Blob | string; - if (baseValue instanceof File) value = baseValue; - else value = JSON.stringify(baseValue); - formData.append(key, value); - } - } - return formData; - } -} diff --git a/src/lib/client/action/client.ts b/src/lib/client/action/client.ts deleted file mode 100644 index e5fc7582..00000000 --- a/src/lib/client/action/client.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SESSION_PROJECT_HEADER } from '@utils/const'; -import { HttpClient } from '@utils/http'; - -import { ProjectArchiveRepository } from './repositories/archive.repository'; -import { ProjectFsRepository } from './repositories/fs.repository'; -import { ProjectLoaderRepository } from './repositories/loader.repository'; -import { ProjectPackageRepository } from './repositories/package.repository'; -import { ProjectRepository } from './repositories/project.repository'; -import { ProjectSaveRepository } from './repositories/save.repository'; - -export interface ActionClient { - archive: ProjectArchiveRepository; - fs: ProjectFsRepository; - loader: ProjectLoaderRepository; - package: ProjectPackageRepository; - project: ProjectRepository; - save: ProjectSaveRepository; -} - -export const getActionClient = (projectId?: string): ActionClient => { - const client = new HttpClient( - '', - projectId ? { headers: { [SESSION_PROJECT_HEADER]: projectId } } : {}, - ); - - return { - archive: new ProjectArchiveRepository(client), - fs: new ProjectFsRepository(client), - loader: new ProjectLoaderRepository(client), - package: new ProjectPackageRepository(client), - project: new ProjectRepository(client), - save: new ProjectSaveRepository(client), - }; -}; - -export const noProjectActions = getActionClient(); diff --git a/src/lib/client/action/index.ts b/src/lib/client/action/index.ts deleted file mode 100644 index c2c5d62f..00000000 --- a/src/lib/client/action/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { noProjectActions, type ActionClient, getActionClient } from './client'; -export * from './types'; diff --git a/src/lib/client/action/repositories/archive.repository.ts b/src/lib/client/action/repositories/archive.repository.ts deleted file mode 100644 index b15b8871..00000000 --- a/src/lib/client/action/repositories/archive.repository.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { Archive } from '../types'; - -export class ProjectArchiveRepository extends BaseRepository { - create(): Promise { - return this.run(`/actions/project/archive?/create`); - } -} diff --git a/src/lib/client/action/repositories/fs.repository.ts b/src/lib/client/action/repositories/fs.repository.ts deleted file mode 100644 index e19e0de1..00000000 --- a/src/lib/client/action/repositories/fs.repository.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { ReaddirFsActionInput, ReaddirFsActionResult } from '../types'; - -export class ProjectFsRepository extends BaseRepository { - readdir(input: ReaddirFsActionInput): Promise { - return this.run(`/actions/project/fs?/readdir`, input); - } -} diff --git a/src/lib/client/action/repositories/library.repository.ts b/src/lib/client/action/repositories/library.repository.ts deleted file mode 100644 index 5ed4341e..00000000 --- a/src/lib/client/action/repositories/library.repository.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { InstallLibraryActionInput, InstallLibraryPackageResult } from '../types'; - -export class ProjectLibraryRepository extends BaseRepository { - install(input: InstallLibraryActionInput): Promise { - return this.run(`/actions/project/library?/install`, input); - } -} diff --git a/src/lib/client/action/repositories/loader.repository.ts b/src/lib/client/action/repositories/loader.repository.ts deleted file mode 100644 index d7dd0526..00000000 --- a/src/lib/client/action/repositories/loader.repository.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { ActionLoaderEnv, ActionLoaderManifest } from '../types'; - -export class ProjectLoaderRepository extends BaseRepository { - env(): Promise { - return this.run(`/actions/project/loader?/env`); - } - - manifest(): Promise { - return this.run(`/actions/project/loader?/manifest`); - } - - build(): Promise { - return this.run(`/actions/project/loader?/build`); - } -} diff --git a/src/lib/client/action/repositories/package.repository.ts b/src/lib/client/action/repositories/package.repository.ts deleted file mode 100644 index b6a3cbb4..00000000 --- a/src/lib/client/action/repositories/package.repository.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { - AssetPkg, - ComponentPkg, - CreateComponentActionInput, - CreateSystemActionInput, - InstallPackagesActionInput, - Package, - SearchInput, - SearchPackages, - SystemPkg, -} from '../types'; - -export class ProjectPackageRepository extends BaseRepository { - createComponent(input: CreateComponentActionInput): Promise { - return this.run(`/actions/project/package?/create-component`, input); - } - - createSystem(input: CreateSystemActionInput): Promise { - return this.run(`/actions/project/package?/create-system`, input); - } - - getComponents(): Promise { - return this.run(`/actions/project/package?/get-components`); - } - - getSystems(): Promise { - return this.run(`/actions/project/package?/get-systems`); - } - - getAssets(): Promise { - return this.run(`/actions/project/package?/get-assets`); - } - - installPackages(input: InstallPackagesActionInput): Promise { - return this.run(`/actions/project/package?/install-packages`, input); - } - - searchPackages(input: SearchInput): Promise { - return this.run(`/actions/project/package?/search-packages`, input); - } -} diff --git a/src/lib/client/action/repositories/project.repository.ts b/src/lib/client/action/repositories/project.repository.ts deleted file mode 100644 index 47243d3d..00000000 --- a/src/lib/client/action/repositories/project.repository.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { - ActionProject, - CompleteProjectActionInput, - CreateProjectActionInput, - GatewayProjectResult, - InfoProjectInput, - InfoProjectResult, - LoadProjectActionInput, -} from '../types'; - -export class ProjectRepository extends BaseRepository { - load(input: LoadProjectActionInput): Promise { - return this.run(`/actions/project?/load`, input); - } - - new(input: CreateProjectActionInput): Promise { - return this.run(`/actions/project?/new`, input); - } - - complete(input: CompleteProjectActionInput): Promise { - return this.run(`/actions/project?/complete`, input); - } - - getInfo(): Promise { - return this.run(`/actions/project?/get-info`); - } - - setInfo(input: InfoProjectInput): Promise { - return this.run(`/actions/project?/set-info`, input); - } - - getGatewayProjects(): Promise { - return this.run(`/actions/project?/get-gateway-projects`); - } - - syncGatewayProject(): Promise { - return this.run(`/actions/project?/sync-gateway-project`); - } -} diff --git a/src/lib/client/action/repositories/save.repository.ts b/src/lib/client/action/repositories/save.repository.ts deleted file mode 100644 index e2939f4c..00000000 --- a/src/lib/client/action/repositories/save.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { GetSaveResult, SetSaveInput } from '../types'; - -export class ProjectSaveRepository extends BaseRepository { - get(): Promise { - return this.run(`/actions/project/save?/get`); - } - set(input: SetSaveInput): Promise { - return this.run(`/actions/project/save?/set`, input); - } -} diff --git a/src/lib/client/action/types/archive.type.ts b/src/lib/client/action/types/archive.type.ts deleted file mode 100644 index fc411076..00000000 --- a/src/lib/client/action/types/archive.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { CreateArchiveResult } from '$lib/server/actions/project/archive/create.action'; - -export type Archive = CreateArchiveResult; diff --git a/src/lib/client/action/types/fs.type.ts b/src/lib/client/action/types/fs.type.ts deleted file mode 100644 index ee726958..00000000 --- a/src/lib/client/action/types/fs.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReaddirFsBody } from '$lib/server/actions/project/fs/readdir.action'; -import type { DirectoryContent } from '$lib/server/file-system/project-directory'; - -export type ReaddirFsActionInput = ReaddirFsBody; - -export type ReaddirFsActionResult = DirectoryContent; diff --git a/src/lib/client/action/types/index.ts b/src/lib/client/action/types/index.ts deleted file mode 100644 index 6c4c59e6..00000000 --- a/src/lib/client/action/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './archive.type'; -export * from './fs.type'; -export * from './library.type'; -export * from './loader.type'; -export * from './project.type'; -export * from './package.type'; -export * from './save.type'; diff --git a/src/lib/client/action/types/library.type.ts b/src/lib/client/action/types/library.type.ts deleted file mode 100644 index c3a38802..00000000 --- a/src/lib/client/action/types/library.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InstallLibraryBody } from '$lib/server/actions/project/library/install-library.action'; -import type { LibraryPackage } from '$lib/server/project/library'; - -export type InstallLibraryActionInput = InstallLibraryBody; - -export type InstallLibraryPackageResult = LibraryPackage; diff --git a/src/lib/client/action/types/loader.type.ts b/src/lib/client/action/types/loader.type.ts deleted file mode 100644 index 3b24c56a..00000000 --- a/src/lib/client/action/types/loader.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Loader } from '$lib/server/project/loader'; - -export type ActionLoaderManifest = ReturnType; - -export type ActionLoaderEnv = ReturnType; diff --git a/src/lib/client/action/types/package.type.ts b/src/lib/client/action/types/package.type.ts deleted file mode 100644 index 8754b87e..00000000 --- a/src/lib/client/action/types/package.type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CreateComponentBody } from '$lib/server/actions/project/package/create-component.action'; -import type { CreateSystemBody } from '$lib/server/actions/project/package/create-system.action'; -import type { InstallPackagesBody } from '$lib/server/actions/project/package/install-packages.action'; -import type { SearchPackagesBody } from '$lib/server/actions/project/package/search-packages.action'; -import type { PaginateResult, Package as ServerPackage } from '$lib/server/api'; -import type { - AssetPackage, - ComponentPackage, - SystemPackage, -} from '$lib/server/project/package/package.type'; - -export type ComponentPkg = ComponentPackage; - -export type SystemPkg = SystemPackage; - -export type AssetPkg = AssetPackage; - -export type Package = ComponentPackage | SystemPackage; - -export type InstallPackagesActionInput = InstallPackagesBody; - -export type CreateComponentActionInput = CreateComponentBody; - -export type CreateSystemActionInput = CreateSystemBody; - -export type SearchInput = SearchPackagesBody; - -export type ApiPackage = ServerPackage; - -export type SearchPackages = PaginateResult; diff --git a/src/lib/client/action/types/project.type.ts b/src/lib/client/action/types/project.type.ts deleted file mode 100644 index 50a1e50f..00000000 --- a/src/lib/client/action/types/project.type.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CompleteProjectBody } from '$lib/server/actions/project/complete.action'; -import type { GatewayProject } from '$lib/server/actions/project/gateway.action'; -import type { InfoProject, InfoProjectBody } from '$lib/server/actions/project/info.action'; -import type { LoadProjectBody } from '$lib/server/actions/project/load.action'; -import type { CreateProjectBody } from '$lib/server/actions/project/new.action'; -import type { Project } from '$lib/server/project'; - -export type ActionProject = Project; - -export type LoadProjectActionInput = LoadProjectBody; - -export type CreateProjectActionInput = CreateProjectBody; -export type CompleteProjectActionInput = CompleteProjectBody; - -export type InfoProjectResult = InfoProject; -export type InfoProjectInput = InfoProjectBody; - -export type GatewayProjectResult = GatewayProject; diff --git a/src/lib/client/action/types/save.type.ts b/src/lib/client/action/types/save.type.ts deleted file mode 100644 index 3f71760f..00000000 --- a/src/lib/client/action/types/save.type.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SetSaveBody } from '$lib/server/actions/project/save/set-save.action'; - -import type { Save } from '@utils/types'; - -export type GetSaveResult = Save; - -export type SetSaveInput = SetSaveBody; diff --git a/src/lib/client/config/config.ts b/src/lib/client/config/config.ts deleted file mode 100644 index ef1d4989..00000000 --- a/src/lib/client/config/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PUBLIC_MODE } from '$env/static/public'; - -import type { Config } from './config.type'; - -export const getConfig = (): Config => { - return { - mode: PUBLIC_MODE === 'ONLINE' ? 'online' : 'offline', - }; -}; diff --git a/src/lib/client/config/config.type.ts b/src/lib/client/config/config.type.ts deleted file mode 100644 index 0b54c056..00000000 --- a/src/lib/client/config/config.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Config { - mode: 'offline' | 'online'; -} diff --git a/src/lib/client/config/index.ts b/src/lib/client/config/index.ts deleted file mode 100644 index ce28a644..00000000 --- a/src/lib/client/config/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { Config } from './config.type'; -export { getConfig } from './config'; diff --git a/src/lib/client/ecs/asset/asset-handle.ts b/src/lib/client/ecs/asset/asset-handle.ts deleted file mode 100644 index c89aae34..00000000 --- a/src/lib/client/ecs/asset/asset-handle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type Writable, get, writable } from 'svelte/store'; - -import { resolveStore } from '../utils'; -import type { AssetManager } from './asset-manager'; -import type { Asset } from './asset.type'; - -const _storage = writable>>({}); - -export class AssetHandle { - private _manager: AssetManager; - private readonly _store: Writable; - public readonly id: string; - - static reset() { - _storage.set({}); - } - - constructor(manager: AssetManager, asset: Asset) { - this._manager = manager; - this.id = asset.id; - - this._store = resolveStore(_storage, this.id, asset); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - update(asset: Partial) { - this._store.set({ ...get(this._store), ...asset }); - } - - delete() { - return this._manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/asset/asset-manager.ts b/src/lib/client/ecs/asset/asset-manager.ts deleted file mode 100644 index cc2b0765..00000000 --- a/src/lib/client/ecs/asset/asset-manager.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type Unsubscriber, get, writable } from 'svelte/store'; - -import type { AssetPkg } from '$lib/client/action'; -import { useProject } from '$lib/client/project'; - -import { assetTransformer, assetsTransformer } from '../transformers'; -import { resetSubscriptions } from '../utils'; -import { AssetHandle } from './asset-handle'; -import type { Asset } from './asset.type'; - -const _storage = writable([]); - -const _subscriptions = writable>({}); - -export class AssetManager { - static reset() { - resetSubscriptions(_subscriptions); - _storage.set([]); - } - - constructor(assets: Asset[]) { - _storage.set(assets); - } - - get store() { - return _storage; - } - - get data() { - return get(_storage); - } - - async createMany(files: File[]): Promise { - const { fs } = useProject(); - - await Promise.all(files.map(this.create.bind(this))); - - await this.sync(); - const dir = await fs.getDirectory(); - await dir.readdir(true); - } - - async create(file: File): Promise { - const { fs } = useProject(); - const assetDir = await fs.getDirectory('static'); - const assetFile = await assetDir.getFile(file.name); - const stream = await assetFile.createWritable(); - await file.stream().pipeTo(stream); - await assetFile.sync(); - } - - add(asset: AssetPkg) { - this._add(assetTransformer(asset)); - } - - async sync() { - const { actions } = useProject(); - const assets = await actions.package.getAssets(); - _storage.set(assetsTransformer(assets)); - } - - get(id: string): AssetHandle { - const asset = get(_storage).find((asset) => asset.id === id); - if (!asset) throw new Error(`Asset with id ${id} not found`); - const handle = new AssetHandle(this, asset); - - this._subscribe(id, handle); - - return handle; - } - - async delete(id: string) { - const assets = get(_storage); - const asset = assets.find((c) => c.id === id); - - if (!asset) throw new Error(`System not found: ${id}`); - - _storage.set(assets.filter((c) => c.id !== id)); - const { fs } = useProject(); - const file = await fs.getFile(asset.path); - await file.delete(); - - const subscriptions = get(_subscriptions); - if (subscriptions[id]) { - subscriptions[id](); - subscriptions[id] = null; - _subscriptions.set(subscriptions); - } - } - - private _add(asset: Asset) { - const assets = get(_storage); - assets.push(asset); - _storage.set(assets); - } - - private _subscribe(id: string, handle: AssetHandle) { - setTimeout(() => { - const subscriptions = get(_subscriptions); - if (subscriptions[id]) return; - subscriptions[id] = handle.store.subscribe((asset) => this._update(id, asset)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, asset: Asset) { - const assets = get(_storage); - const index = assets.findIndex((s) => s.id === id); - if (index === -1) return; - assets[index] = asset; - _storage.set(assets); - } -} diff --git a/src/lib/client/ecs/asset/asset.type.ts b/src/lib/client/ecs/asset/asset.type.ts deleted file mode 100644 index 81777b29..00000000 --- a/src/lib/client/ecs/asset/asset.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Asset { - id: string; - path: string; -} diff --git a/src/lib/client/ecs/component/component-handle.ts b/src/lib/client/ecs/component/component-handle.ts deleted file mode 100644 index 5a3ad199..00000000 --- a/src/lib/client/ecs/component/component-handle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type Writable, get, writable } from 'svelte/store'; - -import { resolveStore } from '../utils'; -import type { ComponentManager } from './component-manager'; -import type { Component } from './component.type'; - -const _storage = writable>>({}); - -export class ComponentHandle { - private _manager: ComponentManager; - private readonly _store: Writable; - public readonly id: string; - - static reset() { - _storage.set({}); - } - - constructor(manager: ComponentManager, component: Component) { - this._manager = manager; - this.id = component.id; - - this._store = resolveStore(_storage, this.id, component); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - update(component: Partial) { - this._store.set({ ...get(this._store), ...component }); - } - - delete() { - return this._manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/component/component-manager.ts b/src/lib/client/ecs/component/component-manager.ts deleted file mode 100644 index 57127b04..00000000 --- a/src/lib/client/ecs/component/component-manager.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type Unsubscriber, get, writable } from 'svelte/store'; - -import type { ComponentPkg } from '$lib/client/action'; -import { useProject } from '$lib/client/project'; - -import { componentTransformer, componentsTransformer } from '../transformers'; -import { resetSubscriptions } from '../utils'; -import { ComponentHandle } from './component-handle'; -import type { Component } from './component.type'; - -const _storage = writable([]); - -const _subscriptions = writable>({}); - -export class ComponentManager { - static reset() { - resetSubscriptions(_subscriptions); - _storage.set([]); - } - - constructor(components: Component[]) { - _storage.set(components); - } - - get store() { - return _storage; - } - - get data() { - return get(_storage); - } - - async create(name: string) { - const { actions, fs } = useProject(); - const component = await actions.package.createComponent({ componentName: name }); - this._add(componentTransformer(component)); - const dir = await fs.getDirectory(); - await dir.readdir(true); - } - - add(component: ComponentPkg) { - this._add(componentTransformer(component)); - } - - async sync() { - const { actions } = useProject(); - const components = await actions.package.getComponents(); - _storage.set(componentsTransformer(components)); - } - - get(id: string): ComponentHandle { - const component = get(_storage).find((component) => component.id === id); - if (!component) throw new Error(`Component with id ${id} not found`); - const handle = new ComponentHandle(this, component); - - this._subscribe(id, handle); - - return handle; - } - - async delete(id: string) { - const components = get(_storage); - const component = components.find((c) => c.id === id); - - if (!component) throw new Error(`System not found: ${id}`); - - _storage.set(components.filter((c) => c.id !== id)); - const { fs } = useProject(); - const file = await fs.getFile(component.path); - await file.delete(); - - const subscriptions = get(_subscriptions); - if (subscriptions[id]) { - subscriptions[id](); - subscriptions[id] = null; - _subscriptions.set(subscriptions); - } - } - - private _add(component: Component) { - const components = get(_storage); - components.push(component); - _storage.set(components); - } - - private _subscribe(id: string, handle: ComponentHandle) { - setTimeout(() => { - const subscriptions = get(_subscriptions); - if (subscriptions[id]) return; - subscriptions[id] = handle.store.subscribe((component) => this._update(id, component)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, component: Component) { - const components = get(_storage); - const index = components.findIndex((s) => s.id === id); - if (index === -1) return; - components[index] = component; - _storage.set(components); - } -} diff --git a/src/lib/client/ecs/component/component.type.ts b/src/lib/client/ecs/component/component.type.ts deleted file mode 100644 index 5247dd9f..00000000 --- a/src/lib/client/ecs/component/component.type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ComponentManifest } from '$lib/server/project/package/manifest/manifest.type'; - -export type ComponentParam = ComponentManifest['params'][number]; - -export interface Component { - id: string; - name: string; - path: string; - params: ComponentParam[]; -} diff --git a/src/lib/client/ecs/ecs-handler.ts b/src/lib/client/ecs/ecs-handler.ts deleted file mode 100644 index b5f566c1..00000000 --- a/src/lib/client/ecs/ecs-handler.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - assetsTransformer, - componentsTransformer, - librariesTransformer, - scenesTransformer, - systemsTransformer, -} from '$lib/client/ecs/transformers'; -import type { Project } from '$lib/client/project'; - -import { AssetHandle } from './asset/asset-handle'; -import { AssetManager } from './asset/asset-manager'; -import { ComponentHandle } from './component/component-handle'; -import { ComponentManager } from './component/component-manager'; -import { LibraryHandle } from './library/library-handle'; -import { LibraryManager } from './library/library-manager'; -import { EntityComponentManager } from './scene/entity/component/component-manager'; -import { ComponentParamHandle } from './scene/entity/component/component-param-handle'; -import { ComponentParamManager } from './scene/entity/component/component-param-manager'; -import { SceneEntityHandle } from './scene/entity/entity-handle'; -import { SceneEntityManager } from './scene/entity/entity-manager'; -import { SceneHandle } from './scene/scene-handle'; -import { SceneManager } from './scene/scene-manager'; -import { SceneSystemManager } from './scene/system/system-manager'; -import { SystemHandle } from './system/system-handle'; -import { SystemManager } from './system/system-manager'; - -export class ECSHandler { - private readonly _project: Project; - - private _sceneManager: SceneManager | undefined; - private _assetManager: AssetManager | undefined; - private _componentManager: ComponentManager | undefined; - private _systemManager: SystemManager | undefined; - private _libraryManager: LibraryManager | undefined; - - static reset() { - // * Child must be reset before parents - ComponentParamHandle.reset(); - ComponentParamManager.reset(); - EntityComponentManager.reset(); - SceneSystemManager.reset(); - SceneEntityHandle.reset(); - SceneEntityManager.reset(); - AssetHandle.reset(); - AssetManager.reset(); - ComponentHandle.reset(); - ComponentManager.reset(); - SystemManager.reset(); - SystemHandle.reset(); - LibraryManager.reset(); - LibraryHandle.reset(); - SceneHandle.reset(); - SceneManager.reset(); - } - - constructor(project: Project) { - this._project = project; - } - - async init() { - /** - * @todo - * This init is only made for the current version of the editor - * When scenes will be implemented, this init must be changed accordingly - */ - - const assets = await this._project.actions.package.getAssets(); - const components = await this._project.actions.package.getComponents(); - const systems = await this._project.actions.package.getSystems(); - // Cannot use save handler as it needs to use ecs handler to subscribe to ecs changes - const save = await this._project.actions.save.get(); - - this._assetManager = new AssetManager(assetsTransformer(assets)); - this._componentManager = new ComponentManager(componentsTransformer(components)); - this._systemManager = new SystemManager(systemsTransformer(systems)); - this._libraryManager = new LibraryManager(librariesTransformer(save)); - // Scene Manager needs others managers to be initialized - this._sceneManager = new SceneManager(this, scenesTransformer(save), ['default'], 'default'); - } - - get scenes(): SceneManager { - if (!this._sceneManager) throw new Error('ECS handler not initialized'); - return this._sceneManager; - } - - get assets(): AssetManager { - if (!this._assetManager) throw new Error('ECS handler not initialized'); - return this._assetManager; - } - - get components(): ComponentManager { - if (!this._componentManager) throw new Error('ECS handler not initialized'); - return this._componentManager; - } - - get systems(): SystemManager { - if (!this._systemManager) throw new Error('ECS handler not initialized'); - return this._systemManager; - } - - get libraries(): LibraryManager { - if (!this._libraryManager) throw new Error('ECS handler not initialized'); - return this._libraryManager; - } -} diff --git a/src/lib/client/ecs/index.ts b/src/lib/client/ecs/index.ts deleted file mode 100644 index bfdfd86c..00000000 --- a/src/lib/client/ecs/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -export { ECSHandler } from './ecs-handler'; - -export type { AssetHandle } from './asset/asset-handle'; -export type { AssetManager } from './asset/asset-manager'; -export type { Asset } from './asset/asset.type'; - -export type { ComponentHandle } from './component/component-handle'; -export type { ComponentManager } from './component/component-manager'; -export type { Component, ComponentParam } from './component/component.type'; - -export type { LibraryManager } from './library/library-manager'; -export type { LibraryHandle } from './library/library-handle'; -export type { Library } from './library/library.type'; - -export type { SystemManager } from './system/system-manager'; -export type { SystemHandle } from './system/system-handle'; -export type { System } from './system/system.type'; - -export type { SceneHandle } from './scene/scene-handle'; -export type { SceneManager } from './scene/scene-manager'; -export type { Scene } from './scene/scene.type'; - -export type { SceneSystemManager } from './scene/system/system-manager'; -export type { SceneSystemHandle } from './scene/system/system-handle'; - -export type { SceneEntityHandle } from './scene/entity/entity-handle'; -export type { SceneEntityManager } from './scene/entity/entity-manager'; -export type { Entity } from './scene/entity/entity.type'; - -export type { EntityComponentHandle } from './scene/entity/component/component-handle'; -export type { EntityComponentManager } from './scene/entity/component/component-manager'; - -export type { ComponentParamHandle } from './scene/entity/component/component-param-handle'; -export type { ComponentParamManager } from './scene/entity/component/component-param-manager'; diff --git a/src/lib/client/ecs/library/library-handle.ts b/src/lib/client/ecs/library/library-handle.ts deleted file mode 100644 index 6c2c9fb3..00000000 --- a/src/lib/client/ecs/library/library-handle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type Writable, get, writable } from 'svelte/store'; - -import { resolveStore } from '../utils'; -import type { LibraryManager } from './library-manager'; -import type { Library } from './library.type'; - -const _storage = writable>>({}); - -export class LibraryHandle { - private _manager: LibraryManager; - public readonly id: string; - private readonly _store: Writable; - - static reset() { - _storage.set({}); - } - - constructor(manager: LibraryManager, library: Library) { - this._manager = manager; - this.id = library.id; - - this._store = resolveStore(_storage, this.id, library); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - update(library: Partial) { - this._store.set({ ...get(this._store), ...library }); - } - - delete() { - this._manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/library/library-manager.ts b/src/lib/client/ecs/library/library-manager.ts deleted file mode 100644 index 862d0247..00000000 --- a/src/lib/client/ecs/library/library-manager.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type Unsubscriber, get, writable } from 'svelte/store'; - -import { resetSubscriptions } from '../utils'; -import { LibraryHandle } from './library-handle'; -import type { Library } from './library.type'; - -const _storage = writable([]); -const _subscriptions = writable>({}); - -export class LibraryManager { - static reset() { - resetSubscriptions(_subscriptions); - _storage.set([]); - } - - constructor(libraries: Library[]) { - _storage.set(libraries); - } - - get store() { - return _storage; - } - - get data() { - return get(_storage); - } - - add(library: { name: string }): string { - const libraries = get(_storage); - libraries.push({ ...library, id: library.name }); - _storage.set(libraries); - return library.name; - } - - get(id: string): LibraryHandle { - const library = get(_storage).find((library) => library.id === id); - if (!library) throw new Error(`Library with id ${id} not found`); - const handle = new LibraryHandle(this, library); - - this._subscribe(id, handle); - - return handle; - } - - delete(id: string) { - const libraries = get(_storage); - _storage.set(libraries.filter((library) => library.id !== id)); - - const subscriptions = get(_subscriptions); - if (subscriptions[id]) { - subscriptions[id](); - subscriptions[id] = null; - _subscriptions.set(subscriptions); - } - } - - private _subscribe(id: string, handle: LibraryHandle) { - setTimeout(() => { - const subscriptions = get(_subscriptions); - if (subscriptions[id]) return; - subscriptions[id] = handle.store.subscribe((library) => this._update(id, library)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, library: Library) { - const libraries = get(_storage); - const index = libraries.findIndex((s) => s.id === id); - if (index === -1) return; - libraries[index] = library; - _storage.set(libraries); - } -} diff --git a/src/lib/client/ecs/library/library.type.ts b/src/lib/client/ecs/library/library.type.ts deleted file mode 100644 index 4c3389c3..00000000 --- a/src/lib/client/ecs/library/library.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Library { - id: string; - name: string; -} diff --git a/src/lib/client/ecs/scene/entity/component/component-handle.ts b/src/lib/client/ecs/scene/entity/component/component-handle.ts deleted file mode 100644 index 27be6f6f..00000000 --- a/src/lib/client/ecs/scene/entity/component/component-handle.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type Readable, get } from 'svelte/store'; - -import type { Component } from '../../../component/component.type'; -import type { EntityComponentManager } from './component-manager'; -import { ComponentParamManager } from './component-param-manager'; - -export class EntityComponentHandle { - public readonly manager: EntityComponentManager; - public readonly id: string; - private readonly _store: Readable; - private readonly _params: ComponentParamManager; - - constructor( - manager: EntityComponentManager, - componentId: string, - params: Record, - ) { - this.manager = manager; - this._store = manager.entity.manager.scene.manager.ecs.components.get(componentId).store; - this.id = componentId; - this._params = new ComponentParamManager(this, params); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - get params(): ComponentParamManager { - return this._params; - } - - delete() { - this.manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/scene/entity/component/component-manager.ts b/src/lib/client/ecs/scene/entity/component/component-manager.ts deleted file mode 100644 index 4ba59a71..00000000 --- a/src/lib/client/ecs/scene/entity/component/component-manager.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { resetListener, resetSubscriptions } from '../../../utils'; -import { resolveStore } from '../../../utils'; -import type { SceneEntityHandle } from '../entity-handle'; -import { EntityComponentHandle } from './component-handle'; - -const _storage = writable>>>>({}); - -const _subscriptions = writable>({}); - -const _listener = writable(); - -export class EntityComponentManager { - public readonly entity: SceneEntityHandle; - private readonly _store: Writable>>; - - private readonly _storageResolvable: string; - - static reset() { - resetSubscriptions(_subscriptions); - resetListener(_listener); - _storage.set({}); - } - - constructor(entity: SceneEntityHandle, components: Record>) { - this.entity = entity; - - this._storageResolvable = `${this.entity.manager.scene.id}/${this.entity.id}`; - - this._store = resolveStore(_storage, this._storageResolvable, components); - - this._listen(); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - add(component: string) { - const components = get(this._store); - const manifest = this.entity.manager.scene.manager.ecs.components.get(component).data; - components[component] = { - ...Object.fromEntries( - manifest.params - .filter((param) => param.default !== undefined) - .map((param) => [param.name, param.default as string]), - ), - }; - this._store.set(components); - } - - get(id: string): EntityComponentHandle { - const params = get(this._store)[id]; - if (!params) throw new Error(`Component with id ${id} not found`); - const handle = new EntityComponentHandle(this, id, params); - - this._subscribe(id, handle); - - return handle; - } - - delete(id: string) { - const components = get(this._store); - const newComponents: Record> = {}; - Object.entries(components).forEach(([key, params]) => { - if (key !== id) newComponents[key] = params; - }); - this._store.set(newComponents); - - const fullId = `${this._storageResolvable}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) { - subscriptions[fullId](); - subscriptions[fullId] = null; - _subscriptions.set(subscriptions); - } - } - - private _listen() { - if (get(_listener)) return; - const unsub = this.entity.manager.scene.manager.ecs.components.store.subscribe((components) => { - const entityComponents = get(this._store); - const newComponents: Record> = {}; - Object.entries(entityComponents).forEach(([key, params]) => { - if (components.find((s) => s.id === key)) newComponents[key] = params; - }); - this._store.set(newComponents); - }); - _listener.set([unsub]); - } - - private _subscribe(id: string, handle: EntityComponentHandle) { - setTimeout(() => { - const fullId = `${this._storageResolvable}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) return; - subscriptions[fullId] = handle.params.values.subscribe((params) => this._update(id, params)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, params: Record) { - const component = get(this._store); - component[id] = params; - this._store.set(component); - } -} diff --git a/src/lib/client/ecs/scene/entity/component/component-param-handle.ts b/src/lib/client/ecs/scene/entity/component/component-param-handle.ts deleted file mode 100644 index 3beb490c..00000000 --- a/src/lib/client/ecs/scene/entity/component/component-param-handle.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type Readable, type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import type { ComponentParam } from '../../../component/component.type'; -import { resetListeners, resolveStore } from '../../../utils'; -import type { ComponentParamManager } from './component-param-manager'; - -const _storage = writable>>({}); -const _valueStorage = writable>>({}); - -const _listeners = writable>({}); - -export class ComponentParamHandle { - public readonly manager: ComponentParamManager; - public readonly id: string; - private readonly fullId: string; - private readonly _store: Writable; - private readonly _valueStore: Writable; - - static reset() { - resetListeners(_listeners); - _storage.set({}); - _valueStorage.set({}); - } - - constructor(manager: ComponentParamManager, param: ComponentParam, value: any | undefined) { - this.manager = manager; - this.id = param.name; - this.fullId = `${this.manager.component.manager.entity.manager.scene.id}/${this.manager.component.manager.entity.id}/${this.manager.component.id}/${this.id}`; - - this._store = resolveStore(_storage, this.fullId, param); - this._valueStore = resolveStore(_valueStorage, this.fullId, value); - - this._listen(); - } - - get store(): Readable { - return this._store; - } - - get value() { - return this._valueStore; - } - - delete() { - this.manager.delete(this.id); - } - - private _listen() { - const listeners = get(_listeners); - if (listeners[this.fullId]) return; - const unsub = this.manager.component.store.subscribe((component) => { - const newParam = component.params.find((param) => param.name === this.id); - if (!newParam) return; - this._store.set(newParam); - }); - _listeners.set({ ...listeners, [this.fullId]: [unsub] }); - } -} diff --git a/src/lib/client/ecs/scene/entity/component/component-param-manager.ts b/src/lib/client/ecs/scene/entity/component/component-param-manager.ts deleted file mode 100644 index b30729f4..00000000 --- a/src/lib/client/ecs/scene/entity/component/component-param-manager.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import type { ComponentParam } from '../../../component/component.type'; -import { resetListener, resetSubscriptions } from '../../../utils'; -import { resolveStore } from '../../../utils'; -import type { EntityComponentHandle } from './component-handle'; -import { ComponentParamHandle } from './component-param-handle'; - -const _storage = writable>>({}); -const _valueStorage = writable>>>({}); - -const _subscriptions = writable>({}); - -const _listener = writable(); - -export class ComponentParamManager { - public readonly component: EntityComponentHandle; - private readonly _store: Writable; - private readonly _valuesStore: Writable>; - - private readonly _storageResolvable: string; - - static reset() { - resetSubscriptions(_subscriptions); - resetListener(_listener); - _storage.set({}); - _valueStorage.set({}); - } - - constructor(component: EntityComponentHandle, params: Record) { - this.component = component; - - this._storageResolvable = `${this.component.manager.entity.manager.scene.id}/${this.component.manager.entity.id}/${this.component.id}`; - - this._store = resolveStore(_storage, this._storageResolvable, get(component.store).params); - this._valuesStore = resolveStore(_valueStorage, this._storageResolvable, params); - - this._listen(); - } - - get store() { - return this._store; - } - - get values() { - return this._valuesStore; - } - - get(id: string): ComponentParamHandle { - const param = get(this._store).find((param) => param.name === id); - if (!param) throw new Error(`Param with id ${id} not found`); - - const value = get(this._valuesStore)[id]; - const handle = new ComponentParamHandle(this, param, value); - - this._subscribe(id, handle); - - return handle; - } - - delete(id: string) { - const params = get(this._store); - this._store.set( - params.map((param) => (param.name === id ? { ...param, value: undefined } : param)), - ); - - const fullId = `${this._storageResolvable}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) { - subscriptions[fullId](); - subscriptions[fullId] = null; - _subscriptions.set(subscriptions); - } - } - - private _listen() { - if (get(_listener)) return; - const unsub = this.component.store.subscribe((component) => { - this._store.set(component.params); - }); - _listener.set([unsub]); - } - - private _subscribe(id: string, handle: ComponentParamHandle) { - setTimeout(() => { - const fullId = `${this._storageResolvable}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) return; - subscriptions[fullId] = handle.value.subscribe((param) => this._update(id, param)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, value: any | undefined) { - const params = get(this._valuesStore); - params[id] = value; - this._valuesStore.set(params); - } -} diff --git a/src/lib/client/ecs/scene/entity/entity-handle.ts b/src/lib/client/ecs/scene/entity/entity-handle.ts deleted file mode 100644 index c61d37e8..00000000 --- a/src/lib/client/ecs/scene/entity/entity-handle.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { resetListeners, resolveStore } from '../../utils'; -import { EntityComponentManager } from './component/component-manager'; -import type { SceneEntityManager } from './entity-manager'; -import type { Entity } from './entity.type'; - -const _storage = writable>>({}); - -const _listeners = writable>({}); - -export class SceneEntityHandle { - public readonly manager: SceneEntityManager; - public readonly id: string; - private readonly fullId: string; - private readonly _store: Writable; - private readonly _components: EntityComponentManager; - - static reset() { - resetListeners(_listeners); - _storage.set({}); - } - - constructor(manager: SceneEntityManager, entity: Entity) { - this.manager = manager; - this.id = entity.id; - this.fullId = `${manager.scene.id}/${entity.id}`; - - this._store = resolveStore(_storage, this.fullId, entity); - - this._components = new EntityComponentManager(this, entity.components); - - this._listen(); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - get components() { - return this._components; - } - - update(entity: Partial) { - this._store.set({ ...get(this._store), ...entity }); - } - - delete() { - this.manager.delete(this.id); - } - - setSelected() { - this.manager.selected = this; - } - - private _listen() { - const listeners = get(_listeners); - if (listeners[this.fullId]) return; - const unsub = this._components.store.subscribe((components) => { - const entity = get(this._store); - entity.components = components; - this._store.set(entity); - }); - _listeners.set({ ...listeners, [this.fullId]: [unsub] }); - } -} diff --git a/src/lib/client/ecs/scene/entity/entity-manager.ts b/src/lib/client/ecs/scene/entity/entity-manager.ts deleted file mode 100644 index 07613869..00000000 --- a/src/lib/client/ecs/scene/entity/entity-manager.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { getId, resetSubscriptions } from '../../utils'; -import { resolveStore } from '../../utils'; -import type { SceneHandle } from '../scene-handle'; -import { SceneEntityHandle } from './entity-handle'; -import type { Entity } from './entity.type'; - -const _storage = writable>>({}); - -const _subscriptions = writable>({}); - -const selectedEntity = writable(undefined); - -export class SceneEntityManager { - public readonly scene: SceneHandle; - private readonly _store: Writable; - - static reset() { - resetSubscriptions(_subscriptions); - _storage.set({}); - } - - constructor(scene: SceneHandle, entities: Entity[]) { - this.scene = scene; - - this._store = resolveStore(_storage, this.scene.id, entities); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - get selectedData(): SceneEntityHandle | undefined { - return get(selectedEntity); - } - - get selected(): Writable { - return selectedEntity; - } - - set selected(entity: SceneEntityHandle | undefined) { - selectedEntity.set(entity); - } - - add(entity: Omit): string { - const entities = get(this._store); - const id = getId(this.data, entity.name); - entities.push({ ...entity, id }); - this._store.set(entities); - return id; - } - - get(id: string): SceneEntityHandle { - const entity = get(this._store).find((entity) => entity.id === id); - if (!entity) throw new Error(`Entity with id ${id} not found`); - const handle = new SceneEntityHandle(this, entity); - - this._subscribe(id, handle); - - return handle; - } - - delete(id: string) { - const entities = get(this._store); - this._store.set(entities.filter((entity) => entity.id !== id)); - - const fullId = `${this.scene.id}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) { - subscriptions[fullId](); - subscriptions[fullId] = null; - _subscriptions.set(subscriptions); - } - - if (this.selectedData?.id === id) { - this.selected = undefined; - } - } - - private _subscribe(id: string, handle: SceneEntityHandle) { - setTimeout(() => { - const fullId = `${this.scene.id}/${id}`; - const subscriptions = get(_subscriptions); - if (subscriptions[fullId]) return; - subscriptions[fullId] = handle.store.subscribe((entity) => this._update(id, entity)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, entity: Entity) { - const entities = get(this._store); - const index = entities.findIndex((s) => s.id === id); - if (index === -1) return; - entities[index] = entity; - this._store.set(entities); - } -} diff --git a/src/lib/client/ecs/scene/entity/entity.type.ts b/src/lib/client/ecs/scene/entity/entity.type.ts deleted file mode 100644 index 50e6edd9..00000000 --- a/src/lib/client/ecs/scene/entity/entity.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Entity { - id: string; - name: string; - treePath: string; - components: Record>; -} diff --git a/src/lib/client/ecs/scene/scene-handle.ts b/src/lib/client/ecs/scene/scene-handle.ts deleted file mode 100644 index a1729e2b..00000000 --- a/src/lib/client/ecs/scene/scene-handle.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { resetListeners, resolveStore } from '../utils'; -import { SceneEntityManager } from './entity/entity-manager'; -import type { SceneManager } from './scene-manager'; -import type { Scene } from './scene.type'; -import { SceneSystemManager } from './system/system-manager'; - -const _storage = writable>>({}); - -const _listeners = writable>({}); - -export class SceneHandle { - public readonly manager: SceneManager; - public readonly id: string; - private readonly _store: Writable; - private readonly _entities: SceneEntityManager; - private readonly _systems: SceneSystemManager; - - static reset() { - resetListeners(_listeners); - _storage.set({}); - } - - constructor(manager: SceneManager, scene: Scene) { - this.manager = manager; - this.id = scene.id; - - this._store = resolveStore(_storage, this.id, scene); - - this._entities = new SceneEntityManager(this, scene.entities); - this._systems = new SceneSystemManager(this, scene.systems); - - this._listen(); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - get entities() { - return this._entities; - } - - get systems() { - return this._systems; - } - - update(scene: Partial) { - this._store.set({ ...get(this._store), ...scene }); - } - - delete() { - this.manager.delete(this.id); - } - - setActive() { - this.manager.active = this; - } - - setDefault() { - this.manager.default = this.id; - } - - private _listen() { - const listeners = get(_listeners); - if (listeners[this.id]) return; - const unsub = [ - this._entities.store.subscribe((entities) => { - const scene = get(this._store); - scene.entities = entities; - this._store.set(scene); - }), - this._systems.store.subscribe((systems) => { - const scene = get(this._store); - scene.systems = systems; - this._store.set(scene); - }), - ]; - _listeners.set({ ...listeners, [this.id]: unsub }); - } -} diff --git a/src/lib/client/ecs/scene/scene-manager.ts b/src/lib/client/ecs/scene/scene-manager.ts deleted file mode 100644 index aa7e0999..00000000 --- a/src/lib/client/ecs/scene/scene-manager.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import type { ECSHandler } from '../ecs-handler'; -import { getId, resetSubscriptions } from '../utils'; -import { SceneHandle } from './scene-handle'; -import type { Scene } from './scene.type'; - -const _storage = writable([]); -const _rootScenesStorage = writable([]); -const _activeSceneStorage = writable(); -const _defaultSceneStorage = writable(); - -const _subscriptions = writable>({}); - -export class SceneManager { - public readonly ecs: ECSHandler; - - static reset() { - resetSubscriptions(_subscriptions); - _storage.set([]); - _rootScenesStorage.set([]); - _activeSceneStorage.set(undefined as any); - _defaultSceneStorage.set(undefined as any); - } - - constructor(ecs: ECSHandler, scenes: Scene[], rootScenes: string[], defaultSceneId: string) { - this.ecs = ecs; - _storage.set(scenes); - - const defaultScene = scenes.find((scene) => scene.id === defaultSceneId); - if (!defaultScene) throw new Error(`Default scene with id ${defaultSceneId} not found`); - - _activeSceneStorage.set(new SceneHandle(this, defaultScene)); - _defaultSceneStorage.set(defaultSceneId); - _rootScenesStorage.set(rootScenes); - } - - get store() { - return _storage; - } - - get data() { - return get(_storage); - } - - get activeData(): SceneHandle { - return get(_activeSceneStorage); - } - - get active(): Writable { - return _activeSceneStorage; - } - - set active(handle: SceneHandle) { - _activeSceneStorage.set(handle); - } - - get defaultData(): string { - return get(_defaultSceneStorage); - } - - get default(): Writable { - return _defaultSceneStorage; - } - - set default(handle: string) { - _defaultSceneStorage.set(handle); - } - - get rootScenes(): string[] { - return get(_rootScenesStorage); - } - - get rootScenesStore(): Writable { - return _rootScenesStorage; - } - - set rootScenes(scenes: string[]) { - _rootScenesStorage.set(Array.from(new Set(scenes))); - } - - add(scene: Omit): string { - const scenes = get(_storage); - const id = getId(this.data, scene.name); - scenes.push({ ...scene, id }); - _storage.set(scenes); - return id; - } - - get(id: string): SceneHandle { - const scene = get(_storage).find((scene) => scene.id === id); - if (!scene) throw new Error(`Scene with id ${id} not found`); - const handle = new SceneHandle(this, scene); - - this._subscribe(id, handle); - - return handle; - } - - delete(id: string) { - const scenes = get(_storage); - _storage.set(scenes.filter((scene) => scene.id !== id)); - - const subscriptions = get(_subscriptions); - if (subscriptions[id]) { - subscriptions[id](); - subscriptions[id] = null; - _subscriptions.set(subscriptions); - } - - if (this.rootScenes.includes(id)) - this.rootScenes = this.rootScenes.filter((scene) => scene !== id); - - if (this.defaultData === id) { - this.get(this.data.find((v) => !!v)?.id ?? '').setDefault(); - } - - if (this.activeData.id === id) { - this.get( - (this.data.find((scene) => scene.id === this.defaultData) ?? this.data.find((v) => !!v)) - ?.id ?? '', - ).setActive(); - } - } - - private _subscribe(id: string, handle: SceneHandle) { - setTimeout(() => { - const subscriptions = get(_subscriptions); - if (subscriptions[id]) return; - subscriptions[id] = handle.store.subscribe((scene) => this._update(id, scene)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, scene: Scene) { - const scenes = get(_storage); - const index = scenes.findIndex((s) => s.id === id); - if (index === -1) return; - scenes[index] = scene; - _storage.set(scenes); - } -} diff --git a/src/lib/client/ecs/scene/scene.type.ts b/src/lib/client/ecs/scene/scene.type.ts deleted file mode 100644 index c7c8330f..00000000 --- a/src/lib/client/ecs/scene/scene.type.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Entity } from './entity/entity.type'; - -export interface Scene { - id: string; - name: string; - path: string; - subScenes: string[]; - assets: string[]; - systems: string[]; - entities: Entity[]; -} diff --git a/src/lib/client/ecs/scene/system/system-handle.ts b/src/lib/client/ecs/scene/system/system-handle.ts deleted file mode 100644 index 10965d3b..00000000 --- a/src/lib/client/ecs/scene/system/system-handle.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type Readable, get } from 'svelte/store'; - -import type { System } from '../../system/system.type'; -import type { SceneSystemManager } from './system-manager'; - -export class SceneSystemHandle { - public readonly manager: SceneSystemManager; - public readonly id: string; - private readonly _store: Readable; - - constructor(manager: SceneSystemManager, systemId: string) { - this.manager = manager; - this.id = systemId; - - this._store = manager.scene.manager.ecs.systems.get(systemId).store; - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - delete() { - this.manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/scene/system/system-manager.ts b/src/lib/client/ecs/scene/system/system-manager.ts deleted file mode 100644 index 382f07ba..00000000 --- a/src/lib/client/ecs/scene/system/system-manager.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { resetListener, resolveStore } from '../../utils'; -import type { SceneHandle } from '../scene-handle'; -import { SceneSystemHandle } from './system-handle'; - -const _storage = writable>>({}); - -const _listener = writable(); - -export class SceneSystemManager { - public readonly scene: SceneHandle; - private readonly _store: Writable; - - static reset() { - resetListener(_listener); - _storage.set({}); - } - - constructor(scene: SceneHandle, systems: string[]) { - this.scene = scene; - - this._store = resolveStore(_storage, this.scene.id, systems); - - this._listen(); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - add(system: string) { - const systems = get(this._store); - systems.push(system); - this._store.set(systems); - } - - get(id: string) { - const system = get(this._store).find((system) => system === id); - if (!system) throw new Error(`System with id ${id} not found`); - return new SceneSystemHandle(this, system); - } - - delete(id: string) { - const systems = get(this._store); - this._store.set(systems.filter((system) => system !== id)); - } - - private _listen() { - if (get(_listener)) return; - const unsub = this.scene.manager.ecs.systems.store.subscribe((systems) => { - const sceneSystems = get(this._store); - sceneSystems.filter((system) => systems.find((s) => s.id === system)); - this._store.set(sceneSystems); - }); - _listener.set([unsub]); - } -} diff --git a/src/lib/client/ecs/system/system-handle.ts b/src/lib/client/ecs/system/system-handle.ts deleted file mode 100644 index 2a84c7c3..00000000 --- a/src/lib/client/ecs/system/system-handle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type Writable, get, writable } from 'svelte/store'; - -import { resolveStore } from '../utils'; -import type { SystemManager } from './system-manager'; -import type { System } from './system.type'; - -const _storage = writable>>({}); - -export class SystemHandle { - private _manager: SystemManager; - public readonly id: string; - private readonly _store: Writable; - - static reset() { - _storage.set({}); - } - - constructor(manager: SystemManager, system: System) { - this._manager = manager; - this.id = system.id; - - this._store = resolveStore(_storage, this.id, system); - } - - get store() { - return this._store; - } - - get data() { - return get(this._store); - } - - update(system: Partial) { - this._store.set({ ...get(this._store), ...system }); - } - - delete() { - return this._manager.delete(this.id); - } -} diff --git a/src/lib/client/ecs/system/system-manager.ts b/src/lib/client/ecs/system/system-manager.ts deleted file mode 100644 index 9f97fdbd..00000000 --- a/src/lib/client/ecs/system/system-manager.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { type Unsubscriber, get, writable } from 'svelte/store'; - -import type { SystemPkg } from '$lib/client/action'; -import { useProject } from '$lib/client/project'; - -import { systemTransformer, systemsTransformer } from '../transformers'; -import { resetSubscriptions } from '../utils'; -import { SystemHandle } from './system-handle'; -import type { System } from './system.type'; - -const _storage = writable([]); -const _subscriptions = writable>({}); - -export class SystemManager { - static reset() { - resetSubscriptions(_subscriptions); - _storage.set([]); - } - - constructor(systems: System[]) { - _storage.set(systems); - } - - get store() { - return _storage; - } - - get data() { - return get(_storage); - } - - async create(name: string) { - const { actions, fs } = useProject(); - const system = await actions.package.createSystem({ systemName: name }); - this._add(systemTransformer(system)); - const dir = await fs.getDirectory(); - await dir.readdir(true); - } - - add(system: SystemPkg) { - this._add(systemTransformer(system)); - } - - async sync() { - const { actions } = useProject(); - const systems = await actions.package.getSystems(); - _storage.set(systemsTransformer(systems)); - } - - get(id: string): SystemHandle { - const system = get(_storage).find((system) => system.id === id); - if (!system) throw new Error(`System with id ${id} not found`); - const handle = new SystemHandle(this, system); - - this._subscribe(id, handle); - - return handle; - } - - async delete(id: string) { - const systems = get(_storage); - const system = systems.find((s) => s.id === id); - - if (!system) throw new Error(`System not found: ${id}`); - - _storage.set(systems.filter((s) => s.id !== id)); - const { fs } = useProject(); - const file = await fs.getFile(system.path); - await file.delete(); - - const subscriptions = get(_subscriptions); - if (subscriptions[id]) { - subscriptions[id](); - subscriptions[id] = null; - _subscriptions.set(subscriptions); - } - } - - private _add(system: System) { - const systems = get(_storage); - systems.push(system); - _storage.set(systems); - } - - private _subscribe(id: string, handle: SystemHandle) { - setTimeout(() => { - const subscriptions = get(_subscriptions); - if (subscriptions[id]) return; - subscriptions[id] = handle.store.subscribe((system) => this._update(id, system)); - _subscriptions.set(subscriptions); - }, 0); - } - - private _update(id: string, system: System) { - const systems = get(_storage); - const index = systems.findIndex((s) => s.id === id); - if (index === -1) return; - systems[index] = system; - _storage.set(systems); - } -} diff --git a/src/lib/client/ecs/system/system.type.ts b/src/lib/client/ecs/system/system.type.ts deleted file mode 100644 index 1fb33ead..00000000 --- a/src/lib/client/ecs/system/system.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface System { - id: string; - name: string; - path: string; -} diff --git a/src/lib/client/ecs/transformers.ts b/src/lib/client/ecs/transformers.ts deleted file mode 100644 index 08b00df1..00000000 --- a/src/lib/client/ecs/transformers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AssetPkg, ComponentPkg, SystemPkg } from '$lib/client/action'; -import type { Asset, Component, Library, Scene, System } from '$lib/client/ecs'; - -import type { Save, SaveLibrary } from '@utils/types'; - -export const componentTransformer = (component: ComponentPkg): Component => ({ - id: component.manifest.id, - name: component.manifest.name, - path: component.save.path, - params: component.manifest.params, -}); - -export const componentsTransformer = (components: ComponentPkg[]): Component[] => - components.map(componentTransformer); - -export const systemTransformer = (system: SystemPkg): System => ({ - id: system.manifest.id, - name: system.manifest.name, - path: system.save.path, -}); - -export const systemsTransformer = (systems: SystemPkg[]): System[] => - systems.map(systemTransformer); - -export const assetTransformer = (asset: AssetPkg): Asset => ({ - id: asset.path, - path: asset.path, -}); - -export const assetsTransformer = (assets: AssetPkg[]): Asset[] => assets.map(assetTransformer); - -export const libraryTransformer = (lib: SaveLibrary): Library => ({ - id: lib.path, - name: lib.name, -}); - -export const librariesTransformer = (save: Save): Library[] => - save.libraries.map(libraryTransformer); - -export const scenesTransformer = (save: Save): Scene[] => [ - { - id: 'default', - name: 'Default Scene', - path: 'unknown', - subScenes: [], - assets: [], - systems: save.systems.map((system) => system.name), - entities: save.entities.map((entity) => ({ - id: entity.id, - name: entity.id, - treePath: entity.treePath, - components: entity.components, - })), - }, -]; diff --git a/src/lib/client/ecs/utils.ts b/src/lib/client/ecs/utils.ts deleted file mode 100644 index 4d325011..00000000 --- a/src/lib/client/ecs/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type Unsubscriber, type Writable, get, writable } from 'svelte/store'; - -import { formatFrom } from '@utils/format'; -import { randomString } from '@utils/string'; - -export const resetSubscriptions = (store: Writable>) => { - const subscriptions = get(store); - Object.values(subscriptions).forEach((sub) => { - try { - sub?.(); - } catch { - /* empty */ - } - }); - store.set({}); -}; - -export const resetListener = (store: Writable) => { - const listeners = get(store); - listeners?.forEach((sub) => { - try { - sub(); - } catch { - /* empty */ - } - }); - store.set(null); -}; - -export const resetListeners = (store: Writable>) => { - const listeners = get(store); - Object.values(listeners).forEach((sub) => { - sub?.forEach((unsub) => { - try { - unsub(); - } catch { - /* empty */ - } - }); - }); - store.set({}); -}; - -export const resolveStore = ( - storage: Writable>>, - resolvable: string, - defaultValue?: T, -) => { - const content = get(storage); - if (resolvable in content) return content[resolvable]; - content[resolvable] = writable(defaultValue); - storage.set(content); - return content[resolvable]; -}; - -export const getId = (data: { id: string }[], name: string): string => { - const baseId = formatFrom.all(name).toSnake(); - let id = baseId; - while (data.find((d) => d.id === id)) { - id = `${baseId}_${randomString(5)}`; - } - return id; -}; diff --git a/src/lib/client/event/event-emitter.ts b/src/lib/client/event/event-emitter.ts deleted file mode 100644 index 6f70bbb1..00000000 --- a/src/lib/client/event/event-emitter.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IEventEmitter, ListenerType, QueuedEvent } from './types'; - -export class EventEmitter< - Events extends string, - EventsMap extends Record, -> implements IEventEmitter { - public listeners: { - [K in keyof EventsMap]?: ListenerType[]; - } = {}; - - public eventQueue: QueuedEvent[] = []; - private readonly _dequeueOnEmit: boolean; - - constructor(dequeueOnEmit = false) { - this._dequeueOnEmit = dequeueOnEmit; - } - - runEvents(): void { - if (!this.eventQueue.length) return; - this.eventQueue.forEach((e) => { - this._executeEvent(e); - }); - - this.eventQueue = []; - } - - emitEvent(event: K, ...args: EventsMap[K]): void { - this.eventQueue.push({ - event, - args, - }); - if (this._dequeueOnEmit) this.runEvents(); - } - addListener( - event: K, - listener: ListenerType, - ): void { - if (!this.listeners[event]) this.listeners[event] = []; - this.listeners[event].push(listener); - } - on(event: K, listener: ListenerType): void { - this.addListener(event, listener); - } - - removeListener( - event: K, - listener: ListenerType, - ): void { - if (!this.listeners[event]) return; - const index = this.listeners[event].indexOf(listener); - if (index >= 0) { - this.listeners[event].splice(index, 1); - } - } - off(event: K, listener: ListenerType): void { - this.removeListener(event, listener); - } - - removeListenersForEvent(event: keyof EventsMap): void { - if (!this.listeners[event]) return; - this.listeners[event] = []; - } - removeAllListeners(): void { - this.listeners = {}; - } - - private _executeEvent({ - event, - args, - }: QueuedEvent): void { - this.listeners[event]?.forEach((listener) => { - try { - listener(...args); - } catch (error) { - console.error(`Error handling event [${String(event)}]:`, error); - } - }); - } -} diff --git a/src/lib/client/event/event-handler.ts b/src/lib/client/event/event-handler.ts deleted file mode 100644 index 9f3f71b9..00000000 --- a/src/lib/client/event/event-handler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { get, writable } from 'svelte/store'; - -import { moveComponentListener } from '$lib/client/event/listeners/move-component.listener'; - -import { EventEmitter } from './event-emitter'; -import type { CoreEvents, CoreEventsMap } from './events/core-events'; -import type { EditorEvents, EditorEventsMap } from './events/editor-events'; -import type { ListenerType } from './types'; - -const listeners = writable<(() => void)[]>([]); - -export class EventHandler { - public readonly _coreEvents = new EventEmitter(); - public readonly _editorEvents = new EventEmitter(true); - - static reset() { - get(listeners).forEach((unsub) => unsub()); - listeners.set([]); - } - - async init() { - listeners.set([moveComponentListener(this)]); - } - - emit(event: K, ...args: CoreEventsMap[K]) { - this._coreEvents.emitEvent(event, ...args); - } - - on( - event: K, - callback: ListenerType, - ) { - this._editorEvents.on(event, callback); - } -} diff --git a/src/lib/client/event/events/core-events.ts b/src/lib/client/event/events/core-events.ts deleted file mode 100644 index 4d96e5c5..00000000 --- a/src/lib/client/event/events/core-events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Save } from '@utils/types'; - -export enum CoreEvents { - HOT_RELOAD = 'hot-reload', - HARD_RELOAD = 'hard-reload', - PAUSE_GAME = 'pause-game', - STOP_GAME = 'stop-game', - UNPAUSE_GAME = 'unpause-game', -} - -export interface CoreEventsMap { - [CoreEvents.HOT_RELOAD]: [save: Save]; - [CoreEvents.HARD_RELOAD]: [save: Save]; - [CoreEvents.PAUSE_GAME]: []; - [CoreEvents.STOP_GAME]: []; - [CoreEvents.UNPAUSE_GAME]: []; -} diff --git a/src/lib/client/event/events/editor-events.ts b/src/lib/client/event/events/editor-events.ts deleted file mode 100644 index 20c3c321..00000000 --- a/src/lib/client/event/events/editor-events.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum EditorEvents { - MOVE_COMPONENT = 'move-component', -} - -export interface EditorEventsMap { - [EditorEvents.MOVE_COMPONENT]: [ - entityId: string, - componentId: string, - position: { x: number; y: number }, - ]; -} diff --git a/src/lib/client/event/index.ts b/src/lib/client/event/index.ts deleted file mode 100644 index 7a43e86f..00000000 --- a/src/lib/client/event/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { EventHandler } from './event-handler'; -export type * from './types'; -export * from './events/core-events'; -export * from './events/editor-events'; diff --git a/src/lib/client/event/listeners/move-component.listener.ts b/src/lib/client/event/listeners/move-component.listener.ts deleted file mode 100644 index cda54a29..00000000 --- a/src/lib/client/event/listeners/move-component.listener.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { EditorEvents, type EventHandler } from '$lib/client/event'; -import { useProject } from '$lib/client/project'; - -export const moveComponentListener = (handler: EventHandler): (() => void) => { - handler.on(EditorEvents.MOVE_COMPONENT, (entityId, _componentId, { x, y }) => { - const { ecs } = useProject(); - const entity = ecs.scenes.activeData.entities.get(entityId); - const component = entity.components.get('Position2D'); - component.params.get('x').value.set(x); - component.params.get('y').value.set(y); - }); - return () => {}; -}; diff --git a/src/lib/client/event/types/event-emitter.type.ts b/src/lib/client/event/types/event-emitter.type.ts deleted file mode 100644 index 1e23f077..00000000 --- a/src/lib/client/event/types/event-emitter.type.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type ListenerType< - Events extends string, - EventsMap extends Record, - K extends keyof EventsMap, -> = (...args: EventsMap[K]) => void; - -export type QueuedEvent = { - event: K; - args: EventsMap[K]; -}; - -export interface IEventEmitter> { - listeners: { - [K in keyof EventsMap]?: ListenerType[]; - }; - - eventQueue: QueuedEvent[]; - - runEvents(): void; - - emitEvent(event: K, ...args: EventsMap[K]): void; - - addListener( - event: K, - listener: ListenerType, - ): void; - - on(event: K, listener: ListenerType): void; - - removeListener( - event: K, - listener: ListenerType, - ): void; - - off(event: K, listener: ListenerType): void; - - removeListenersForEvent(event: keyof EventsMap): void; - - removeAllListeners(): void; -} diff --git a/src/lib/client/event/types/index.ts b/src/lib/client/event/types/index.ts deleted file mode 100644 index 532d5720..00000000 --- a/src/lib/client/event/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './event-emitter.type'; diff --git a/src/lib/client/info/index.ts b/src/lib/client/info/index.ts deleted file mode 100644 index 6297afe2..00000000 --- a/src/lib/client/info/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { InfoHandler } from './info'; diff --git a/src/lib/client/info/info.store.ts b/src/lib/client/info/info.store.ts deleted file mode 100644 index 3fb9af40..00000000 --- a/src/lib/client/info/info.store.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { writable } from 'svelte/store'; - -import type { ProjectInfo } from './info.type'; - -export const infoStore = writable(); diff --git a/src/lib/client/info/info.ts b/src/lib/client/info/info.ts deleted file mode 100644 index b8d75562..00000000 --- a/src/lib/client/info/info.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { get } from 'svelte/store'; - -import type { Project } from '$lib/client/project'; - -import { infoStore } from './info.store'; -import type { ProjectInfo, ProjectInfoInput } from './info.type'; - -export class InfoHandler { - static reset(): void { - infoStore.set(null); - } - - constructor(private readonly core: Project) {} - - get(cacheOnly: true): ProjectInfo | null; - get(): Promise; - get(cacheOnly?: boolean): ProjectInfo | null | Promise { - if (cacheOnly) return get(infoStore); - return this.fetch(); - } - - async fetch(noCache: boolean = false): Promise { - if (!noCache) { - const cache = get(infoStore); - if (cache) return cache; - } - const res = await this.core.actions.project.getInfo(); - infoStore.set(res); - return res; - } - - async set(input: ProjectInfoInput): Promise { - await this.core.actions.project.setInfo(input); - await this.fetch(true); - } -} diff --git a/src/lib/client/info/info.type.ts b/src/lib/client/info/info.type.ts deleted file mode 100644 index 775aa384..00000000 --- a/src/lib/client/info/info.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { InfoProject, InfoProjectBody } from '$lib/server/actions/project/info.action'; - -export type ProjectInfo = InfoProject; - -export type ProjectInfoInput = InfoProjectBody; diff --git a/src/lib/client/loader/index.ts b/src/lib/client/loader/index.ts deleted file mode 100644 index a172c260..00000000 --- a/src/lib/client/loader/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Loader } from './loader'; -export * from './types/game.type'; -export * from './types/manifest.type'; diff --git a/src/lib/client/loader/loader.ts b/src/lib/client/loader/loader.ts deleted file mode 100644 index a88d42be..00000000 --- a/src/lib/client/loader/loader.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Project } from '$lib/client/project'; -import { SyncFileSystem } from '$lib/client/sync-file-system'; - -import type { RawEventEmitter } from './types/event-emitter.type'; -import type { IGameOptions } from './types/game.type'; -import type { IExtendedManifestFile, IManifest } from './types/manifest.type'; - -type MainFunction = (options: IGameOptions) => Promise; - -export class Loader { - private readonly fs: SyncFileSystem; - - constructor(private readonly core: Project) { - this.fs = new SyncFileSystem(this.core, 'build'); - } - - async build(): Promise { - await this.core.actions.loader.build(); - } - - async start(container: HTMLDivElement): Promise { - const manifest = await this.fetchManifest(); - const env = await this.fetchEnv(); - const { mainFile, files } = await this.resolveGameFiles(manifest); - const save = this.core.save.save; - - const main = await this.loadMainFile(mainFile); - - main({ - files, - env, - editor: { - save, - coreEvents: this.core.event._coreEvents as unknown as RawEventEmitter, - editorEvents: this.core.event._editorEvents as unknown as RawEventEmitter, - }, - container, - }).then(() => console.log('Game started!')); - } - - private async loadMainFile(file: string): Promise { - const res = await import(/* @vite-ignore */ file); - if (res['main']) return res['main']; - throw new Error('No main function found in the main.js file'); - } - - private fetchEnv(): Promise> { - return this.core.actions.loader.env(); - } - - private fetchManifest(): Promise { - return this.core.actions.loader.manifest(); - } - - private async resolveGameFiles( - manifest: IManifest, - ): Promise<{ mainFile: string; files: Map }> { - const files = await this.fetchFiles(manifest); - - let mainFile: string | undefined; - const resMap = new Map(); - - for (const file of files) { - if (file.gamePath === '/main.js') { - mainFile = file.localPath; - continue; - } - resMap.set(file.gamePath, file.localPath); - } - - if (!mainFile) throw new Error('No main.js file found in the manifest'); - return { mainFile, files: resMap }; - } - - private async fetchFiles(manifest: IManifest): Promise { - return await Promise.all( - manifest.files.map(async ({ path }) => { - const file = await this.fs.getFile(path); - await file.fetch(); - return { gamePath: path, localPath: await file.getUrl() }; - }), - ); - } -} diff --git a/src/lib/client/loader/types/event-emitter.type.ts b/src/lib/client/loader/types/event-emitter.type.ts deleted file mode 100644 index c74d0275..00000000 --- a/src/lib/client/loader/types/event-emitter.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { IEventEmitter } from '$lib/client/event'; - -export type RawEventEmitter = IEventEmitter>; diff --git a/src/lib/client/loader/types/game.type.ts b/src/lib/client/loader/types/game.type.ts deleted file mode 100644 index c02a4457..00000000 --- a/src/lib/client/loader/types/game.type.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Save } from '@utils/types'; - -import type { RawEventEmitter } from './event-emitter.type'; - -export interface IGameOptions { - container: HTMLDivElement; - files: Map; - env: Record; - editor: { - save: Save; - coreEvents: RawEventEmitter; - editorEvents: RawEventEmitter; - }; -} diff --git a/src/lib/client/loader/types/manifest.type.ts b/src/lib/client/loader/types/manifest.type.ts deleted file mode 100644 index a9b59a4a..00000000 --- a/src/lib/client/loader/types/manifest.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface IManifest { - version: string; - files: { path: string }[]; -} - -export interface IExtendedManifestFile { - gamePath: string; - localPath: string; -} diff --git a/src/lib/client/project/index.ts b/src/lib/client/project/index.ts deleted file mode 100644 index 81ca00b2..00000000 --- a/src/lib/client/project/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { Project } from './project'; -export { ProjectCache, type ProjectDataCache } from './project-loader/project-cache'; -export { - ProjectLoader, - useProject, - getProject, - getProjectStore, -} from './project-loader/project-loader'; -export { PLErrors, PLException } from './project-loader/exceptions'; diff --git a/src/lib/client/project/package-handler.ts b/src/lib/client/project/package-handler.ts deleted file mode 100644 index cf38dd42..00000000 --- a/src/lib/client/project/package-handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Project } from '$lib/client/project'; - -export class PackageHandler { - private readonly _project: Project; - - constructor(project: Project) { - this._project = project; - } - - async installPackages(names: [string, ...string[]]): Promise { - await this._project.actions.package.installPackages({ names }); - - // installPackages does not return dependencies, so it's required to sync to fetch all new components/systems - await this._project.ecs.components.sync(); - await this._project.ecs.systems.sync(); - - const dir = await this._project.fs.getDirectory(); - await dir.readdir(true); - } -} diff --git a/src/lib/client/project/project-loader/exceptions.ts b/src/lib/client/project/project-loader/exceptions.ts deleted file mode 100644 index 389f89ef..00000000 --- a/src/lib/client/project/project-loader/exceptions.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { goto } from '$app/navigation'; -import { resolve } from '$app/paths'; - -import { getConfig } from '$lib/client/config'; -import { ProjectLoader } from '$lib/client/project'; - -import { toastError } from '@utils-client/toasts'; - -const config = getConfig(); - -export enum PLErrors { - No_Project = 'no_project', - No_Init_Project = 'no_init_project', - Invalid_Create_Action = 'invalid_create_action', - Invalid_Complete_Action = 'invalid_complete_action', - Empty_Project = 'empty_project', -} - -const traces: Record = { - [PLErrors.No_Project]: 'No project found', - [PLErrors.No_Init_Project]: 'Project not initialized', - [PLErrors.Invalid_Create_Action]: 'Unable to create project', - [PLErrors.Invalid_Complete_Action]: 'Unable to complete project', - [PLErrors.Empty_Project]: 'Empty project', -}; - -const fallbacks = { - [PLErrors.No_Project]: () => async () => { - await goto(resolve('/')); - toastError('No project found', 'Select a project or create a new one'); - }, - [PLErrors.No_Init_Project]: (id: string) => async () => { - await goto(resolve(`/load?id=${id}`)); - }, - [PLErrors.Invalid_Create_Action]: (error: any) => async () => { - toastError('Unable to create project', error.message ?? 'Unknown error'); - }, - [PLErrors.Invalid_Complete_Action]: (error: any) => async () => { - toastError('Unable to init project', error.message ?? 'Unknown error'); - }, - [PLErrors.Empty_Project]: (id: string, resolvable: string) => async () => { - if (config.mode === 'offline') { - ProjectLoader.unload(); - await goto(resolve('/')); - toastError('Invalid project', 'Select another project or create a new one'); - } - await goto(resolve(`/load?id=${id}&gatewayId=${resolvable}`)); - }, -}; - -export class PLException extends Error { - public readonly reason: T; - public readonly fb: () => Promise; - public readonly args: Parameters<(typeof fallbacks)[T]>; - - constructor(reason: T, ...args: Parameters<(typeof fallbacks)[T]>) { - super(`Failed to load project: ${traces[reason]}`); - this.reason = reason; - this.fb = (fallbacks[reason] as any)(...(args as [])); - this.args = args; - } -} - -export const runPLSafe = async ( - cb: () => Promise, - fb: (e: unknown) => PLException, -): Promise => { - try { - return await cb(); - } catch (e) { - if (e instanceof PLException) throw e; - throw fb(e); - } -}; diff --git a/src/lib/client/project/project-loader/init-functions.ts b/src/lib/client/project/project-loader/init-functions.ts deleted file mode 100644 index 24e9cad2..00000000 --- a/src/lib/client/project/project-loader/init-functions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Project } from '$lib/client/project'; -import { updateTabsStore } from '$lib/components/Tabs/store'; - -export const initTabs = (project: Project) => { - updateTabsStore(project.id); -}; diff --git a/src/lib/client/project/project-loader/project-cache.ts b/src/lib/client/project/project-loader/project-cache.ts deleted file mode 100644 index 46359236..00000000 --- a/src/lib/client/project/project-loader/project-cache.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { noProjectActions } from '$lib/client/action'; -import { getConfig } from '$lib/client/config'; - -import { randomString } from '@utils/string'; - -export interface ProjectDataCache { - id: string; - resolvable: string; - lastOpened: number; - name?: string; - imageUrl?: string; - invalid?: boolean; -} - -export class ProjectCache { - private static storageKey = 'projects'; - private static isOnline = getConfig().mode === 'online'; - - static async getProjects(): Promise { - if (this.isOnline) return ProjectCache.getOnlineProjects(); - const storedProjects = localStorage.getItem(this.storageKey); - if (storedProjects) { - return (JSON.parse(storedProjects) as ProjectDataCache[]).sort( - (a, b) => b.lastOpened - a.lastOpened, - ); - } - return []; - } - - static async getOnlineProjects(): Promise { - const projects = await noProjectActions.project.getGatewayProjects(); - return projects.map((project) => ({ - id: randomString(10), - resolvable: project.gatewayId, - lastOpened: Date.now(), - name: project.name, - })); - } - - static async getProject(id: string): Promise { - const projects = await this.getProjects(); - const project = projects.find((project) => project.id === id); - if (project) return project; - throw new Error('Project not found'); - } - - static async addProject(project: ProjectDataCache): Promise { - if (this.isOnline) return; - const projects = await this.getProjects(); - projects.push(project); - localStorage.setItem(this.storageKey, JSON.stringify(projects)); - } - - static async removeProject(id: string): Promise { - if (this.isOnline) return; - let projects = await this.getProjects(); - projects = projects.filter((project) => project.id !== id); - localStorage.setItem(this.storageKey, JSON.stringify(projects)); - } - - static async updateProject(id: string, updatedProject: Partial): Promise { - if (this.isOnline) return; - const projects = await this.getProjects(); - const projectIndex = projects.findIndex((project) => project.id === id); - - if (projectIndex !== -1) { - projects[projectIndex] = { ...projects[projectIndex], ...updatedProject }; - localStorage.setItem(this.storageKey, JSON.stringify(projects)); - } - } - - static async invalidateProject(id: string): Promise { - return ProjectCache.updateProject(id, { invalid: true }); - } - - static async clearProjects(): Promise { - if (this.isOnline) return; - localStorage.removeItem(this.storageKey); - } - - static async addOrUpdateProject(project: ProjectDataCache): Promise { - if (this.isOnline) return; - const projects = await this.getProjects(); - const projectIndex = projects.findIndex( - (p) => p.id === project.id || p.resolvable === project.resolvable, - ); - if (projectIndex !== -1) { - projects[projectIndex] = { ...projects[projectIndex], ...project }; - } else { - projects.push(project); - } - localStorage.setItem(this.storageKey, JSON.stringify(projects)); - } -} diff --git a/src/lib/client/project/project-loader/project-loader.ts b/src/lib/client/project/project-loader/project-loader.ts deleted file mode 100644 index 7f8a4045..00000000 --- a/src/lib/client/project/project-loader/project-loader.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { get, writable } from 'svelte/store'; - -import { - type ActionProject, - type CompleteProjectActionInput, - type CreateProjectActionInput, - noProjectActions, -} from '$lib/client/action'; -import { getConfig } from '$lib/client/config'; -import { Project, ProjectCache, type ProjectDataCache } from '$lib/client/project'; -import { SfsTreeCache } from '$lib/client/sync-file-system'; - -import { FileSystemManager } from '@utils-client/file-system'; - -import { PLErrors, PLException, runPLSafe } from './exceptions'; -import { initTabs } from './init-functions'; - -type ProjectCacheResolvable = ActionProject; - -const projectStore = writable(null); - -export class ProjectLoader { - static async create(input: CreateProjectActionInput) { - const res = await runPLSafe( - () => noProjectActions.project.new(input), - (e) => new PLException(PLErrors.Invalid_Create_Action, e), - ); - - return ProjectLoader.init(res); - } - - static async complete(input: CompleteProjectActionInput) { - const res = await runPLSafe( - () => noProjectActions.project.complete(input), - (e) => new PLException(PLErrors.Invalid_Complete_Action, e), - ); - - return ProjectLoader.init(res); - } - - static async loadFromCacheWithTryId(cache: ProjectDataCache) { - try { - return await ProjectLoader.loadFromId(cache.id); - } catch { - /* empty */ - } - - return ProjectLoader.loadFromCache(cache); - } - - static async loadFromIdWithCacheFetching(id: string) { - try { - return await ProjectLoader.loadFromId(id); - } catch { - /* empty */ - } - - const cache = await ProjectCache.getProject(id); - - return ProjectLoader.loadFromCache(cache); - } - - static async loadFromCache(cache: ProjectDataCache) { - await ProjectCache.removeProject(cache.id); - const config = getConfig(); - const input = - config.mode === 'offline' ? { path: cache.resolvable } : { gatewayId: cache.resolvable }; - - const res = await noProjectActions.project.load(input); - - const treeCache = new SfsTreeCache(cache.id); - await treeCache.init(); - await treeCache.changeId(res.id); - - const fs = new FileSystemManager('projects'); - const dir = await fs.getDirectory(cache.id); - await dir.rename(res.id); - - return await ProjectLoader.init(res); - } - - static async loadFromPath(path: string) { - const res = await noProjectActions.project.load({ path }); - - return ProjectLoader.init(res); - } - - static async loadFromGatewayId(gatewayId: string) { - const res = await noProjectActions.project.load({ gatewayId }); - - return ProjectLoader.init(res); - } - - static async loadFromId(id: string) { - const res = await noProjectActions.project.load({ id }); - - return ProjectLoader.init(res); - } - - static async init(input: ProjectCacheResolvable): Promise { - Project.reset(); - - const project = new Project(input.id); - projectStore.set(project); - - const infos = await runPLSafe( - () => project.info.get(), - () => new PLException(PLErrors.Empty_Project, input.id, input.cacheResolvable), - ); - - await ProjectCache.addOrUpdateProject({ - id: input.id, - resolvable: input.cacheResolvable, - name: infos.name, - lastOpened: Date.now(), - }); - - await this.initFunctions(project); - - return project; - } - - static async initFunctions(project: Project): Promise { - initTabs(project); - } - - static unload() { - projectStore.set(null); - Project.reset(); - } -} - -export const useProject = () => { - const project = get(projectStore); - if (!project) { - const error = new PLException(PLErrors.No_Project); - void error.fb(); - throw error; - } - - if (!project.isReady()) { - const error = new PLException(PLErrors.No_Init_Project, project.id); - void error.fb(); - throw error; - } - - return project; -}; - -export const getProject = () => get(projectStore); - -export const getProjectStore = () => projectStore; diff --git a/src/lib/client/project/project.ts b/src/lib/client/project/project.ts deleted file mode 100644 index 6d267a4a..00000000 --- a/src/lib/client/project/project.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type ActionClient, getActionClient } from '$lib/client/action'; -import { ECSHandler } from '$lib/client/ecs'; -import { EventHandler } from '$lib/client/event'; -import { InfoHandler } from '$lib/client/info'; -import { Loader } from '$lib/client/loader'; -import { PackageHandler } from '$lib/client/project/package-handler'; -import { SaveHandler } from '$lib/client/project/save-handler'; -import { SyncFileSystem } from '$lib/client/sync-file-system'; - -export class Project { - private _info: InfoHandler | undefined; - private _actions: ActionClient | undefined; - private _fs: SyncFileSystem | undefined; - private _loader: Loader | undefined; - private _save: SaveHandler | undefined; - private _packageHandler: PackageHandler | undefined; - private _eventHandler: EventHandler | undefined; - private _ecsHandler: ECSHandler | undefined; - - private _inited = false; - - static reset(): void { - InfoHandler.reset(); - EventHandler.reset(); - ECSHandler.reset(); - } - - constructor(public id: string) { - this._save = new SaveHandler(this); - this._packageHandler = new PackageHandler(this); - } - - async init(): Promise { - await this.fs.init(); - await this.ecs.init(); - await this.save.init(); - await this.event.init(); - this._inited = true; - return this; - } - - isReady(): boolean { - return this._inited; - } - - get info(): InfoHandler { - if (!this._info) this._info = new InfoHandler(this); - return this._info; - } - - get actions(): ActionClient { - if (!this._actions) this._actions = getActionClient(this.id); - return this._actions; - } - - get fs(): SyncFileSystem { - if (!this._fs) this._fs = new SyncFileSystem(this, 'project'); - return this._fs; - } - - get loader(): Loader { - if (!this._loader) this._loader = new Loader(this); - return this._loader; - } - - get save(): SaveHandler { - if (!this._save) this._save = new SaveHandler(this); - return this._save; - } - - get packages(): PackageHandler { - if (!this._packageHandler) this._packageHandler = new PackageHandler(this); - return this._packageHandler; - } - - get event(): EventHandler { - if (!this._eventHandler) this._eventHandler = new EventHandler(); - return this._eventHandler; - } - - get ecs(): ECSHandler { - if (!this._ecsHandler) this._ecsHandler = new ECSHandler(this); - return this._ecsHandler; - } -} diff --git a/src/lib/client/project/save-handler.ts b/src/lib/client/project/save-handler.ts deleted file mode 100644 index a89c5413..00000000 --- a/src/lib/client/project/save-handler.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type Writable, get, writable } from 'svelte/store'; - -import { getConfig } from '$lib/client/config'; -import { CoreEvents } from '$lib/client/event'; -import type { Project } from '$lib/client/project'; - -import type { Save } from '@utils/types'; - -export class SaveHandler { - private _project: Project; - private _save: Writable = writable({ - libraries: [], - entities: [], - components: [], - systems: [], - }); - private _readyToSync = true; - private _syncTimer?: Timer; - private _needSync: Writable = writable(false); - private _syncGatewayEnable?: boolean = true; - - constructor(project: Project) { - this._project = project; - } - - async init() { - await this.fetchFromServer(); - - this._listeners(); - - this._save.subscribe(() => { - this.syncToServer(); - this._project.event.emit(CoreEvents.HOT_RELOAD, this.save); - }); - if (getConfig().mode === 'online') { - this.initSyncGateway(); - } - } - - async fetchFromServer() { - this._save.set(await this._project.actions.save.get()); - } - - async syncToServer() { - if (this._readyToSync) { - this._readyToSync = false; - - await this._project.actions.save.set({ save: get(this._save) }); - - this._syncTimer = setTimeout(() => { - this._readyToSync = true; - if (get(this._needSync)) { - this.syncToServer(); - this._needSync.set(false); - } - }, 5000); - } else { - this._needSync.set(true); - } - } - - async forceSyncToServer() { - this._readyToSync = true; - this._needSync.set(false); - clearTimeout(this._syncTimer); - await this.syncToServer(); - } - - get save(): Save { - return get(this._save); - } - - private _listeners() { - this._project.ecs.components.store.subscribe((components) => { - this._save.set({ - ...this.save, - components: components.map((component) => ({ - name: component.id, - path: component.path, - paramsNames: component.params.map((p) => { - if (p.type === 'asset') return `__RESERVED_ASSET_${p.name}`; - return p.name; - }), - })), - }); - void this.forceSyncToServer(); - }); - - this._project.ecs.scenes.activeData.systems.store.subscribe((systems) => { - this._save.set({ - ...this.save, - systems: systems.map((id) => { - const system = this._project.ecs.systems.get(id).data; - return { - name: system.id, - path: system.path, - }; - }), - }); - void this.forceSyncToServer(); - }); - - this._project.ecs.scenes.activeData.entities.store.subscribe((entities) => { - this._save.set({ - ...this.save, - entities: entities.map((entity) => ({ - id: entity.id, - treePath: entity.treePath, - components: entity.components, - })), - }); - void this.syncToServer(); - }); - } - - private initSyncGateway() { - setInterval( - async () => { - if (this._syncGatewayEnable) { - await this._project.actions.project.syncGatewayProject(); - } - }, - 1000 * 60 * 5, - ); - window.addEventListener('beforeunload', (e) => { - const res = this._project.actions.project.syncGatewayProject(); - if (!res) e.preventDefault(); - }); - } -} diff --git a/src/lib/client/sync-file-system/index.ts b/src/lib/client/sync-file-system/index.ts deleted file mode 100644 index 4cca18a8..00000000 --- a/src/lib/client/sync-file-system/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SyncFileSystem } from './sfs'; -export { SfsDirectory, type SfsDirectoryMap } from './sfs-directory'; -export { SfsFile } from './sfs-file'; -export { SfsTreeCache } from './sfs-tree-cache'; diff --git a/src/lib/client/sync-file-system/sfs-directory.ts b/src/lib/client/sync-file-system/sfs-directory.ts deleted file mode 100644 index 2b948d2d..00000000 --- a/src/lib/client/sync-file-system/sfs-directory.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { SyncFileSystem } from '$lib/client/sync-file-system/sfs'; -import type { DirectoryContent } from '$lib/server/file-system/project-directory'; - -import type { FileSystemDirectory } from '@utils-client/file-system'; - -import { SfsFile } from './sfs-file'; - -export type SfsDirectoryMap = DirectoryContent; - -export class SfsDirectory { - constructor( - private readonly _handler: SyncFileSystem, - private _cache: FileSystemDirectory, - private readonly _path: string, - ) {} - - get path(): string { - return this._path; - } - - async getFile(path: string): Promise { - const fullPath = `${this._path}/${path}`; - if (await this._cache.fileExist(path)) - return new SfsFile(this._handler, await this._cache?.getFile(path, false), fullPath); - return new SfsFile(this._handler, null, fullPath); - } - - async getDirectory(path: string): Promise { - const fullPath = `${this._path}/${path}`; - return new SfsDirectory(this._handler, await this._cache.getDirectory(path, true), fullPath); - } - - async readdir(noCache: boolean = false): Promise { - let res: SfsDirectoryMap | null = null; - - if (!noCache && this._handler.treeCache) res = await this._handler.treeCache.read(this._path); - - if (res) return res; - - res = await this._handler.project.actions.fs.readdir({ path: this._path }); - - if (this._handler.treeCache) await this._handler.treeCache.write(this._path, res); - - return res; - } -} diff --git a/src/lib/client/sync-file-system/sfs-file.ts b/src/lib/client/sync-file-system/sfs-file.ts deleted file mode 100644 index ad9a0eae..00000000 --- a/src/lib/client/sync-file-system/sfs-file.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { SyncFileSystem } from '$lib/client/sync-file-system/sfs'; - -import { SESSION_PROJECT_HEADER } from '@utils/const'; - -import type { FileSystemFile } from '@utils-client/file-system'; - -export class SfsFile { - private readonly _route: string; - - constructor( - private readonly _handler: SyncFileSystem, - private _cache: FileSystemFile | null, - private readonly _path: string, - ) { - this._route = `${this._handler.route}?path=${encodeURIComponent(`${this._path}?url`)}`; - } - - get path(): string { - return this._path; - } - - async getUrl(): Promise { - await this._preRead(); - return this._cache!.getUrl(); - } - - async read(): Promise { - await this._preRead(); - return this._cache!.read(); - } - - async readJson(): Promise { - await this._preRead(); - return this._cache!.readJson(); - } - - async fetch(): Promise { - await this._preWrite(); - const res = await fetch(this._route, { - method: 'GET', - headers: { [SESSION_PROJECT_HEADER]: this._handler.project.id }, - }); - if (!res.ok || !res.body) { - throw new Error('Failed to fetch file'); - } - const stream = await this._cache!.handle.createWritable(); - await res.body.pipeTo(stream); - } - - async write(test: string): Promise { - await this._preWrite(); - await this._cache!.write(test); - await this.sync(); - } - - async writeJson(content: any): Promise { - await this._preWrite(); - await this._cache!.writeJson(content); - await this.sync(); - } - - async createWritable(): Promise { - await this._preWrite(); - return await this._cache!.createWritable(); - } - - async sync(): Promise { - if (!this._cache) return; - const file = await this._cache.getFile(); - const res = await fetch(this._route, { - body: file, - method: 'POST', - headers: { - [SESSION_PROJECT_HEADER]: this._handler.project.id, - 'Content-Type': 'application/octet-stream', - }, - }); - if (!res.ok) throw new Error(`Failed to sync file: ${await res.text()}`); - } - - async delete(): Promise { - const res = await fetch(this._route, { - method: 'DELETE', - headers: { - [SESSION_PROJECT_HEADER]: this._handler.project.id, - }, - }); - if (!res.ok) throw new Error(`Failed to delete file: ${await res.text()}`); - } - - async getFile(): Promise { - await this._preRead(); - return this._cache!; - } - - private async _preRead() { - if (!this._cache) { - await this.fetch(); - } - } - - private async _preWrite() { - if (!this._cache) { - this._cache = await this._handler.cache.getFile(this.path, true); - } - } -} diff --git a/src/lib/client/sync-file-system/sfs-tree-cache.ts b/src/lib/client/sync-file-system/sfs-tree-cache.ts deleted file mode 100644 index e2d4759f..00000000 --- a/src/lib/client/sync-file-system/sfs-tree-cache.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { IndexedDB } from '@utils-client/indexed-db'; - -import type { SfsDirectoryMap } from './sfs-directory'; - -export const SFS_DB_STORE_NAME = 'fs-project-tree'; - -export class SfsTreeCache extends IndexedDB { - constructor(private readonly projectId: string) { - super(); - } - - init(): Promise { - return this.open(1, async (db) => { - db.createObjectStore(SFS_DB_STORE_NAME); - }); - } - - async changeId(newId: string): Promise { - const store = this.getStore(SFS_DB_STORE_NAME); - const data = await store.get(this.projectId); - if (!data) return; - await store.set(newId, data); - await store.delete(this.projectId); - } - - async read(path: string): Promise { - const store = this.getStore(SFS_DB_STORE_NAME); - const data = await store.get(this.projectId); - return this._extractMapFromPath(path, data ?? null); - } - - async write(path: string, data: SfsDirectoryMap): Promise { - const currentData = await this.read(path); - const store = this.getStore(SFS_DB_STORE_NAME); - const map = this._generateMapFromPath( - path, - currentData ?? { files: [], directories: {} }, - data, - ); - await store.set(this.projectId, map); - } - - private _extractMapFromPath(path: string, map: SfsDirectoryMap | null): SfsDirectoryMap | null { - const parts = path.split('/'); - let currentMap: SfsDirectoryMap | null = map; - for (const part of parts) { - if (currentMap === null) return null; - if (part in currentMap.directories) currentMap = currentMap.directories[part] ?? null; - } - return currentMap; - } - - private _generateMapFromPath( - path: string, - map: SfsDirectoryMap, - data: SfsDirectoryMap, - ): SfsDirectoryMap | null { - if (!path) return data; - const parts = path.split('/'); - return this._generateMapFromPathRec(parts, map, data); - } - - private _generateMapFromPathRec( - parts: string[], - map: SfsDirectoryMap, - data: SfsDirectoryMap, - ): SfsDirectoryMap | null { - if (parts.length === 0) return data; - const [part, ...rest] = parts; - - map.directories[part] = this._generateMapFromPathRec( - rest, - map.directories[part] ?? { files: [], directories: {} }, - data, - ); - return map; - } -} diff --git a/src/lib/client/sync-file-system/sfs.ts b/src/lib/client/sync-file-system/sfs.ts deleted file mode 100644 index 587d0710..00000000 --- a/src/lib/client/sync-file-system/sfs.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Project } from '$lib/client/project'; - -import { FileSystemManager } from '@utils-client/file-system'; - -import { SfsDirectory } from './sfs-directory'; -import { SfsFile } from './sfs-file'; -import { SfsTreeCache } from './sfs-tree-cache'; - -export type SyncFileSystemPart = 'build' | 'project'; - -const FS_ROUTE: Record = { - build: '/fs/build', - project: '/fs/project', -}; - -export class SyncFileSystem { - public readonly project: Project; - public readonly route: string; - public readonly cache: FileSystemManager; - public readonly treeCache: SfsTreeCache | undefined; - - constructor(project: Project, part: SyncFileSystemPart) { - this.project = project; - this.route = FS_ROUTE[part]; - this.cache = new FileSystemManager(`projects/${project.id}/${part}`); - if (part === 'project') this.treeCache = new SfsTreeCache(project.id); - } - - async init(): Promise { - await this.treeCache?.init(); - const dir = await this.getDirectory(); - await dir.readdir(true); - } - - async getFile(path: string): Promise { - return new SfsFile( - this, - (await this.cache.fileExist(path)) ? await this.cache.getFile(path, false) : null, - path, - ); - } - - async getDirectory(path?: string): Promise { - return new SfsDirectory(this, await this.cache.getDirectory(path, true), path ?? ''); - } -} diff --git a/src/lib/client/utils/error.ts b/src/lib/client/utils/error.ts deleted file mode 100644 index 989506ed..00000000 --- a/src/lib/client/utils/error.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PLException } from '$lib/client/project'; - -import { Exception } from '@utils/exception'; -import type { MaybePromise } from '@utils/types'; - -import { toastError } from '@utils-client/toasts'; - -export const handleError = (error: unknown, context?: string) => { - const message = `Failed to ${context ?? 'execute'}`; - if (error instanceof Exception) { - console.error(`${message} (${error.error} - ${error.status}): ${error.message}`); - toastError(message, error.message); - return; - } - console.error(`${message}: ${error}`); - toastError(message, `${error}`); -}; - -export const runSafe = async ( - context: string, - cb: () => Promise, - fallback?: () => MaybePromise, -): Promise => { - try { - return await cb(); - } catch (error) { - if (error instanceof PLException) { - error.fb(); - return null; - } - handleError(error, context); - } - fallback?.(); - return null; -}; diff --git a/src/lib/client/utils/file-system/file-system-directory.ts b/src/lib/client/utils/file-system/file-system-directory.ts deleted file mode 100644 index 5481e779..00000000 --- a/src/lib/client/utils/file-system/file-system-directory.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { FileSystemFile } from './file-system-file'; - -export type FileSystemMapChildren = Map; -export type FileSystemMapDirectoryChildren = Map; - -export type FileSystemMapEntry = [string, FileSystemDirectory | FileSystemFile]; -export type FileSystemMapEntryDirectory = [string, FileSystemDirectory]; - -export class FileSystemDirectory { - readonly handle: FileSystemDirectoryHandle; - - constructor(handle: FileSystemDirectoryHandle) { - this.handle = handle; - } - - get name(): string { - return this.handle.name; - } - - async getFile(name: string, create = true): Promise { - return new FileSystemFile(await this.handle.getFileHandle(name, { create })); - } - - async fileExist(name: string): Promise { - try { - await this.handle.getFileHandle(name, { create: false }); - return true; - } catch { - return false; - } - } - - async getDirectory(name: string, create = true): Promise { - return new FileSystemDirectory(await this.handle.getDirectoryHandle(name, { create })); - } - - async getChildren(): Promise { - const result: FileSystemMapChildren = new Map(); - const entries = this.handle.entries(); - - for await (const [name, handle] of entries) { - result.set( - name, - handle instanceof FileSystemDirectoryHandle - ? new FileSystemDirectory(handle) - : new FileSystemFile(handle), - ); - } - return result; - } - - async removeChild(name: string, recursive = true): Promise { - return this.handle.removeEntry(name, { recursive }); - } - - async getParents(root: FileSystemDirectory): Promise { - const parentsFolderNames = await root.handle.resolve(this.handle); - const parents: FileSystemDirectory[] = []; - let previousParent: FileSystemDirectory = root; - - if (!parentsFolderNames) return null; - - for (const folder of parentsFolderNames) { - previousParent = await previousParent.getDirectory(folder); - parents.push(previousParent); - } - return parents; - } - - async clear(): Promise { - for await (const name of this.handle.keys()) { - await this.removeChild(name); - } - } - - async rename(name: string): Promise { - if ('move' in this.handle && typeof this.handle.move === 'function') - return await this.handle.move(name); - } -} diff --git a/src/lib/client/utils/file-system/file-system-file.ts b/src/lib/client/utils/file-system/file-system-file.ts deleted file mode 100644 index 0971258c..00000000 --- a/src/lib/client/utils/file-system/file-system-file.ts +++ /dev/null @@ -1,155 +0,0 @@ -const FILE_TYPES = { - aac: 'audio/aac', - abw: 'application/x-abiword', - apng: 'image/apng', - arc: 'application/x-freearc', - avi: 'video/x-msvideo', - avif: 'image/avif', - azw: 'application/vnd.amazon.ebook', - bin: 'application/octet-stream', - bmp: 'image/bmp', - bz: 'application/x-bzip', - bz2: 'application/x-bzip2', - cda: 'application/x-cdf', - cjs: 'text/javascript', - csh: 'application/x-csh', - css: 'text/css', - csv: 'text/csv', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - eot: 'application/vnd.ms-fontobject', - epub: 'application/epub+zip', - gz: 'application/gzip', - gif: 'image/gif', - htm: 'text/html', - html: 'text/html', - ico: 'image/vnd.microsoft.icon', - ics: 'text/calendar', - jar: 'application/java-archive', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - js: 'application/javascript', - json: 'application/json', - jsonld: 'application/ld+json', - md: 'text/markdown', - mid: 'audio/midi', - midi: 'audio/midi', - mjs: 'text/javascript', - mp3: 'audio/mpeg', - mp4: 'video/mp4', - mpeg: 'video/mpeg', - mpkg: 'application/vnd.apple.installer+xml', - odp: 'application/vnd.oasis.opendocument.presentation', - ods: 'application/vnd.oasis.opendocument.spreadsheet', - odt: 'application/vnd.oasis.opendocument.text', - oga: 'audio/ogg', - ogv: 'video/ogg', - ogx: 'application/ogg', - opus: 'audio/ogg', - otf: 'font/otf', - png: 'image/png', - pdf: 'application/pdf', - php: 'application/x-httpd-php', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - rar: 'application/vnd.rar', - rtf: 'application/rtf', - sh: 'application/x-sh', - svg: 'image/svg+xml', - swf: 'application/x-shockwave-flash', - tar: 'application/x-tar', - tif: 'image/tiff', - tiff: 'image/tiff', - ts: 'text/plain', - ttf: 'font/ttf', - txt: 'text/plain', - vsd: 'application/vnd.visio', - wasm: 'application/wasm', - wav: 'audio/x-wav', - weba: 'audio/webm', - webm: 'video/webm', - webmanifest: 'application/manifest+json', - webp: 'image/webp', - wgsl: 'text/wgsl', - woff: 'font/woff', - woff2: 'font/woff2', - xhtml: 'application/xhtml+xml', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - xml: 'application/xml', - xul: 'application/vnd.mozilla.xul+xml', - zip: 'application/zip', - '3gp': 'video/3gpp', - '3g2': 'video/3gpp2', - '7z': 'application/x-7z-compressed', -}; - -const URL_CACHE = new Map(); - -export class FileSystemFile { - readonly handle: FileSystemFileHandle; - - constructor(handle: FileSystemFileHandle) { - this.handle = handle; - } - - get name(): string { - return this.handle.name; - } - - getFile(): Promise { - return this.handle.getFile(); - } - - async isSameFile(file: FileSystemFile): Promise { - return await this.handle.isSameEntry(file.handle); - } - - async read(): Promise { - const file = await this.handle.getFile(); - return file.text(); - } - - async readJson(): Promise { - const raw = await this.read(); - return JSON.parse(raw) as T; - } - - async write(text: string): Promise { - const writable = await this.handle.createWritable(); - await writable.write(text); - await writable.close(); - } - - async writeBinary(base64: string): Promise { - const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - const writable = await this.handle.createWritable(); - await writable.write(binary); - await writable.close(); - } - - async writeJson(content: any): Promise { - const raw = JSON.stringify(content); - return this.write(raw); - } - - async createWritable(): Promise { - return this.handle.createWritable(); - } - - async getUrl(): Promise { - if (URL_CACHE.has(this.handle.name)) URL.revokeObjectURL(URL_CACHE.get(this.handle.name)!); - const file = await this.handle.getFile(); - const blob = new Blob([await file.arrayBuffer()], { type: this._resolveFileType(file.name) }); - const url = URL.createObjectURL(blob); - URL_CACHE.set(this.handle.name, url); - return url; - } - - private _resolveFileType(name: string): string { - const extension = name.split('.').pop(); - const type = FILE_TYPES[extension as keyof typeof FILE_TYPES]; - if (type) return type; - return 'text/plain'; - } -} diff --git a/src/lib/client/utils/file-system/file-system-handler.ts b/src/lib/client/utils/file-system/file-system-handler.ts deleted file mode 100644 index 199bb5c4..00000000 --- a/src/lib/client/utils/file-system/file-system-handler.ts +++ /dev/null @@ -1,66 +0,0 @@ -export class FileSystemHandler { - private fs!: FileSystemDirectoryHandle; - private readonly rootPath: string; - - constructor(root: string) { - this.rootPath = root; - } - - async getDirectory(path?: string, create?: boolean): Promise { - await this._checkRootHandle(); - if (!path) return this.fs; - return this._getSubDirFromRawPath(this.fs, path, create); - } - - async getFile(path: string, create = true): Promise { - await this._checkRootHandle(); - const paths = this._parsePath(path); - const fileName = paths.pop(); - if (!fileName) { - throw new Error(); - } - const handle = await this._getSubDirFromPath(this.fs, paths, create); - return handle.getFileHandle(fileName, { create }); - } - - private async _checkRootHandle(): Promise { - if (!this.fs) await this._initRootHandle(); - } - - private async _initRootHandle() { - const rootHandle = await navigator.storage.getDirectory(); - this.fs = await this._getSubDirFromRawPath(rootHandle, this.rootPath); - } - - private async _getSubDirFromRawPath( - handle: FileSystemDirectoryHandle, - rawPath: string, - create?: boolean, - ): Promise { - const path = this._parsePath(rawPath); - return this._getSubDirFromPath(handle, path, create); - } - - private async _getSubDirFromPath( - handle: FileSystemDirectoryHandle, - path: string[], - create?: boolean, - ): Promise { - for (const dir of path) { - handle = await this._getSubDir(handle, dir, create); - } - return handle; - } - - private async _getSubDir( - handle: FileSystemDirectoryHandle, - name: string, - create = true, - ): Promise { - return handle.getDirectoryHandle(name, { create }); - } - - private _parsePath(path: string): string[] { - return path.split('/').filter((path: string) => !!path); - } -} diff --git a/src/lib/client/utils/file-system/file-system-manager.ts b/src/lib/client/utils/file-system/file-system-manager.ts deleted file mode 100644 index 8385b505..00000000 --- a/src/lib/client/utils/file-system/file-system-manager.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FileSystemDirectory } from './file-system-directory'; -import { FileSystemFile } from './file-system-file'; -import { FileSystemHandler } from './file-system-handler'; - -export class FileSystemManager { - private readonly fs: FileSystemHandler; - - constructor(root: string) { - this.fs = new FileSystemHandler(root); - } - - async getFile(path: string, create?: boolean): Promise { - return new FileSystemFile(await this.fs.getFile(path, create)); - } - - async getDirectory(path?: string, create?: boolean): Promise { - return new FileSystemDirectory(await this.fs.getDirectory(path, create)); - } - - async fileExist(path: string): Promise { - try { - await this.getFile(path, false); - return true; - } catch { - return false; - } - } - - async directoryExist(path: string): Promise { - try { - await this.getDirectory(path, false); - return true; - } catch { - return false; - } - } - - async clear(): Promise { - try { - const dir = await this.getDirectory('/'); - await dir.clear(); - return true; - } catch { - return false; - } - } -} diff --git a/src/lib/client/utils/file-system/index.ts b/src/lib/client/utils/file-system/index.ts deleted file mode 100644 index 830dc1b4..00000000 --- a/src/lib/client/utils/file-system/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { FileSystemDirectory } from './file-system-directory'; -export { FileSystemFile } from './file-system-file'; -export { FileSystemManager } from './file-system-manager'; diff --git a/src/lib/client/utils/indexed-db/idb-object.ts b/src/lib/client/utils/indexed-db/idb-object.ts deleted file mode 100644 index 436274e4..00000000 --- a/src/lib/client/utils/indexed-db/idb-object.ts +++ /dev/null @@ -1,35 +0,0 @@ -export class IndexedDBObject { - private readonly _store: IDBObjectStore; - - constructor(store: IDBObjectStore) { - this._store = store; - } - - get store(): IDBObjectStore { - return this._store; - } - - get(key: string): Promise { - return new Promise((resolve, reject) => { - const request = this._store.get(key); - request.onsuccess = () => resolve((request.result as T) ?? null); - request.onerror = () => reject(request.error); - }); - } - - set(key: string, data: T): Promise { - return new Promise((resolve, reject) => { - const request = this._store.put(data, key); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } - - delete(key: string): Promise { - return new Promise((resolve, reject) => { - const request = this._store.delete(key); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } -} diff --git a/src/lib/client/utils/indexed-db/idb.ts b/src/lib/client/utils/indexed-db/idb.ts deleted file mode 100644 index 634111b4..00000000 --- a/src/lib/client/utils/indexed-db/idb.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { IndexedDBObject } from './idb-object'; - -const DB_NAME = 'nanoforge'; - -export class IndexedDB { - private _db: IDBDatabase | undefined; - - protected constructor() {} - - protected get db(): IDBDatabase { - if (!this._db) { - throw new Error('Database is not open'); - } - return this._db; - } - - protected async open( - version?: number, - upgrade?: (db: IDBDatabase) => Promise, - ): Promise { - const window = (await import('@utils-client/window')).default; - if (!window.indexedDB) { - throw new Error("Your browser doesn't support a stable version of IndexedDB."); - } - const request = window.indexedDB.open(DB_NAME, version ?? 1); - - [this._db] = await Promise.all([ - new Promise((resolve, reject) => { - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }), - new Promise | undefined>((resolve) => { - request.onupgradeneeded = () => { - upgrade?.(request.result); - }; - // * I don't know how to do it :/ - setTimeout(resolve, 500); - }), - ]); - } - - protected close() { - this._db?.close(); - } - - protected createStore(name: string, keyPath?: string): void { - this._assertDB(); - - this._db!.createObjectStore(name, { keyPath }); - } - - protected getStore(name: string) { - this._assertDB(); - - return new IndexedDBObject(this._db!.transaction(name, 'readwrite').objectStore(name)); - } - - protected deleteStore(name: string) { - this._assertDB(); - - this._db!.deleteObjectStore(name); - } - - private _assertDB() { - if (!this._db) { - throw new Error('Database is not open'); - } - } -} diff --git a/src/lib/client/utils/indexed-db/index.ts b/src/lib/client/utils/indexed-db/index.ts deleted file mode 100644 index b6dc1345..00000000 --- a/src/lib/client/utils/indexed-db/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IndexedDB } from './idb'; diff --git a/src/lib/client/utils/store.ts b/src/lib/client/utils/store.ts deleted file mode 100644 index c7cced4b..00000000 --- a/src/lib/client/utils/store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type Writable, writable } from 'svelte/store'; - -import { browser } from '$app/environment'; - -export function persistedWritable(key: string, initialValue: T): Writable { - const store = writable(initialValue); - - if (browser) { - const raw = localStorage.getItem(key); - if (raw) { - try { - store.set(JSON.parse(raw)); - } catch { - localStorage.removeItem(key); - } - } - - store.subscribe((value) => { - localStorage.setItem(key, JSON.stringify(value)); - }); - } - - return store; -} diff --git a/src/lib/client/utils/toasts.ts b/src/lib/client/utils/toasts.ts deleted file mode 100644 index 42f35fe4..00000000 --- a/src/lib/client/utils/toasts.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { toast } from 'svelte-sonner'; - -export const toastError = (message: string, description?: string) => - toast.error(message, { - description, - position: 'top-right', - style: 'background: oklch(var(--destructive)); color: oklch(var(--destructive-foreground));', - }); diff --git a/src/lib/client/utils/window.ts b/src/lib/client/utils/window.ts deleted file mode 100644 index 76f1ace4..00000000 --- a/src/lib/client/utils/window.ts +++ /dev/null @@ -1 +0,0 @@ -export default window; diff --git a/src/lib/components/Menu/MenuBar.svelte b/src/lib/components/Menu/MenuBar.svelte deleted file mode 100644 index a579dcee..00000000 --- a/src/lib/components/Menu/MenuBar.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - -
    - {#each elements as menu (menu.name)} - - {#each menu.items as item, i (i)} - - {#if 'snippet' in item} - {@render item.snippet()} - {:else} - {item.name} - {/if} - - {/each} - - {/each} -
    - - diff --git a/src/lib/components/Menu/MenuButton.svelte b/src/lib/components/Menu/MenuButton.svelte deleted file mode 100644 index 9bcb9f32..00000000 --- a/src/lib/components/Menu/MenuButton.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
    - - - - - - {@render children?.()} - - -
    diff --git a/src/lib/components/Menu/MenuItem.svelte b/src/lib/components/Menu/MenuItem.svelte deleted file mode 100644 index 3e3bb356..00000000 --- a/src/lib/components/Menu/MenuItem.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
    - - {#if link} - - - {@render children?.()} - - {:else} - - {/if} - -
    diff --git a/src/lib/components/Menu/export-dialog.svelte b/src/lib/components/Menu/export-dialog.svelte deleted file mode 100644 index e6232bf7..00000000 --- a/src/lib/components/Menu/export-dialog.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - Export project - - {#if loading} - Building the project archive… - {:else if expired} - This archive link has expired. - {:else} - Your archive is ready to download. - {/if} - - - -
    - {#if loading} - -

    Preparing your archive…

    - {:else if expired} - -

    - This link is no longer available. Please export the project again. -

    - {:else if downloadHref} - - - - -

    - - This link expires after 15 minutes. -

    - {/if} -
    - - - - -
    -
    diff --git a/src/lib/components/Panel/Layout.svelte b/src/lib/components/Panel/Layout.svelte deleted file mode 100644 index 89bed5b7..00000000 --- a/src/lib/components/Panel/Layout.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - -{#if isWidget(layout)} - -{:else if isPanel(layout)} - - {#each layout.children as child, index (index)} - - - {#if index < layout.children.length - 1} - handleResize(index, delta)} - onDragStart={() => handleDragStart(index)} - onDragEnd={() => handleDragEnd(index)} - /> - {/if} - {/each} - -{/if} diff --git a/src/lib/components/Panel/Panel.svelte b/src/lib/components/Panel/Panel.svelte deleted file mode 100644 index aa7c5b4a..00000000 --- a/src/lib/components/Panel/Panel.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
    - {@render children()} -
    diff --git a/src/lib/components/Panel/Widget.svelte b/src/lib/components/Panel/Widget.svelte deleted file mode 100644 index c23269a1..00000000 --- a/src/lib/components/Panel/Widget.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -
    - {#if WidgetComponent} - - {:else} - {id || 'Widget'} - {/if} -
    diff --git a/src/lib/components/Panel/WidgetResizeBar.svelte b/src/lib/components/Panel/WidgetResizeBar.svelte deleted file mode 100644 index 520b159b..00000000 --- a/src/lib/components/Panel/WidgetResizeBar.svelte +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/src/lib/components/Panel/types.ts b/src/lib/components/Panel/types.ts deleted file mode 100644 index 80e67af1..00000000 --- a/src/lib/components/Panel/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type WidgetConfig = { - type: 'widget'; - size?: number; - id?: string; -}; - -export type PanelConfig = { - type: 'panel'; - direction: 'row' | 'col'; - size?: number; - children: LayoutItem[]; -}; - -export type LayoutItem = WidgetConfig | PanelConfig; diff --git a/src/lib/components/Panel/utils.ts b/src/lib/components/Panel/utils.ts deleted file mode 100644 index 4a65c1da..00000000 --- a/src/lib/components/Panel/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { LayoutItem, PanelConfig, WidgetConfig } from './types'; - -export function isWidget(item: LayoutItem): item is WidgetConfig { - return item.type === 'widget'; -} - -export function isPanel(item: LayoutItem): item is PanelConfig { - return item.type === 'panel'; -} - -export function cloneLayout(layout: LayoutItem): LayoutItem { - if (isWidget(layout)) { - return { ...layout }; - } - return { - ...layout, - children: layout.children.map(cloneLayout), - }; -} diff --git a/src/lib/components/Tabs/CodeEditor/CodeEditor.svelte b/src/lib/components/Tabs/CodeEditor/CodeEditor.svelte deleted file mode 100644 index 4eaeaa4e..00000000 --- a/src/lib/components/Tabs/CodeEditor/CodeEditor.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -
    - -
    diff --git a/src/lib/components/Tabs/MainTab/MainTab.svelte b/src/lib/components/Tabs/MainTab/MainTab.svelte deleted file mode 100644 index 76631f9f..00000000 --- a/src/lib/components/Tabs/MainTab/MainTab.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
    - -
    diff --git a/src/lib/components/Tabs/Tab.svelte b/src/lib/components/Tabs/Tab.svelte deleted file mode 100644 index 18d061d4..00000000 --- a/src/lib/components/Tabs/Tab.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/src/lib/components/Tabs/TabBar.svelte b/src/lib/components/Tabs/TabBar.svelte deleted file mode 100644 index 91291194..00000000 --- a/src/lib/components/Tabs/TabBar.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {#each state.tabs as tab (tab.id)} - tabsStore.selectTab(tab.id)} - onClose={() => tabsStore.closeTab(tab.id)} - /> - {/each} -
    diff --git a/src/lib/components/Tabs/registry.ts b/src/lib/components/Tabs/registry.ts deleted file mode 100644 index 1e7c4b27..00000000 --- a/src/lib/components/Tabs/registry.ts +++ /dev/null @@ -1,26 +0,0 @@ -import CodeEditor from './CodeEditor/CodeEditor.svelte'; -import MainTab from './MainTab/MainTab.svelte'; -import type { Tab } from './types'; - -export const tabRegistry: Record = { - main: { - id: 'main', - icon: 'i-material-icon-theme-huff', - component: MainTab, - }, - ts: { - id: 'ts', - icon: 'i-material-icon-theme-typescript', - component: CodeEditor, - }, - '3d': { - id: '3d', - icon: 'i-material-icon-theme-3d', - component: MainTab, - }, - song: { - id: 'song', - icon: 'i-material-icon-theme-lyric', - component: MainTab, - }, -}; diff --git a/src/lib/components/Tabs/store.ts b/src/lib/components/Tabs/store.ts deleted file mode 100644 index 302ea56d..00000000 --- a/src/lib/components/Tabs/store.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { get } from 'svelte/store'; - -import { persistedWritable } from '@utils-client/store'; - -import type { TabInstance, TabsState } from './types'; - -const initialState: TabsState = { - tabs: [ - { - id: 'tab-main', - type: 'main', - title: 'Game', - metadata: {}, - }, - ], - selectedTabId: 'tab-main', -}; - -function createTabsStore(projectId: string) { - const { subscribe, update, set } = persistedWritable( - `editor.tabs.${projectId}`, - initialState, - ); - - async function openTab(tab: Omit & { id?: string }) { - const existing = await findExistingTab(tab); - - if (existing) { - update((state) => ({ - ...state, - selectedTabId: existing.id, - })); - return; - } - - const newTab: TabInstance = { - ...tab, - id: tab.id ?? crypto.randomUUID(), - }; - - update((state) => ({ - tabs: [...state.tabs, newTab], - selectedTabId: newTab.id, - })); - } - - function closeTab(tabId: string) { - update((state) => { - const index = state.tabs.findIndex((t) => t.id === tabId); - if (index === -1) return state; - - const closingTab = state.tabs[index]; - if (closingTab.type === 'main') return state; - - const isSelected = state.selectedTabId === tabId; - const nextTabs = state.tabs.filter((t) => t.id !== tabId); - - if (!isSelected) { - return { ...state, tabs: nextTabs }; - } - - const nextSelected = - nextTabs[index]?.id ?? nextTabs[index - 1]?.id ?? nextTabs[0]?.id ?? null; - - return { - tabs: nextTabs, - selectedTabId: nextSelected, - }; - }); - } - - function selectTab(tabId: string) { - update((state) => ({ - ...state, - selectedTabId: tabId, - })); - } - - function moveTab(fromId: string, toId: string) { - update((state) => { - const from = state.tabs.findIndex((t) => t.id === fromId); - const to = state.tabs.findIndex((t) => t.id === toId); - if (from === -1 || to === -1 || from === to) return state; - - const tabs = [...state.tabs]; - const [item] = tabs.splice(from, 1); - tabs.splice(to, 0, item); - - return { ...state, tabs }; - }); - } - - function reset() { - set(initialState); - } - - async function findExistingTab( - tab: Omit & { id?: string }, - ): Promise { - const tabs = get({ subscribe }).tabs; - if (tab.metadata?.path) { - for (const t of tabs) { - if (!t.metadata?.path) continue; - - if (t.metadata.path === tab.metadata.path) { - return t; - } - } - - return undefined; - } - - if (tab.id) { - return tabs.find((t) => t.id === tab.id); - } - - return undefined; - } - - return { - subscribe, - openTab, - closeTab, - selectTab, - moveTab, - reset, - }; -} - -export const updateTabsStore = (projectId: string) => { - tabsStore = createTabsStore(projectId); -}; - -export let tabsStore = createTabsStore('default'); diff --git a/src/lib/components/Tabs/types.ts b/src/lib/components/Tabs/types.ts deleted file mode 100644 index 6d869706..00000000 --- a/src/lib/components/Tabs/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Component } from 'svelte'; - -export type TabTypeId = 'main' | 'ts' | '3d' | 'song' | 'img' | 'unknown'; - -export interface Tab { - id: TabTypeId; - icon: string; - component: Component; -} - -export interface TabInstance { - id: string; - type: TabTypeId; - title: string; - metadata?: { - path?: string; - }; -} - -export interface TabsState { - tabs: TabInstance[]; - selectedTabId: string | null; -} diff --git a/src/lib/components/Widget/CodeEditor/MonacoEditor.svelte b/src/lib/components/Widget/CodeEditor/MonacoEditor.svelte deleted file mode 100644 index 565afb97..00000000 --- a/src/lib/components/Widget/CodeEditor/MonacoEditor.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - -
    - - diff --git a/src/lib/components/Widget/CodeEditor/project-loader.ts b/src/lib/components/Widget/CodeEditor/project-loader.ts deleted file mode 100644 index 95264752..00000000 --- a/src/lib/components/Widget/CodeEditor/project-loader.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type Monaco from 'monaco-editor'; - -import { type SfsDirectory } from '$lib/client/sync-file-system'; -import type { DirectoryContent } from '$lib/server/file-system/project-directory'; - -export function pathToUri(monaco: typeof Monaco, path: string) { - const clean = normalizePath(path); - return monaco.Uri.parse(`file:///${clean}`); -} - -export function normalizePath(path: string) { - return path.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+/, ''); -} - -function joinPath(base: string, name: string) { - return normalizePath(base ? `${base}/${name}` : name); -} - -export async function loadMonacoProject(monaco: typeof Monaco, fsRoot: SfsDirectory) { - const rootContent = await fsRoot.readdir(true); - - async function loadDirectory(dir: SfsDirectory, content: DirectoryContent, basePath: string) { - for (const fileName of content.files) { - const fullPath = joinPath(basePath, fileName); - if (!/\.(ts|js)$/.test(fileName)) continue; - - const file = await dir.getFile(fileName); - const text = (await file.read()) ?? ''; - - const uri = pathToUri(monaco, fullPath); - - let model = monaco.editor.getModel(uri); - - if (!model) { - model = monaco.editor.createModel(text, 'typescript', uri); - } else { - model.setValue(text); - } - } - - for (const [dirName, subContent] of Object.entries(content.directories)) { - if (!subContent) continue; - - const subDir = await dir.getDirectory(dirName); - await loadDirectory(subDir, subContent, joinPath(basePath, dirName)); - } - } - - await loadDirectory(fsRoot, rootContent, ''); -} diff --git a/src/lib/components/Widget/EditorGame/EditorGame.svelte b/src/lib/components/Widget/EditorGame/EditorGame.svelte deleted file mode 100644 index 5863a35d..00000000 --- a/src/lib/components/Widget/EditorGame/EditorGame.svelte +++ /dev/null @@ -1,122 +0,0 @@ - - -
    - -
    -
    -
    - {#if gameState === GameState.PAUSE || gameState === GameState.INIT_STATE || gameState === GameState.STOP} - - {:else} - - {/if} - - - -
    -
    -
    -
    -
    -
    -
    - - diff --git a/src/lib/components/Widget/EditorGame/game.svelte.ts b/src/lib/components/Widget/EditorGame/game.svelte.ts deleted file mode 100644 index a26f0744..00000000 --- a/src/lib/components/Widget/EditorGame/game.svelte.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum GameState { - INIT_STATE = 0, - RELOAD_FROM_SERVER, - RELOAD_FROM_SAVE, - PLAY, - PAUSE, - STOP, -} diff --git a/src/lib/components/Widget/OutputLog/OutputLogWidget.svelte b/src/lib/components/Widget/OutputLog/OutputLogWidget.svelte deleted file mode 100644 index baf9a1d6..00000000 --- a/src/lib/components/Widget/OutputLog/OutputLogWidget.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -
    -
    - -
    - -
    - {#each filteredLogs as log (log.id)} -
    - - [{log.time}] - - - {#if log.source} - - [{log.source}] - - {/if} - - - {log.message} - -
    - {/each} - - {#if filteredLogs.length === 0} -
    - {search !== '' ? 'No logs matching your search' : 'No logs available'} -
    - {/if} -
    -
    diff --git a/src/lib/components/Widget/content-browser/content-item.svelte b/src/lib/components/Widget/content-browser/content-item.svelte deleted file mode 100644 index eb82f1bc..00000000 --- a/src/lib/components/Widget/content-browser/content-item.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/src/lib/components/Widget/content-browser/content-list.svelte b/src/lib/components/Widget/content-browser/content-list.svelte deleted file mode 100644 index 6a7221f0..00000000 --- a/src/lib/components/Widget/content-browser/content-list.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
    -
    - {#each items as item (item.name)} - - {/each} -
    -
    diff --git a/src/lib/components/Widget/content-browser/header.svelte b/src/lib/components/Widget/content-browser/header.svelte deleted file mode 100644 index fc02d22f..00000000 --- a/src/lib/components/Widget/content-browser/header.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -
    - - - {#each dirs as dir, i (i)} - - - - - {/each} - - - - - -
    diff --git a/src/lib/components/Widget/content-browser/icon.svelte b/src/lib/components/Widget/content-browser/icon.svelte deleted file mode 100644 index b415ff88..00000000 --- a/src/lib/components/Widget/content-browser/icon.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -{#if item.type === 'dir'} - -{:else} - -{/if} diff --git a/src/lib/components/Widget/content-browser/index.ts b/src/lib/components/Widget/content-browser/index.ts deleted file mode 100644 index b13a2388..00000000 --- a/src/lib/components/Widget/content-browser/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Widget from './widget.svelte'; - -export { Widget, Widget as ContentBrowserWidget }; diff --git a/src/lib/components/Widget/content-browser/side-item.svelte b/src/lib/components/Widget/content-browser/side-item.svelte deleted file mode 100644 index 77dd3dd5..00000000 --- a/src/lib/components/Widget/content-browser/side-item.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - -{#if open} - -{/if} diff --git a/src/lib/components/Widget/content-browser/side-list.svelte b/src/lib/components/Widget/content-browser/side-list.svelte deleted file mode 100644 index 8e2156b5..00000000 --- a/src/lib/components/Widget/content-browser/side-list.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -{#each items as item (getPath(item.name))} - -{/each} diff --git a/src/lib/components/Widget/content-browser/skeleton.svelte b/src/lib/components/Widget/content-browser/skeleton.svelte deleted file mode 100644 index 481d2cf3..00000000 --- a/src/lib/components/Widget/content-browser/skeleton.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
    -
    - {#each { length: 6 }, i (i)} - - {/each} -
    -
    -
    - - -
    -
    -
    - {#each { length: 12 }, i (i)} -
    - - -
    - {/each} -
    -
    -
    -
    diff --git a/src/lib/components/Widget/content-browser/store.ts b/src/lib/components/Widget/content-browser/store.ts deleted file mode 100644 index b7ffc309..00000000 --- a/src/lib/components/Widget/content-browser/store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store'; - -export const currentDir = writable(''); diff --git a/src/lib/components/Widget/content-browser/types.ts b/src/lib/components/Widget/content-browser/types.ts deleted file mode 100644 index 1bdf57de..00000000 --- a/src/lib/components/Widget/content-browser/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ContentBrowserItem = ContentBrowserItemFile | ContentBrowserItemDir; - -export type ContentBrowserItemBase = { - name: string; -}; - -export type ContentBrowserItemFile = ContentBrowserItemBase & { - type: 'file'; -}; - -export type ContentBrowserItemDir = ContentBrowserItemBase & { - type: 'dir'; - children: ContentBrowserItem[]; -}; diff --git a/src/lib/components/Widget/content-browser/utils.ts b/src/lib/components/Widget/content-browser/utils.ts deleted file mode 100644 index 176fd489..00000000 --- a/src/lib/components/Widget/content-browser/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const joinPath = (path: string, name: string) => `${path ? `${path}/` : ''}${name}`; diff --git a/src/lib/components/Widget/content-browser/widget.svelte b/src/lib/components/Widget/content-browser/widget.svelte deleted file mode 100644 index f72ad67b..00000000 --- a/src/lib/components/Widget/content-browser/widget.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -{#if query.isFetched && !query.isLoading && query.data} -
    -
    - i.type === 'dir')} /> -
    -
    - - -
    -
    -{:else} - -{/if} diff --git a/src/lib/components/Widget/ecs-tree/assets/asset.items.ts b/src/lib/components/Widget/ecs-tree/assets/asset.items.ts deleted file mode 100644 index e040292f..00000000 --- a/src/lib/components/Widget/ecs-tree/assets/asset.items.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PackageItems } from '../types'; - -export const ASSET_ITEMS: PackageItems = { - icon: { - name: 'i-ic-baseline-landscape', - color: 'text-purple-400', - }, - canAdd: true, - canDelete: true, - canImport: true, -}; diff --git a/src/lib/components/Widget/ecs-tree/assets/assets-tab.svelte b/src/lib/components/Widget/ecs-tree/assets/assets-tab.svelte deleted file mode 100644 index c0ba5489..00000000 --- a/src/lib/components/Widget/ecs-tree/assets/assets-tab.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/assets/dialog-create-asset.svelte b/src/lib/components/Widget/ecs-tree/assets/dialog-create-asset.svelte deleted file mode 100644 index cff16ef4..00000000 --- a/src/lib/components/Widget/ecs-tree/assets/dialog-create-asset.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - - 1 ? `Import ${files.length} files` : 'Import'} - confirmDisabled={files.length === 0} - bind:open - onConfirm={handleConfirm} -> -
    - - -
    inputEl?.click()} - onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && inputEl?.click()} - > - -

    - Drag & drop files here, or - browse -

    - -
    - - {#if files.length > 0} - -
    - {#each files as file, i (file.name + file.size)} -
    - - {file.name} - {formatSize(file.size)} - -
    - {/each} -
    -
    - {/if} -
    -
    diff --git a/src/lib/components/Widget/ecs-tree/components/component.items.ts b/src/lib/components/Widget/ecs-tree/components/component.items.ts deleted file mode 100644 index 2f9c1aa0..00000000 --- a/src/lib/components/Widget/ecs-tree/components/component.items.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PackageItems } from '../types'; - -export const COMPONENT_ITEMS: PackageItems = { - icon: { - name: 'i-ic-baseline-token', - color: 'text-blue-400', - }, - addTooltip: 'Add to selected entity', - disableAddTooltipNotSelected: 'No entity selected', - disableAddTooltipAlreadyAdded: 'The selected entity already had this component', - canAdd: true, - canDelete: true, - canImport: true, -}; diff --git a/src/lib/components/Widget/ecs-tree/components/components-tab.svelte b/src/lib/components/Widget/ecs-tree/components/components-tab.svelte deleted file mode 100644 index 8e769a0f..00000000 --- a/src/lib/components/Widget/ecs-tree/components/components-tab.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/const.ts b/src/lib/components/Widget/ecs-tree/const.ts deleted file mode 100644 index fa907662..00000000 --- a/src/lib/components/Widget/ecs-tree/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const ENTITY_DRAG_KEY = 'ecs-entity-drag'; diff --git a/src/lib/components/Widget/ecs-tree/dialogs/dialog-name.svelte b/src/lib/components/Widget/ecs-tree/dialogs/dialog-name.svelte deleted file mode 100644 index 997d72dd..00000000 --- a/src/lib/components/Widget/ecs-tree/dialogs/dialog-name.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - - - {#snippet children(confirm)} -
    - - {#if error} - Error: {error} - {/if} -
    - {/snippet} -
    diff --git a/src/lib/components/Widget/ecs-tree/dialogs/dialog-select.svelte b/src/lib/components/Widget/ecs-tree/dialogs/dialog-select.svelte deleted file mode 100644 index 6642090f..00000000 --- a/src/lib/components/Widget/ecs-tree/dialogs/dialog-select.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - -
    - -
    -
    diff --git a/src/lib/components/Widget/ecs-tree/index.ts b/src/lib/components/Widget/ecs-tree/index.ts deleted file mode 100644 index 45923991..00000000 --- a/src/lib/components/Widget/ecs-tree/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Widget from './widget.svelte'; - -export { Widget, Widget as ECSTreeWidget }; diff --git a/src/lib/components/Widget/ecs-tree/libraries/libraries-tab.svelte b/src/lib/components/Widget/ecs-tree/libraries/libraries-tab.svelte deleted file mode 100644 index 641b9808..00000000 --- a/src/lib/components/Widget/ecs-tree/libraries/libraries-tab.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/libraries/library.items.ts b/src/lib/components/Widget/ecs-tree/libraries/library.items.ts deleted file mode 100644 index 11a760f6..00000000 --- a/src/lib/components/Widget/ecs-tree/libraries/library.items.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PackageItems } from '../types'; - -export const LIBRARY_ITEMS: PackageItems = { - icon: { - name: 'i-ic-round-library-books', - color: 'text-teal-400', - }, -}; diff --git a/src/lib/components/Widget/ecs-tree/packages/dialog-create-package.svelte b/src/lib/components/Widget/ecs-tree/packages/dialog-create-package.svelte deleted file mode 100644 index 2e8849cf..00000000 --- a/src/lib/components/Widget/ecs-tree/packages/dialog-create-package.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/packages/dialog-import-package.svelte b/src/lib/components/Widget/ecs-tree/packages/dialog-import-package.svelte deleted file mode 100644 index ca12d56b..00000000 --- a/src/lib/components/Widget/ecs-tree/packages/dialog-import-package.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/packages/package-row.svelte b/src/lib/components/Widget/ecs-tree/packages/package-row.svelte deleted file mode 100644 index 2591c7be..00000000 --- a/src/lib/components/Widget/ecs-tree/packages/package-row.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - -
    -
    -
    -
    - - - {$pkg.name ?? $pkg.path?.split('/').at(-1) ?? $pkg.id} - -
    -
    - {$pkg.path ?? $pkg.id} -
    -
    -
    - {#if tooltip} - - - - {/if} - -
    -
    -
    -
    - - {#if type !== 'library'} - - - Open code - - {#if type !== 'asset'} - - - {items.addTooltip} - - {/if} - - {/if} - - {#if deleteLoading} - - {:else} - - {/if} - Delete - - -
    diff --git a/src/lib/components/Widget/ecs-tree/packages/package-tab.svelte b/src/lib/components/Widget/ecs-tree/packages/package-tab.svelte deleted file mode 100644 index 0778a103..00000000 --- a/src/lib/components/Widget/ecs-tree/packages/package-tab.svelte +++ /dev/null @@ -1,175 +0,0 @@ - - -{#if type === 'asset'} - -{:else} - -{/if} - -
    -
    - - - - - - {#if query} - - (query = '')} - variant="ghost" - class="rounded-md text-destructive hover:text-destructive hover:bg-destructive/10" - > - - - - {/if} - - - - {#snippet child({ props })} - - {/snippet} - - - {#if type === 'asset'} - (createOpen = true)}> - - Upload - - {:else if type !== 'library'} - (createOpen = true)}> - - Create - - {/if} - marketplace.open()}> - - Import - - - -
    - - - {#each sorted as pkg (pkg.id)} - {@const handle = manager.get(pkg.id)} - - {/each} - - {#if sorted.length === 0} -
    - {query ? 'No matches.' : `No ${namePlural} - add one with +.`} -
    - {/if} -
    -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/dialog-edit-scene.svelte b/src/lib/components/Widget/ecs-tree/scenes/dialog-edit-scene.svelte deleted file mode 100644 index 749cac0c..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/dialog-edit-scene.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/scenes/dialog-new-scene.svelte b/src/lib/components/Widget/ecs-tree/scenes/dialog-new-scene.svelte deleted file mode 100644 index 74f32477..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/dialog-new-scene.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-delete-entity.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-delete-entity.svelte deleted file mode 100644 index 19a9acc2..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-delete-entity.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-edit-entity.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-edit-entity.svelte deleted file mode 100644 index 86479add..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-edit-entity.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-new-entity.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-new-entity.svelte deleted file mode 100644 index 07faf0cb..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/dialog-new-entity.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree-node.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree-node.svelte deleted file mode 100644 index 9b908434..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree-node.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - {#if !readonly} - - - - New entity - - - - New folder - - - - - Edit - - - - - Delete - - - {/if} - diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree.svelte deleted file mode 100644 index 1978f247..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/entity-tree.svelte +++ /dev/null @@ -1,360 +0,0 @@ - - - - - -
    -
    - Entities -
    - - - - {#snippet child({ props })} - - {/snippet} - - - - - New entity - - - - New folder - - - -
    -
    - {#if searchOpen} - - - - - - {#if search} - - (search = '')} - variant="ghost" - class="rounded-md text-destructive hover:text-destructive hover:bg-destructive/10" - > - - - - {/if} - - {/if} -
    - -{#if ancestor && inherited.length > 0} -
    - - {#if !inheritedCollapsed} -
    - {#each inheritedTree as node (node.kind === 'entity' ? node.id : node.path)} - {#if node.kind === 'entity'} - - {:else} - - {/if} - {/each} -
    - {/if} -
    -{/if} - -
    - {#each tree as node (node.kind === 'entity' ? node.id : `${node.path}-folder`)} - {#if node.kind === 'entity'} - - {:else} - - {/if} - {/each} - - {#if $entities.length === 0 && folders.size === 0} -
    - No entities - use + to add one. -
    - {/if} -
    - -{#if dragging} - -{/if} diff --git a/src/lib/components/Widget/ecs-tree/scenes/entities/folder-tree-node.svelte b/src/lib/components/Widget/ecs-tree/scenes/entities/folder-tree-node.svelte deleted file mode 100644 index 5eba3ae1..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/entities/folder-tree-node.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - - - - {#if !readonly} - - - - New entity - - - - New folder - - - - - Edit - - - - Delete folder & contents - - - {/if} - - -{#if expandedFolders.has(node.path)} - {#each node.children as child (child.kind === 'entity' ? child.id : `${child.path}-folder`)} - {#if child.kind === 'entity'} - - {:else} - - {/if} - {/each} -{/if} diff --git a/src/lib/components/Widget/ecs-tree/scenes/scene-detail.svelte b/src/lib/components/Widget/ecs-tree/scenes/scene-detail.svelte deleted file mode 100644 index 2124ad30..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/scene-detail.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -
    -
    - - {$scene.name} - {#if isActive} -
    - - Active -
    - {/if} -
    - - - - Entities - Systems - - - - - - - - -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/scene-list.svelte b/src/lib/components/Widget/ecs-tree/scenes/scene-list.svelte deleted file mode 100644 index ac873697..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/scene-list.svelte +++ /dev/null @@ -1,265 +0,0 @@ - - - - -{#snippet sceneRow(scene: Scene, depth: number)} - {@const isSelf = dragSceneId === scene.id} - {@const isInvalid = - !dragSceneId || dragSceneId === scene.id || isDescendantOf(scene.id, dragSceneId)} - {@const isDropBefore = dropTarget?.id === scene.id && dropTarget.pos === 'before'} - {@const isDropOn = dropTarget?.id === scene.id && dropTarget.pos === 'on'} - {@const isDropAfter = dropTarget?.id === scene.id && dropTarget.pos === 'after'} - {@const barLeft = 8 + depth * 12} - {@const handle = manager.get(scene.id)} - -
    -
    - {#if isDropBefore} -
    - {/if} - -
    - - {#if expandedSceneItems.has(scene.id) && scene.subScenes.length > 0} - {#each scene.subScenes as subId (subId)} - {@const sub = $scenes.find((s) => s.id === subId)} - {#if sub}{@render sceneRow(sub, depth + 1)}{/if} - {/each} -
    - {/if} - -
    - {#if dragSceneId && !isInvalid} - - {/if} -
    -
    -{/snippet} - -
    -
    - Scenes - -
    - -
    { - if (dragSceneId) e.preventDefault(); - }} - ondragleave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) dropTarget = null; - }} - ondrop={() => { - dragSceneId = null; - dropTarget = null; - }} - > - {#each rootScenes as scene (scene.id)} - {@render sceneRow(scene, 0)} - {/each} -
    -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/scene-row.svelte b/src/lib/components/Widget/ecs-tree/scenes/scene-row.svelte deleted file mode 100644 index 458cb115..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/scene-row.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - -
    - {#if $scene.subScenes.length > 0} - - {:else} - - {/if} - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/scenes-tab.svelte b/src/lib/components/Widget/ecs-tree/scenes/scenes-tab.svelte deleted file mode 100644 index 99886ba9..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/scenes-tab.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -
    - - - - {#if selected} - - {:else} -
    - Select a scene -
    - {/if} -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/systems/dialog-add-scene-system.svelte b/src/lib/components/Widget/ecs-tree/scenes/systems/dialog-add-scene-system.svelte deleted file mode 100644 index 1ab7d657..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/systems/dialog-add-scene-system.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - ({ label: sys.name, value: sys.id }))} - bind:open - {onConfirm} -/> diff --git a/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-list.svelte b/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-list.svelte deleted file mode 100644 index e0e566e8..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-list.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - - - -
    - Systems - -
    - -
    - {#each $systems as sysName (sysName)} - {@const isSelf = dragId === sysName} - {@const isDropBefore = - !isNext(dragId, sysName, 'before') && - dropTarget?.id === sysName && - dropTarget.pos === 'before'} - {@const isDropAfter = - !isNext(dragId, sysName, 'after') && dropTarget?.id === sysName && dropTarget.pos === 'after'} - {@const handle = manager.get(sysName)} - - - - {#if isDropBefore} -
    - {/if} - - {#if isDropAfter} -
    - {/if} -
    - - - - Open code - - - - Find in systems - - - - - Remove from scene - - -
    - {/each} - - {#if $systems.length === 0} -
    No systems - use + to add one.
    - {/if} -
    diff --git a/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-row.svelte b/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-row.svelte deleted file mode 100644 index fb676d03..00000000 --- a/src/lib/components/Widget/ecs-tree/scenes/systems/scene-system-row.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - -{$system.name} diff --git a/src/lib/components/Widget/ecs-tree/systems/system.items.ts b/src/lib/components/Widget/ecs-tree/systems/system.items.ts deleted file mode 100644 index 4f681e1b..00000000 --- a/src/lib/components/Widget/ecs-tree/systems/system.items.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PackageItems } from '../types'; - -export const SYSTEM_ITEMS: PackageItems = { - icon: { - name: 'i-icomoon-free-steam', - color: 'text-amber-400', - }, - addTooltip: 'Add to active scene', - disableAddTooltipNotSelected: 'No active scene', - disableAddTooltipAlreadyAdded: 'The active scene already had this system', - canAdd: true, - canDelete: true, - canImport: true, -}; diff --git a/src/lib/components/Widget/ecs-tree/systems/systems-tab.svelte b/src/lib/components/Widget/ecs-tree/systems/systems-tab.svelte deleted file mode 100644 index e4264cf5..00000000 --- a/src/lib/components/Widget/ecs-tree/systems/systems-tab.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/lib/components/Widget/ecs-tree/types.ts b/src/lib/components/Widget/ecs-tree/types.ts deleted file mode 100644 index 78c1a987..00000000 --- a/src/lib/components/Widget/ecs-tree/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface PackageItems { - icon: { - name: string; - color: string; - }; - addTooltip?: string; - disableAddTooltipNotSelected?: string; - disableAddTooltipAlreadyAdded?: string; - - canAdd?: boolean; - canDelete?: boolean; - canImport?: boolean; -} - -export type TreeEntity = { kind: 'entity'; id: string; scene?: string }; -export type TreeFolder = { kind: 'folder'; name: string; path: string; children: TreeNode[] }; -export type TreeNode = TreeEntity | TreeFolder; - -export type EntityDragItem = { type: 'entity'; id: string } | { type: 'folder'; path: string }; - -export interface EntityDragContext { - readonly dragging: EntityDragItem | null; - startDrag(item: EntityDragItem): void; - endDrag(): void; - dropOnFolder(folderPath: string): void; - dropOnEntity(entityId: string): void; - dropOnRoot(): void; -} - -export interface Package { - id: string; - name?: string; - path?: string; -} diff --git a/src/lib/components/Widget/ecs-tree/utils.ts b/src/lib/components/Widget/ecs-tree/utils.ts deleted file mode 100644 index 0b4b7201..00000000 --- a/src/lib/components/Widget/ecs-tree/utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Entity } from '$lib/client/ecs'; - -import type { TreeEntity, TreeFolder, TreeNode } from './types'; - -export const resolveParentPath = (path: string): string => { - if (!path) return ''; - const parts = path.split('/'); - return parts.slice(0, -1).join('/'); -}; - -export function buildTree( - entities: (Entity & { scene?: string })[], - extraFolderPaths: Iterable = [], - search?: string, -): TreeNode[] { - if (search) { - entities = entities.filter((e) => e.id.includes(search)); - extraFolderPaths = Array.from(extraFolderPaths).filter((p) => - p.split('/').at(-1)?.includes(search), - ); - } - const folderMap = new Map(); - const root: TreeNode[] = []; - - function getOrCreate(path: string): TreeFolder { - if (folderMap.has(path)) return folderMap.get(path)!; - const parts = path.split('/'); - const folder: TreeFolder = { kind: 'folder', name: parts.at(-1)!, path, children: [] }; - folderMap.set(path, folder); - const parentPath = parts.slice(0, -1).join('/'); - (parentPath ? getOrCreate(parentPath).children : root).push(folder); - return folder; - } - - for (const path of extraFolderPaths) if (path) getOrCreate(path); - for (const entity of entities) { - (entity.treePath ? getOrCreate(entity.treePath).children : root).push({ - kind: 'entity', - id: entity.id, - scene: entity.scene, - }); - } - - function sort(nodes: TreeNode[]): TreeNode[] { - const folders = nodes - .filter((n): n is TreeFolder => n.kind === 'folder') - .sort((a, b) => a.name.localeCompare(b.name)); - const entities = nodes - .filter((n): n is TreeEntity => n.kind === 'entity') - .sort((a, b) => a.id.localeCompare(b.id)); - for (const f of folders) f.children = sort(f.children); - return [...folders, ...entities]; - } - - return sort(root); -} - -export function folderPaths(treePath: string): string[] { - if (!treePath) return []; - const parts = treePath.split('/'); - return parts.map((_, i) => parts.slice(0, i + 1).join('/')); -} diff --git a/src/lib/components/Widget/ecs-tree/widget.svelte b/src/lib/components/Widget/ecs-tree/widget.svelte deleted file mode 100644 index cc0f67d7..00000000 --- a/src/lib/components/Widget/ecs-tree/widget.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - Scenes - Components - Systems - Assets - Libraries - - - - - - - - - - - - - - - - - diff --git a/src/lib/components/Widget/entity-inspector/component-list.svelte b/src/lib/components/Widget/entity-inspector/component-list.svelte deleted file mode 100644 index 2dad2283..00000000 --- a/src/lib/components/Widget/entity-inspector/component-list.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {#each Object.keys($components) as component (component)} - {@const handle = manager.get(component)} - - {/each} -
    diff --git a/src/lib/components/Widget/entity-inspector/component-param-list.svelte b/src/lib/components/Widget/entity-inspector/component-param-list.svelte deleted file mode 100644 index cf06c52f..00000000 --- a/src/lib/components/Widget/entity-inspector/component-param-list.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -{#each $params as param (param.name)} - {@const handle = manager.get(param.name)} - -{/each} diff --git a/src/lib/components/Widget/entity-inspector/component-param-row.svelte b/src/lib/components/Widget/entity-inspector/component-param-row.svelte deleted file mode 100644 index ae8e3f89..00000000 --- a/src/lib/components/Widget/entity-inspector/component-param-row.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - -
    -
    {$param.name}
    - - {#if $param.type === 'string'} - {#if $param.enum} - - {:else} - - {/if} - {:else if $param.type === 'number'} - - {:else if $param.type === 'boolean'} - - {:else if $param.type === 'asset'} - - {/if} -
    diff --git a/src/lib/components/Widget/entity-inspector/component-row.svelte b/src/lib/components/Widget/entity-inspector/component-row.svelte deleted file mode 100644 index f72f2259..00000000 --- a/src/lib/components/Widget/entity-inspector/component-row.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - - -{#if !componentCollapsed} - -{/if} diff --git a/src/lib/components/Widget/entity-inspector/dialog-component-selector.svelte b/src/lib/components/Widget/entity-inspector/dialog-component-selector.svelte deleted file mode 100644 index 417fbe4e..00000000 --- a/src/lib/components/Widget/entity-inspector/dialog-component-selector.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - Add component - Select a component to add to {$entity.name} - - - - -
    - {#if availableComponents.length === 0} -
    No component found
    - {:else} - {#each availableComponents as component (component.name)} - - {/each} - {/if} -
    -
    -
    diff --git a/src/lib/components/Widget/entity-inspector/index.ts b/src/lib/components/Widget/entity-inspector/index.ts deleted file mode 100644 index 8805b919..00000000 --- a/src/lib/components/Widget/entity-inspector/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Widget from './widget.svelte'; - -export { Widget, Widget as EntityInspectorWidget }; diff --git a/src/lib/components/Widget/entity-inspector/widget.svelte b/src/lib/components/Widget/entity-inspector/widget.svelte deleted file mode 100644 index f1be0b28..00000000 --- a/src/lib/components/Widget/entity-inspector/widget.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
    - {#if $entity} -
    -
    - {$entity.id} - -
    - -
    -
    - -
    - - {:else} -
    - Select an entity in the left panel -
    - {/if} -
    diff --git a/src/lib/components/Widget/widgets.ts b/src/lib/components/Widget/widgets.ts deleted file mode 100644 index 989dd166..00000000 --- a/src/lib/components/Widget/widgets.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Component } from 'svelte'; - -import MonacoEditor from './CodeEditor/MonacoEditor.svelte'; -import EditorGame from './EditorGame/EditorGame.svelte'; -import OutputLogWidget from './OutputLog/OutputLogWidget.svelte'; -import { ContentBrowserWidget } from './content-browser'; -import { ECSTreeWidget } from './ecs-tree'; -import { EntityInspectorWidget } from './entity-inspector'; - -export interface WidgetType { - id: string; - component: Component; -} - -export const widgetsTypes: WidgetType[] = [ - { - id: 'content-browser', - component: ContentBrowserWidget, - }, - { - id: 'ecs-tree', - component: ECSTreeWidget, - }, - { - id: 'entity-inspector', - component: EntityInspectorWidget, - }, - { - id: 'code-editor', - component: MonacoEditor as Component, - }, - { - id: 'output-log', - component: OutputLogWidget, - }, - { - id: 'editor-game', - component: EditorGame, - }, -]; diff --git a/src/lib/components/dialogs/base-dialog.svelte b/src/lib/components/dialogs/base-dialog.svelte deleted file mode 100644 index 5b134859..00000000 --- a/src/lib/components/dialogs/base-dialog.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - - - {#if title || description} - - {#if title} - {title} - {/if} - {#if description} - {description} - {/if} - - {/if} - {@render children?.(confirm, cancel)} - - - - {confirmText ?? 'Confirm'} - - - - diff --git a/src/lib/components/dialogs/delete-confirm-dialog.svelte b/src/lib/components/dialogs/delete-confirm-dialog.svelte deleted file mode 100644 index a40dd049..00000000 --- a/src/lib/components/dialogs/delete-confirm-dialog.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - {#if 'title' in props} - {props.title} - {:else} - Deleting {props.type} - {/if} - {#if description} - {description} - {/if} - - - {#if 'children' in props} - {@render props.children()} - {:else} -
    - Are you sure you want to delete {props.name} ? -
    - {/if} - - - - - -
    -
    diff --git a/src/lib/components/dialogs/index.ts b/src/lib/components/dialogs/index.ts deleted file mode 100644 index 887b861b..00000000 --- a/src/lib/components/dialogs/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Base from './base-dialog.svelte'; -import DeleteConfirm from './delete-confirm-dialog.svelte'; -import Input from './input-dialog.svelte'; - -export { Base as BaseDialog, DeleteConfirm as DeleteConfirmDialog, Input as InputDialog }; diff --git a/src/lib/components/dialogs/input-dialog.svelte b/src/lib/components/dialogs/input-dialog.svelte deleted file mode 100644 index 8c7b1219..00000000 --- a/src/lib/components/dialogs/input-dialog.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/lib/components/forms/Form.svelte b/src/lib/components/forms/Form.svelte deleted file mode 100644 index 83bf0b46..00000000 --- a/src/lib/components/forms/Form.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {@render children()} -
    diff --git a/src/lib/components/forms/FormField.svelte b/src/lib/components/forms/FormField.svelte deleted file mode 100644 index 5cc30b27..00000000 --- a/src/lib/components/forms/FormField.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - - {#snippet children({ props })} - {#if label} - {label} - {/if} - {@render fieldChildren({ props })} - {/snippet} - - {#if description} - {description} - {/if} - - diff --git a/src/lib/components/forms/form.svelte.ts b/src/lib/components/forms/form.svelte.ts deleted file mode 100644 index 9524b592..00000000 --- a/src/lib/components/forms/form.svelte.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type SuperForm, defaults, superForm } from 'sveltekit-superforms'; -import { zod4, zodClient } from 'sveltekit-superforms/adapters'; -import type { ZodType } from 'zod'; - -export const useForm = >(config: { - schema: ZodType; - defaultValues?: Partial; - onSubmit?: (values: T) => void | Promise; -}): SuperForm => { - const result = superForm(defaults(zod4(config.schema as any)), { - SPA: true, - validators: zodClient(config.schema as any), - resetForm: false, - onUpdate: async ({ form: f }) => { - if (f.valid) { - await config.onSubmit?.(f.data as T); - } - }, - }); - - if (config.defaultValues) { - result.form.update((v) => ({ ...v, ...config.defaultValues })); - } - - return result as unknown as SuperForm; -}; - -export type FormInstance = SuperForm; diff --git a/src/lib/components/forms/form.type.ts b/src/lib/components/forms/form.type.ts deleted file mode 100644 index 396b9e73..00000000 --- a/src/lib/components/forms/form.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type { FormInstance } from './form.svelte'; diff --git a/src/lib/components/forms/inputs/FormFieldInput.svelte b/src/lib/components/forms/inputs/FormFieldInput.svelte deleted file mode 100644 index f5a3f2de..00000000 --- a/src/lib/components/forms/inputs/FormFieldInput.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - {#snippet children({ props })} - form.update((v) => ({ ...v, [name]: e.currentTarget.value }))} - /> - {/snippet} - diff --git a/src/lib/components/forms/inputs/FormFieldMultiSelect.svelte b/src/lib/components/forms/inputs/FormFieldMultiSelect.svelte deleted file mode 100644 index 7b39f859..00000000 --- a/src/lib/components/forms/inputs/FormFieldMultiSelect.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - {#snippet children({ props })} - form.update((s) => ({ ...s, [name]: v }))} - /> - {/snippet} - diff --git a/src/lib/components/forms/inputs/FormFieldSelect.svelte b/src/lib/components/forms/inputs/FormFieldSelect.svelte deleted file mode 100644 index 91c04379..00000000 --- a/src/lib/components/forms/inputs/FormFieldSelect.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - - {#snippet children({ props })} - form.update((s) => ({ ...s, [name]: v }))} - > - - {triggerContent} - - - {#each options as option (option.value)} - - {/each} - - - {/snippet} - diff --git a/src/lib/components/forms/inputs/FormFieldSwitch.svelte b/src/lib/components/forms/inputs/FormFieldSwitch.svelte deleted file mode 100644 index 7740886b..00000000 --- a/src/lib/components/forms/inputs/FormFieldSwitch.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - {#snippet children({ props })} - form.update((s) => ({ ...s, [name]: v }))} - /> - {/snippet} - diff --git a/src/lib/components/marketplace/index.ts b/src/lib/components/marketplace/index.ts deleted file mode 100644 index 2ec52b8a..00000000 --- a/src/lib/components/marketplace/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as MarketplaceDialog } from './marketplace-dialog.svelte'; -export { setMarketplaceContext, getMarketplaceContext } from './marketplace.context'; -export type { MarketplaceContext } from './marketplace.context'; diff --git a/src/lib/components/marketplace/marketplace-dialog.svelte b/src/lib/components/marketplace/marketplace-dialog.svelte deleted file mode 100644 index f3f5d585..00000000 --- a/src/lib/components/marketplace/marketplace-dialog.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - - - - -
    - Marketplace - -
    -
    - -
    - (selectedPackage = pkg)} - {onQueue} - {onDequeue} - /> -
    - -
    -
    - - -
    -
    diff --git a/src/lib/components/marketplace/marketplace-footer.svelte b/src/lib/components/marketplace/marketplace-footer.svelte deleted file mode 100644 index 05c9e11a..00000000 --- a/src/lib/components/marketplace/marketplace-footer.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -{#if activeTab === 'marketplace'} -
    -
    - {#each input as name (name)} - - {name} - - - {/each} - {#if input.length === 0} - Select packages to install - {/if} -
    -
    - - -
    -
    -{:else} -
    - -
    -{/if} diff --git a/src/lib/components/marketplace/marketplace-package-details.svelte b/src/lib/components/marketplace/marketplace-package-details.svelte deleted file mode 100644 index df1b9a72..00000000 --- a/src/lib/components/marketplace/marketplace-package-details.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -{#if pkg} - -
    -
    -
    - {#if isInstalled(pkg)} - - {:else} - - {/if} -
    -
    -

    {pkg.name}

    -

    {typeLabel(pkg.type)}

    -
    - {#if activeTab === 'marketplace' && !isInstalled(pkg)} - {#if isQueued(pkg)} - - {:else} - - {/if} - {/if} -
    - - {#if pkg.description} -
    -

    - Description -

    -

    {pkg.description}

    -
    - {/if} - - {#if pkg.tags.length > 0} -
    -

    Tags

    -
    - {#each pkg.tags as tag (tag)} - {tag} - {/each} -
    -
    - {/if} - -
    -

    Type

    - {typeLabel(pkg.type)} -
    -
    -
    -{:else} -
    - -

    Select a package to view details

    -
    -{/if} diff --git a/src/lib/components/marketplace/marketplace-package-item.svelte b/src/lib/components/marketplace/marketplace-package-item.svelte deleted file mode 100644 index 4f379c6b..00000000 --- a/src/lib/components/marketplace/marketplace-package-item.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - -
    e.key === 'Enter' && onSelect()} -> -
    - {#if installed} - - {:else} - - {/if} -
    -
    -

    {pkg.name}

    -

    {typeLabel(pkg.type)}

    -
    - - {#if installed} - - Installed - - {:else if onQueue || onDequeue} - {#if queued} - - {:else} - - {/if} - {/if} -
    diff --git a/src/lib/components/marketplace/marketplace-package-list.svelte b/src/lib/components/marketplace/marketplace-package-list.svelte deleted file mode 100644 index b5a2d405..00000000 --- a/src/lib/components/marketplace/marketplace-package-list.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - - - {#if isLoading} -
    - -
    - {:else if packages.length === 0} -

    {emptyMessage}

    - {:else} -
    - {#each packages as pkg (pkg.id)} - onSelect(pkg)} - onQueue={onQueue ? () => onQueue(pkg) : undefined} - onDequeue={onDequeue ? () => onDequeue(pkg.name) : undefined} - /> - {/each} -
    - {/if} -
    diff --git a/src/lib/components/marketplace/marketplace-package-tabs.svelte b/src/lib/components/marketplace/marketplace-package-tabs.svelte deleted file mode 100644 index 9a43bef7..00000000 --- a/src/lib/components/marketplace/marketplace-package-tabs.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -
    - -
    - - Marketplace - Installed - -
    - -
    - { - refetch(); - onSelect(null); - }} - onClear={() => { - search = ''; - refetch(); - }} - /> -
    - - - - - - - - -
    -
    diff --git a/src/lib/components/marketplace/marketplace-search.svelte b/src/lib/components/marketplace/marketplace-search.svelte deleted file mode 100644 index 4b6df712..00000000 --- a/src/lib/components/marketplace/marketplace-search.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - -
    - - - - {#if isFetching} - - {:else} - - {/if} - - {#if search} - - - - {/if} - -
    diff --git a/src/lib/components/marketplace/marketplace.context.ts b/src/lib/components/marketplace/marketplace.context.ts deleted file mode 100644 index 77174f79..00000000 --- a/src/lib/components/marketplace/marketplace.context.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getContext, setContext } from 'svelte'; - -const MARKETPLACE_CONTEXT_KEY = 'marketplace'; - -export interface MarketplaceContext { - open: () => void; -} - -export function setMarketplaceContext(ctx: MarketplaceContext): void { - setContext(MARKETPLACE_CONTEXT_KEY, ctx); -} - -export function getMarketplaceContext(): MarketplaceContext { - return getContext(MARKETPLACE_CONTEXT_KEY); -} diff --git a/src/lib/components/project-loader/full-page-project-spinner.svelte b/src/lib/components/project-loader/full-page-project-spinner.svelte deleted file mode 100644 index 7e400d09..00000000 --- a/src/lib/components/project-loader/full-page-project-spinner.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -
    - -
    diff --git a/src/lib/components/project-loader/index.ts b/src/lib/components/project-loader/index.ts deleted file mode 100644 index ae20edaf..00000000 --- a/src/lib/components/project-loader/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import FullPageProjectSpinner from './full-page-project-spinner.svelte'; -import ProgressBar from './progress-bar.svelte'; -import ProjectSpinner from './project-spinner.svelte'; - -export { ProgressBar, ProjectSpinner, FullPageProjectSpinner }; diff --git a/src/lib/components/project-loader/progress-bar.svelte b/src/lib/components/project-loader/progress-bar.svelte deleted file mode 100644 index 88c692a0..00000000 --- a/src/lib/components/project-loader/progress-bar.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/lib/components/project-loader/project-spinner.svelte b/src/lib/components/project-loader/project-spinner.svelte deleted file mode 100644 index 89a72646..00000000 --- a/src/lib/components/project-loader/project-spinner.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - Project loading... - Retrieving save - - - - - - diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte deleted file mode 100644 index 16be78a1..00000000 --- a/src/lib/components/ui/badge/badge.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - {@render children?.()} - diff --git a/src/lib/components/ui/badge/index.ts b/src/lib/components/ui/badge/index.ts deleted file mode 100644 index f05fb87f..00000000 --- a/src/lib/components/ui/badge/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Badge } from './badge.svelte'; -export { badgeVariants, type BadgeVariant } from './badge.svelte'; diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte deleted file mode 100644 index 9a7d7595..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte deleted file mode 100644 index 47960813..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
  • - {@render children?.()} -
  • diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte deleted file mode 100644 index 1b795733..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#if child} - {@render child({ props: attrs })} -{:else} - - {@render children?.()} - -{/if} diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte deleted file mode 100644 index dbdbe7f8..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
      - {@render children?.()} -
    diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte deleted file mode 100644 index 505525a7..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - {@render children?.()} - diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte deleted file mode 100644 index 4aca93fe..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/src/lib/components/ui/breadcrumb/breadcrumb.svelte deleted file mode 100644 index 4e735376..00000000 --- a/src/lib/components/ui/breadcrumb/breadcrumb.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/lib/components/ui/breadcrumb/index.ts b/src/lib/components/ui/breadcrumb/index.ts deleted file mode 100644 index f99a0da2..00000000 --- a/src/lib/components/ui/breadcrumb/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Ellipsis from './breadcrumb-ellipsis.svelte'; -import Item from './breadcrumb-item.svelte'; -import Link from './breadcrumb-link.svelte'; -import List from './breadcrumb-list.svelte'; -import Page from './breadcrumb-page.svelte'; -import Separator from './breadcrumb-separator.svelte'; -import Root from './breadcrumb.svelte'; - -export { - Root, - Ellipsis, - Item, - Separator, - Link, - List, - Page, - // - Root as Breadcrumb, - Ellipsis as BreadcrumbEllipsis, - Item as BreadcrumbItem, - Separator as BreadcrumbSeparator, - Link as BreadcrumbLink, - List as BreadcrumbList, - Page as BreadcrumbPage, -}; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte deleted file mode 100644 index 9d24463f..00000000 --- a/src/lib/components/ui/button/button.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - - -{#if href} - - {@render children?.()} - -{:else} - -{/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts deleted file mode 100644 index 66c1087b..00000000 --- a/src/lib/components/ui/button/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Root, { - type ButtonProps, - type ButtonSize, - type ButtonVariant, - buttonVariants, -} from './button.svelte'; - -export { - Root, - type ButtonProps as Props, - // - Root as Button, - buttonVariants, - type ButtonProps, - type ButtonSize, - type ButtonVariant, -}; diff --git a/src/lib/components/ui/card/card-action.svelte b/src/lib/components/ui/card/card-action.svelte deleted file mode 100644 index c855d691..00000000 --- a/src/lib/components/ui/card/card-action.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte deleted file mode 100644 index 6ce47b9c..00000000 --- a/src/lib/components/ui/card/card-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte deleted file mode 100644 index 7dd1425a..00000000 --- a/src/lib/components/ui/card/card-description.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -

    - {@render children?.()} -

    diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte deleted file mode 100644 index ea19c667..00000000 --- a/src/lib/components/ui/card/card-footer.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte deleted file mode 100644 index 5fa96c3e..00000000 --- a/src/lib/components/ui/card/card-header.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte deleted file mode 100644 index 668c1e1f..00000000 --- a/src/lib/components/ui/card/card-title.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte deleted file mode 100644 index c0de4f94..00000000 --- a/src/lib/components/ui/card/card.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - -
    img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', - className, - )} - {...restProps} -> - {@render children?.()} -
    diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts deleted file mode 100644 index c721e719..00000000 --- a/src/lib/components/ui/card/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Action from './card-action.svelte'; -import Content from './card-content.svelte'; -import Description from './card-description.svelte'; -import Footer from './card-footer.svelte'; -import Header from './card-header.svelte'; -import Title from './card-title.svelte'; -import Root from './card.svelte'; - -export { - Root, - Content, - Description, - Footer, - Header, - Title, - Action, - // - Root as Card, - Content as CardContent, - Description as CardDescription, - Footer as CardFooter, - Header as CardHeader, - Title as CardTitle, - Action as CardAction, -}; diff --git a/src/lib/components/ui/checkbox/checkbox.svelte b/src/lib/components/ui/checkbox/checkbox.svelte deleted file mode 100644 index 0921fbd7..00000000 --- a/src/lib/components/ui/checkbox/checkbox.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - {#snippet children({ checked, indeterminate })} -
    - {#if checked} - - {:else if indeterminate} - - {/if} -
    - {/snippet} -
    diff --git a/src/lib/components/ui/checkbox/index.ts b/src/lib/components/ui/checkbox/index.ts deleted file mode 100644 index f52cb027..00000000 --- a/src/lib/components/ui/checkbox/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Root from './checkbox.svelte'; - -export { - Root, - // - Root as Checkbox, -}; diff --git a/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte b/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte deleted file mode 100644 index 4b36704e..00000000 --- a/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.()} - {/snippet} - diff --git a/src/lib/components/ui/context-menu/context-menu-content.svelte b/src/lib/components/ui/context-menu/context-menu-content.svelte deleted file mode 100644 index b924d919..00000000 --- a/src/lib/components/ui/context-menu/context-menu-content.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/src/lib/components/ui/context-menu/context-menu-group-heading.svelte b/src/lib/components/ui/context-menu/context-menu-group-heading.svelte deleted file mode 100644 index 446e838e..00000000 --- a/src/lib/components/ui/context-menu/context-menu-group-heading.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-group.svelte b/src/lib/components/ui/context-menu/context-menu-group.svelte deleted file mode 100644 index 19f694bb..00000000 --- a/src/lib/components/ui/context-menu/context-menu-group.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-item.svelte b/src/lib/components/ui/context-menu/context-menu-item.svelte deleted file mode 100644 index 9225922a..00000000 --- a/src/lib/components/ui/context-menu/context-menu-item.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-label.svelte b/src/lib/components/ui/context-menu/context-menu-label.svelte deleted file mode 100644 index f08196cd..00000000 --- a/src/lib/components/ui/context-menu/context-menu-label.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/context-menu/context-menu-portal.svelte b/src/lib/components/ui/context-menu/context-menu-portal.svelte deleted file mode 100644 index 2897cc0b..00000000 --- a/src/lib/components/ui/context-menu/context-menu-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-radio-group.svelte b/src/lib/components/ui/context-menu/context-menu-radio-group.svelte deleted file mode 100644 index b1abdf31..00000000 --- a/src/lib/components/ui/context-menu/context-menu-radio-group.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-radio-item.svelte b/src/lib/components/ui/context-menu/context-menu-radio-item.svelte deleted file mode 100644 index 93544659..00000000 --- a/src/lib/components/ui/context-menu/context-menu-radio-item.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.({ checked })} - {/snippet} - diff --git a/src/lib/components/ui/context-menu/context-menu-separator.svelte b/src/lib/components/ui/context-menu/context-menu-separator.svelte deleted file mode 100644 index 9dcf8537..00000000 --- a/src/lib/components/ui/context-menu/context-menu-separator.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-shortcut.svelte b/src/lib/components/ui/context-menu/context-menu-shortcut.svelte deleted file mode 100644 index 5e13d6c7..00000000 --- a/src/lib/components/ui/context-menu/context-menu-shortcut.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - {@render children?.()} - diff --git a/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/src/lib/components/ui/context-menu/context-menu-sub-content.svelte deleted file mode 100644 index bc441dd8..00000000 --- a/src/lib/components/ui/context-menu/context-menu-sub-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte deleted file mode 100644 index d309e4cb..00000000 --- a/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - {@render children?.()} - - diff --git a/src/lib/components/ui/context-menu/context-menu-sub.svelte b/src/lib/components/ui/context-menu/context-menu-sub.svelte deleted file mode 100644 index 0ba70c9b..00000000 --- a/src/lib/components/ui/context-menu/context-menu-sub.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/src/lib/components/ui/context-menu/context-menu-trigger.svelte deleted file mode 100644 index 3831cca1..00000000 --- a/src/lib/components/ui/context-menu/context-menu-trigger.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/context-menu.svelte b/src/lib/components/ui/context-menu/context-menu.svelte deleted file mode 100644 index 4b6908e2..00000000 --- a/src/lib/components/ui/context-menu/context-menu.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/context-menu/index.ts b/src/lib/components/ui/context-menu/index.ts deleted file mode 100644 index 4177ab6e..00000000 --- a/src/lib/components/ui/context-menu/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import CheckboxItem from './context-menu-checkbox-item.svelte'; -import Content from './context-menu-content.svelte'; -import GroupHeading from './context-menu-group-heading.svelte'; -import Group from './context-menu-group.svelte'; -import Item from './context-menu-item.svelte'; -import Label from './context-menu-label.svelte'; -import Portal from './context-menu-portal.svelte'; -import RadioGroup from './context-menu-radio-group.svelte'; -import RadioItem from './context-menu-radio-item.svelte'; -import Separator from './context-menu-separator.svelte'; -import Shortcut from './context-menu-shortcut.svelte'; -import SubContent from './context-menu-sub-content.svelte'; -import SubTrigger from './context-menu-sub-trigger.svelte'; -import Sub from './context-menu-sub.svelte'; -import Trigger from './context-menu-trigger.svelte'; -import Root from './context-menu.svelte'; - -export { - Root, - Sub, - Portal, - Item, - GroupHeading, - Label, - Group, - Trigger, - Content, - Shortcut, - Separator, - RadioItem, - SubContent, - SubTrigger, - RadioGroup, - CheckboxItem, - // - Root as ContextMenu, - Sub as ContextMenuSub, - Portal as ContextMenuPortal, - Item as ContextMenuItem, - GroupHeading as ContextMenuGroupHeading, - Group as ContextMenuGroup, - Content as ContextMenuContent, - Trigger as ContextMenuTrigger, - Shortcut as ContextMenuShortcut, - RadioItem as ContextMenuRadioItem, - Separator as ContextMenuSeparator, - RadioGroup as ContextMenuRadioGroup, - SubContent as ContextMenuSubContent, - SubTrigger as ContextMenuSubTrigger, - CheckboxItem as ContextMenuCheckboxItem, - Label as ContextMenuLabel, -}; diff --git a/src/lib/components/ui/dialog/dialog-close.svelte b/src/lib/components/ui/dialog/dialog-close.svelte deleted file mode 100644 index 2f313f6f..00000000 --- a/src/lib/components/ui/dialog/dialog-close.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte deleted file mode 100644 index 788a1b16..00000000 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - {@render children?.()} - {#if showCloseButton} - - {#snippet child({ props })} - - {/snippet} - - {/if} - - diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte deleted file mode 100644 index fbd49a61..00000000 --- a/src/lib/components/ui/dialog/dialog-description.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte deleted file mode 100644 index 5ef8a1e2..00000000 --- a/src/lib/components/ui/dialog/dialog-footer.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
    - {@render children?.()} - {#if showCloseButton} - - {#snippet child({ props })} - - {/snippet} - - {/if} -
    diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte deleted file mode 100644 index 2a53a7bb..00000000 --- a/src/lib/components/ui/dialog/dialog-header.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte deleted file mode 100644 index fc4c0d37..00000000 --- a/src/lib/components/ui/dialog/dialog-overlay.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog-portal.svelte b/src/lib/components/ui/dialog/dialog-portal.svelte deleted file mode 100644 index 4a9048a8..00000000 --- a/src/lib/components/ui/dialog/dialog-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte deleted file mode 100644 index 3ae932e6..00000000 --- a/src/lib/components/ui/dialog/dialog-title.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/lib/components/ui/dialog/dialog-trigger.svelte deleted file mode 100644 index 434c574e..00000000 --- a/src/lib/components/ui/dialog/dialog-trigger.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/dialog.svelte b/src/lib/components/ui/dialog/dialog.svelte deleted file mode 100644 index 88b4799a..00000000 --- a/src/lib/components/ui/dialog/dialog.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts deleted file mode 100644 index 7dc204cd..00000000 --- a/src/lib/components/ui/dialog/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Close from './dialog-close.svelte'; -import Content from './dialog-content.svelte'; -import Description from './dialog-description.svelte'; -import Footer from './dialog-footer.svelte'; -import Header from './dialog-header.svelte'; -import Overlay from './dialog-overlay.svelte'; -import Portal from './dialog-portal.svelte'; -import Title from './dialog-title.svelte'; -import Trigger from './dialog-trigger.svelte'; -import Root from './dialog.svelte'; - -export { - Root, - Title, - Portal, - Footer, - Header, - Trigger, - Overlay, - Content, - Description, - Close, - // - Root as Dialog, - Title as DialogTitle, - Portal as DialogPortal, - Footer as DialogFooter, - Header as DialogHeader, - Trigger as DialogTrigger, - Overlay as DialogOverlay, - Content as DialogContent, - Description as DialogDescription, - Close as DialogClose, -}; diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte deleted file mode 100644 index 6b4d728f..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte deleted file mode 100644 index 4f59a7d6..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - {#snippet children({ checked, indeterminate })} - - {#if indeterminate} - - {:else if checked} - - {/if} - - {@render childrenProp?.()} - {/snippet} - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte deleted file mode 100644 index 4e9143f3..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte deleted file mode 100644 index 321802ba..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte deleted file mode 100644 index bf972af4..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte deleted file mode 100644 index 38a52697..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte deleted file mode 100644 index 9066564e..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte deleted file mode 100644 index 130c20f7..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte deleted file mode 100644 index 74523661..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte deleted file mode 100644 index b37cfd31..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.({ checked })} - {/snippet} - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte deleted file mode 100644 index 343ae2c8..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte deleted file mode 100644 index 02254af5..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - {@render children?.()} - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte deleted file mode 100644 index 05fe6a2b..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte deleted file mode 100644 index 4f3e0451..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - {@render children?.()} - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte deleted file mode 100644 index 02f5f63a..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte deleted file mode 100644 index 7409c777..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte deleted file mode 100644 index 5c2776c5..00000000 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts deleted file mode 100644 index 6ec2de86..00000000 --- a/src/lib/components/ui/dropdown-menu/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import CheckboxGroup from './dropdown-menu-checkbox-group.svelte'; -import CheckboxItem from './dropdown-menu-checkbox-item.svelte'; -import Content from './dropdown-menu-content.svelte'; -import GroupHeading from './dropdown-menu-group-heading.svelte'; -import Group from './dropdown-menu-group.svelte'; -import Item from './dropdown-menu-item.svelte'; -import Label from './dropdown-menu-label.svelte'; -import Portal from './dropdown-menu-portal.svelte'; -import RadioGroup from './dropdown-menu-radio-group.svelte'; -import RadioItem from './dropdown-menu-radio-item.svelte'; -import Separator from './dropdown-menu-separator.svelte'; -import Shortcut from './dropdown-menu-shortcut.svelte'; -import SubContent from './dropdown-menu-sub-content.svelte'; -import SubTrigger from './dropdown-menu-sub-trigger.svelte'; -import Sub from './dropdown-menu-sub.svelte'; -import Trigger from './dropdown-menu-trigger.svelte'; -import Root from './dropdown-menu.svelte'; - -export { - CheckboxGroup, - CheckboxItem, - Content, - Portal, - Root as DropdownMenu, - CheckboxGroup as DropdownMenuCheckboxGroup, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Portal as DropdownMenuPortal, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - RadioGroup as DropdownMenuRadioGroup, - RadioItem as DropdownMenuRadioItem, - Separator as DropdownMenuSeparator, - Shortcut as DropdownMenuShortcut, - Sub as DropdownMenuSub, - SubContent as DropdownMenuSubContent, - SubTrigger as DropdownMenuSubTrigger, - Trigger as DropdownMenuTrigger, - GroupHeading as DropdownMenuGroupHeading, - Group, - GroupHeading, - Item, - Label, - RadioGroup, - RadioItem, - Root, - Separator, - Shortcut, - Sub, - SubContent, - SubTrigger, - Trigger, -}; diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte deleted file mode 100644 index 48d9e1d4..00000000 --- a/src/lib/components/ui/form/form-button.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/input-group/input-group-input.svelte b/src/lib/components/ui/input-group/input-group-input.svelte deleted file mode 100644 index 91b78c16..00000000 --- a/src/lib/components/ui/input-group/input-group-input.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/lib/components/ui/input-group/input-group-text.svelte b/src/lib/components/ui/input-group/input-group-text.svelte deleted file mode 100644 index af33024e..00000000 --- a/src/lib/components/ui/input-group/input-group-text.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - {@render children?.()} - diff --git a/src/lib/components/ui/input-group/input-group-textarea.svelte b/src/lib/components/ui/input-group/input-group-textarea.svelte deleted file mode 100644 index 1882b9f9..00000000 --- a/src/lib/components/ui/input-group/input-group-textarea.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/lib/components/ui/tooltip-text/index.ts b/src/lib/components/ui/tooltip-text/index.ts deleted file mode 100644 index 0dd33a64..00000000 --- a/src/lib/components/ui/tooltip-text/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import TooltipText from './tooltip-text.svelte'; - -export { TooltipText }; diff --git a/src/lib/components/ui/tooltip-text/tooltip-text.svelte b/src/lib/components/ui/tooltip-text/tooltip-text.svelte deleted file mode 100644 index 4746c288..00000000 --- a/src/lib/components/ui/tooltip-text/tooltip-text.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - {@render children()} - - -

    {text}

    -
    -
    -
    diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts deleted file mode 100644 index ace2ed29..00000000 --- a/src/lib/components/ui/tooltip/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Content from './tooltip-content.svelte'; -import Portal from './tooltip-portal.svelte'; -import Provider from './tooltip-provider.svelte'; -import Trigger from './tooltip-trigger.svelte'; -import Root from './tooltip.svelte'; - -export { - Root, - Trigger, - Content, - Provider, - Portal, - // - Root as Tooltip, - Content as TooltipContent, - Trigger as TooltipTrigger, - Provider as TooltipProvider, - Portal as TooltipPortal, -}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte deleted file mode 100644 index ef7ecf6c..00000000 --- a/src/lib/components/ui/tooltip/tooltip-content.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - - {@render children?.()} - - {#snippet child({ props })} -
    - {/snippet} -
    -
    -
    diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte deleted file mode 100644 index 7b9e8f9f..00000000 --- a/src/lib/components/ui/tooltip/tooltip-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte deleted file mode 100644 index 49f8379b..00000000 --- a/src/lib/components/ui/tooltip/tooltip-provider.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte deleted file mode 100644 index b7640e4e..00000000 --- a/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte deleted file mode 100644 index 42a939b4..00000000 --- a/src/lib/components/ui/tooltip/tooltip.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/lib/components/ui/tristate-switch/index.ts b/src/lib/components/ui/tristate-switch/index.ts deleted file mode 100644 index 9a93349f..00000000 --- a/src/lib/components/ui/tristate-switch/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Root, { type TriState } from './tristate-switch.svelte'; - -export { - Root, - // - type TriState, - Root as TristateSwitch, -}; diff --git a/src/lib/components/ui/tristate-switch/tristate-switch.svelte b/src/lib/components/ui/tristate-switch/tristate-switch.svelte deleted file mode 100644 index e968d043..00000000 --- a/src/lib/components/ui/tristate-switch/tristate-switch.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - diff --git a/src/lib/server/actions/project/archive/create.action.ts b/src/lib/server/actions/project/archive/create.action.ts deleted file mode 100644 index f95121dd..00000000 --- a/src/lib/server/actions/project/archive/create.action.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export interface CreateArchiveResult { - id: string; -} - -export const createArchiveAction = useActionHandler(async ({ archive }) => { - const id = await archive.create(); - return { id }; -}); diff --git a/src/lib/server/actions/project/complete.action.ts b/src/lib/server/actions/project/complete.action.ts deleted file mode 100644 index daec4ebd..00000000 --- a/src/lib/server/actions/project/complete.action.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; - -import { resolveSessionFunctions } from '$lib/server/actions/project/load.action'; -import { loadProject } from '$lib/server/project'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class CompleteProjectBody { - @Expose() - @IsString() - @IsNotEmpty() - gatewayId!: string; - - @Expose() - @IsString() - @IsOptional() - @IsEnum(['js', 'ts']) - language?: 'js' | 'ts'; - - @Expose() - @IsBoolean() - @IsOptional() - multiplayerServer?: boolean; -} - -export const completeProjectAction = useActionHandler( - async (handler) => { - const { body, cli, api } = handler; - - const project = await api.projects.getProject(body.gatewayId); - const resolver = await resolveSessionFunctions.gatewayId(body.gatewayId, handler); - - const parts = resolver.path.split('/'); - const end = parts.pop(); - - cli.new({ - editor: true, - directory: parts.join('/'), - name: project.name, - path: end, - packageManager: 'bun', - language: body.language, - server: body.multiplayerServer, - docker: false, - lint: false, - initFunctions: true, - strict: false, - git: false, - }); - - return await loadProject(resolver, handler); - }, - { - body: CompleteProjectBody, - onlineOnly: true, - projectOptional: true, - }, -); diff --git a/src/lib/server/actions/project/fs/readdir.action.ts b/src/lib/server/actions/project/fs/readdir.action.ts deleted file mode 100644 index d8d4742d..00000000 --- a/src/lib/server/actions/project/fs/readdir.action.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class ReaddirFsBody { - @Expose() - @IsString() - @IsOptional() - path?: string; -} - -export const readdirFsAction = useActionHandler( - async ({ project, body }) => { - const dir = project.client.fs.getDirectory(body.path ?? ''); - return dir.read(true); - }, - { body: ReaddirFsBody }, -); diff --git a/src/lib/server/actions/project/gateway.action.ts b/src/lib/server/actions/project/gateway.action.ts deleted file mode 100644 index 81b2df41..00000000 --- a/src/lib/server/actions/project/gateway.action.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export interface GatewayProject { - name: string; - gatewayId: string; -} - -export const getGatewayProjectsAction = useActionHandler( - async ({ api }) => { - const projects = await api.projects.getProjects(); - - return projects.map((project) => ({ - gatewayId: project.id, - name: project.name, - })); - }, - { onlineOnly: true, projectOptional: true }, -); - -export const syncGatewayProjectAction = useActionHandler( - async ({ git }) => { - console.log('Syncing gateway project'); - return await git.push(); - }, - { onlineOnly: true }, -); diff --git a/src/lib/server/actions/project/info.action.ts b/src/lib/server/actions/project/info.action.ts deleted file mode 100644 index 7847e895..00000000 --- a/src/lib/server/actions/project/info.action.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export interface InfoProject { - name: string; - language: 'ts' | 'js'; - hasServer: boolean; -} - -export class InfoProjectBody { - @Expose() - @IsString() - @IsOptional() - name?: string; -} - -export const getInfoProjectAction = useActionHandler(async ({ fs }) => { - const configFile = fs.getFile('nanoforge.config.json'); - const config = await configFile.readJson(); - - return { - name: config.name, - language: config.language, - hasServer: config.server?.enabled ?? false, - }; -}); - -export const setInfoProjectAction = useActionHandler( - async ({ body, fs }) => { - const configFile = fs.getFile('nanoforge.config.json'); - const config = await configFile.readJson(); - - if (body.name) config.name = body.name; - - configFile.writeJson(config); - }, - { body: InfoProjectBody }, -); diff --git a/src/lib/server/actions/project/library/install-library.action.ts b/src/lib/server/actions/project/library/install-library.action.ts deleted file mode 100644 index a8d6c7db..00000000 --- a/src/lib/server/actions/project/library/install-library.action.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; - -import type { LibraryPackage } from '$lib/server/project/library'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class InstallLibraryBody { - @Expose() - @IsString() - @IsNotEmpty() - libraryName!: string; -} - -export const installLibraryProjectAction = useActionHandler( - async ({ body, project }): Promise => { - return await project.client.library.installLibrary(body.libraryName); - }, - { - body: InstallLibraryBody, - }, -); diff --git a/src/lib/server/actions/project/load.action.ts b/src/lib/server/actions/project/load.action.ts deleted file mode 100644 index 6c10e478..00000000 --- a/src/lib/server/actions/project/load.action.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; -import { join } from 'path'; - -import { Git } from '$lib/server/git'; -import { loadProject } from '$lib/server/project'; -import { loadProjectFromId } from '$lib/server/project/load-project'; -import type { SessionProject } from '$lib/server/session'; - -import { Exception } from '@utils/exception'; - -import { type Handler, useActionHandler } from '@utils-server/request-handler'; - -export class LoadProjectBody { - @Expose() - @IsString() - @IsOptional() - id?: string; - - @Expose() - @IsString() - @IsOptional() - path?: string; - - @Expose() - @IsString() - @IsOptional() - gitUrl?: string; - - @Expose() - @IsString() - @IsOptional() - gatewayId?: string; -} - -const resolveSessionFromGatewayId = async ( - gatewayId: string, - { api, context }: Handler, -): Promise => { - if (!context.online) - throw new Exception('Bad Request', 'Cannot load project from gatewayId while offline', 400); - - const project = await api.projects.getProject(gatewayId); - - const git = new Git({ - ...context, - project: { path: '', gateway: { id: gatewayId, token: project.token } }, - }); - const basePath = await git.clone(project.gatewayProjectRegistryUrl); - return { - path: join(basePath, project.gatewayProjectRegistryMetadata.dir ?? ''), - gateway: { id: gatewayId, token: project.token }, - }; -}; - -const resolveSessionFromGitUrl = async ( - gitUrl: string, - { git, context }: Handler, -): Promise => { - if (context.online) - throw new Exception('Bad Request', 'Cannot load project from gitUrl while online', 400); - - const path = await git.clone(gitUrl); - return { path }; -}; - -const resolveSessionFromPath = async ( - path: string, - { context }: Handler, -): Promise => { - if (context.online) - throw new Exception('Bad Request', 'Cannot load project from path while online', 400); - return { path }; -}; - -export const resolveSessionFunctions: Record< - keyof Omit, - (el: string, options: Handler) => Promise -> = { - path: resolveSessionFromPath, - gitUrl: resolveSessionFromGitUrl, - gatewayId: resolveSessionFromGatewayId, -}; - -export const loadProjectAction = useActionHandler( - async (handler) => { - const { body } = handler; - - if (body.id) return await loadProjectFromId(body.id, handler); - - const el = Object.entries(body).find(([, value]) => value) as [ - keyof Omit, - string, - ]; - if (!el) throw new Exception('Bad Request', 'No load origin provided', 400); - - const resolveSessionFunction = resolveSessionFunctions[el[0]]; - - const project = await resolveSessionFunction(el[1], handler); - - return await loadProject(project, handler); - }, - { - projectOptional: true, - body: LoadProjectBody, - }, -); diff --git a/src/lib/server/actions/project/loader/build.action.ts b/src/lib/server/actions/project/loader/build.action.ts deleted file mode 100644 index cfbeae31..00000000 --- a/src/lib/server/actions/project/loader/build.action.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export const buildProjectAction = useActionHandler(async ({ cli }) => { - cli.generate({ editor: true }); - cli.build({ editor: true }); -}); diff --git a/src/lib/server/actions/project/loader/env.action.ts b/src/lib/server/actions/project/loader/env.action.ts deleted file mode 100644 index adc11efd..00000000 --- a/src/lib/server/actions/project/loader/env.action.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export const fetchEnvLoaderAction = useActionHandler(async ({ project }) => { - return project.client.loader.getEnv(); -}); diff --git a/src/lib/server/actions/project/loader/manifest.action.ts b/src/lib/server/actions/project/loader/manifest.action.ts deleted file mode 100644 index 89fec5d5..00000000 --- a/src/lib/server/actions/project/loader/manifest.action.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export const fetchManifestLoaderAction = useActionHandler(async ({ project }) => { - return project.client.loader.getManifest(); -}); diff --git a/src/lib/server/actions/project/new.action.ts b/src/lib/server/actions/project/new.action.ts deleted file mode 100644 index 4c8c8a92..00000000 --- a/src/lib/server/actions/project/new.action.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; -import { join } from 'path'; - -import { loadProject } from '$lib/server/project'; - -import { useActionHandler } from '@utils-server/request-handler'; -import { IsFalseOrString } from '@utils-server/validators'; - -export class CreateProjectBody { - @Expose() - @IsString() - @IsNotEmpty() - @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) - projectName!: string; - - @Expose() - @IsString() - @IsOptional() - projectPath?: string; - - @Expose() - @IsString() - @IsOptional() - @IsEnum(['npm', 'pnpm', 'yarn', 'bun']) - packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun'; - - @Expose() - @IsString() - @IsOptional() - @IsEnum(['js', 'ts']) - language?: 'js' | 'ts'; - - @Expose() - @IsBoolean() - @IsOptional() - multiplayerServer?: boolean; - - @Expose() - @IsBoolean() - @IsOptional() - dockerContainerization?: boolean; - - @Expose() - @IsBoolean() - @IsOptional() - createGitRepository?: boolean; - - @Expose() - @IsFalseOrString() - @IsOptional() - gitRemote?: string | false; -} - -export const createProjectAction = useActionHandler( - async (handler) => { - const { body, cli } = handler; - - cli.new({ - editor: true, - directory: body.projectPath || undefined, - name: body.projectName, - path: body.projectName, - packageManager: body.packageManager, - language: body.language, - server: body.multiplayerServer, - docker: body.dockerContainerization, - git: body.createGitRepository, - gitRemote: body.gitRemote, - lint: false, - initFunctions: true, - strict: false, - skipInstall: false, - }); - - return await loadProject({ path: join(body.projectPath ?? '', body.projectName) }, handler); - }, - { - body: CreateProjectBody, - offlineOnly: true, - projectOptional: true, - }, -); diff --git a/src/lib/server/actions/project/package/create-component.action.ts b/src/lib/server/actions/project/package/create-component.action.ts deleted file mode 100644 index dc21e39a..00000000 --- a/src/lib/server/actions/project/package/create-component.action.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; - -import type { ComponentPackage } from '$lib/server/project/package/package.type'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class CreateComponentBody { - @Expose() - @IsString() - @IsNotEmpty() - componentName!: string; -} - -export const createComponentProjectAction = useActionHandler( - async ({ body, project }): Promise => { - return project.client.package.createComponent(body.componentName); - }, - { - body: CreateComponentBody, - }, -); diff --git a/src/lib/server/actions/project/package/create-system.action.ts b/src/lib/server/actions/project/package/create-system.action.ts deleted file mode 100644 index 9ba9c7f2..00000000 --- a/src/lib/server/actions/project/package/create-system.action.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; - -import type { SystemPackage } from '$lib/server/project/package/package.type'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class CreateSystemBody { - @Expose() - @IsString() - @IsNotEmpty() - systemName!: string; -} - -export const createSystemProjectAction = useActionHandler( - async ({ body, project }): Promise => { - return project.client.package.createSystem(body.systemName); - }, - { - body: CreateSystemBody, - }, -); diff --git a/src/lib/server/actions/project/package/get-assets.action.ts b/src/lib/server/actions/project/package/get-assets.action.ts deleted file mode 100644 index 39aab7b7..00000000 --- a/src/lib/server/actions/project/package/get-assets.action.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AssetPackage } from '$lib/server/project/package'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export const getAssetsAction = useActionHandler(async ({ project }): Promise => { - return await project.client.package.getAssets(); -}); diff --git a/src/lib/server/actions/project/package/get-components.action.ts b/src/lib/server/actions/project/package/get-components.action.ts deleted file mode 100644 index 31387d8d..00000000 --- a/src/lib/server/actions/project/package/get-components.action.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ComponentPackage } from '$lib/server/project/package'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export const getComponentsAction = useActionHandler( - async ({ project }): Promise => { - return await project.client.package.getComponents(); - }, -); diff --git a/src/lib/server/actions/project/package/get-systems.action.ts b/src/lib/server/actions/project/package/get-systems.action.ts deleted file mode 100644 index 53b9c0be..00000000 --- a/src/lib/server/actions/project/package/get-systems.action.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SystemPackage } from '$lib/server/project/package'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export const getSystemsAction = useActionHandler(async ({ project }): Promise => { - return await project.client.package.getSystems(); -}); diff --git a/src/lib/server/actions/project/package/install-packages.action.ts b/src/lib/server/actions/project/package/install-packages.action.ts deleted file mode 100644 index d699f77a..00000000 --- a/src/lib/server/actions/project/package/install-packages.action.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; - -import type { ComponentPackage, SystemPackage } from '$lib/server/project/package/package.type'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class InstallPackagesBody { - @Expose() - @IsString({ each: true }) - @IsNotEmpty() - names!: [string, ...string[]]; -} - -export const installPackagesAction = useActionHandler( - async ({ body, project }): Promise<(ComponentPackage | SystemPackage)[]> => { - return await project.client.package.installPackages(body.names); - }, - { - body: InstallPackagesBody, - }, -); diff --git a/src/lib/server/actions/project/package/search-packages.action.ts b/src/lib/server/actions/project/package/search-packages.action.ts deleted file mode 100644 index 9a6f6a6b..00000000 --- a/src/lib/server/actions/project/package/search-packages.action.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNumber, IsOptional, IsString } from 'class-validator'; - -import type { Package, PaginateResult } from '$lib/server/api'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class SearchPackagesBody { - @Expose() - @IsString() - @IsOptional() - search?: string; - - @Expose() - @IsNumber() - @IsOptional() - page?: number; - - @Expose() - @IsNumber() - @IsOptional() - limit?: number; -} - -export const searchPackagesAction = useActionHandler( - async ({ body, api }): Promise> => { - return api.packages.getAll(body); - }, - { - body: SearchPackagesBody, - }, -); diff --git a/src/lib/server/actions/project/save/get-save.action.ts b/src/lib/server/actions/project/save/get-save.action.ts deleted file mode 100644 index a2c77485..00000000 --- a/src/lib/server/actions/project/save/get-save.action.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActionHandler } from '@utils-server/request-handler'; - -export const getSaveAction = useActionHandler(async ({ project }) => { - return project.client.save.getSave(); -}); diff --git a/src/lib/server/actions/project/save/set-save.action.ts b/src/lib/server/actions/project/save/set-save.action.ts deleted file mode 100644 index 726f6077..00000000 --- a/src/lib/server/actions/project/save/set-save.action.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsObject } from 'class-validator'; - -import type { Save } from '@utils/types'; - -import { useActionHandler } from '@utils-server/request-handler'; - -export class SetSaveBody { - @Expose() - @IsObject() - @IsNotEmpty() - save!: Save; -} - -export const setSaveAction = useActionHandler( - async ({ project, body }) => { - return project.client.save.updateSave(body.save); - }, - { - body: SetSaveBody, - }, -); diff --git a/src/lib/server/api/base.repository.ts b/src/lib/server/api/base.repository.ts deleted file mode 100644 index a5e5ee86..00000000 --- a/src/lib/server/api/base.repository.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { HttpClient, RequestOptions } from '@utils/http'; - -type FullRequestOptions = RequestOptions & { offline?: boolean }; - -export class BaseRepository { - private readonly _client: HttpClient; - private readonly _online: boolean; - - constructor(client: HttpClient, online: boolean = true) { - this._client = client; - this._online = online; - } - - protected get(path: string, options?: FullRequestOptions): Promise { - return this.runRequest('get', path, options); - } - - protected post( - path: string, - body?: I, - options?: FullRequestOptions, - ): Promise { - return this.runRequestBody('post', path, body, options); - } - - protected put( - path: string, - body?: I, - options?: FullRequestOptions, - ): Promise { - return this.runRequestBody('put', path, body, options); - } - - protected patch( - path: string, - body?: I, - options?: FullRequestOptions, - ): Promise { - return this.runRequestBody('patch', path, body, options); - } - - protected delete( - path: string, - options?: FullRequestOptions, - ): Promise { - return this.runRequest('delete', path, options); - } - - private assertOnline() { - if (!this._online) throw new Error('This route is only available in online mode'); - } - - private async runRequest( - request: 'get' | 'delete', - path: string, - options?: FullRequestOptions, - ): Promise { - if (!options?.offline) this.assertOnline(); - - const res = await this._client[request](path, options); - if (!res.ok) - throw new Error(`Request failed with status code ${res.status}`, { - cause: await res.json(), - }); - return (await res.json()) as R; - } - - private async runRequestBody( - request: 'post' | 'put' | 'patch', - path: string, - body?: I, - options?: FullRequestOptions, - ): Promise { - if (!options?.offline) this.assertOnline(); - - const res = await this._client[request]( - path, - body === undefined ? undefined : JSON.stringify(body), - options, - ); - const data = (await res.json()) as R; - if (!res.ok) - throw new Error(`Request failed with status code ${res.status}`, { - cause: data, - }); - return data; - } -} diff --git a/src/lib/server/api/client.ts b/src/lib/server/api/client.ts deleted file mode 100644 index 8ea5654a..00000000 --- a/src/lib/server/api/client.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; - -import { env } from '$env/dynamic/private'; -import { PUBLIC_MODE } from '$env/static/public'; - -import { HttpClient } from '@utils/http'; - -import { useTokenMiddleware } from './middlewares/refresh-token.middleware'; -import { AuthRepository } from './repositories/auth.repository'; -import { PackageRepository } from './repositories/package.repository'; -import { ProjectRepository } from './repositories/projects.repository'; -import { RegistryRepository } from './repositories/registry.repository'; - -export interface Api { - auth: AuthRepository; - packages: PackageRepository; - projects: ProjectRepository; - registry: RegistryRepository; -} - -const DEFAULT_API_URL = 'https://api.nanoforge.eu'; - -export const getNoAuthApi = (): Api => { - const client = new HttpClient(env.API_URL ?? DEFAULT_API_URL, { - headers: { - 'Content-Type': 'application/json', - }, - }); - const isOnline = PUBLIC_MODE === 'ONLINE'; - - return { - auth: new AuthRepository(client, isOnline), - packages: new PackageRepository(client, isOnline), - registry: new RegistryRepository(client, isOnline), - } as Api; -}; - -export const getApi = (cookies: Cookies): Api => { - if (PUBLIC_MODE !== 'ONLINE') throw new Error('API is only available in online mode'); - if (!env.API_KEY) throw new Error('API_KEY is not defined'); - const client = new HttpClient(env.API_URL ?? DEFAULT_API_URL, { - headers: { - 'Content-Type': 'application/json', - 'Api-Key': env.API_KEY, - }, - }).useMiddlewares(useTokenMiddleware(cookies)); - return { - auth: new AuthRepository(client), - packages: new PackageRepository(client), - projects: new ProjectRepository(client), - registry: new RegistryRepository(client), - }; -}; diff --git a/src/lib/server/api/index.ts b/src/lib/server/api/index.ts deleted file mode 100644 index 4c57d22f..00000000 --- a/src/lib/server/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getApi, getNoAuthApi, type Api } from './client'; -export * from './types'; diff --git a/src/lib/server/api/middlewares/refresh-token.middleware.ts b/src/lib/server/api/middlewares/refresh-token.middleware.ts deleted file mode 100644 index c3f1c79f..00000000 --- a/src/lib/server/api/middlewares/refresh-token.middleware.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; - -import { Exception } from '@utils/exception'; -import type { MiddlewareNext, MiddlewareParams } from '@utils/http'; - -import { setTokensInCookies } from '@utils-server/cookies'; - -import { getNoAuthApi } from '../client'; - -const refreshToken = async (cookies: Cookies, force: boolean = false): Promise => { - let accessToken = force ? undefined : cookies.get('accessToken'); - const refreshToken = cookies.get('refreshToken'); - - if (!accessToken) { - if (!refreshToken) { - throw new Exception('Unauthorized', 'No token found', 401); - } - - try { - const tokens = await getNoAuthApi().auth.refreshToken({ - refreshToken, - }); - - setTokensInCookies(cookies, tokens); - accessToken = tokens.accessToken; - } catch { - throw new Exception('Unauthorized', 'Invalid refresh token', 401); - } - } - - return accessToken; -}; - -const getRequestParams = (params: MiddlewareParams, token: string): MiddlewareParams => ({ - ...params, - options: { - ...params.options, - headers: { - ...params.options.headers, - Authorization: `Bearer ${token}`, - }, - }, -}); - -export const useTokenMiddleware = (cookies: Cookies) => { - return async (params: MiddlewareParams, next: MiddlewareNext) => { - const accessToken = cookies.get('accessToken') ?? (await refreshToken(cookies)); - - const res = await next(getRequestParams(params, accessToken)); - - if (res.status === 401) { - const newAccessToken = await refreshToken(cookies, true); - return await next(getRequestParams(params, newAccessToken)); - } - return res; - }; -}; diff --git a/src/lib/server/api/repositories/auth.repository.ts b/src/lib/server/api/repositories/auth.repository.ts deleted file mode 100644 index 184be283..00000000 --- a/src/lib/server/api/repositories/auth.repository.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { RefreshTokenInput, TokenResponse } from '../types'; - -export class AuthRepository extends BaseRepository { - refreshToken(input: RefreshTokenInput): Promise { - return this.post(`/auth/refresh-token`, input); - } -} diff --git a/src/lib/server/api/repositories/package.repository.ts b/src/lib/server/api/repositories/package.repository.ts deleted file mode 100644 index 14d8c565..00000000 --- a/src/lib/server/api/repositories/package.repository.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getUrl } from '@utils/http'; - -import { BaseRepository } from '../base.repository'; -import type { Package, PaginateQuery, PaginateResult, SearchQuery } from '../types'; - -export class PackageRepository extends BaseRepository { - getAll(query?: PaginateQuery & SearchQuery): Promise> { - return this.get(getUrl(`/packages`, query), { offline: true }); - } -} diff --git a/src/lib/server/api/repositories/projects.repository.ts b/src/lib/server/api/repositories/projects.repository.ts deleted file mode 100644 index 33ffe221..00000000 --- a/src/lib/server/api/repositories/projects.repository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { ApiProject } from '../types'; - -export class ProjectRepository extends BaseRepository { - getProject(id: string): Promise { - return this.get(`/editor/projects/${id}`); - } - - getProjects(): Promise { - return this.get(`/projects`); - } -} diff --git a/src/lib/server/api/repositories/registry.repository.ts b/src/lib/server/api/repositories/registry.repository.ts deleted file mode 100644 index 9aba34ab..00000000 --- a/src/lib/server/api/repositories/registry.repository.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRepository } from '../base.repository'; -import type { RegistryPackageType } from '../types'; - -export class RegistryRepository extends BaseRepository { - getPackage(packageName: string): Promise { - return this.get(`/registry/${packageName}`, { offline: true }); - } -} diff --git a/src/lib/server/api/types/auth.type.ts b/src/lib/server/api/types/auth.type.ts deleted file mode 100644 index 1d77249d..00000000 --- a/src/lib/server/api/types/auth.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface TokenResponse { - accessToken: string; - refreshToken: string; - tokenExpiresAt: string; -} - -export interface RefreshTokenInput { - refreshToken: string; -} diff --git a/src/lib/server/api/types/index.ts b/src/lib/server/api/types/index.ts deleted file mode 100644 index 83f48949..00000000 --- a/src/lib/server/api/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './auth.type'; -export * from './package.type'; -export * from './paginate.type'; -export * from './project.type'; -export * from './registry.type'; diff --git a/src/lib/server/api/types/package.type.ts b/src/lib/server/api/types/package.type.ts deleted file mode 100644 index 5600cc7d..00000000 --- a/src/lib/server/api/types/package.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PackageTypeEnum } from '$lib/server/project/package'; - -export interface Package { - id: string; - name: string; - type: PackageTypeEnum; - description: string | null; - tags: string[]; -} diff --git a/src/lib/server/api/types/paginate.type.ts b/src/lib/server/api/types/paginate.type.ts deleted file mode 100644 index e8bcd6fc..00000000 --- a/src/lib/server/api/types/paginate.type.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface PaginateQuery { - page?: number; - limit?: number; -} - -export interface SearchQuery { - search?: string; -} - -export type PaginateResult = { - data: T[]; - total: number; - page: number; - limit: number; - totalPages: number; -}; diff --git a/src/lib/server/api/types/project.type.ts b/src/lib/server/api/types/project.type.ts deleted file mode 100644 index 9facd644..00000000 --- a/src/lib/server/api/types/project.type.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ApiProject { - id: string; - code: string; - name: string; - description: string; - gatewayProjectRegistryUrl: string; - gatewayProjectRegistryMetadata: { - dir: string | null; - }; - token: string; -} diff --git a/src/lib/server/api/types/registry.type.ts b/src/lib/server/api/types/registry.type.ts deleted file mode 100644 index 269cc85b..00000000 --- a/src/lib/server/api/types/registry.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PackageTypeEnum } from '$lib/server/project/package'; - -export interface RegistryPackageType { - name: string; - type: PackageTypeEnum; - description: string; - tags: string[]; - _file: string; -} diff --git a/src/lib/server/cli/cli-defaults.ts b/src/lib/server/cli/cli-defaults.ts deleted file mode 100644 index 360d7d52..00000000 --- a/src/lib/server/cli/cli-defaults.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { WithOptional } from '@utils/types/base.type'; - -import type { - CliBuildOptions, - CliCreateOptions, - CliDevOptions, - CliGenerateOptions, - CliInstallOptions, - CliNewOptions, - CliStartOptions, -} from './cli.type'; - -export const CLI_NEW_DEFAULTS: WithOptional = { - packageManager: 'npm', - language: 'ts', - strict: false, - server: false, - skipInstall: false, - docker: false, - git: false, - gitRemote: false, -}; - -export const CLI_INSTALL_DEFAULTS: WithOptional = {}; - -export const CLI_BUILD_DEFAULTS: WithOptional = {}; - -export const CLI_START_DEFAULTS: WithOptional = {}; - -export const CLI_DEV_DEFAULTS: WithOptional = {}; - -export const CLI_GENERATE_DEFAULTS: WithOptional = {}; - -export const CLI_CREATE_DEFAULTS: WithOptional = {}; diff --git a/src/lib/server/cli/cli-error.ts b/src/lib/server/cli/cli-error.ts deleted file mode 100644 index 96007be8..00000000 --- a/src/lib/server/cli/cli-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Exception } from '@utils/exception'; - -export class CliError extends Exception { - constructor(message: string) { - super('Internal Server Error', message, 500); - } -} diff --git a/src/lib/server/cli/cli.ts b/src/lib/server/cli/cli.ts deleted file mode 100644 index 7eb578aa..00000000 --- a/src/lib/server/cli/cli.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { env } from '$env/dynamic/private'; - -import { - CLI_BUILD_DEFAULTS, - CLI_CREATE_DEFAULTS, - CLI_DEV_DEFAULTS, - CLI_GENERATE_DEFAULTS, - CLI_INSTALL_DEFAULTS, - CLI_NEW_DEFAULTS, - CLI_START_DEFAULTS, -} from '$lib/server/cli/cli-defaults'; - -import { formatFrom } from '@utils/format'; - -import type { Context } from '@utils-server/request-handler'; - -import { CliError } from './cli-error'; -import type { - CliBuildOptions, - CliCreateOptions, - CliDevOptions, - CliGenerateOptions, - CliInstallOptions, - CliNewOptions, - CliPartial, - CliRunOptions, - CliStartOptions, -} from './cli.type'; - -export class Cli { - private readonly _projectPath?: string; - - constructor(context: Context) { - this._projectPath = context.project?.path; - } - - new(opts: CliPartial, runOpts?: CliRunOptions): void { - return this.runCommand('new', [], { ...opts }, CLI_NEW_DEFAULTS, runOpts); - } - - install( - pkgs: [string, ...string[]], - opts: CliPartial, - runOpts?: CliRunOptions, - ): void { - this.assertProject(); - return this.runCommand( - 'install', - pkgs, - { ...opts, directory: this._projectPath }, - CLI_INSTALL_DEFAULTS, - runOpts, - ); - } - - build(opts: CliPartial, runOpts?: CliRunOptions): void { - this.assertProject(); - return this.runCommand( - 'build', - [], - { ...opts, directory: this._projectPath }, - CLI_BUILD_DEFAULTS, - runOpts, - ); - } - - start(opts: CliPartial, runOpts?: CliRunOptions): void { - this.assertProject(); - return this.runCommand( - 'start', - [], - { ...opts, directory: this._projectPath }, - CLI_START_DEFAULTS, - runOpts, - ); - } - - dev(opts: CliPartial, runOpts?: CliRunOptions): void { - this.assertProject(); - return this.runCommand( - 'dev', - [], - { ...opts, directory: this._projectPath }, - CLI_DEV_DEFAULTS, - runOpts, - ); - } - - generate(opts: CliPartial, runOpts?: CliRunOptions): void { - this.assertProject(); - return this.runCommand( - 'generate', - [], - { ...opts, directory: this._projectPath }, - CLI_GENERATE_DEFAULTS, - runOpts, - ); - } - - create( - part: 'component' | 'system', - opts: CliPartial, - runOpts?: CliRunOptions, - ): void { - this.assertProject(); - return this.runCommand( - 'create', - [part], - { ...opts, directory: this._projectPath }, - CLI_CREATE_DEFAULTS, - runOpts, - ); - } - - private runCommand( - command: string, - params: string[], - opts: Record, - defaultOpts: Record, - { async = false }: CliRunOptions = {}, - ): void { - const fullCommand = [ - env.NF_CLI_PATH ?? 'nf', - command, - ...params, - ...this.resolveParams(opts, defaultOpts), - ]; - - if (async) { - const res = Bun.spawn(fullCommand, { - stdout: 'pipe', - stderr: 'pipe', - cwd: env.FS_ROOT ?? undefined, - }); - res.exited.then((exitCode) => { - if (exitCode !== 0) { - console.log(res.stdout.toString()); - console.error(res.stderr.toString()); - throw new CliError(res.stderr.toString()); - } - }); - } else { - const res = Bun.spawnSync(fullCommand, { - stdout: 'pipe', - stderr: 'pipe', - cwd: env.FS_ROOT ?? undefined, - }); - - if (res.exitCode !== 0) { - console.log(res.stdout.toString()); - console.error(res.stderr.toString()); - throw new CliError(res.stderr.toString()); - } - } - } - - private mergeParams( - ...opts: Record[] - ): Record { - const merged: Record = {}; - for (const opt of opts) { - for (const [key, value] of Object.entries(opt)) { - if (value === undefined) continue; - merged[key] = value; - } - } - return merged; - } - - private resolveParams( - opts: Record, - defaultOpts: Record, - ): string[] { - const params = []; - for (const [key, value] of Object.entries(this.mergeParams(defaultOpts, opts))) { - const name = formatFrom.camel(key).toKebab(); - - if (typeof value === 'boolean') { - if (value) params.push(`--${name}`); - else params.push(`--no-${name}`); - } else { - params.push(`--${name}`, `${value}`); - } - } - return params; - } - - private assertProject() { - if (!this._projectPath) throw new CliError('No project path set'); - if (!Bun.file(this._projectPath).exists()) throw new CliError('Project does not exist'); - } -} diff --git a/src/lib/server/cli/cli.type.ts b/src/lib/server/cli/cli.type.ts deleted file mode 100644 index 4b805fcf..00000000 --- a/src/lib/server/cli/cli.type.ts +++ /dev/null @@ -1,75 +0,0 @@ -export type CliPartial = (T extends { directory?: string } - ? Omit, 'directory'> - : Partial) & - Pick; - -export interface CliRunOptions { - async?: boolean; -} - -export interface CliNewOptions { - directory?: string; - name: string; - path?: string; - packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun'; - language: 'js' | 'ts'; - strict: boolean; - server: boolean; - initFunctions?: boolean; - skipInstall: boolean; - docker: boolean; - lint?: false; - editor?: true; - git: boolean; - gitRemote: string | false; -} - -export interface CliInstallOptions { - directory?: string; - lib?: true; - server?: true; -} - -export interface CliBuildOptions { - directory?: string; - config?: string; - clientEntry?: string; - serverEntry?: string; - clientStaticDir?: string; - serverStaticDir?: string; - clientOutDir?: string; - serverOutDir?: string; - editor?: true; - watch?: true; -} - -export interface CliStartOptions { - directory?: string; - config?: string; - clientDir?: string; - serverDir?: string; - watch?: true; - cert?: string; - key?: string; -} - -export interface CliDevOptions { - directory?: string; - config?: string; - generate?: true; -} - -export interface CliGenerateOptions { - directory?: string; - config?: string; - editor?: true; - watch?: true; -} - -export interface CliCreateOptions { - directory?: string; - config?: string; - name: string; - server?: true; - path?: string; -} diff --git a/src/lib/server/cli/index.ts b/src/lib/server/cli/index.ts deleted file mode 100644 index 52a67647..00000000 --- a/src/lib/server/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Cli } from './cli'; diff --git a/src/lib/server/file-system/archive.ts b/src/lib/server/file-system/archive.ts deleted file mode 100644 index a84bf0a6..00000000 --- a/src/lib/server/file-system/archive.ts +++ /dev/null @@ -1,88 +0,0 @@ -import micromatch from 'micromatch'; -import { join } from 'path'; -import * as tar from 'tar'; - -import { Exception } from '@utils/exception'; - -import type { Context, Handler } from '@utils-server/request-handler'; - -import { resolveArchiveRootPath } from './file-system.functions'; -import { directoryContentToFileEntries } from './project-directory'; - -interface Archive { - path: string; - sessionId: string; -} - -const archives = new Map(); - -export class ArchiveSystem { - private readonly handler: Handler; - private readonly context: Context; - private readonly _rootPath: string; - private readonly _sessionId: string; - - constructor(handler: Handler, context: Context) { - this.handler = handler; - this.context = context; - this._sessionId = context.session.id; - this._rootPath = resolveArchiveRootPath(); - } - - async create(ignoreFile: string = '.gitignore'): Promise { - const dir = this.handler.fs.getDirectory(''); - - let files = directoryContentToFileEntries(dir.read(true)).map((n) => - n.startsWith('/') ? n.slice(1) : n, - ); - try { - const ignore = await Bun.file(join(dir.path, ignoreFile)).text(); - files = micromatch.not(files, ignore.split('\n').filter(Boolean), { - dot: true, - contains: true, - }); - } catch (e) { - console.warn(`Could not read ${ignoreFile} file`, e); - } - - const id = `${this._resolveId()}.tgz`; - - const path = join(this._rootPath, id); - - await tar.c( - { - gzip: true, - file: path, - cwd: dir.path, - }, - files, - ); - - archives.set(id, { sessionId: this._sessionId, path }); - setTimeout(this._delete.bind(this, id), 1000 * 60 * 15); - return id; - } - - async get(id: string): Promise { - const archive = archives.get(id); - if (!archive) throw new Exception('Not Found', 'Archive not found', 404); - if (archive.sessionId !== this._sessionId) - throw new Exception('Forbidden', 'Archive is not yours', 403); - - return Bun.file(archive.path); - } - private async _delete(id: string): Promise { - const archive = archives.get(id); - if (!archive) return; - try { - await Bun.file(archive.path).delete(); - archives.delete(id); - } catch (e) { - console.error('Could not delete archive', e); - } - } - - private _resolveId(): string { - return `${this.context.project.path.split('/').pop() ?? 'unknown'}-${new Date().toISOString()}`; - } -} diff --git a/src/lib/server/file-system/file-system-error.ts b/src/lib/server/file-system/file-system-error.ts deleted file mode 100644 index 401ec2ee..00000000 --- a/src/lib/server/file-system/file-system-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Exception } from '@utils/exception'; - -export class FileSystemError extends Exception { - constructor(message: string) { - super('Bad Request', message, 400); - } -} diff --git a/src/lib/server/file-system/file-system.functions.ts b/src/lib/server/file-system/file-system.functions.ts deleted file mode 100644 index 78c71c55..00000000 --- a/src/lib/server/file-system/file-system.functions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { resolve } from 'path'; - -import { env } from '$env/dynamic/private'; - -export const resolveRootPath = (userPath: string) => { - const rootPath = resolve(env.FS_ROOT ?? ''); - - return resolve(rootPath, userPath); -}; - -export const resolveArchiveRootPath = () => { - return resolve(env.ARCHIVE_ROOT ?? ''); -}; diff --git a/src/lib/server/file-system/file-system.ts b/src/lib/server/file-system/file-system.ts deleted file mode 100644 index cd4376b5..00000000 --- a/src/lib/server/file-system/file-system.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Exception } from '@utils/exception'; - -import type { Context } from '@utils-server/request-handler/context'; - -import { resolveRootPath } from './file-system.functions'; -import { ProjectDirectory } from './project-directory'; -import { ProjectFile } from './project-file'; - -export class FileSystem { - private readonly _rootPath: string; - - constructor(arg: string | Context) { - if (typeof arg === 'string') { - this._rootPath = resolveRootPath(arg); - return; - } - - if (!arg.project) { - throw new Exception('Bad Request', 'Project is not defined', 400); - } - - this._rootPath = resolveRootPath(arg.project.path); - } - - getDirectory(path: string) { - return new ProjectDirectory(path, this._rootPath); - } - - getFile(path: string) { - return new ProjectFile(path, this._rootPath); - } -} diff --git a/src/lib/server/file-system/index.ts b/src/lib/server/file-system/index.ts deleted file mode 100644 index 6ccae8d7..00000000 --- a/src/lib/server/file-system/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { ArchiveSystem } from './archive'; -export { FileSystem } from './file-system'; -export { directoryContentToFileEntries, type ProjectDirectory } from './project-directory'; -export type { ProjectFile } from './project-file'; diff --git a/src/lib/server/file-system/project-directory.ts b/src/lib/server/file-system/project-directory.ts deleted file mode 100644 index 7003aefd..00000000 --- a/src/lib/server/file-system/project-directory.ts +++ /dev/null @@ -1,151 +0,0 @@ -import fs from 'node:fs'; -import { dirname, resolve } from 'path'; - -import { FileSystemError } from './file-system-error'; - -export type DirectoryContent = { - files: string[]; - directories: Record; -}; - -export function directoryContentToFileEntries( - directoryContent: DirectoryContent, - basePath: string = '', -) { - const entries: string[] = []; - - directoryContent.files.forEach((file) => { - entries.push(basePath + '/' + file); - }); - Object.entries(directoryContent.directories).forEach(([path, dirContent]) => { - if (!dirContent) return; - entries.push(...directoryContentToFileEntries(dirContent, basePath + '/' + path)); - }); - return entries; -} - -export class ProjectDirectory { - public path: string; - private readonly projectPath: string; - - constructor(dirPath: string, projectPath: string) { - this.path = resolve(projectPath, './' + dirPath); - this.projectPath = projectPath; - - this._checkPathIsInsideProject(); - } - - read(recursive: boolean = false, createIfNotExist: boolean = false): DirectoryContent { - if (createIfNotExist) { - if (!fs.existsSync(this.path)) this.create(); - } - this._checkPathExists(); - this._checkPathIsDir(); - this._checkPathIsReadable(); - return this._readDirContent(this.path, recursive); - } - - create(path: string = this.path): void { - this._checkPathNotExists(); - - fs.mkdirSync(path, { recursive: true }); - } - - delete(recursive: boolean = false): void { - this._checkPathExists(); - this._checkPathIsDir(); - if (!recursive) { - this._checkDirIsEmpty(); - } - fs.rmSync(this.path, { recursive: recursive }); - } - - rename(newPath: string): void { - const absoluteNewDirPath = resolve(this.projectPath, './' + newPath); - this._checkPathIsInsideProject(absoluteNewDirPath); - this._checkPathExists(); - this._checkPathIsDir(); - this._checkPathIsWritable(); - const newFolderPath = dirname(absoluteNewDirPath); - this._checkPathExists(newFolderPath); - this._checkPathIsWritable(newFolderPath); - this._checkPathNotExists(absoluteNewDirPath); - fs.renameSync(this.path, absoluteNewDirPath); - this.path = absoluteNewDirPath; - } - - assertExists() { - this._checkPathExists(); - this._checkPathIsDir(); - this._checkPathIsReadable(); - } - - private _checkPathIsInsideProject(path: string = this.path) { - if (!path.startsWith(this.projectPath)) { - throw new FileSystemError(`Path ${path} is outside of the project directory`); - } - } - - private _checkPathExists(path: string = this.path) { - if (!fs.existsSync(path)) { - throw new FileSystemError(`Path ${path} should exist`); - } - } - - private _checkPathNotExists(path: string = this.path) { - if (fs.existsSync(path)) { - throw new FileSystemError(`Path ${path} should not exist`); - } - } - - private _checkPathIsDir(path: string = this.path) { - let stats: fs.Stats; - try { - stats = fs.lstatSync(path); - } catch { - throw new FileSystemError(`Path ${path} does not exist`); - } - if (!stats.isDirectory()) { - throw new FileSystemError(`Path ${path} is not a directory`); - } - } - - private _checkPathIsWritable(path: string = this.path) { - try { - fs.accessSync(path, fs.constants.W_OK); - } catch { - throw new FileSystemError(`Path ${path} writable`); - } - } - - private _checkPathIsReadable(path: string = this.path) { - try { - fs.accessSync(path, fs.constants.R_OK); - } catch { - throw new FileSystemError(`Path ${path} writable`); - } - } - - private _checkDirIsEmpty(path: string = this.path) { - if (fs.readdirSync(path).length > 0) { - throw new FileSystemError(`Directory ${path} is not empty`); - } - } - - private _readDirContent(path: string = this.path, recursive: boolean = false): DirectoryContent { - const dirContent: DirectoryContent = { - files: [], - directories: {}, - }; - fs.readdirSync(path, { withFileTypes: true, recursive: false }).forEach((item) => { - if (item.isFile()) { - dirContent.files.push(item.name); - } else if (item.isDirectory()) { - dirContent.directories[item.name] = recursive - ? this._readDirContent(path + '/' + item.name, recursive) - : null; - } - }); - return dirContent; - } -} diff --git a/src/lib/server/file-system/project-file.ts b/src/lib/server/file-system/project-file.ts deleted file mode 100644 index a17e3c51..00000000 --- a/src/lib/server/file-system/project-file.ts +++ /dev/null @@ -1,154 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { FileSystemError } from './file-system-error'; - -export class ProjectFile { - private _path: string; - private readonly projectPath: string; - - constructor(filePath: string, projectPath: string) { - this._path = path.resolve(projectPath, './' + filePath); - this.projectPath = projectPath; - - this._checkPathIsInsideProject(); - } - - get path(): string { - this._checkPathExists(); - return this._path; - } - - read(encoding?: BufferEncoding): string { - this.isReadable(); - return fs.readFileSync(this._path).toString(encoding); - } - - readStream(): ReadableStream { - this.isReadable(); - const file = Bun.file(this._path); - return file.stream(); - } - - readJson(): T { - const raw = this.read(); - return JSON.parse(raw) as T; - } - - write(text: string): void { - this._checkWritableAndCreateFolder(); - fs.writeFileSync(this._path, text, { flush: true }); - } - - writeJson(content: any): void { - const raw = JSON.stringify(content); - this.write(raw); - } - - getWriteStream(): fs.WriteStream { - this._checkWritableAndCreateFolder(); - return fs.createWriteStream(this._path); - } - - delete(): void { - this._checkPathExists(); - this._checkPathIsFile(); - this._checkPathIsWritable(); - fs.rmSync(this._path); - } - - rename(newPath: string): void { - const absoluteNewPath = path.resolve(this.projectPath, './' + newPath); - this._checkPathIsInsideProject(newPath); - this._checkPathExists(); - this._checkPathIsFile(); - this._checkPathIsWritable(); - const newFolderPath = path.dirname(absoluteNewPath); - this._checkPathExists(newFolderPath); - this._checkPathIsWritable(newFolderPath); - this._checkPathNotExists(absoluteNewPath); - fs.renameSync(this._path, absoluteNewPath); - this._path = absoluteNewPath; - } - - isReadable(): void { - this._checkPathExists(); - this._checkPathIsFile(); - this._checkPathIsReadable(); - } - - private _checkWritableAndCreateFolder(): void { - const folderPath = path.dirname(this._path); - try { - this._checkPathExists(folderPath); - } catch { - fs.mkdirSync(folderPath, { recursive: true }); - } - if (fs.existsSync(this._path)) { - this._checkPathIsFile(); - this._checkPathIsWritable(); - } else { - this._checkPathExists(folderPath); - this._checkPathIsDir(folderPath); - this._checkPathIsWritable(folderPath); - } - } - - private _checkPathIsInsideProject(path: string = this._path) { - if (!path.startsWith(this.projectPath)) { - throw new FileSystemError(`Path ${path} is outside of the project directory`); - } - } - - private _checkPathExists(path: string = this._path) { - if (!fs.existsSync(path)) { - throw new FileSystemError(`Path ${path} should exist`); - } - } - - private _checkPathNotExists(path: string = this._path) { - if (fs.existsSync(path)) { - throw new FileSystemError(`Path ${path} should not exist`); - } - } - - private _checkPathIsFile(path: string = this._path) { - let stats: fs.Stats; - try { - stats = fs.lstatSync(path); - } catch { - throw new FileSystemError(`Path ${path} does not exist`); - } - if (!stats.isFile()) { - throw new FileSystemError(`Path ${path} is not a this.path`); - } - } - - private _checkPathIsDir(path: string = this._path) { - let stats: fs.Stats; - try { - stats = fs.lstatSync(path); - } catch { - throw new FileSystemError(`Path ${path} does not exist`); - } - if (!stats.isDirectory()) { - throw new FileSystemError(`Path ${path} is not a directory`); - } - } - - private _checkPathIsWritable(path: string = this._path) { - try { - fs.accessSync(path, fs.constants.W_OK); - } catch { - throw new FileSystemError(`Path ${path} writable`); - } - } - - private _checkPathIsReadable(path: string = this._path) { - try { - fs.accessSync(path, fs.constants.R_OK); - } catch { - throw new FileSystemError(`Path ${path} writable`); - } - } -} diff --git a/src/lib/server/git/git.ts b/src/lib/server/git/git.ts deleted file mode 100644 index 18bffe48..00000000 --- a/src/lib/server/git/git.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { $ } from 'bun'; -import { existsSync } from 'fs'; -import { resolve } from 'path'; - -import { env } from '$env/dynamic/private'; - -import type { Context } from '@utils-server/request-handler'; -import { generateKey } from '@utils-server/string'; - -export class Git { - private readonly _rootPath: string; - private readonly _token: string | null; - private readonly _path: string; - - constructor(context: Context) { - this._rootPath = resolve(env.FS_ROOT ?? ''); - this._token = context.project.gateway?.token ?? null; - this._path = context.project.path; - } - - async clone(url: string): Promise { - const path = await this.resolvePath(url); - if (existsSync(path)) return path; - if (this._token) - url = url.replace('https://github.com/', `https://oauth2:${this._token}@github.com/`); - await this.runCommand('clone', [url, path]); - return path; - } - - async push() { - await this.runCommand('add', ['--all'], { path: this._path }); - const res = await this.runCommand('status', [], { path: this._path }); - if (res.stdout.includes('nothing to commit')) return; - await this.runCommand('commit', ['-m', `nanoforged at ${new Date().toISOString()}`], { - path: this._path, - }); - await this.runCommand('push', ['-u', 'origin', 'main'], { path: this._path }); - } - - private runCommand(command: string, params: string[], options?: { path?: string }) { - const cwd = resolve(this._rootPath, options?.path ?? ''); - - return $`git ${command} ${params}`.cwd(cwd).env({ ...process.env }); - } - - private async resolvePath(url: string) { - const basePath = url.split('/').pop()?.replace('.git', ''); - if (!basePath) throw new Error('Invalid URL'); - - const fullBasePath = resolve(this._rootPath, basePath); - let path = fullBasePath; - - while (await Bun.file(path).exists()) path = resolve(fullBasePath, generateKey(5)); - return path; - } -} diff --git a/src/lib/server/git/index.ts b/src/lib/server/git/index.ts deleted file mode 100644 index 3a6f0733..00000000 --- a/src/lib/server/git/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Git } from './git'; diff --git a/src/lib/server/project/index.ts b/src/lib/server/project/index.ts deleted file mode 100644 index 1df00d0e..00000000 --- a/src/lib/server/project/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { loadProject } from './load-project'; -export type { Project } from './project.type'; -export { ProjectHandler } from './project-handler'; -export { ProjectManager } from './project-manager'; diff --git a/src/lib/server/project/library/index.ts b/src/lib/server/project/library/index.ts deleted file mode 100644 index a08aaebf..00000000 --- a/src/lib/server/project/library/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './library.type'; diff --git a/src/lib/server/project/library/library-handler.ts b/src/lib/server/project/library/library-handler.ts deleted file mode 100644 index 5eaebad6..00000000 --- a/src/lib/server/project/library/library-handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ProjectHandler } from '$lib/server/project'; - -import { formatFrom } from '@utils/format'; - -import type { LibraryPackage } from './library.type'; - -export class LibraryHandler { - private readonly handler: ProjectHandler; - - constructor(handler: ProjectHandler) { - this.handler = handler; - } - - async installLibrary(name: string): Promise { - this.handler._cli.install([name], { - server: this.handler._part === 'server' || undefined, - lib: true, - }); - - return { - save: { - id: this._resolveIdFromName(name), - type: '', - name: '', - path: name, - }, - }; - } - - private _resolveIdFromName(name: string): string { - const [org, pkg] = name.split('/'); - if (!org || !pkg) throw new Error('Invalid library name'); - const id = !pkg ? org : pkg; - return `${formatFrom.kebab(id).toCamel()}Library`; - } -} diff --git a/src/lib/server/project/library/library.type.ts b/src/lib/server/project/library/library.type.ts deleted file mode 100644 index 20096aac..00000000 --- a/src/lib/server/project/library/library.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { SaveLibrary } from '@utils/types'; - -export interface LibraryPackage { - save: SaveLibrary; -} diff --git a/src/lib/server/project/load-project.ts b/src/lib/server/project/load-project.ts deleted file mode 100644 index dc28a3eb..00000000 --- a/src/lib/server/project/load-project.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - type SessionProject, - addProjectToSession, - getOrCreateSession, - tryAddProjectSession, -} from '$lib/server/session'; -import { getProject } from '$lib/server/session/project/project-functions'; - -import { Exception } from '@utils/exception'; - -import type { Handler } from '@utils-server/request-handler'; - -import type { Project } from './project.type'; - -export const loadProject = async ( - projectSession: SessionProject, - handler: Handler, -): Promise => { - // @todo remake this route and session system as it's set before the project is loaded - - const session = await getOrCreateSession(handler.event.locals.session); - const projectId = tryAddProjectSession(projectSession); - addProjectToSession(session, projectId); - - handler.context = { ...handler.context, project: projectSession }; - - return { - id: projectId, - cacheResolvable: handler.context.online - ? (projectSession.gateway?.id as string) - : projectSession.path, - }; -}; - -export const loadProjectFromId = async (id: string, handler: Handler): Promise => { - // @todo remake this route and session system as it's set before the project is loaded - - const session = await getOrCreateSession(handler.event.locals.session); - const projectSession = getProject(id, session); - - if (!projectSession) throw new Exception('Bad Request', 'Project not found', 400); - - addProjectToSession(session, id); - - handler.context = { ...handler.context, project: projectSession }; - - return { - id, - cacheResolvable: handler.context.online - ? (projectSession.gateway?.id as string) - : projectSession.path, - }; -}; diff --git a/src/lib/server/project/loader/env.ts b/src/lib/server/project/loader/env.ts deleted file mode 100644 index c4834d0d..00000000 --- a/src/lib/server/project/loader/env.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { config } from 'dotenv'; -import { resolve } from 'path'; - -import type { Part } from '@utils/types'; - -const PREFIX_CLIENT = 'NANOFORGE_CLIENT_'; -const PREFIX_SERVER = 'NANOFORGE_SERVER_'; -const PREFIX = 'NANOFORGE_'; - -const loadEnv = (path: string): Record => { - const env = {}; - config({ path, processEnv: env }); - return env; -}; - -const parseEnv = (part: Part, env: Record): Record => { - const partPrefix = part === 'client' ? PREFIX_CLIENT : PREFIX_SERVER; - return Object.fromEntries( - Object.entries(env) - .filter( - ([key]) => - key.startsWith(PREFIX) && - ((part === 'client' && !key.startsWith(PREFIX_SERVER)) || - (part === 'server' && !key.startsWith(PREFIX_CLIENT))), - ) - .map(([key, value]) => [key.replace(new RegExp(`^${partPrefix}|${PREFIX}`), ''), value]), - ); -}; - -export const resolveEnv = (part: Part, projectPath: string) => { - return parseEnv(part, loadEnv(resolve(projectPath, '.env'))); -}; diff --git a/src/lib/server/project/loader/index.ts b/src/lib/server/project/loader/index.ts deleted file mode 100644 index 0223844c..00000000 --- a/src/lib/server/project/loader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Loader } from './loader'; diff --git a/src/lib/server/project/loader/loader.ts b/src/lib/server/project/loader/loader.ts deleted file mode 100644 index f06f6699..00000000 --- a/src/lib/server/project/loader/loader.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { join } from 'path'; - -import { - FileSystem, - type ProjectFile, - directoryContentToFileEntries, -} from '$lib/server/file-system'; - -import type { ProjectHandler } from '../project-handler'; -import { resolveEnv } from './env'; -import type { Manifest } from './types'; - -export class Loader { - private readonly handler: ProjectHandler; - private readonly _fs: FileSystem; - private readonly _basePath: string; - - constructor(handler: ProjectHandler) { - this.handler = handler; - this._basePath = join(this.handler._path, '.nanoforge', this.handler._part); - this._fs = new FileSystem(this._basePath); - } - - getEnv(): Record { - return resolveEnv(this.handler._part, this.handler._path); - } - - getManifest(): Manifest { - const entries = directoryContentToFileEntries(this._fs.getDirectory('.').read(true)); - return { - version: '1.0.0', - files: entries.map((path) => ({ path })), - }; - } - - getFile(path: string): ProjectFile { - return this._fs.getFile(path); - } -} diff --git a/src/lib/server/project/loader/types.ts b/src/lib/server/project/loader/types.ts deleted file mode 100644 index 7973dea5..00000000 --- a/src/lib/server/project/loader/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Manifest { - version: string; - files: { path: string }[]; -} diff --git a/src/lib/server/project/package/index.ts b/src/lib/server/project/package/index.ts deleted file mode 100644 index df8afa17..00000000 --- a/src/lib/server/project/package/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './package.enum'; -export * from './package.type'; diff --git a/src/lib/server/project/package/manifest/manifest-handler.ts b/src/lib/server/project/package/manifest/manifest-handler.ts deleted file mode 100644 index 57ef9e1b..00000000 --- a/src/lib/server/project/package/manifest/manifest-handler.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { join } from 'path'; - -import { FileSystemError } from '$lib/server/file-system/file-system-error'; -import type { ProjectHandler } from '$lib/server/project'; - -import { PACKAGES_PATH, PACKAGES_PATH_FORMATTER } from '../package.const'; -import { PackageTypeEnum } from '../package.enum'; -import type { ManifestPackage, PackageType } from '../package.type'; -import { resolveManifest } from './manifest-resolver'; - -const resolveSave = ( - type: T, - name: string, - path: string, - manifest: PackageType['manifest'], -): PackageType['save'] => { - const base = { name, path }; - if (type === PackageTypeEnum.SYSTEM) return base; - return { - ...base, - paramsNames: (manifest as PackageType['manifest']).params.map( - ({ name }) => name, - ), - }; -}; - -export class ManifestHandler { - private readonly handler: ProjectHandler; - - constructor(handler: ProjectHandler) { - this.handler = handler; - } - - resolveFromName(type: T, rawName: string): PackageType { - return this.resolveFromFilename(type, PACKAGES_PATH_FORMATTER[type](rawName)); - } - - resolveFromFilename(type: T, filename: string): PackageType { - const path = join(PACKAGES_PATH[type], filename); - - return this.resolveFromPath(type, path); - } - - resolveFromPath(type: T, path: string): PackageType { - const manifest = this._resolveManifest(type, path); - - return { - manifest, - save: resolveSave(type, manifest.id, path, manifest), - } as PackageType; - } - - private _resolveManifest( - type: T, - path: string, - ): PackageType['manifest'] { - return this._findPackageManifest((p) => this._getPackageManifest(type, p), path); - } - - private _getPackageManifest( - type: T, - path: string, - ): PackageType['manifest'] { - const content = this.handler._rootFs.getFile(this._resolvePartPath(path)).read(); - return resolveManifest(type, content); - } - - private _findPackageManifest( - manifestGetter: (path: string) => PackageType['manifest'], - path: string, - ): PackageType['manifest'] { - const manifest = ['', '.ts', '.js'].reduce( - (result: PackageType['manifest'] | undefined, p) => { - if (result) return result; - - try { - return manifestGetter(path + p); - } catch (e) { - if (!(e instanceof FileSystemError)) throw e; - } - }, - undefined, - ); - - if (!manifest) { - throw new FileSystemError("Can't find package manifest"); - } - return manifest; - } - - private _resolvePartPath(path: string): string { - return join(this.handler._part, path); - } -} diff --git a/src/lib/server/project/package/manifest/manifest-resolver.ts b/src/lib/server/project/package/manifest/manifest-resolver.ts deleted file mode 100644 index 52a150f6..00000000 --- a/src/lib/server/project/package/manifest/manifest-resolver.ts +++ /dev/null @@ -1,85 +0,0 @@ -import ts, { type Expression, type ObjectLiteralElementLike } from 'typescript'; - -import { PackageTypeEnum } from '../package.enum'; -import type { ManifestPackage } from '../package.type'; - -export const MANIFEST_TITLES = { - [PackageTypeEnum.COMPONENT]: 'EDITOR_COMPONENT_MANIFEST', - [PackageTypeEnum.SYSTEM]: 'EDITOR_SYSTEM_MANIFEST', -} as const; - -const findManifestNode = (title: string, source: ts.SourceFile): ts.VariableDeclaration | null => { - let res = null; - source.forEachChild((node) => { - if (!ts.isVariableStatement(node)) return; - node.declarationList.declarations.forEach((decl) => { - if (!ts.isVariableDeclaration(decl)) return; - if (decl.name.getText() === title) res = decl; - }); - }); - return res; -}; - -const getName = (source: ts.SourceFile): string | null => { - let res = null; - source.forEachChild((node) => { - if (!ts.isExportAssignment(node)) return; - if ( - !node.getChildren().some((n) => n.getText() === 'export') || - !node.getChildren().some((n) => n.getText() === 'default') - ) - return; - res = node.expression.getFirstToken()?.getText(); - }); - return res; -}; - -const parseProperty = (prop: ObjectLiteralElementLike): any => { - if (!ts.isPropertyAssignment(prop)) return {}; - - const name = prop.name.getText(); - const value = prop.initializer; - if (!value) return {}; - - return { [name]: parseElement(value) }; -}; - -const parseElement = (value: Expression): any => { - if (ts.isStringLiteral(value)) return value.text; - if (ts.isNumericLiteral(value)) return Number(value.text); - if (ts.isLiteralTypeLiteral(value)) { - const txt = value.getText(); - if (txt === 'true') return true; - if (txt === 'false') return false; - if (txt === 'null') return null; - if (txt === 'undefined') return undefined; - } - if (ts.isArrayLiteralExpression(value)) return value.elements.map((el) => parseElement(el)); - if (ts.isObjectLiteralExpression(value)) - return value.properties.reduce((acc, prop) => { - return { ...acc, ...parseProperty(prop) }; - }, {}); - throw new Error('Unknown element type'); -}; - -const getManifestFromNode = (source: ts.VariableDeclaration | null): any | null => { - if (!source) return null; - - const init = source.initializer; - if (!init) return null; - if (!ts.isObjectLiteralExpression(init)) return null; - - return parseElement(init); -}; - -const parseManifest = (type: ManifestPackage, source: ts.SourceFile): any | null => { - const id = getName(source); - const manifest = getManifestFromNode(findManifestNode(MANIFEST_TITLES[type], source)); - if (!id || !manifest) return null; - return { id, type, ...manifest }; -}; - -export const resolveManifest = (type: ManifestPackage, content: string): any | null => { - const source = ts.createSourceFile('tmp.ts', content, ts.ScriptTarget.ESNext, true); - return parseManifest(type, source); -}; diff --git a/src/lib/server/project/package/manifest/manifest.type.ts b/src/lib/server/project/package/manifest/manifest.type.ts deleted file mode 100644 index 9df064b1..00000000 --- a/src/lib/server/project/package/manifest/manifest.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { EditorComponentManifest, EditorSystemManifest } from '@nanoforge-dev/ecs-lib'; - -import { type PackageTypeEnum } from '../package.enum'; - -export type ComponentManifest = EditorComponentManifest & { - id: string; - type: PackageTypeEnum.COMPONENT; -}; -export type SystemManifest = EditorSystemManifest & { id: string; type: PackageTypeEnum.SYSTEM }; diff --git a/src/lib/server/project/package/package-handler.ts b/src/lib/server/project/package/package-handler.ts deleted file mode 100644 index 8cc19451..00000000 --- a/src/lib/server/project/package/package-handler.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { join } from 'path'; - -import type { DirectoryContent } from '$lib/server/file-system/project-directory'; -import type { ProjectHandler } from '$lib/server/project'; - -import { ManifestHandler } from './manifest/manifest-handler'; -import { PACKAGES_PATH } from './package.const'; -import { PackageTypeEnum } from './package.enum'; -import type { - AssetPackage, - ComponentPackage, - CreatablePackage, - PackageType, - SystemPackage, -} from './package.type'; - -export class PackageHandler { - private readonly handler: ProjectHandler; - private readonly manifestHandler: ManifestHandler; - - constructor(handler: ProjectHandler) { - this.handler = handler; - this.manifestHandler = new ManifestHandler(handler); - } - - async installPackages(names: [string, ...string[]]): Promise { - if (names.length === 0) return []; - this.handler._cli.install(names, { server: this.handler._part === 'server' || undefined }); - - return Promise.all( - names.map(async (name) => { - const r = await this.handler._api.registry.getPackage(name); - return this._resolvesPackage(r.type, r._file); - }), - ); - } - - createComponent(name: string): ComponentPackage { - this._createPackage(PackageTypeEnum.COMPONENT, name); - return this.manifestHandler.resolveFromName(PackageTypeEnum.COMPONENT, name); - } - - createSystem(name: string): SystemPackage { - this._createPackage(PackageTypeEnum.SYSTEM, name); - return this.manifestHandler.resolveFromName(PackageTypeEnum.SYSTEM, name); - } - - async getComponents(): Promise { - return this._resolvesPackages(PackageTypeEnum.COMPONENT); - } - - async getSystems(): Promise { - return this._resolvesPackages(PackageTypeEnum.SYSTEM); - } - - async getAssets(): Promise { - return this._resolvesPackages(PackageTypeEnum.ASSET).map((asset) => ({ - ...asset, - path: asset.path.replace(new RegExp(`^${PACKAGES_PATH.asset}/`), ''), - })); - } - - private _resolvesPackages(type: T): PackageType[] { - const basePath = PACKAGES_PATH[type]; - const dir = this.handler.fs.getDirectory(basePath); - const content = dir.read(true, true); - const paths = this._resolvesPackageFilesPathFromContent(content); - return paths.map((path) => this._resolvesPackage(type, path, basePath)); - } - - private _resolvesPackage( - type: T, - filename: string, - basePath: string = PACKAGES_PATH[type], - ): PackageType { - const path = join(basePath, filename); - - if (type === PackageTypeEnum.ASSET) return { path } as PackageType; - return this.manifestHandler.resolveFromPath(type, path) as PackageType; - } - - private _resolvesPackageFilesPathFromContent( - content: DirectoryContent, - path: string = '', - ): string[] { - const files = content.files.map((file) => join(path, file)); - const directories = Object.entries(content.directories).map(([key, value]) => { - if (!value) return []; - return this._resolvesPackageFilesPathFromContent(value, join(path, key)).filter(Boolean); - }); - - return [...files, ...directories.flat()]; - } - - private _createPackage(type: CreatablePackage, name: string): void { - this.handler._cli.create(type, { - name, - server: this.handler._part === 'server' || undefined, - }); - } -} diff --git a/src/lib/server/project/package/package.const.ts b/src/lib/server/project/package/package.const.ts deleted file mode 100644 index a9ad9aa6..00000000 --- a/src/lib/server/project/package/package.const.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { formatFrom } from '@utils/format'; - -import { PackageTypeEnum } from './package.enum'; - -export const PACKAGES_PATH: Record = { - [PackageTypeEnum.COMPONENT]: 'components', - [PackageTypeEnum.SYSTEM]: 'systems', - [PackageTypeEnum.ASSET]: 'static', -}; - -export const PACKAGES_NAME_FORMATTER: Record string> = { - [PackageTypeEnum.COMPONENT]: (n) => formatFrom.all(n).toPascal() + 'Component', - [PackageTypeEnum.SYSTEM]: (n) => formatFrom.all(n).toCamel() + 'System', - [PackageTypeEnum.ASSET]: (n) => n, -}; - -export const PACKAGES_PATH_FORMATTER: Record string> = { - [PackageTypeEnum.COMPONENT]: (n) => formatFrom.all(n).toKebab() + '.component', - [PackageTypeEnum.SYSTEM]: (n) => formatFrom.all(n).toKebab() + '.system', - [PackageTypeEnum.ASSET]: (n) => n, -}; diff --git a/src/lib/server/project/package/package.enum.ts b/src/lib/server/project/package/package.enum.ts deleted file mode 100644 index 393991f4..00000000 --- a/src/lib/server/project/package/package.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum PackageTypeEnum { - COMPONENT = 'component', - SYSTEM = 'system', - ASSET = 'asset', -} diff --git a/src/lib/server/project/package/package.type.ts b/src/lib/server/project/package/package.type.ts deleted file mode 100644 index 9675fb36..00000000 --- a/src/lib/server/project/package/package.type.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SaveComponent, SaveSystem } from '@utils/types'; - -import type { ComponentManifest, SystemManifest } from './manifest/manifest.type'; -import type { PackageTypeEnum } from './package.enum'; - -export interface ComponentPackage { - manifest: ComponentManifest; - save: SaveComponent; -} - -export interface SystemPackage { - manifest: SystemManifest; - save: SaveSystem; -} - -export interface AssetPackage { - path: string; -} - -export interface Packages { - [PackageTypeEnum.COMPONENT]: ComponentPackage; - [PackageTypeEnum.SYSTEM]: SystemPackage; - [PackageTypeEnum.ASSET]: AssetPackage; -} - -export type PackageType = T extends PackageTypeEnum - ? Packages[T] - : ComponentPackage | SystemPackage | AssetPackage; - -export type ManifestPackage = PackageTypeEnum.COMPONENT | PackageTypeEnum.SYSTEM; -export type CreatablePackage = PackageTypeEnum.COMPONENT | PackageTypeEnum.SYSTEM; diff --git a/src/lib/server/project/project-handler.ts b/src/lib/server/project/project-handler.ts deleted file mode 100644 index 1213a0fe..00000000 --- a/src/lib/server/project/project-handler.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { join } from 'path'; - -import type { Api } from '$lib/server/api'; -import type { Cli } from '$lib/server/cli'; -import { FileSystem } from '$lib/server/file-system'; - -import { Exception } from '@utils/exception'; -import type { Part } from '@utils/types'; - -import type { Context, Handler } from '@utils-server/request-handler'; - -import { LibraryHandler } from './library/library-handler'; -import { Loader } from './loader'; -import { PackageHandler } from './package/package-handler'; -import { SaveHandler } from './save/save-handler'; - -export class ProjectHandler { - public readonly _path: string; - public readonly _part: Part; - public readonly _cli: Cli; - public readonly _api: Api; - public readonly _rootFs: FileSystem; - - private _fs: FileSystem | undefined; - private _loader: Loader | undefined; - private _save: SaveHandler | undefined; - private _package: PackageHandler | undefined; - private _library: LibraryHandler | undefined; - - constructor(handler: Handler, context: Context, part: Part) { - if (!context.project) throw new Exception('Bad Request', 'Project missing in context', 400); - this._path = context.project.path; - this._part = part; - this._cli = handler.cli; - this._api = handler.api; - this._rootFs = handler.fs; - } - - /** - * Get the file system of the project content - * This file system has his root at / - */ - get fs(): FileSystem { - if (!this._fs) this._fs = new FileSystem(join(this._path, this._part)); - return this._fs; - } - - /** - * Get the loader functions of the project - */ - get loader(): Loader { - if (!this._loader) this._loader = new Loader(this); - return this._loader; - } - - get save(): SaveHandler { - if (!this._save) this._save = new SaveHandler(this); - return this._save; - } - - get package(): PackageHandler { - if (!this._package) this._package = new PackageHandler(this); - return this._package; - } - - get library(): LibraryHandler { - if (!this._library) this._library = new LibraryHandler(this); - return this._library; - } -} diff --git a/src/lib/server/project/project-manager.ts b/src/lib/server/project/project-manager.ts deleted file mode 100644 index 4edb79d9..00000000 --- a/src/lib/server/project/project-manager.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ProjectHandler } from '$lib/server/project/project-handler'; - -import type { Context, Handler } from '@utils-server/request-handler'; - -export class ProjectManager { - private readonly _handler: Handler; - private readonly _context: Context; - - private _client: ProjectHandler | undefined; - private _server: ProjectHandler | undefined; - - constructor(handler: Handler, context: Context) { - this._handler = handler; - this._context = context; - } - - /** - * Get the client project handler - * @beta The separation between client and server projects is not the final goal of the project manager. - */ - get client(): ProjectHandler { - if (!this._client) this._client = new ProjectHandler(this._handler, this._context, 'client'); - return this._client; - } - - /** - * Get the server project handler - * @beta The separation between client and server projects is not the final goal of the project manager. - */ - get server(): ProjectHandler { - if (!this._server) this._server = new ProjectHandler(this._handler, this._context, 'server'); - return this._server; - } -} diff --git a/src/lib/server/project/project.type.ts b/src/lib/server/project/project.type.ts deleted file mode 100644 index d1324d0e..00000000 --- a/src/lib/server/project/project.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Project { - id: string; - cacheResolvable: string; -} diff --git a/src/lib/server/project/save/save-handler.ts b/src/lib/server/project/save/save-handler.ts deleted file mode 100644 index 39540496..00000000 --- a/src/lib/server/project/save/save-handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ProjectHandler } from '$lib/server/project'; - -import type { Save } from '@utils/types'; - -export class SaveHandler { - private readonly handler: ProjectHandler; - - constructor(handler: ProjectHandler) { - this.handler = handler; - } - - async getSave(): Promise { - return this.handler._rootFs.getFile(this.resolveSavePath()).readJson(); - } - - async updateSave(save: Save): Promise { - this.handler._rootFs.getFile(this.resolveSavePath()).writeJson(save); - } - - private resolveSavePath(): string { - return `.nanoforge/${this.handler._part}.save.json`; - } -} diff --git a/src/lib/server/session/index.ts b/src/lib/server/session/index.ts deleted file mode 100644 index 6be6a3a2..00000000 --- a/src/lib/server/session/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './session-handle'; -export { resolveSession, addProjectToSession, getOrCreateSession } from './session-functions'; -export type { Session } from './session.type'; -export { resolveProject, tryAddProjectSession } from './project/project-functions'; -export type { SessionProject } from './project/project.type'; diff --git a/src/lib/server/session/project/project-functions.ts b/src/lib/server/session/project/project-functions.ts deleted file mode 100644 index 1df1e9c1..00000000 --- a/src/lib/server/session/project/project-functions.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SESSION_PROJECT_HEADER } from '@utils/const'; - -import { generateKey } from '@utils-server/string'; - -import type { Session } from '../session.type'; -import { projectStore } from './project.store'; -import type { SessionProject } from './project.type'; - -export const projectExists = (id: string | null | undefined) => { - return !!id && projectStore.has(id); -}; - -export const hasRightToAccessProject = (id: string | null | undefined, session: Session) => { - return projectExists(id) && session.projects.includes(id as string); -}; - -export const resolveProject = (headers: Headers, session: Session) => { - if (!session) return null; - if (!session.projects.length) return null; - - const id = headers.get(SESSION_PROJECT_HEADER); - return getProject(id, session); -}; - -export const getProject = (id: string | null, session: Session) => { - if (!hasRightToAccessProject(id, session)) return null; - - return projectStore.get(id as string) ?? null; -}; - -export const tryAddProjectSession = (session: SessionProject): string => { - let id = generateKey(10); - while (projectStore.has(id)) id = generateKey(10); - projectStore.set(id, session); - return id; -}; diff --git a/src/lib/server/session/project/project.store.ts b/src/lib/server/session/project/project.store.ts deleted file mode 100644 index 1563222a..00000000 --- a/src/lib/server/session/project/project.store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SessionProject } from '$lib/server/session/project/project.type'; - -export const projectStore = new Map(); diff --git a/src/lib/server/session/project/project.type.ts b/src/lib/server/session/project/project.type.ts deleted file mode 100644 index a970c5bc..00000000 --- a/src/lib/server/session/project/project.type.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface SessionProject { - path: string; - gateway?: { - id: string; - token: string; - }; -} diff --git a/src/lib/server/session/session-functions.ts b/src/lib/server/session/session-functions.ts deleted file mode 100644 index 5332e5e6..00000000 --- a/src/lib/server/session/session-functions.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Session as SvelteSession } from 'svelte-kit-sessions'; - -import { Exception } from '@utils/exception'; - -import { generateKey } from '@utils-server/string'; - -import { PUBLIC_PATHS } from './session.const'; -import { sessionStore } from './session.store'; -import type { Session } from './session.type'; - -export const isPublicPath = (url: URL) => { - return PUBLIC_PATHS.includes(url.pathname) || url.pathname.startsWith('/actions'); -}; - -export const resolveSession = async (sessionHandler: SvelteSession): Promise => { - const id = sessionHandler.data.id; - - if (!id) return null; - - if (!sessionStore.has(id)) { - await resetSession(sessionHandler); - throw new Exception('Bad Request', 'Invalid session', 400); - } - return sessionStore.get(id) as Session; -}; - -export const isSessionExist = (id: string) => sessionStore.has(id); - -export const resetSession = (sessionHandler: SvelteSession): Promise => { - return sessionHandler.destroy(); -}; - -export const addProjectToSession = (session: Session, projectId: string): void => { - if (!sessionStore.has(session.id)) { - throw new Exception('Bad Request', 'Invalid session', 400); - } - - sessionStore.set(session.id, { - ...session, - projects: Array.from(new Set([...session.projects, projectId]).values()), - }); -}; - -export const getOrCreateSession = async (sessionHandler: SvelteSession): Promise => { - const session = await resolveSession(sessionHandler); - if (session) return session; - - const id = generateKey(10); - sessionStore.set(id, { id, projects: [] }); - await sessionHandler.setData({ id }); - await sessionHandler.save(); - return sessionStore.get(id) as Session; -}; diff --git a/src/lib/server/session/session-handle.ts b/src/lib/server/session/session-handle.ts deleted file mode 100644 index d7847734..00000000 --- a/src/lib/server/session/session-handle.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type Handle, redirect } from '@sveltejs/kit'; -import { sveltekitSessionHandle } from 'svelte-kit-sessions'; - -import { env } from '$env/dynamic/private'; - -import { generateKey } from '@utils-server/string'; - -import { isPublicPath, isSessionExist, resetSession } from './session-functions'; - -export const sessionHandle = sveltekitSessionHandle({ - secret: env.SESSION_SECRET || generateKey(), -}); - -export const checkAuthorizationHandle: Handle = async ({ event, resolve }) => { - const sessionData = event.locals.session.data; - - if (isPublicPath(event.url)) return resolve(event); - - if (!isSessionExist(sessionData.id)) { - await resetSession(event.locals.session); - throw redirect(302, '/'); - } - return resolve(event); -}; diff --git a/src/lib/server/session/session.const.ts b/src/lib/server/session/session.const.ts deleted file mode 100644 index fda8772f..00000000 --- a/src/lib/server/session/session.const.ts +++ /dev/null @@ -1 +0,0 @@ -export const PUBLIC_PATHS = ['/', '/load']; diff --git a/src/lib/server/session/session.store.ts b/src/lib/server/session/session.store.ts deleted file mode 100644 index d38664c4..00000000 --- a/src/lib/server/session/session.store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Session } from './session.type'; - -export const sessionStore = new Map(); diff --git a/src/lib/server/session/session.type.ts b/src/lib/server/session/session.type.ts deleted file mode 100644 index f1e09ff0..00000000 --- a/src/lib/server/session/session.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Session { - id: string; - projects: string[]; -} diff --git a/src/lib/server/utils/cookies/index.ts b/src/lib/server/utils/cookies/index.ts deleted file mode 100644 index 2c4e1108..00000000 --- a/src/lib/server/utils/cookies/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; - -import { env } from '$env/dynamic/private'; - -import type { TokenResponse } from '$lib/server/api'; - -export const setTokensInCookies = ( - cookies: Cookies, - { accessToken, refreshToken, tokenExpiresAt }: TokenResponse, -) => { - cookies.set('accessToken', accessToken, { - httpOnly: true, - secure: env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - expires: new Date(tokenExpiresAt), - }); - - cookies.set('refreshToken', refreshToken, { - httpOnly: true, - secure: env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 30, // 30 days - }); -}; diff --git a/src/lib/server/utils/request-handler/action-handler.ts b/src/lib/server/utils/request-handler/action-handler.ts deleted file mode 100644 index b5aa17e2..00000000 --- a/src/lib/server/utils/request-handler/action-handler.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type Action, type ActionFailure, fail } from '@sveltejs/kit'; - -import { Exception } from '@utils/exception'; - -import { parseFormDataBody } from './body'; -import { getContext } from './context'; -import { Handler } from './handler'; -import { assertRequest } from './request.policy'; -import type { Callback, RequestHandlerOptions } from './types'; - -const handleError = (e: unknown): ActionFailure<{ error: string; message: unknown }> => { - console.error(e); - if (e instanceof Exception) { - return fail(e.status, { error: e.error, message: e.message }); - } - return fail(500, { error: 'Internal Server Error', message: (e as any).toString() }); -}; - -export const useActionHandler = ( - callback: Callback, - options?: RequestHandlerOptions, -): Action => { - return async (event) => { - try { - const context = await getContext(event); - - const body = await parseFormDataBody(await event.request.formData(), options?.body); - - assertRequest(context, options); - - const handler = new Handler(context, event, body); - - return await callback(handler); - } catch (e) { - return handleError(e); - } - }; -}; diff --git a/src/lib/server/utils/request-handler/body.ts b/src/lib/server/utils/request-handler/body.ts deleted file mode 100644 index d36b1d8d..00000000 --- a/src/lib/server/utils/request-handler/body.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; - -import { Exception } from '@utils/exception'; -import type { ClassType } from '@utils/types'; - -export const parseFormDataBody = ( - rawBody: FormData, - c: ClassType | undefined, -): Promise => { - const baseBody = Object.fromEntries( - Array.from(rawBody.entries()).map(([key, value]) => [key, JSON.parse(value)]), - ); - return parseBody(baseBody, c); -}; - -export const parseBody = async ( - baseBody: any, - c: ClassType | undefined, -): Promise => { - if (!c) return baseBody as Body; - const data = plainToInstance(c, baseBody, { - excludeExtraneousValues: true, - }); - const errors = await validate(data); - if (errors.length > 0) - throw new Exception( - 'Bad Request', - `Invalid body :\n${errors.toString().replace(/,/g, '\n')}`, - 400, - ); - return baseBody as Body; -}; diff --git a/src/lib/server/utils/request-handler/context.ts b/src/lib/server/utils/request-handler/context.ts deleted file mode 100644 index 39f00abb..00000000 --- a/src/lib/server/utils/request-handler/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; - -import { PUBLIC_MODE } from '$env/static/public'; - -import { - type Session, - type SessionProject, - resolveProject, - resolveSession, -} from '$lib/server/session'; - -export interface Context { - online: boolean; - session: Session; - project: SessionProject; -} - -export const getContext = async (event: RequestEvent): Promise => { - const session = (await resolveSession(event.locals.session)) as Session; - const project = resolveProject(event.request.headers, session) as SessionProject; - - return { - online: PUBLIC_MODE === 'ONLINE', - session, - project, - }; -}; diff --git a/src/lib/server/utils/request-handler/handler.ts b/src/lib/server/utils/request-handler/handler.ts deleted file mode 100644 index 30b22dd9..00000000 --- a/src/lib/server/utils/request-handler/handler.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; - -import { type Api, getApi, getNoAuthApi } from '$lib/server/api'; -import { Cli } from '$lib/server/cli'; -import { ArchiveSystem, FileSystem } from '$lib/server/file-system'; -import { Git } from '$lib/server/git'; -import { ProjectManager } from '$lib/server/project'; - -import type { Context } from '@utils-server/request-handler/context'; - -export class Handler { - private _context: Context; - private readonly _event: RequestEvent; - private readonly _body: Body; - - private _apiCache: Api | undefined; - private _cliCache: Cli | undefined; - private _fsCache: FileSystem | undefined; - private _gitCache: Git | undefined; - private _projectCache: ProjectManager | undefined; - private _archiveCache: ArchiveSystem | undefined; - - constructor(context: Context, event: RequestEvent, body: Body) { - this._context = context; - this._event = event; - this._body = body; - } - - get context(): Context { - return this._context; - } - - set context(context: Context) { - this._context = context; - - this._apiCache = undefined; - this._cliCache = undefined; - this._fsCache = undefined; - this._gitCache = undefined; - this._archiveCache = undefined; - } - - get event(): RequestEvent { - return this._event; - } - - get body(): Body { - return this._body; - } - - get api(): Api { - if (!this._apiCache) - this._apiCache = this._context.online ? getApi(this._event.cookies) : getNoAuthApi(); - return this._apiCache; - } - - get cli(): Cli { - if (!this._cliCache) this._cliCache = new Cli(this._context); - return this._cliCache; - } - - get fs(): FileSystem { - if (!this._fsCache) this._fsCache = new FileSystem(this._context); - return this._fsCache; - } - - get git(): Git { - if (!this._gitCache) this._gitCache = new Git(this._context); - return this._gitCache; - } - - get project(): ProjectManager { - if (!this._projectCache) this._projectCache = new ProjectManager(this, this._context); - return this._projectCache; - } - - get archive(): ArchiveSystem { - if (!this._archiveCache) this._archiveCache = new ArchiveSystem(this, this._context); - return this._archiveCache; - } -} diff --git a/src/lib/server/utils/request-handler/index.ts b/src/lib/server/utils/request-handler/index.ts deleted file mode 100644 index a2343e05..00000000 --- a/src/lib/server/utils/request-handler/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './action-handler'; -export * from './request-handler'; -export * from './context'; -export * from './handler'; -export * from './types'; diff --git a/src/lib/server/utils/request-handler/request-handler.ts b/src/lib/server/utils/request-handler/request-handler.ts deleted file mode 100644 index e4d1ed29..00000000 --- a/src/lib/server/utils/request-handler/request-handler.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; - -import { Exception } from '@utils/exception'; -import type { MaybePromise } from '@utils/types'; - -import { getContext } from './context'; -import { Handler } from './handler'; -import { assertRequest } from './request.policy'; -import type { RequestHandlerOptions } from './types'; - -type Callback = (opts: Handler) => MaybePromise; - -const handleError = (e: unknown): Response => { - if (e instanceof Exception) { - return Response.json({ error: e.error, message: e.message }, { status: e.status }); - } - console.error(e); - return Response.json({ error: 'Internal Server Error', message: e }, { status: 500 }); -}; - -export const useRequestHandler = ( - callback: Callback, - options?: RequestHandlerOptions, -): ((event: RequestEvent) => MaybePromise) => { - return async (event: RequestEvent) => { - try { - const context = await getContext(event); - - assertRequest(context, options); - - const handler = new Handler(context, event, {}); - - return await callback(handler); - } catch (e) { - return handleError(e); - } - }; -}; diff --git a/src/lib/server/utils/request-handler/request.policy.ts b/src/lib/server/utils/request-handler/request.policy.ts deleted file mode 100644 index 47fbbbc7..00000000 --- a/src/lib/server/utils/request-handler/request.policy.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Exception } from '@utils/exception'; - -import type { Context } from './context'; -import type { RequestHandlerOptions } from './types'; - -const DEFAULT_OPTIONS: RequestHandlerOptions = { - onlineOnly: false, - offlineOnly: false, - projectOptional: false, -}; - -const assertProject = (context: Context, options: RequestHandlerOptions): void | never => { - if (options.projectOptional) return; - - if (!context.session) throw new Exception('Bad Request', 'No session', 400); - if (!context.project) throw new Exception('Bad Request', 'Request required a project', 400); - - if (context.online && !context.project.gateway) - throw new Exception('Internal Server Error', "Project don't have gateway on online mode", 500); -}; - -const assertMode = (context: Context, options: RequestHandlerOptions): void | never => { - if (options.onlineOnly && !context.online) - throw new Exception('Method Not Allowed', 'This action is not available offline', 405); - if (options.offlineOnly && context.online) - throw new Exception('Method Not Allowed', 'This action is not available online', 405); -}; - -export const assertRequest = ( - context: Context, - rawOptions?: RequestHandlerOptions, -): void | never => { - const options = { ...DEFAULT_OPTIONS, ...rawOptions }; - - assertMode(context, options); - assertProject(context, options); -}; diff --git a/src/lib/server/utils/request-handler/types.ts b/src/lib/server/utils/request-handler/types.ts deleted file mode 100644 index e660e66d..00000000 --- a/src/lib/server/utils/request-handler/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ClassType, MaybePromise } from '@utils/types'; - -import type { Handler } from './handler'; - -export type Callback = (opts: Handler) => MaybePromise; - -export interface RequestHandlerOptions { - onlineOnly?: boolean; - offlineOnly?: boolean; - projectOptional?: boolean; - body?: ClassType; -} diff --git a/src/lib/server/utils/string.ts b/src/lib/server/utils/string.ts deleted file mode 100644 index 08c33af8..00000000 --- a/src/lib/server/utils/string.ts +++ /dev/null @@ -1,6 +0,0 @@ -import crypto from 'crypto'; - -export const generateKey = (size = 50) => { - const buffer = crypto.randomBytes(size); - return buffer.toString('base64url').substring(0, size); -}; diff --git a/src/lib/server/utils/validators/index.ts b/src/lib/server/utils/validators/index.ts deleted file mode 100644 index 94e79c62..00000000 --- a/src/lib/server/utils/validators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IsFalseOrString } from './is-string-or-false.validator'; diff --git a/src/lib/server/utils/validators/is-string-or-false.validator.ts b/src/lib/server/utils/validators/is-string-or-false.validator.ts deleted file mode 100644 index 92eb3408..00000000 --- a/src/lib/server/utils/validators/is-string-or-false.validator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type ValidationOptions, isBoolean, isString, registerDecorator } from 'class-validator'; - -export const IsFalseOrString = (validationOptions?: ValidationOptions) => { - return (object: object, propertyName: string) => { - registerDecorator({ - target: object.constructor, - propertyName: propertyName, - ...(validationOptions ? { options: validationOptions } : {}), - constraints: [], - validator: { - validate(value: string) { - return (isBoolean(value) && !value) || isString(value); - }, - defaultMessage() { - return `$value must be a valid IP address or FQDN`; - }, - }, - }); - }; -}; diff --git a/src/lib/utils/const.ts b/src/lib/utils/const.ts deleted file mode 100644 index 53c0e25d..00000000 --- a/src/lib/utils/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const SESSION_PROJECT_HEADER = 'x-project-id'; diff --git a/src/lib/utils/exception.ts b/src/lib/utils/exception.ts deleted file mode 100644 index e7795541..00000000 --- a/src/lib/utils/exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class Exception extends Error { - constructor( - public error: string, - public message: string, - public status: number, - options?: { cause?: Error }, - ) { - super(`${error}: ${message}`, options); - } -} diff --git a/src/lib/utils/file.ts b/src/lib/utils/file.ts deleted file mode 100644 index 2ac7a207..00000000 --- a/src/lib/utils/file.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { TabTypeId } from '$lib/components/Tabs/types'; - -const FILE_TYPES: [TabTypeId, string[]][] = [ - ['ts', ['ts', 'js']], - ['3d', ['fbd']], - ['song', ['mp3', 'wav', 'flac']], - ['img', ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp']], -]; - -export const getType = (name: string) => { - const [type] = FILE_TYPES.find(([, exts]) => exts.includes(name.split('.').pop()!)) ?? [ - 'unknown', - ]; - return type; -}; diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts deleted file mode 100644 index 52d79253..00000000 --- a/src/lib/utils/format.ts +++ /dev/null @@ -1,58 +0,0 @@ -class WordList { - words: string[]; - - constructor(words: string[]) { - this.words = words; - } - - toKebab() { - return this.words.join('-'); - } - - toPascal() { - return this.words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); - } - - toCamel() { - return this.words - .map((word, i) => - i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1), - ) - .join(''); - } - - toSnake() { - return this.words.join('_'); - } -} - -const toWords = (str: string): string[] => { - return str - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') - .replace(/[-_]+/g, ' ') - .trim() - .split(/\s+/) - .filter(Boolean); -}; - -const fromKebab = (str: string): WordList => new WordList(str.split('-')); - -const fromPascal = (str: string): WordList => - new WordList(str.split(/(?=[A-Z])/).map((word) => word.toLowerCase())); - -const fromCamel = (str: string): WordList => - new WordList(str.split(/(?=[A-Z])/).map((word) => word.toLowerCase())); - -const fromSnake = (str: string): WordList => new WordList(str.split('_')); - -const fromAll = (str: string): WordList => - new WordList(toWords(str).map((word) => word.toLowerCase())); - -export const formatFrom = { - kebab: fromKebab, - pascal: fromPascal, - camel: fromCamel, - snake: fromSnake, - all: fromAll, -}; diff --git a/src/lib/utils/http/client.ts b/src/lib/utils/http/client.ts deleted file mode 100644 index 6fdd45a7..00000000 --- a/src/lib/utils/http/client.ts +++ /dev/null @@ -1,123 +0,0 @@ -export type RequestOptions = Omit; - -export interface MiddlewareParams { - path: string; - fullPath: string; - options: RequestOptions; -} - -export type MiddlewareNext = (params?: MiddlewareParams) => Promise; - -export type Middleware = ( - params: MiddlewareParams, - next: MiddlewareNext, -) => Promise | undefined; - -type BaseRequest = (path: string, options: RequestOptions) => Promise; - -export class HttpClient { - private readonly _baseUrl: string; - private readonly _baseOptions: RequestOptions; - private readonly _middlewares: Middleware[]; - - constructor(baseUrl: string, options?: RequestOptions) { - this._baseUrl = baseUrl; - this._baseOptions = options ?? { - headers: { - 'Content-Type': 'application/json', - }, - }; - this._middlewares = []; - } - - get(path: string, options?: RequestOptions): Promise { - return this._applyMiddlewares(path, options, (newPath, newOptions) => { - return this._request(newPath, { - ...newOptions, - method: 'GET', - }); - }); - } - - post(path: string, body?: string | FormData, options?: RequestOptions): Promise { - return this._applyMiddlewares(path, options, (newPath, newOptions) => { - return this._request(newPath, { - ...newOptions, - method: 'POST', - body: body, - }); - }); - } - - put(path: string, body?: string, options?: RequestOptions): Promise { - return this._applyMiddlewares(path, options, (newPath, newOptions) => { - return this._request(newPath, { - ...newOptions, - method: 'PUT', - body: body, - }); - }); - } - - patch(path: string, body?: string, options?: RequestOptions): Promise { - return this._applyMiddlewares(path, options, async (newPath, newOptions) => { - return this._request(newPath, { - ...newOptions, - method: 'PATCH', - body: body, - }); - }); - } - - delete(path: string, options?: RequestOptions): Promise { - return this._applyMiddlewares(path, options, (newPath, newOptions) => { - return this._request(newPath, { - ...newOptions, - method: 'DELETE', - }) as Promise; - }); - } - - useMiddlewares(...middlewares: Middleware[]): HttpClient { - for (const middleware of middlewares) this._middlewares.push(middleware); - return this; - } - - private _request(path: string, request: RequestInit): Promise { - return fetch(path, request); - } - - private _applyMiddlewares( - path: string, - options: RequestOptions | undefined, - callback: BaseRequest, - ): Promise { - const baseParams = { - path, - fullPath: this._getUrl(path), - options: { - ...this._baseOptions, - ...options, - }, - }; - const middlewares = this._middlewares.slice(); - let response: Response; - - const execution = async (params?: MiddlewareParams): Promise => { - if (!params) params = baseParams; - - const middleware = middlewares.shift(); - - if (!middleware) response = (await callback(params.fullPath, params.options)) as Response; - else response = (await middleware(params, execution)) ?? response; - - return response; - }; - - return execution(baseParams); - } - - private _getUrl(path: string): string { - return `${this._baseUrl}${path}`; - } -} diff --git a/src/lib/utils/http/index.ts b/src/lib/utils/http/index.ts deleted file mode 100644 index 3fbc7585..00000000 --- a/src/lib/utils/http/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - HttpClient, - type MiddlewareNext, - type MiddlewareParams, - type RequestOptions, -} from './client'; -export { getUrl } from './query'; diff --git a/src/lib/utils/http/query.ts b/src/lib/utils/http/query.ts deleted file mode 100644 index 43bc5009..00000000 --- a/src/lib/utils/http/query.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const getUrl = (url: string, query?: Record) => { - const entries = Object.entries(query ?? {}).filter(([, value]) => value !== undefined); - if (entries.length === 0) return url; - return `${url}?${new URLSearchParams(entries).toString()}`; -}; diff --git a/src/lib/utils/null.ts b/src/lib/utils/null.ts deleted file mode 100644 index 2aa9bb94..00000000 --- a/src/lib/utils/null.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const isNullish = (value: any): boolean => { - return value === undefined || value === null; -}; diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts deleted file mode 100644 index a293376c..00000000 --- a/src/lib/utils/object.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isNullish } from '@utils/null'; - -export const cloneDeep = (obj: T): T => JSON.parse(JSON.stringify(obj)); - -type AssignType = { - [K in keyof T]: T[K] extends object ? AssignType : V; -}; - -export const assignDeep = , V>(obj: T, value: V): AssignType => { - const copy: any = {}; - for (const key in obj) { - copy[key] = - !isNullish(obj[key]) && typeof obj[key] === 'object' ? assignDeep(obj[key], value) : value; - } - return copy; -}; - -export const flatObjectValues = (object: any): any[] => { - let results: any[] = []; - - for (const value of Object.values(object)) { - const values = typeof value === 'object' ? flatObjectValues(value) : [value]; - results = [...results, ...values]; - } - - return results; -}; diff --git a/src/lib/utils/string.ts b/src/lib/utils/string.ts deleted file mode 100644 index 162272f2..00000000 --- a/src/lib/utils/string.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); - -export const randomString = ( - nb: number, - { - numbers = true, - lowers = true, - uppers = false, - specials = false, - }: { numbers?: boolean; lowers?: boolean; uppers?: boolean; specials?: boolean } = {}, -) => { - let chars = ''; - if (numbers) chars += '0123456789'; - if (lowers) chars += 'abcdefghijklmnopqrstuvwxyz'; - if (uppers) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - if (specials) chars += '!@#$%^&*()_+~`|}{[]:;?><,./-='; - return [...Array(nb)].map(() => chars[Math.floor(Math.random() * chars.length)]).join(''); -}; diff --git a/src/lib/utils/types/base.type.ts b/src/lib/utils/types/base.type.ts deleted file mode 100644 index c5187d97..00000000 --- a/src/lib/utils/types/base.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type WithOptional = Omit & Partial>; diff --git a/src/lib/utils/types/class.type.ts b/src/lib/utils/types/class.type.ts deleted file mode 100644 index 3b2428af..00000000 --- a/src/lib/utils/types/class.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type ClassType = new (...args: any[]) => T; diff --git a/src/lib/utils/types/index.ts b/src/lib/utils/types/index.ts deleted file mode 100644 index 0db02026..00000000 --- a/src/lib/utils/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './base.type'; -export * from './class.type'; -export * from './part.type'; -export * from './promise.type'; -export * from './save.type'; diff --git a/src/lib/utils/types/part.type.ts b/src/lib/utils/types/part.type.ts deleted file mode 100644 index b51b16c9..00000000 --- a/src/lib/utils/types/part.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type Part = 'client' | 'server'; diff --git a/src/lib/utils/types/promise.type.ts b/src/lib/utils/types/promise.type.ts deleted file mode 100644 index 9cd354b3..00000000 --- a/src/lib/utils/types/promise.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type MaybePromise = T | Promise; diff --git a/src/lib/utils/types/save.type.ts b/src/lib/utils/types/save.type.ts deleted file mode 100644 index 1f79a466..00000000 --- a/src/lib/utils/types/save.type.ts +++ /dev/null @@ -1,39 +0,0 @@ -export enum SaveLibraryTypeEnum { - COMPONENT_SYSTEM = 'component-system', - GRAPHICS = 'graphics', - ASSET_MANAGER = 'asset-manager', - NETWORK = 'network', - INPUT = 'input', - SOUND = 'sound', -} - -export interface SaveLibrary { - id: string; - type: SaveLibraryTypeEnum | string; - name: string; - path: string; -} - -export interface SaveComponent { - name: string; - path: string; - paramsNames: string[]; -} - -export interface SaveSystem { - name: string; - path: string; -} - -export interface SaveEntity { - id: string; - treePath: string; - components: Record>; -} - -export interface Save { - libraries: SaveLibrary[]; - components: SaveComponent[]; - systems: SaveSystem[]; - entities: SaveEntity[]; -} diff --git a/src/lib/utils/ui.ts b/src/lib/utils/ui.ts deleted file mode 100644 index ebfd2739..00000000 --- a/src/lib/utils/ui.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export type { WithElementRef, WithoutChild, WithChildren, WithoutChildrenOrChild } from 'bits-ui'; - -export type WithoutChildren = Omit; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index 164f29d5..00000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - {@render children()} - diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 7c4dd291..00000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - - - -
    - - -
    - - -
    -

    Projects

    - - {#if isOnline} - - {:else} - - {/if} -
    - - - -
    -

    Recent

    - - {#if cacheQuery.isLoading || !cacheQuery.isFetched} - - {:else if cacheQuery.data && cacheQuery.data.length > 0} -
    - {#each cacheQuery.data as project (project.id)} - handleCacheProject(project)} - onRemove={() => handleCacheRemoveProject(project)} - /> - {/each} -
    - - - {:else} -
    -

    No recent projects

    -
    - {/if} -
    -
    -
    -
    -
    diff --git a/src/routes/actions/project/+page.server.ts b/src/routes/actions/project/+page.server.ts deleted file mode 100644 index af35f1b2..00000000 --- a/src/routes/actions/project/+page.server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { completeProjectAction } from '$lib/server/actions/project/complete.action'; -import { - getGatewayProjectsAction, - syncGatewayProjectAction, -} from '$lib/server/actions/project/gateway.action'; -import { - getInfoProjectAction, - setInfoProjectAction, -} from '$lib/server/actions/project/info.action'; -import { loadProjectAction } from '$lib/server/actions/project/load.action'; -import { createProjectAction } from '$lib/server/actions/project/new.action'; - -export const actions = { - load: loadProjectAction, - new: createProjectAction, - complete: completeProjectAction, - 'get-info': getInfoProjectAction, - 'set-info': setInfoProjectAction, - 'get-gateway-projects': getGatewayProjectsAction, - 'sync-gateway-project': syncGatewayProjectAction, -}; diff --git a/src/routes/actions/project/archive/+page.server.ts b/src/routes/actions/project/archive/+page.server.ts deleted file mode 100644 index 45b49e56..00000000 --- a/src/routes/actions/project/archive/+page.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createArchiveAction } from '$lib/server/actions/project/archive/create.action'; - -export const actions = { - create: createArchiveAction, -}; diff --git a/src/routes/actions/project/fs/+page.server.ts b/src/routes/actions/project/fs/+page.server.ts deleted file mode 100644 index d1eae25e..00000000 --- a/src/routes/actions/project/fs/+page.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { readdirFsAction } from '$lib/server/actions/project/fs/readdir.action'; - -export const actions = { - readdir: readdirFsAction, -}; diff --git a/src/routes/actions/project/library/+page.server.ts b/src/routes/actions/project/library/+page.server.ts deleted file mode 100644 index a9ca4267..00000000 --- a/src/routes/actions/project/library/+page.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { installLibraryProjectAction } from '$lib/server/actions/project/library/install-library.action'; - -export const actions = { - install: installLibraryProjectAction, -}; diff --git a/src/routes/actions/project/loader/+page.server.ts b/src/routes/actions/project/loader/+page.server.ts deleted file mode 100644 index 6a0272f4..00000000 --- a/src/routes/actions/project/loader/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { buildProjectAction } from '$lib/server/actions/project/loader/build.action'; -import { fetchEnvLoaderAction } from '$lib/server/actions/project/loader/env.action'; -import { fetchManifestLoaderAction } from '$lib/server/actions/project/loader/manifest.action'; - -export const actions = { - build: buildProjectAction, - env: fetchEnvLoaderAction, - manifest: fetchManifestLoaderAction, -}; diff --git a/src/routes/actions/project/package/+page.server.ts b/src/routes/actions/project/package/+page.server.ts deleted file mode 100644 index 078dcaee..00000000 --- a/src/routes/actions/project/package/+page.server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createComponentProjectAction } from '$lib/server/actions/project/package/create-component.action'; -import { createSystemProjectAction } from '$lib/server/actions/project/package/create-system.action'; -import { getAssetsAction } from '$lib/server/actions/project/package/get-assets.action'; -import { getComponentsAction } from '$lib/server/actions/project/package/get-components.action'; -import { getSystemsAction } from '$lib/server/actions/project/package/get-systems.action'; -import { installPackagesAction } from '$lib/server/actions/project/package/install-packages.action'; -import { searchPackagesAction } from '$lib/server/actions/project/package/search-packages.action'; - -export const actions = { - 'create-component': createComponentProjectAction, - 'create-system': createSystemProjectAction, - 'get-components': getComponentsAction, - 'get-systems': getSystemsAction, - 'get-assets': getAssetsAction, - 'install-packages': installPackagesAction, - 'search-packages': searchPackagesAction, -}; diff --git a/src/routes/actions/project/save/+page.server.ts b/src/routes/actions/project/save/+page.server.ts deleted file mode 100644 index 67ebdf1f..00000000 --- a/src/routes/actions/project/save/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { getSaveAction } from '$lib/server/actions/project/save/get-save.action'; -import { setSaveAction } from '$lib/server/actions/project/save/set-save.action'; - -export const actions = { - get: getSaveAction, - set: setSaveAction, -}; diff --git a/src/routes/components/cache-project-list-skeleton.svelte b/src/routes/components/cache-project-list-skeleton.svelte deleted file mode 100644 index c21d5543..00000000 --- a/src/routes/components/cache-project-list-skeleton.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
    - {#each { length: 3 }, i (i)} -
    - -
    - - -
    -
    - {/each} -
    diff --git a/src/routes/components/cache-project.svelte b/src/routes/components/cache-project.svelte deleted file mode 100644 index 8de4f81b..00000000 --- a/src/routes/components/cache-project.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - -
    - -
    - {#if isLoading} -
    - -
    - {:else} - - {/if} -
    -
    diff --git a/src/routes/components/create-project-dialog.svelte b/src/routes/components/create-project-dialog.svelte deleted file mode 100644 index 8ee55f1f..00000000 --- a/src/routes/components/create-project-dialog.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/src/routes/components/create-project-form.svelte b/src/routes/components/create-project-form.svelte deleted file mode 100644 index 21ebb5e9..00000000 --- a/src/routes/components/create-project-form.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - -
    - - - - - - - -
    diff --git a/src/routes/components/header.svelte b/src/routes/components/header.svelte deleted file mode 100644 index 48d90613..00000000 --- a/src/routes/components/header.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -
    -
    - Logo - Nanoforge - Editor -
    -
    diff --git a/src/routes/components/index.ts b/src/routes/components/index.ts deleted file mode 100644 index b7672795..00000000 --- a/src/routes/components/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import CacheProjectListSkeleton from './cache-project-list-skeleton.svelte'; -import CacheProject from './cache-project.svelte'; -import CreateProjectDialog from './create-project-dialog.svelte'; -import CreateProjectForm from './create-project-form.svelte'; -import Header from './header.svelte'; -import OfflineProjectButtons from './offline-project-buttons.svelte'; -import OnlineProjectButtons from './online-project-buttons.svelte'; -import OpenProjectDialog from './open-project-dialog.svelte'; -import OpenProjectForm from './open-project-form.svelte'; - -export default { - CacheProject, - CacheProjectListSkeleton, - CreateProjectDialog, - CreateProjectForm, - Header, - OfflineProjectButtons, - OnlineProjectButtons, - OpenProjectDialog, - OpenProjectForm, -}; diff --git a/src/routes/components/offline-project-buttons.svelte b/src/routes/components/offline-project-buttons.svelte deleted file mode 100644 index fba858e4..00000000 --- a/src/routes/components/offline-project-buttons.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/src/routes/components/online-project-buttons.svelte b/src/routes/components/online-project-buttons.svelte deleted file mode 100644 index e992a9a0..00000000 --- a/src/routes/components/online-project-buttons.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - diff --git a/src/routes/components/open-project-dialog.svelte b/src/routes/components/open-project-dialog.svelte deleted file mode 100644 index 1b43c0f6..00000000 --- a/src/routes/components/open-project-dialog.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/src/routes/components/open-project-form.svelte b/src/routes/components/open-project-form.svelte deleted file mode 100644 index 82846d12..00000000 --- a/src/routes/components/open-project-form.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
    - - - - - - - -
    diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte deleted file mode 100644 index 5051b273..00000000 --- a/src/routes/dashboard/+page.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -{#if loaded && $projectStore} - -
    -
    -
    - Logo -
    - - -
    -
    -
    - -
    -
    -
    - {#key $tabsStore.selectedTabId} - {#if Component && tab} - - {/if} - {/key} -
    -
    -{:else} - -{/if} diff --git a/src/routes/fs/archive/[slug]/+server.ts b/src/routes/fs/archive/[slug]/+server.ts deleted file mode 100644 index 24621735..00000000 --- a/src/routes/fs/archive/[slug]/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Exception } from '@utils/exception'; - -import { useRequestHandler } from '@utils-server/request-handler'; - -export const GET = useRequestHandler( - async ({ event, archive }) => { - const id = event.params.slug; - if (!id) throw new Exception('Bad Request', 'Missing id param', 400); - - const file = await archive.get(id); - - const stream = file.stream(); - - return new Response(stream); - }, - { projectOptional: true }, -); diff --git a/src/routes/fs/build/+server.ts b/src/routes/fs/build/+server.ts deleted file mode 100644 index 12509057..00000000 --- a/src/routes/fs/build/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Exception } from '@utils/exception'; - -import { useRequestHandler } from '@utils-server/request-handler'; - -/** - * To request a built file, use the following URL: - * /fs/build?path=path/to/file - * The path must be relative to /.nanoforge/client - * Only the client is handled for now - */ -export const GET = useRequestHandler(({ event, project }) => { - const path = event.url.searchParams.get('path'); - if (!path) throw new Exception('Bad Request', 'Missing path query param', 400); - - const file = project.client.loader.getFile(decodeURIComponent(path).replace(/\?url$/, '')); - - const stream = file.readStream(); - - return new Response(stream); -}); diff --git a/src/routes/fs/project/+server.ts b/src/routes/fs/project/+server.ts deleted file mode 100644 index 1ddb881f..00000000 --- a/src/routes/fs/project/+server.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Writable } from 'stream'; - -import { Exception } from '@utils/exception'; - -import { useRequestHandler } from '@utils-server/request-handler'; - -/** - * To request a project file, use the following URL: - * /fs/project?path=path/to/file - * The path must be relative to /client - * Only the client is handled for now - */ -export const GET = useRequestHandler(({ event, project }) => { - const path = event.url.searchParams.get('path'); - if (!path) throw new Exception('Bad Request', 'Missing path query param', 400); - - const file = project.client.fs.getFile(decodeURIComponent(path).replace(/\?url$/, '')); - - const stream = file.readStream(); - - return new Response(stream); -}); - -/** - * To post a project file, use the following URL: - * /fs/project?path=path/to/file - * The path must be relative to /client - * Only the client is handled for now - */ -export const POST = useRequestHandler(async ({ event, project }) => { - const path = event.url.searchParams.get('path'); - if (!path) throw new Exception('Bad Request', 'Missing path query param', 400); - - const file = project.client.fs.getFile(decodeURIComponent(path).replace(/\?url$/, '')); - - if (!event.request.body) throw new Exception('Bad Request', 'Missing body', 400); - - const stream = file.getWriteStream(); - await event.request.body.pipeTo(Writable.toWeb(stream)); - - return Response.json({ success: true }); -}); - -/** - * To delete a project file, use the following URL: - * /fs/project?path=path/to/file - * The path must be relative to /client - * Only the client is handled for now - */ -export const DELETE = useRequestHandler(async ({ event, project }) => { - const path = event.url.searchParams.get('path'); - if (!path) throw new Exception('Bad Request', 'Missing path query param', 400); - - const file = project.client.fs.getFile(decodeURIComponent(path).replace(/\?url$/, '')); - - file.delete(); - - return Response.json({ success: true }); -}); diff --git a/src/routes/load/+page.svelte b/src/routes/load/+page.svelte deleted file mode 100644 index f6d313ce..00000000 --- a/src/routes/load/+page.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -{#if query.isFetched && !query.isLoading && query.data} -
    - -
    -{:else} - -{/if} diff --git a/src/routes/load/components/complete-project-form.svelte b/src/routes/load/components/complete-project-form.svelte deleted file mode 100644 index 7e8c45b2..00000000 --- a/src/routes/load/components/complete-project-form.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - -
    - - - - - -
    -
    diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index 2552e309..00000000 --- a/svelte.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; -import adapter from 'svelte-adapter-bun'; - -/** @type {import('@sveltejs/kit').Config} */ -export default { - preprocess: [vitePreprocess()], - onwarn: (warning, handler) => { - if (['css_unused_selector'].includes(warning.code)) return; - handler(warning); - }, - kit: { - adapter: adapter({ - out: 'dist', - }), - alias: { - '@utils/*': './src/lib/utils/*', - '@utils-client/*': './src/lib/client/utils/*', - '@utils-server/*': './src/lib/server/utils/*', - }, - files: { - assets: 'public', - }, - }, -}; diff --git a/tooling/example-plugin/.prettierignore b/tooling/example-plugin/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/tooling/example-plugin/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/tooling/example-plugin/LICENSE b/tooling/example-plugin/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/tooling/example-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tooling/example-plugin/README.md b/tooling/example-plugin/README.md new file mode 100644 index 00000000..0cbc1302 --- /dev/null +++ b/tooling/example-plugin/README.md @@ -0,0 +1,37 @@ +
    +
    +

    + NanoForge +

    +
    +

    + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-example-plugin` is an internal package of the NanoForge Editor. + +Example editor plugin (fixture for end-to-end checks and the plugin authoring guide). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/tooling/example-plugin +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/tooling/example-plugin/nanoforge.manifest.json b/tooling/example-plugin/nanoforge.manifest.json new file mode 100644 index 00000000..3a18fee5 --- /dev/null +++ b/tooling/example-plugin/nanoforge.manifest.json @@ -0,0 +1,57 @@ +{ + "$schema": "../../packages/kernel/schemas/plugin.schema.json", + "type": "plugin", + "name": "@acme/hello", + "version": "0.1.0", + "displayName": "Hello", + "description": "Says hello when a project with the ECS is opened.", + "engines": { + "editor": ">=0.1.0" + }, + "engineLibs": { + "optional": { + "@nanoforge-dev/ecs": "*" + } + }, + "entry": { + "client": "index.js" + }, + "contributes": { + "commands": [ + { + "id": "hello.greet", + "title": "Say hello", + "category": "Hello" + }, + { + "id": "hello.twice", + "title": "Say hello twice", + "category": "Hello" + }, + { + "id": "hello.log", + "title": "Log hello", + "category": "Hello" + } + ], + "keymapPresets": [ + { + "id": "hello", + "title": "Hello keys", + "bindings": [{ "key": "F9", "command": "hello.twice" }] + } + ], + "menus": [ + { + "menu": "help", + "command": "hello.twice", + "group": "hello" + }, + { + "menu": "help", + "command": "hello.log", + "group": "hello" + } + ] + } +} diff --git a/tooling/example-plugin/package.json b/tooling/example-plugin/package.json new file mode 100644 index 00000000..f7882b5f --- /dev/null +++ b/tooling/example-plugin/package.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-example-plugin", + "version": "0.0.0", + "description": "NanoForge Editor - Example editor plugin (fixture for end-to-end checks and the plugin authoring guide)", + "keywords": [ + "nanoforge", + "game", + "editor", + "plugin" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "tooling/example-plugin" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "vite build", + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src" + }, + "devDependencies": { + "@nanoforge-dev/editor-sdk": "workspace:*", + "@nanoforge-dev/editor-vite-plugin": "workspace:*", + "svelte": "catalog:core", + "vite": "catalog:core" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/tooling/example-plugin/src/index.ts b/tooling/example-plugin/src/index.ts new file mode 100644 index 00000000..658d9664 --- /dev/null +++ b/tooling/example-plugin/src/index.ts @@ -0,0 +1,58 @@ +import { + EditorServices, + HistoryServiceToken, + definePlugin, + observe, +} from '@nanoforge-dev/editor-sdk'; +import { FILE_TEMPLATES } from '@nanoforge-dev/editor-sdk/ui'; + +export default definePlugin({ + activate(context) { + context.registerMessages('en', { greeting: 'Hello from {name}!' }); + context.registerMessages('fr', { greeting: 'Bonjour de {name} !' }); + context.registerCommand('hello.greet', () => context.t('greeting', { name: context.name })); + const history = context.services.get(HistoryServiceToken); + const hello = history.registerContext({ id: 'hello', label: 'Hello', owner: context.name }); + context.subscriptions.add(hello); + let greetings = 0; + const greet = (label: string) => ({ + label, + do: () => void greetings++, + undo: () => void greetings--, + }); + context.subscriptions.add( + context.registerCommand('hello.twice', () => + hello.stack.transaction('Say hello twice', async (tx) => { + await tx.push(greet('Hello')); + await tx.push(greet('Hello again')); + }), + ), + ); + context.subscriptions.add( + context.registerCommand('hello.log', () => { + context.logger.info('Hello state', { greetings, nested: { deep: true, list: [1, 2] } }); + context.logger.warn('Careful with greetings'); + for (let index = 0; index < 3; index++) context.logger.info('tick'); + context.logger.debug('A quiet detail'); + context.logger.info('Edit apps/client/src/main.ts:3:1 to say more'); + }), + ); + context.subscriptions.add( + context.contribute(FILE_TEMPLATES, { + id: 'hello.greeting', + title: 'Greeting', + category: 'Hello', + defaultName: 'greeting.txt', + create: ({ name }) => [{ path: name, content: `Hello from ${name}!\n` }], + }), + ); + + const bus = context.services.get(EditorServices.EventBus); + context.subscriptions.add( + observe(context.eligibleApps, (apps) => { + context.logger.info(`Eligible apps: ${apps.map((app) => app.name).join(', ') || 'none'}`); + }), + ); + context.logger.info('Hello plugin activated', typeof bus.emit); + }, +}); diff --git a/tooling/example-plugin/vite.config.ts b/tooling/example-plugin/vite.config.ts new file mode 100644 index 00000000..d408d8f6 --- /dev/null +++ b/tooling/example-plugin/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +export default defineConfig({ + plugins: [nanoforgeEditorPlugin()], +}); diff --git a/tooling/vite-plugin/.cliff-jumperrc.json b/tooling/vite-plugin/.cliff-jumperrc.json new file mode 100644 index 00000000..0d195e6f --- /dev/null +++ b/tooling/vite-plugin/.cliff-jumperrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/favware/cliff-jumper/main/assets/cliff-jumper.schema.json", + "name": "editor-vite-plugin", + "org": "nanoforge-dev", + "packagePath": "tooling/vite-plugin", + "tagTemplate": "{{new-version}}", + "identifierBase": false +} diff --git a/tooling/vite-plugin/.prettierignore b/tooling/vite-plugin/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/tooling/vite-plugin/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/tooling/vite-plugin/CHANGELOG.md b/tooling/vite-plugin/CHANGELOG.md new file mode 100644 index 00000000..6361e43e --- /dev/null +++ b/tooling/vite-plugin/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/tooling/vite-plugin/LICENSE b/tooling/vite-plugin/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/tooling/vite-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tooling/vite-plugin/README.md b/tooling/vite-plugin/README.md new file mode 100644 index 00000000..82003368 --- /dev/null +++ b/tooling/vite-plugin/README.md @@ -0,0 +1,52 @@ +
    +
    +

    + NanoForge +

    +
    +

    + npm version + npm downloads + Tests status + Last commit + Contributors +

    +
    + +## About + +`@nanoforge-dev/editor-vite-plugin` is part of the NanoForge Editor. + +Vite plugin to build editor plugins against the host's shared modules. + +## Installation + +**Node.js 26 or newer is required.** + +```sh +npm install @nanoforge-dev/editor-vite-plugin +yarn add @nanoforge-dev/editor-vite-plugin +pnpm add @nanoforge-dev/editor-vite-plugin +bun add @nanoforge-dev/editor-vite-plugin +``` + +## Links + +- [GitHub][source] +- [npm][npm] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/tooling/vite-plugin +[npm]: https://www.npmjs.com/package/@nanoforge-dev/editor-vite-plugin +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/tooling/vite-plugin/cliff.toml b/tooling/vite-plugin/cliff.toml new file mode 100644 index 00000000..8da3ea14 --- /dev/null +++ b/tooling/vite-plugin/cliff.toml @@ -0,0 +1,79 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +{% if version %}\ + # [{{ version | trim_start_matches(pat="v") }}]\ + {% if previous %}\ + {% if previous.version %}\ + ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\ + {% else %}\ + ({{ self::remote_url() }}/tree/{{ version }})\ + {% endif %}\ + {% endif %} \ + - ({{ timestamp | date(format="%Y-%m-%d") }}) +{% else %}\ + # [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ## {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}\ + **{{commit.scope}}:** \ + {% endif %}\ + {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ + {% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\ + {% if commit.breaking %}\ + {% for footer in commit.footers %}\ + {% if footer.breaking %}\ + \n{% raw %} {% endraw %}- **{{ footer.token }}{{ footer.separator }}** {{ footer.value }}\ + {% endif %}\ + {% endfor %}\ + {% endif %}\ + {% endfor %} +{% endfor %}\ +{% if github.contributors | filter(attribute="is_first_time", value=true) | length %}\ + \n### New Contributors\n + {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}\ + - @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }} + {% endfor %}\ +{% endif %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features"}, + { message = "^fix", group = "Bug Fixes"}, + { message = "^docs", group = "Documentation"}, + { message = "^perf", group = "Performance"}, + { message = "^refactor", group = "Refactor"}, + { message = "^types", group = "Typings"}, + { message = ".*deprecated", body = ".*deprecated", group = "Deprecation"}, + { message = "^revert", skip = true}, + { message = "^style", group = "Styling"}, + { message = "^test", group = "Testing"}, + { message = "^chore", skip = true}, + { message = "^ci", skip = true}, + { message = "^build", skip = true}, + { body = ".*security", group = "Security"}, +] +filter_commits = true +protect_breaking_commits = true +tag_pattern = "^[0-9]+" +ignore_tags = "" +topo_order = false +sort_commits = "newest" + +[remote.github] +owner = "NanoForge-dev" +repo = "Editor" diff --git a/tooling/vite-plugin/package.json b/tooling/vite-plugin/package.json new file mode 100644 index 00000000..b2dec733 --- /dev/null +++ b/tooling/vite-plugin/package.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-vite-plugin", + "version": "0.0.0", + "description": "NanoForge Editor - Vite plugin to build editor plugins against the host's shared modules", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "tooling/vite-plugin" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "tsdown", + "lint": "prettier --check . && eslint --format=pretty src", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'tooling/vite-plugin/*'", + "release": "cliff-jumper" + }, + "dependencies": { + "es-module-lexer": "catalog:tooling", + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "peerDependencies": { + "vite": ">=7" + }, + "devDependencies": { + "@favware/cliff-jumper": "catalog:ci", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@types/node": "catalog:core", + "tsdown": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:core", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/tooling/vite-plugin/src/index.ts b/tooling/vite-plugin/src/index.ts new file mode 100644 index 00000000..547146c2 --- /dev/null +++ b/tooling/vite-plugin/src/index.ts @@ -0,0 +1,14 @@ +export { + DROPPED_MODULES, + FORBIDDEN_MODULES, + SHARED_GLOBAL, + MAIN_SHARED_MODULES, + SHARED_MODULES, + WORKER_SHARED_MODULES, + isHostModule, + isSharedModule, +} from './shared-module/shared-modules'; +export type { SharedModuleId } from './shared-module/shared-modules'; +export { type BuiltManifest, MANIFEST_FILE, buildManifest } from './manifest/build-manifest'; +export { rewriteSharedImports } from './rewrite/rewrite-shared-imports'; +export * from './plugin/nanoforge-editor-plugin'; diff --git a/tooling/vite-plugin/src/manifest/build-manifest.ts b/tooling/vite-plugin/src/manifest/build-manifest.ts new file mode 100644 index 00000000..929b75db --- /dev/null +++ b/tooling/vite-plugin/src/manifest/build-manifest.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { parsePluginManifest } from '@nanoforge-dev/editor-kernel'; + +export const MANIFEST_FILE = 'nanoforge.manifest.json'; + +/** The normalized manifest emitted next to the plugin bundles. */ +export interface BuiltManifest { + readonly name: string; + readonly version: string; + readonly build: { readonly svelte?: string; readonly sdk?: string }; + readonly [key: string]: unknown; +} + +/** Version of a package as resolved from `root` (undefined when not installed). */ +const installedVersion = (root: string, name: string): string | undefined => { + try { + const require = createRequire(join(root, 'package.json')); + return (require(`${name}/package.json`) as { version: string }).version; + } catch { + return undefined; + } +}; + +/** + * Reads and validates the plugin manifest, then records the versions of the shared modules the + * plugin was compiled against (checked by the editor, see docs/adr/0001-plugin-ui-runtime.md). + */ +export const buildManifest = (root: string): BuiltManifest => { + const path = join(root, MANIFEST_FILE); + const manifest = parsePluginManifest(JSON.parse(readFileSync(path, 'utf8')), path); + const svelte = installedVersion(root, 'svelte'); + const sdk = installedVersion(root, '@nanoforge-dev/editor-sdk'); + return { + ...manifest, + build: { ...(svelte && { svelte }), ...(sdk && { sdk }) }, + }; +}; diff --git a/tooling/vite-plugin/src/plugin/nanoforge-editor-plugin.ts b/tooling/vite-plugin/src/plugin/nanoforge-editor-plugin.ts new file mode 100644 index 00000000..5774485c --- /dev/null +++ b/tooling/vite-plugin/src/plugin/nanoforge-editor-plugin.ts @@ -0,0 +1,55 @@ +import type { Plugin } from 'vite'; + +import { MANIFEST_FILE, buildManifest } from '../manifest/build-manifest'; +import { rewriteSharedImports } from '../rewrite/rewrite-shared-imports'; +import { isHostModule } from '../shared-module/shared-modules'; + +export interface EditorPluginBuildOptions { + /** Plugin entry files, relative to the plugin root. Defaults to `{ index: 'src/index.ts' }`. */ + entries?: Record; + /** + * Validate `nanoforge.manifest.json` and emit it next to the bundles, with build info. + * Defaults to true; disable for fixtures without a manifest. + */ + manifest?: boolean; +} + +/** + * Builds a NanoForge editor plugin as ES modules that use the host's shared modules + * (Svelte runtime, editor SDK) instead of bundling their own copy. + */ +export const nanoforgeEditorPlugin = (options: EditorPluginBuildOptions = {}): Plugin => { + let root = process.cwd(); + return { + name: 'nanoforge-editor-plugin', + enforce: 'post', + configResolved(config) { + root = config.root; + }, + config: () => ({ + base: './', + build: { + target: 'esnext', + lib: { + entry: options.entries ?? { index: 'src/index.ts' }, + formats: ['es'], + }, + rollupOptions: { + external: (id: string) => isHostModule(id), + }, + }, + }), + async renderChunk(code) { + const result = await rewriteSharedImports(code); + return result === null ? null : { code: result, map: null }; + }, + generateBundle() { + if (options.manifest === false) return; + this.emitFile({ + type: 'asset', + fileName: MANIFEST_FILE, + source: `${JSON.stringify(buildManifest(root), null, 2)}\n`, + }); + }, + }; +}; diff --git a/tooling/vite-plugin/src/rewrite/rewrite-shared-imports.ts b/tooling/vite-plugin/src/rewrite/rewrite-shared-imports.ts new file mode 100644 index 00000000..9977efef --- /dev/null +++ b/tooling/vite-plugin/src/rewrite/rewrite-shared-imports.ts @@ -0,0 +1,100 @@ +import { init, parse } from 'es-module-lexer'; + +import { + DROPPED_MODULES, + FORBIDDEN_MODULES, + SHARED_GLOBAL, + isHostModule, +} from '../shared-module/shared-modules'; + +const lookup = (specifier: string) => + `globalThis[${JSON.stringify(SHARED_GLOBAL)}].require(${JSON.stringify(specifier)})`; + +/** `a as b, c` → `a: b, c` (object destructuring syntax). */ +const toDestructuring = (namedClause: string) => + namedClause + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [imported, local] = part.split(/\s+as\s+/); + const key = /^["']/.test(imported!) ? imported : imported!.trim(); + return local ? `${key}: ${local.trim()}` : key; + }) + .join(', '); + +/** + * Rewrites the static import clause of `import from ''` into declarations. + * Supports default, namespace, named and combined clauses. + */ +const rewriteClause = (clause: string, specifier: string): string => { + const source = lookup(specifier); + const declarations: string[] = []; + let rest = clause.trim(); + + const defaultMatch = /^([\w$]+)\s*(?:,|$)/.exec(rest); + if (defaultMatch) { + declarations.push(`const ${defaultMatch[1]} = ${source}.default;`); + rest = rest.slice(defaultMatch[0].length).trim(); + } + const namespaceMatch = /^\*\s*as\s+([\w$]+)$/.exec(rest); + if (namespaceMatch) { + declarations.push(`const ${namespaceMatch[1]} = ${source};`); + rest = ''; + } + const namedMatch = /^\{([\s\S]*)\}$/.exec(rest); + if (namedMatch) { + const names = toDestructuring(namedMatch[1]!); + if (names) declarations.push(`const { ${names} } = ${source};`); + rest = ''; + } + if (rest) throw new Error(`Unsupported import of shared module "${specifier}": ${clause}`); + return declarations.join('\n'); +}; + +/** + * Replaces every import of a shared module in an ES module chunk by a lookup in the host's + * shared module registry. Returns `null` when the chunk does not import any shared module. + * + * Re-exports (`export … from 'shared'`) are rejected: plugins must import then export. + */ +export const rewriteSharedImports = async (code: string): Promise => { + await init; + const [imports] = parse(code); + const edits: { start: number; end: number; text: string }[] = []; + + for (const entry of imports) { + const specifier = entry.n; + if (!specifier || !isHostModule(specifier)) continue; + + const forbidden = FORBIDDEN_MODULES.get(specifier); + if (forbidden) throw new Error(`Cannot import "${specifier}": ${forbidden}`); + if (DROPPED_MODULES.has(specifier)) { + edits.push({ start: entry.ss, end: entry.se, text: '' }); + continue; + } + + if (entry.d > -1) { + edits.push({ + start: entry.ss, + end: entry.se, + text: `Promise.resolve(${lookup(specifier)})`, + }); + continue; + } + const statement = code.slice(entry.ss, entry.se); + if (/^export\b/.test(statement)) { + throw new Error(`Re-exporting shared module "${specifier}" is not supported: ${statement}`); + } + const clauseMatch = /^import\s*([\s\S]*?)\s*from\s*["']/.exec(statement); + const text = clauseMatch ? rewriteClause(clauseMatch[1]!, specifier) : ''; + edits.push({ start: entry.ss, end: entry.se, text }); + } + if (!edits.length) return null; + + let result = code; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + return result; +}; diff --git a/tooling/vite-plugin/src/shared-module/shared-modules.ts b/tooling/vite-plugin/src/shared-module/shared-modules.ts new file mode 100644 index 00000000..aa2591cd --- /dev/null +++ b/tooling/vite-plugin/src/shared-module/shared-modules.ts @@ -0,0 +1,61 @@ +/** + * Modules provided by the editor host at runtime. Editor plugins never bundle them: + * their imports are rewritten to lookups in the host's shared module registry, so every + * plugin runs against the exact same Svelte runtime and SDK instance as the editor. + * + * Keep in sync with the host registration (apps/editor/src/lib/plugins/shared-modules.ts). + */ +/** Modules the editor page provides (main thread). */ +export const MAIN_SHARED_MODULES = [ + 'svelte', + 'svelte/animate', + 'svelte/attachments', + 'svelte/easing', + 'svelte/events', + 'svelte/internal/client', + 'svelte/legacy', + 'svelte/motion', + 'svelte/reactivity', + 'svelte/reactivity/window', + 'svelte/store', + 'svelte/transition', + '@nanoforge-dev/editor-sdk', + '@nanoforge-dev/editor-sdk/ui', +] as const; + +/** Modules the code worker provides to plugin worker entries (`entry.worker`). */ +export const WORKER_SHARED_MODULES = ['ts-morph', '@nanoforge-dev/editor-sdk/worker'] as const; + +export const SHARED_MODULES = [...MAIN_SHARED_MODULES, ...WORKER_SHARED_MODULES] as const; + +export type SharedModuleId = (typeof SHARED_MODULES)[number]; + +/** Side-effect only modules the host already evaluated: their imports are dropped. */ +export const DROPPED_MODULES: ReadonlySet = new Set([ + 'svelte/internal/disclose-version', + 'svelte/internal/flags/tracing', +]); + +/** Modules that would switch global runtime flags of the host: importing them is a build error. */ +export const FORBIDDEN_MODULES: ReadonlyMap = new Map([ + [ + 'svelte/internal/flags/legacy', + 'Editor plugins must compile Svelte components in runes mode (compilerOptions.runes = true).', + ], + [ + 'svelte/internal/flags/async', + 'Editor plugins cannot enable experimental async Svelte: the editor host does not use it.', + ], +]); + +/** Name of the global object the host exposes; must match the kernel's `SharedModuleRegistry`. */ +export const SHARED_GLOBAL = '__nanoforge_editor_shared__'; + +const SHARED_SET: ReadonlySet = new Set(SHARED_MODULES); + +export const isSharedModule = (specifier: string): specifier is SharedModuleId => + SHARED_SET.has(specifier); + +/** Whether the bundler must leave this import to the host (shared, dropped or forbidden). */ +export const isHostModule = (specifier: string): boolean => + SHARED_SET.has(specifier) || DROPPED_MODULES.has(specifier) || FORBIDDEN_MODULES.has(specifier); diff --git a/tooling/vite-plugin/test/manifest/build-manifest.spec.ts b/tooling/vite-plugin/test/manifest/build-manifest.spec.ts new file mode 100644 index 00000000..1543cc84 --- /dev/null +++ b/tooling/vite-plugin/test/manifest/build-manifest.spec.ts @@ -0,0 +1,38 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { buildManifest } from '../../src/manifest/build-manifest'; + +const pluginDir = (manifest: object) => { + const root = mkdtempSync(join(tmpdir(), 'nf-plugin-')); + writeFileSync(join(root, 'package.json'), '{"name":"fixture"}'); + writeFileSync(join(root, 'nanoforge.manifest.json'), JSON.stringify(manifest)); + const svelte = join(root, 'node_modules', 'svelte'); + mkdirSync(svelte, { recursive: true }); + writeFileSync(join(svelte, 'package.json'), '{"name":"svelte","version":"5.50.0"}'); + return root; +}; + +describe('buildManifest', () => { + it('validates the manifest and records the svelte version', () => { + const root = pluginDir({ + type: 'plugin', + name: '@nanoforge/fixture', + version: '0.1.0', + engines: { editor: '^0.1.0' }, + entry: { client: 'index.js' }, + }); + expect(buildManifest(root)).toMatchObject({ + name: '@nanoforge/fixture', + activation: ['onStartup'], + build: { svelte: '5.50.0' }, + }); + }); + + it('fails the build on invalid manifests', () => { + const root = pluginDir({ type: 'plugin', name: 'bad' }); + expect(() => buildManifest(root)).toThrow(/Invalid plugin manifest/); + }); +}); diff --git a/tooling/vite-plugin/test/rewrite/rewrite-shared-imports.spec.ts b/tooling/vite-plugin/test/rewrite/rewrite-shared-imports.spec.ts new file mode 100644 index 00000000..db8d064a --- /dev/null +++ b/tooling/vite-plugin/test/rewrite/rewrite-shared-imports.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { rewriteSharedImports } from '../../src/rewrite/rewrite-shared-imports'; + +const L = (s: string) => `globalThis["__nanoforge_editor_shared__"].require("${s}")`; + +describe('rewriteSharedImports', () => { + it('returns null when no shared module is imported', async () => { + expect(await rewriteSharedImports(`import x from './x.js'; x();`)).toBeNull(); + }); + + it('rewrites namespace imports (compiled svelte components)', async () => { + const out = await rewriteSharedImports(`import * as $ from 'svelte/internal/client';\n$.x();`); + expect(out).toBe(`const $ = ${L('svelte/internal/client')};;\n$.x();`); + }); + + it('rewrites named, aliased and default imports', async () => { + const out = await rewriteSharedImports( + `import sdk, { mount as m, onMount } from "@nanoforge-dev/editor-sdk";`, + ); + expect(out).toContain(`const sdk = ${L('@nanoforge-dev/editor-sdk')}.default;`); + expect(out).toContain(`const { mount: m, onMount } = ${L('@nanoforge-dev/editor-sdk')};`); + }); + + it('handles minified output', async () => { + const out = await rewriteSharedImports( + `import"svelte/internal/disclose-version";import*as e from"svelte/internal/client";import{writable as t}from"svelte/store";e.a(t);`, + ); + expect(out).toBe( + `;const e = ${L('svelte/internal/client')};;const { writable: t } = ${L('svelte/store')};;e.a(t);`, + ); + }); + + it('rewrites dynamic imports', async () => { + const out = await rewriteSharedImports(`const s = await import('svelte/store');`); + expect(out).toBe(`const s = await Promise.resolve(${L('svelte/store')});`); + }); + + it('keeps non shared imports untouched', async () => { + const out = await rewriteSharedImports(`import a from './a.js';import{b}from"svelte";`); + expect(out).toBe(`import a from './a.js';const { b } = ${L('svelte')};;`); + }); + + it('rejects legacy mode components', async () => { + await expect(rewriteSharedImports(`import 'svelte/internal/flags/legacy';`)).rejects.toThrow( + /runes mode/, + ); + }); + + it('rejects re-exports of shared modules', async () => { + await expect(rewriteSharedImports(`export { mount } from 'svelte';`)).rejects.toThrow( + /Re-exporting shared module/, + ); + }); +}); diff --git a/tooling/vite-plugin/tsconfig.json b/tooling/vite-plugin/tsconfig.json new file mode 100644 index 00000000..10ecbb8a --- /dev/null +++ b/tooling/vite-plugin/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/tooling/vite-plugin/tsdown.config.ts b/tooling/vite-plugin/tsdown.config.ts new file mode 100644 index 00000000..617fdbeb --- /dev/null +++ b/tooling/vite-plugin/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node22', + dts: true, + sourcemap: true, + clean: true, + fixedExtension: false, + deps: { alwaysBundle: ['@nanoforge-dev/editor-kernel'], neverBundle: ['semver', 'zod'] }, +}); diff --git a/tsconfig.json b/tsconfig.json index 153b2290..953a2cf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,41 @@ { - "extends": "./.svelte-kit/tsconfig.json", + "$schema": "https://json.schemastore.org/tsconfig.json", "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, + // Type Checking + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": false, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + + // Modules + "allowImportingTsExtensions": false, + "module": "ESNext", + "moduleResolution": "Bundler", "resolveJsonModule": true, - "skipLibCheck": true, + "resolvePackageJsonExports": true, + "resolvePackageJsonImports": true, + + // Emit + "noEmit": true, + "newLine": "lf", "sourceMap": true, - "strict": true, - "moduleResolution": "bundler", - "types": ["bun"], - "lib": ["esnext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], - "experimentalDecorators": true - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // To make changes to top-level options such as include and exclude, we recommend extending - // the generated config; see https://svelte.dev/docs/kit/configuration#typescript + + // Interop Constraints + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + + // Language and Environment + "lib": ["ESNext", "esnext.disposable", "DOM", "DOM.Iterable"], + "target": "ESNext", + "useDefineForClassFields": true + }, + "exclude": ["node_modules", "dist"] } diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..59ace577 --- /dev/null +++ b/turbo.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["tsconfig.json", "eslint.config.js", "vitest.config.ts"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": [ + "src/**", + "static/**", + "package.json", + "tsconfig.json", + "*.config.{js,ts}", + "nanoforge.manifest.json" + ], + "outputs": ["dist/**", ".svelte-kit/**"], + "outputLogs": "errors-only" + }, + "dev": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true + }, + "typecheck": { + "dependsOn": ["^build"], + "outputs": [], + "outputLogs": "errors-only" + }, + "lint": { + "outputs": [], + "outputLogs": "errors-only" + }, + "format": { + "cache": false, + "outputs": [] + }, + "test:unit": { + "dependsOn": ["^build"], + "inputs": ["src/**", "test/**", "package.json", "tsconfig.json"], + "outputs": [], + "outputLogs": "errors-only" + } + }, + "globalPassThroughEnv": ["CHROME", "CI"] +} diff --git a/uno.config.ts b/uno.config.ts deleted file mode 100644 index e6057f02..00000000 --- a/uno.config.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local'; -import { - defineConfig, - presetAttributify, - presetIcons, - presetTypography, - presetWebFonts, - presetWind4, - transformerDirectives, - transformerVariantGroup, -} from 'unocss'; - -export default defineConfig({ - theme: { - colors: { - background: 'oklch(var(--background))', - foreground: 'oklch(var(--foreground))', - border: 'oklch(var(--border))', - input: 'oklch(var(--input))', - ring: 'oklch(var(--ring))', - card: { - DEFAULT: 'oklch(var(--card))', - foreground: 'oklch(var(--card-foreground))', - }, - popover: { - DEFAULT: 'oklch(var(--popover))', - foreground: 'oklch(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'oklch(var(--primary))', - foreground: 'oklch(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'oklch(var(--secondary))', - foreground: 'oklch(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'oklch(var(--muted))', - foreground: 'oklch(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'oklch(var(--accent))', - foreground: 'oklch(var(--accent-foreground))', - }, - success: { - DEFAULT: 'oklch(var(--success))', - foreground: 'oklch(var(--success-foreground))', - }, - destructive: { - DEFAULT: 'oklch(var(--destructive))', - foreground: 'oklch(var(--destructive-foreground))', - }, - }, - }, - rules: [ - // animate-in / animate-out base classes (tailwindcss-animate equivalent) - [ - 'animate-in', - { - 'animation-name': 'enter', - 'animation-duration': 'var(--un-duration,150ms)', - '--un-enter-opacity': 'initial', - '--un-enter-scale': 'initial', - '--un-enter-rotate': 'initial', - '--un-enter-translate-x': 'initial', - '--un-enter-translate-y': 'initial', - }, - ], - [ - 'animate-out', - { - 'animation-name': 'exit', - 'animation-duration': 'var(--un-duration,150ms)', - '--un-exit-opacity': 'initial', - '--un-exit-scale': 'initial', - '--un-exit-rotate': 'initial', - '--un-exit-translate-x': 'initial', - '--un-exit-translate-y': 'initial', - }, - ], - [/^fade-in-(\d+)$/, ([, n]) => ({ '--un-enter-opacity': `${Number(n) / 100}` })], - [/^fade-out-(\d+)$/, ([, n]) => ({ '--un-exit-opacity': `${Number(n) / 100}` })], - [/^zoom-in-(\d+)$/, ([, n]) => ({ '--un-enter-scale': `${Number(n) / 100}` })], - [/^zoom-out-(\d+)$/, ([, n]) => ({ '--un-exit-scale': `${Number(n) / 100}` })], - [ - /^slide-in-from-top-(\d+)$/, - ([, n]) => ({ '--un-enter-translate-y': `-${Number(n) * 0.25}rem` }), - ], - [ - /^slide-in-from-bottom-(\d+)$/, - ([, n]) => ({ '--un-enter-translate-y': `${Number(n) * 0.25}rem` }), - ], - [ - /^slide-in-from-left-(\d+)$/, - ([, n]) => ({ '--un-enter-translate-x': `-${Number(n) * 0.25}rem` }), - ], - [ - /^slide-in-from-right-(\d+)$/, - ([, n]) => ({ '--un-enter-translate-x': `${Number(n) * 0.25}rem` }), - ], - [ - /^slide-out-to-top-(\d+)$/, - ([, n]) => ({ '--un-exit-translate-y': `-${Number(n) * 0.25}rem` }), - ], - [ - /^slide-out-to-bottom-(\d+)$/, - ([, n]) => ({ '--un-exit-translate-y': `${Number(n) * 0.25}rem` }), - ], - [ - /^slide-out-to-left-(\d+)$/, - ([, n]) => ({ '--un-exit-translate-x': `-${Number(n) * 0.25}rem` }), - ], - [ - /^slide-out-to-right-(\d+)$/, - ([, n]) => ({ '--un-exit-translate-x': `${Number(n) * 0.25}rem` }), - ], - ], - shortcuts: [ - [ - 'modal-overlay', - 'fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4', - ], - ['modal-card', 'bg-card rounded-2xl w-full max-w-md overflow-hidden'], - ['modal-header', 'px-6 pt-7 pb-5 flex items-center gap-3.5'], - ['modal-body', 'px-6 py-5 flex flex-col gap-5'], - ['modal-footer', 'px-6 pb-6 flex items-center justify-end gap-2'], - [ - 'modal-icon-badge', - 'shrink-0 size-10 rounded-xl bg-primary/10 flex items-center justify-center', - ], - ['modal-divider', 'h-px bg-white/[0.04]'], - [ - 'toggle-row', - 'flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-muted/40 transition-colors text-left', - ], - ['toggle-row-title', 'text-sm text-foreground font-medium leading-tight'], - ['toggle-row-desc', 'text-xs text-muted-foreground mt-0.5'], - ['btn-ghost', 'px-4 py-2 text-sm rounded-lg transition-all duration-150'], - [ - 'btn-primary', - 'flex items-center hover:bg-primary/80 gap-2 px-5 py-2 text-sm font-medium rounded-lg transition-colors duration-150 disabled:opacity-40', - ], - ], - presets: [ - presetWind4(), - presetAttributify(), - presetIcons({ - scale: 1.2, - }), - presetTypography(), - presetWebFonts({ - fonts: { - sans: 'DM Sans', - serif: 'DM Serif Display', - mono: 'DM Mono', - }, - processors: createLocalFontProcessor(), - }), - ], - transformers: [transformerDirectives(), transformerVariantGroup()], - safelist: [ - 'i-material-icon-theme-huff', - 'i-material-icon-theme-typescript', - 'i-material-icon-theme-3d', - 'i-material-icon-theme-lyric', - 'i-material-icon-theme-folder-interceptor', - 'i-ic-baseline-add', - 'i-ic-baseline-folder', - 'i-material-icon-theme-json', - 'i-ic-baseline-upload', - 'i-ic-baseline-folder-open', - ], -}); diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index c1d308a1..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { paraglideVitePlugin } from '@inlang/paraglide-js'; -import { sveltekit } from '@sveltejs/kit/vite'; -import extractorSvelte from '@unocss/extractor-svelte'; -import { playwright } from '@vitest/browser-playwright'; -import { builtinModules } from 'module'; -import UnoCSS from 'unocss/vite'; -import { defineConfig } from 'vitest/config'; - -const ssrNodeBuiltinsPlugin = { - name: 'ssr-node-builtins', - enforce: 'pre', - resolveId(id: string, _importer: string | undefined, options: { ssr?: boolean }) { - if (options?.ssr && builtinModules.includes(id) && !id.startsWith('bun')) { - return { id: `node:${id}`, external: true }; - } - }, -}; - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [ - ssrNodeBuiltinsPlugin, - UnoCSS({ - extractors: [extractorSvelte()], - }), - sveltekit(), - paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }), - ], - test: { - expect: { requireAssertions: true }, - projects: [ - { - extends: './vite.config.ts', - test: { - name: 'client', - browser: { - enabled: true, - provider: playwright(), - instances: [{ browser: 'chromium', headless: true }], - }, - include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'], - }, - }, - { - extends: './vite.config.ts', - test: { - name: 'server', - environment: 'node', - include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'], - }, - }, - ], - coverage: { - enabled: true, - reporter: ['text', 'lcov', 'cobertura'], - provider: 'v8', - include: ['src/**/*.{ts,svelte}'], - exclude: ['**/*.{interface,type,d}.ts', '**/{interfaces,types}/*.ts', '**/index.{js,ts}'], - }, - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..899c4484 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Shared unit-test config. Each package runs `vitest run --config ../../vitest.config.ts` + * from its own directory, so `include` is relative to the package. + */ +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + exclude: ['**/node_modules', '**/dist', '**/*.svelte.spec.ts'], + passWithNoTests: true, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src'], + exclude: ['**/*.{type,d}.ts', '**/index.ts'], + }, + }, +}); From 9ee50c0b4957693387c3835d49074832e08a31ed Mon Sep 17 00:00:00 2001 From: Exelo Date: Sat, 10 Oct 2026 22:07:17 +0200 Subject: [PATCH 2/8] refactor: remove file history tracker and consolidate related functionality --- .github/actions/prepare/action.yml | 4 +- .github/workflows/e2e.yml | 33 ++++ .github/workflows/tests.yml | 3 + CLAUDE.md | 3 + Dockerfile | 22 ++- apps/editor/e2e/code-editor.spec.ts | 3 +- apps/editor/e2e/command-palette.spec.ts | 4 +- apps/editor/e2e/console.spec.ts | 4 +- apps/editor/e2e/ecs-live.spec.ts | 8 +- apps/editor/e2e/ecs.spec.ts | 4 +- apps/editor/e2e/file-manager.spec.ts | 3 +- apps/editor/e2e/git.spec.ts | 4 +- apps/editor/e2e/helpers.ts | 9 + apps/editor/e2e/history-panel.spec.ts | 3 +- apps/editor/e2e/inspectors.spec.ts | 4 +- apps/editor/e2e/manual-pass.spec.ts | 4 +- apps/editor/e2e/marketplace.spec.ts | 4 +- apps/editor/e2e/project-plugins.spec.ts | 4 +- apps/editor/e2e/project.spec.ts | 4 +- apps/editor/e2e/runtime.spec.ts | 3 +- apps/editor/e2e/scene.spec.ts | 4 +- apps/editor/e2e/settings-ui.spec.ts | 3 +- apps/editor/e2e/startup.bench.ts | 4 +- apps/editor/e2e/test.ts | 4 + apps/editor/e2e/viewport.spec.ts | 5 +- apps/editor/e2e/workbench.spec.ts | 7 +- apps/editor/package.json | 1 - apps/editor/src/lib/editor/create-editor.ts | 19 ++- .../lib/editor/register-app-contributions.ts | 9 +- .../lib/history/indexed-db-history-store.ts | 32 ++++ .../src/lib/plugin/follow-project-plugins.ts | 53 +++++- .../src/lib/runtime/follow-project-runtime.ts | 3 +- .../src/lib/workspace/workspace-actions.ts | 45 ++++- apps/editor/src/routes/(editor)/+page.svelte | 14 +- .../src/routes/(editor)/load/+page.svelte | 57 ++++++- apps/editor/src/routes/play/[id]/+page.svelte | 2 +- docs/api/README.md | 15 +- docs/docs/editor/play.mdx | 4 +- docs/docs/editor/plugins-and-packages.mdx | 2 +- .../create-your-first-project.mdx | 2 +- .../breakout/apps/client/src/scenes/levels.ts | 2 +- packages/code/src/catalog/catalog-service.ts | 59 +++++-- .../code/src/document/document-service.ts | 8 +- packages/code/src/document/match-glob.ts | 16 +- packages/code/src/engine/code-engine.ts | 15 +- packages/code/src/engine/code-engine.type.ts | 5 + packages/code/src/engine/index.ts | 1 + .../code/src/engine/rename-package.const.ts | 5 + packages/code/src/engine/rename-package.ts | 34 ++++ packages/code/src/index.ts | 1 + packages/code/src/service/code-service.ts | 78 +++++++-- packages/code/src/worker-rpc/serve-engine.ts | 3 + packages/code/src/worker-rpc/worker-rpc.ts | 49 +++++- .../test/document/document-service.spec.ts | 7 + packages/code/test/engine/code-engine.spec.ts | 30 ++++ .../code/test/service/code-service.spec.ts | 34 ++++ .../code/test/worker-rpc/worker-rpc.spec.ts | 29 ++++ packages/history/package.json | 2 - .../history/src/command/composite-command.ts | 61 +++++-- .../history/src/file/file-history-tracker.ts | 120 ------------- packages/history/src/file/index.ts | 1 - .../history/src/service/history-service.ts | 41 +++-- packages/history/src/stack/history-stack.ts | 16 +- .../test/file/file-history-tracker.spec.ts | 65 ------- packages/history/test/file/project-fs.spec.ts | 62 ------- .../test/service/history-service.spec.ts | 38 +++++ .../history/test/stack/history-stack.spec.ts | 35 +++- packages/kernel/src/context/when-tokenizer.ts | 1 + packages/kernel/src/plugin/plugin-host.ts | 66 +++++--- .../kernel/test/context/parse-when.spec.ts | 5 + .../kernel/test/plugin/plugin-host.spec.ts | 23 +++ packages/project/src/workspace/changes.ts | 82 +++++++-- .../src/workspace/library-operations.ts | 13 +- .../project/src/workspace/workspace.type.ts | 13 ++ .../test/workspace/library-operations.spec.ts | 46 +++++ .../protocol/src/session/session.contract.ts | 1 - packages/registry/src/archive/archive.ts | 46 ++++- .../registry/src/client/registry-client.ts | 71 +++++++- packages/registry/src/install/install-item.ts | 2 +- packages/registry/src/item/item.schema.ts | 4 +- .../src/project/packages-file.schema.ts | 2 +- .../registry/src/project/project-packages.ts | 28 ++- packages/registry/test/registry.spec.ts | 87 ++++++++++ packages/rpc/src/router/rpc-session.ts | 1 + .../test/service/runtime-service.spec.ts | 2 +- packages/sdk/src/index.ts | 1 - packages/sdk/test/index.spec.ts | 1 - packages/server-core/src/api/api-client.ts | 126 +++++++++++--- .../server-core/src/env/editor-env.type.ts | 2 + packages/server-core/src/env/env.schema.ts | 5 + packages/server-core/src/env/load-env.ts | 1 + packages/server-core/src/fs/fs-service.ts | 20 ++- packages/server-core/src/fs/watcher.ts | 28 ++- packages/server-core/src/git/git-service.ts | 70 ++++---- packages/server-core/src/git/git-token.ts | 31 +++- packages/server-core/src/http/static-files.ts | 6 +- .../server-core/src/plugin/plugin-sources.ts | 12 +- .../server-core/src/plugin/server-plugins.ts | 55 +++--- packages/server-core/src/process/child-env.ts | 92 ++++++++++ packages/server-core/src/process/index.ts | 1 + .../server-core/src/process/run-process.ts | 43 ++++- packages/server-core/src/project/formatter.ts | 9 +- .../src/project/project-registry.ts | 94 +++++++++-- .../server-core/src/project/type-files.ts | 23 ++- .../rpc/implementation/code.implementation.ts | 2 +- .../implementation/session.implementation.ts | 4 - .../src/runtime/build-orchestrator.ts | 12 +- .../server-core/src/runtime/game-server.ts | 12 +- .../src/runtime/project-runtime.ts | 57 ++++--- .../src/server/create-editor-server.ts | 82 +++++---- .../server-core/src/server/request-guard.ts | 51 ++++++ .../server-core/src/session/session-store.ts | 159 ++++++++++++------ packages/server-core/src/testing.ts | 3 +- packages/server-core/src/util/glob.ts | 36 +++- .../server-core/test/fs/fs-service.spec.ts | 36 ++++ .../server-core/test/git/git-service.spec.ts | 40 +++++ .../server-core/test/mocks/nanoforge-api.ts | 4 +- .../test/process/child-env.spec.ts | 22 +++ .../test/server/create-editor-server.spec.ts | 26 +++ .../test/server/request-guard.spec.ts | 53 ++++++ .../test/session/session-store.spec.ts | 69 ++++++++ .../test/settings/account-backends.spec.ts | 1 + packages/server-core/test/util/glob.spec.ts | 19 +++ .../src/account-sync/account-sync-store.ts | 41 +++-- .../settings/src/definition/core-settings.ts | 11 ++ .../settings/src/service/settings-service.ts | 6 +- .../src/setup/follow-current-project.ts | 21 ++- packages/settings/src/setup/setup-settings.ts | 9 +- .../account-sync/account-sync-store.spec.ts | 42 +++++ .../test/service/settings-service.spec.ts | 18 ++ plugins/ecs/src/library/relocate-item.ts | 20 ++- plugins/scene/src/service/scene-steps.ts | 22 ++- .../dialogs/settings/SettingsDialog.svelte | 5 +- pnpm-lock.yaml | 6 - turbo.json | 2 + 135 files changed, 2493 insertions(+), 743 deletions(-) create mode 100644 packages/code/src/engine/rename-package.const.ts create mode 100644 packages/code/src/engine/rename-package.ts create mode 100644 packages/code/test/worker-rpc/worker-rpc.spec.ts delete mode 100644 packages/history/src/file/file-history-tracker.ts delete mode 100644 packages/history/test/file/file-history-tracker.spec.ts delete mode 100644 packages/history/test/file/project-fs.spec.ts create mode 100644 packages/server-core/src/process/child-env.ts create mode 100644 packages/server-core/src/server/request-guard.ts create mode 100644 packages/server-core/test/process/child-env.spec.ts create mode 100644 packages/server-core/test/server/request-guard.spec.ts create mode 100644 packages/server-core/test/session/session-store.spec.ts create mode 100644 packages/server-core/test/util/glob.spec.ts diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 02af1ec1..96f90b1b 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -23,7 +23,9 @@ runs: # The editor server build and the game runtime (tests included) run on Bun. - name: Install Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 - if: inputs.pnpm-install == 'true' name: Install dependencies diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8867c894..de9b7c2c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,9 +9,19 @@ on: paths: - 'apps/**' - 'packages/**' + - 'plugins/**' - 'tooling/**' + - 'examples/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + - '.github/workflows/e2e.yml' + - '.github/actions/**' workflow_dispatch: +permissions: + contents: read + jobs: e2e: runs-on: ubuntu-latest @@ -22,11 +32,34 @@ jobs: - name: Prepare uses: ./.github/actions/prepare + # The tests that play a game (runtime, inspectors, scene…) need a built engine with the + # editor library; without NANOFORGE_ENGINE they are skipped. + - name: Checkout engine + uses: actions/checkout@v7 + with: + repository: NanoForge-dev/Engine + ref: main + path: engine + + - name: Install emsdk + uses: ./engine/.github/actions/emsdk-install + + - name: Install engine dependencies + uses: ./.github/actions/pnpm-install + with: + path: engine + + - name: Build engine + run: pnpm run build + working-directory: engine + - name: Install browsers run: pnpm --filter @nanoforge-dev/editor exec playwright install --with-deps chromium - name: Run end-to-end tests run: pnpm --filter @nanoforge-dev/editor test:e2e + env: + NANOFORGE_ENGINE: ${{ github.workspace }}/engine - name: Upload traces if: failure() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 071ae1a4..21eba6ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,9 @@ on: - main workflow_dispatch: +permissions: + contents: read + env: PUBLIC_MODE: ${{ vars.PUBLIC_MODE }} PUBLIC_PM_URL: ${{ vars.PUBLIC_PM_URL }} diff --git a/CLAUDE.md b/CLAUDE.md index 3b672cb9..f7a46a57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,8 @@ npx playwright test [spec…] --reporter=line - `E2E_EXTRA_PLUGINS=` adds a dev plugin to the e2e editor. - SDK public API snapshot: after adding SDK exports, run `cd packages/sdk && npx vitest run -u`, and check that the diff shows only what you added. - E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error. Known noise goes in its `IGNORED` list. +- E2E specs open projects with `openProject(page, path)` (`e2e/helpers.ts`): `/load?path=` asks before opening a folder that is not an exact recent path, and a relative path always asks. +- The editor server answers only to loopback host names and `ALLOWED_HOSTS` (DNS rebinding guard): a dev server reached by another name needs it. - To debug a failed e2e test, read `test-results//error-context.md`. For a screenshot, unzip `trace.zip` and open the last frame in `screencast/`. - The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`. @@ -271,6 +273,7 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na | 11 | Done: console + problems, command palette + keyboard shortcuts, inspectors, git (after IntelliJ's windows, VCS menu), marketplace (plugins in Settings, Packages dialog, on a folder registry); last full e2e run 60/60 | | 12 | Done: `docs/api/` hand-off (registry after npm's routes, settings sync, account plugin list as a settings key), contract tests (stand-ins and `CONTRACT_*` targets), `API_FEATURES` flags; last full e2e run 60/60 | | 13 | Done: plugin guide and `nf create plugin`, user docs, performance pass (compiler out of the page bundle), `CHANGELOG-v2.md`. Not released: versions unchanged, nothing tagged or published | +| 14 | Planned: hosted editor parity (sandboxed runtime, dependencies, networked games, push from a hosted editor). Until then the runtime stays off in ONLINE | ### Open design questions (ask the user) diff --git a/Dockerfile b/Dockerfile index dfab206a..6c530fa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ENV PNPM_HOME="/pnpm" ENV CI="true" ENV PATH="$PNPM_HOME:$PATH" -RUN npm install -g pnpm bun +RUN npm install -g pnpm@12.0.0 bun@1.3.14 WORKDIR /app @@ -24,22 +24,34 @@ COPY . /app RUN pnpm install --frozen-lockfile RUN pnpm turbo run build --filter=@nanoforge-dev/editor... -FROM oven/bun:1.3 AS final +FROM oven/bun:1.3.14 AS final ARG FS_ROOT ARG ARCHIVE_ROOT +ARG PUBLIC_MODE +ARG PUBLIC_PM_URL + +# The server reads its mode at runtime (packages/server-core/src/env/load-env.ts) +ENV PUBLIC_MODE=${PUBLIC_MODE} +ENV PUBLIC_PM_URL=${PUBLIC_PM_URL} +ENV FS_ROOT=${FS_ROOT} WORKDIR /app COPY --from=prod /app/apps/editor/dist /app/dist -RUN mkdir -p /app/${FS_ROOT} /app/${ARCHIVE_ROOT} +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* # Runtime dependencies kept out of the server bundle (dist/package.json) RUN cd /app/dist && bun install --production --ignore-scripts -RUN apt update && apt install git -y +# Global tools go where the unprivileged user can run them +ENV BUN_INSTALL_GLOBAL_DIR=/usr/local/share/bun-global +ENV BUN_INSTALL_BIN=/usr/local/bin +RUN bun install -g @nanoforge-dev/cli@2.3.1 + +RUN mkdir -p /app/${FS_ROOT} /app/${ARCHIVE_ROOT} && chown -R bun:bun /app/${FS_ROOT} /app/${ARCHIVE_ROOT} -RUN bun install -g @nanoforge-dev/cli +USER bun ENV HOST=0.0.0.0 CMD [ "bun", "run", "./dist/index.js" ] diff --git a/apps/editor/e2e/code-editor.spec.ts b/apps/editor/e2e/code-editor.spec.ts index 93c3e076..27f9f643 100644 --- a/apps/editor/e2e/code-editor.spec.ts +++ b/apps/editor/e2e/code-editor.spec.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -18,7 +19,7 @@ const editorText = (page: Page) => .then((text) => text.replace(/\u00a0/g, ' ')); const openScratch = async (page: Page, name: string) => { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); for (const folder of ['apps', 'client', 'src', 'scratch']) { await page.getByRole('treeitem', { name: folder, exact: true }).click(); diff --git a/apps/editor/e2e/command-palette.spec.ts b/apps/editor/e2e/command-palette.spec.ts index 4933fbb1..6b92ed6c 100644 --- a/apps/editor/e2e/command-palette.spec.ts +++ b/apps/editor/e2e/command-palette.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -17,7 +17,7 @@ const shortcut = (page: Page, action: string) => const cursorLine = (page: Page) => page.locator('.monaco-editor .active-line-number'); const open = async (page: Page) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await resetLayout(page); diff --git a/apps/editor/e2e/console.spec.ts b/apps/editor/e2e/console.spec.ts index c551d8dd..8b501403 100644 --- a/apps/editor/e2e/console.spec.ts +++ b/apps/editor/e2e/console.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { GAME, PONG } from './workspace'; @@ -17,7 +17,7 @@ const level = (page: Page, name: string) => page.getByRole('group', { name: 'Levels' }).getByRole('button', { name }); const open = async (page: Page, project = 'pong') => { - await page.goto(`/load?path=${project}`); + await openProject(page, project); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await resetLayout(page); diff --git a/apps/editor/e2e/ecs-live.spec.ts b/apps/editor/e2e/ecs-live.spec.ts index 7b0c41a4..93b97033 100644 --- a/apps/editor/e2e/ecs-live.spec.ts +++ b/apps/editor/e2e/ecs-live.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -22,7 +22,7 @@ test('shows the live world, edits it, applies it to the code and pauses a system test.setTimeout(120_000); const original = readFileSync(MAIN, 'utf8'); try { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); await resetLayout(page); await showPanel(page, 'Hierarchy'); @@ -93,7 +93,7 @@ test('moves an entity by dragging it in the Game screen, and applies it to the c test.setTimeout(120_000); const original = readFileSync(MAIN, 'utf8'); try { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); await resetLayout(page); await toolbar(page, 'Play').click(); @@ -151,7 +151,7 @@ test('edits an engine enum param with a select (types from the engine packages)' }) => { const original = readFileSync(MAIN, 'utf8'); try { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); await resetLayout(page); await showPanel(page, 'Hierarchy'); diff --git a/apps/editor/e2e/ecs.spec.ts b/apps/editor/e2e/ecs.spec.ts index 67d39e00..95d4316d 100644 --- a/apps/editor/e2e/ecs.spec.ts +++ b/apps/editor/e2e/ecs.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { expandFolders, resetLayout, showPanel } from './helpers'; +import { expandFolders, openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -15,7 +15,7 @@ const main = () => readFileSync(MAIN, 'utf8'); test.beforeEach(async ({ page }) => { original = readFileSync(MAIN, 'utf8'); - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await resetLayout(page); await showPanel(page, 'Hierarchy'); diff --git a/apps/editor/e2e/file-manager.spec.ts b/apps/editor/e2e/file-manager.spec.ts index 0827fd1a..ec722832 100644 --- a/apps/editor/e2e/file-manager.spec.ts +++ b/apps/editor/e2e/file-manager.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -8,7 +9,7 @@ const files = (page: Page) => page.getByRole('tree', { name: 'Project files' }); const row = (page: Page, name: string) => files(page).getByRole('treeitem', { name, exact: true }); const open = async (page: Page) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(row(page, 'apps')).toBeVisible(); }; diff --git a/apps/editor/e2e/git.spec.ts b/apps/editor/e2e/git.spec.ts index df84a326..77259d50 100644 --- a/apps/editor/e2e/git.spec.ts +++ b/apps/editor/e2e/git.spec.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG, WORKSPACE } from './workspace'; @@ -63,7 +63,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash page, }) => { test.setTimeout(150_000); - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await resetLayout(page); diff --git a/apps/editor/e2e/helpers.ts b/apps/editor/e2e/helpers.ts index f3e1b3fc..f3208464 100644 --- a/apps/editor/e2e/helpers.ts +++ b/apps/editor/e2e/helpers.ts @@ -36,3 +36,12 @@ export const showPanel = async (page: Page, name: string | RegExp): Promise` does: through `/load`, confirming the + * prompt a folder that is not a recent project gets (a relative path always gets it). + */ +export const openProject = async (page: Page, path: string): Promise => { + await page.goto(`/load?path=${encodeURIComponent(path)}`); + await page.getByRole('button', { name: 'Open project', exact: true }).click(); +}; diff --git a/apps/editor/e2e/history-panel.spec.ts b/apps/editor/e2e/history-panel.spec.ts index bd42f36c..aee2dbb4 100644 --- a/apps/editor/e2e/history-panel.spec.ts +++ b/apps/editor/e2e/history-panel.spec.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -11,7 +12,7 @@ const files = (page: Page) => page.getByRole('tree', { name: 'Project files' }); const row = (page: Page, name: string) => files(page).getByRole('treeitem', { name, exact: true }); const open = async (page: Page) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(row(page, 'apps')).toBeVisible(); }; diff --git a/apps/editor/e2e/inspectors.spec.ts b/apps/editor/e2e/inspectors.spec.ts index 7298faac..c9464423 100644 --- a/apps/editor/e2e/inspectors.spec.ts +++ b/apps/editor/e2e/inspectors.spec.ts @@ -1,12 +1,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; const open = async (page: Page, project: string) => { - await page.goto(`/load?path=${project}`); + await openProject(page, project); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await resetLayout(page); diff --git a/apps/editor/e2e/manual-pass.spec.ts b/apps/editor/e2e/manual-pass.spec.ts index 5c09754e..176d6cbb 100644 --- a/apps/editor/e2e/manual-pass.spec.ts +++ b/apps/editor/e2e/manual-pass.spec.ts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { expandFolders, panelTab, resetLayout, showPanel } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout, showPanel } from './helpers'; import { expect, test } from './test'; import { PONG } from './workspace'; @@ -10,7 +10,7 @@ const NOTES = join(PONG, 'scratch/notes.md'); const TODO = join(PONG, 'scratch/todo.md'); test.beforeEach(async ({ page }) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await resetLayout(page); }); diff --git a/apps/editor/e2e/marketplace.spec.ts b/apps/editor/e2e/marketplace.spec.ts index 9c62cda5..735e6551 100644 --- a/apps/editor/e2e/marketplace.spec.ts +++ b/apps/editor/e2e/marketplace.spec.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG, REGISTRY, WORKSPACE } from './workspace'; @@ -32,7 +32,7 @@ const cleanUp = () => { }; const open = async (page: Page) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await resetLayout(page); }; diff --git a/apps/editor/e2e/project-plugins.spec.ts b/apps/editor/e2e/project-plugins.spec.ts index e1e8c825..10323fd4 100644 --- a/apps/editor/e2e/project-plugins.spec.ts +++ b/apps/editor/e2e/project-plugins.spec.ts @@ -1,7 +1,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout } from './helpers'; +import { openProject, resetLayout } from './helpers'; import { expect, test } from './test'; import { PONG } from './workspace'; @@ -43,7 +43,7 @@ test.afterEach(() => { }); test("loads the project's plugins and names the plugins its packages suggest", async ({ page }) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await resetLayout(page); diff --git a/apps/editor/e2e/project.spec.ts b/apps/editor/e2e/project.spec.ts index 84c7fbd3..6528876d 100644 --- a/apps/editor/e2e/project.spec.ts +++ b/apps/editor/e2e/project.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { expect, test } from './test'; import { PONG } from './workspace'; @@ -27,7 +27,7 @@ const dependencies = (file: string) => test.beforeEach(async ({ page }) => { saved = FILES.map((file) => (existsSync(file) ? read(file) : undefined)); - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await resetLayout(page); await page.getByRole('tab', { name: 'Project', exact: true }).click(); diff --git a/apps/editor/e2e/runtime.spec.ts b/apps/editor/e2e/runtime.spec.ts index ca478842..b410650b 100644 --- a/apps/editor/e2e/runtime.spec.ts +++ b/apps/editor/e2e/runtime.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -75,7 +76,7 @@ test('offers to add the editor library to a game that does not register it', asy ); expect(readFileSync(files[0]!, 'utf8')).not.toContain('EditorLibrary'); try { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); await toolbar(page, 'Play').click(); const warning = page diff --git a/apps/editor/e2e/scene.spec.ts b/apps/editor/e2e/scene.spec.ts index 330f7a9e..bb25193e 100644 --- a/apps/editor/e2e/scene.spec.ts +++ b/apps/editor/e2e/scene.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { BREAKOUT } from './workspace'; @@ -39,7 +39,7 @@ test.describe('scenes', () => { test.beforeEach(async ({ page }) => { original = snapshot(); - await page.goto('/load?path=breakout'); + await openProject(page, 'breakout'); await page.waitForURL(/\/project\//); await resetLayout(page); }); diff --git a/apps/editor/e2e/settings-ui.spec.ts b/apps/editor/e2e/settings-ui.spec.ts index 987b6f3c..554b972b 100644 --- a/apps/editor/e2e/settings-ui.spec.ts +++ b/apps/editor/e2e/settings-ui.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -16,7 +17,7 @@ const projectValue = (key: string): unknown => const MAXIMIZE = '@nanoforge/viewport.maximizeOnPlay'; const open = async (page: Page) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); }; diff --git a/apps/editor/e2e/startup.bench.ts b/apps/editor/e2e/startup.bench.ts index b210e225..a0bd9c46 100644 --- a/apps/editor/e2e/startup.bench.ts +++ b/apps/editor/e2e/startup.bench.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; -import { resetLayout } from './helpers'; +import { openProject, resetLayout } from './helpers'; import { expect, test } from './test'; import { GAME } from './workspace'; @@ -25,7 +25,7 @@ test('startup of a project: scripts loaded, code worker cold start, components l const context = await browser.newContext(); const page = await context.newPage(); const started = Date.now(); - await page.goto(`/load?path=${project}`); + await openProject(page, project); await page.waitForURL(/\/project\//); await expect(page.locator('[data-nf-part="screen"]')).toBeVisible(); add('workbench shown (ms)', Date.now() - started); diff --git a/apps/editor/e2e/test.ts b/apps/editor/e2e/test.ts index 8e305719..6855b0d1 100644 --- a/apps/editor/e2e/test.ts +++ b/apps/editor/e2e/test.ts @@ -13,6 +13,10 @@ export const test = base.extend<{ pageErrors: string[] }>({ pageErrors: [ async ({ context }, use) => { const errors: string[] = []; + if (process.env.E2E_CONSOLE) + context.on('console', (message) => + console.log(`[page ${message.type()}] ${message.text()}`), + ); context.on('weberror', (webError) => { const { message } = webError.error(); if (!IGNORED.has(message)) errors.push(message); diff --git a/apps/editor/e2e/viewport.spec.ts b/apps/editor/e2e/viewport.spec.ts index a6482f20..dc020f58 100644 --- a/apps/editor/e2e/viewport.spec.ts +++ b/apps/editor/e2e/viewport.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { openProject } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -14,7 +15,7 @@ const screenTab = (page: Page, name: string) => page.getByRole('tablist', { name: 'Main screens' }).getByRole('tab', { name }); const openGame = async (page: Page) => { - await page.goto('/load?path=game'); + await openProject(page, 'game'); await page.waitForURL(/\/project\//); await screenTab(page, 'Project').click(); await expect(screenTab(page, 'Project')).toHaveAttribute('aria-selected', 'true'); @@ -32,7 +33,7 @@ const stop = async (page: Page) => { }; test('shows the scene editor contributed for the project (the ECS 2D scene)', async ({ page }) => { - await page.goto('/load?path=pong'); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await screenTab(page, 'Scene').click(); await expect(page.getByRole('application', { name: '2D scene' })).toBeVisible(); diff --git a/apps/editor/e2e/workbench.spec.ts b/apps/editor/e2e/workbench.spec.ts index 39782f6f..c158af13 100644 --- a/apps/editor/e2e/workbench.spec.ts +++ b/apps/editor/e2e/workbench.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, panelTab, resetLayout } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout } from './helpers'; import { type Locator, type Page, expect, test } from './test'; const openPong = async (page: Page) => { @@ -169,10 +169,13 @@ test('explains a project that cannot be opened and offers a way out', async ({ p test('opens a project from a link (nf editor )', async ({ page }) => { await page.goto('/load?path=pong'); + await expect(page.getByRole('heading', { name: 'Open pong?' })).toBeVisible(); + await expect(page).toHaveURL(/\/load\?/); + await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); - await page.goto('/load?path=missing-folder'); + await openProject(page, 'missing-folder'); await expect(page.getByText('No project folder at missing-folder')).toBeVisible(); await page.getByRole('button', { name: 'Back to projects' }).click(); await expect(page.getByLabel('Project folder')).toBeVisible(); diff --git a/apps/editor/package.json b/apps/editor/package.json index 61ada86e..bea42e67 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -91,7 +91,6 @@ "playwright": "catalog:test", "svelte": "catalog:core", "svelte-check": "catalog:core", - "typescript": "catalog:build", "vite": "catalog:core", "vitest": "catalog:test", "zod": "catalog:libs" diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts index 8546307d..1eda2ac8 100644 --- a/apps/editor/src/lib/editor/create-editor.ts +++ b/apps/editor/src/lib/editor/create-editor.ts @@ -121,7 +121,14 @@ export const createEditor = async ( }), ); store.add(settingsSetup.registry.register(...Object.values(RuntimeSettings))); - const projectSettings = store.add(followCurrentProject(settingsSetup, projects.current)); + const projectSettings = store.add( + followCurrentProject(settingsSetup, projects.current, (error, project) => + logger.error( + `The settings of project ${project.id} could not be read: defaults apply`, + error, + ), + ), + ); const { settings } = settingsSetup; services.provide(AccountSyncToken, settingsSetup.account); @@ -233,7 +240,15 @@ export const createEditor = async ( await plugins.start(listings.flatMap((listing) => toDescriptor(listing, logger) ?? [])); store.add( - followProjectPlugins({ rpc, projects, plugins, notifications: shell.notifications, logger }), + followProjectPlugins({ + rpc, + projects, + plugins, + notifications: shell.notifications, + logger, + settings, + online: session.mode === 'ONLINE', + }), ); store.add( diff --git a/apps/editor/src/lib/editor/register-app-contributions.ts b/apps/editor/src/lib/editor/register-app-contributions.ts index a0160b65..8d6a3904 100644 --- a/apps/editor/src/lib/editor/register-app-contributions.ts +++ b/apps/editor/src/lib/editor/register-app-contributions.ts @@ -4,6 +4,7 @@ import { type Disposable, DisposableStore, derived, + switchObservable, } from '@nanoforge-dev/editor-kernel'; import { ProjectServiceToken } from '@nanoforge-dev/editor-project'; import type { SessionInfo } from '@nanoforge-dev/editor-protocol'; @@ -54,7 +55,13 @@ export const registerAppContributions = (services: Container, session: SessionIn id: 'core.project', alignment: 'left', order: 0, - text: derived([projects.current], (project) => project?.model.get().name ?? 'No project'), + text: switchObservable( + derived( + [projects.current], + (project) => project && derived([project.model], (model) => model.name), + ), + 'No project', + ), tooltip: 'Close the project', command: 'project.close', }, diff --git a/apps/editor/src/lib/history/indexed-db-history-store.ts b/apps/editor/src/lib/history/indexed-db-history-store.ts index 4d9ca62a..53698f5a 100644 --- a/apps/editor/src/lib/history/indexed-db-history-store.ts +++ b/apps/editor/src/lib/history/indexed-db-history-store.ts @@ -19,6 +19,34 @@ const open = () => opening.onerror = () => resolve(undefined); })); +/** File histories are dropped after a month without a change, and past the 200 newest. */ +const FILE_HISTORY_MAX_AGE_MS = 30 * 24 * 3600 * 1000; +const FILE_HISTORY_MAX = 200; + +/** + * Removes the old `file:*` histories of a project: one is kept per file ever edited, and + * nothing else would ever delete them. + */ +const pruneFileHistories = async (db: IDBDatabase, projectId: string): Promise => { + const prefix = `${projectId}:file:`; + const store = db.transaction(STORE, 'readwrite').objectStore(STORE); + const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); + const keys = (await request(store.getAllKeys(range))) as string[]; + const values = (await request(store.getAll(range))) as PersistedEntry[][]; + const dated = keys + .map((key, index) => ({ key, time: Math.max(0, ...(values[index] ?? []).map((e) => e.time)) })) + .sort((a, b) => b.time - a.time); + const now = Date.now(); + const stale = dated.filter( + (entry, index) => index >= FILE_HISTORY_MAX || now - entry.time > FILE_HISTORY_MAX_AGE_MS, + ); + if (!stale.length) return; + const writing = db.transaction(STORE, 'readwrite').objectStore(STORE); + await Promise.all(stale.map((entry) => request(writing.delete(entry.key)))); +}; + +const pruned = new Set(); + /** Histories kept in the browser (IndexedDB) per project, across reloads. */ export const indexedDbHistoryStore = (projectId: string): HistoryStore => ({ async load(contextId) { @@ -34,5 +62,9 @@ export const indexedDbHistoryStore = (projectId: string): HistoryStore => ({ const key = `${projectId}:${contextId}`; if (entries.length) await request(store.put(entries, key)); else await request(store.delete(key)); + if (!pruned.has(projectId)) { + pruned.add(projectId); + await pruneFileHistories(db, projectId).catch(() => pruned.delete(projectId)); + } }, }); diff --git a/apps/editor/src/lib/plugin/follow-project-plugins.ts b/apps/editor/src/lib/plugin/follow-project-plugins.ts index 92b49e7a..653ed14c 100644 --- a/apps/editor/src/lib/plugin/follow-project-plugins.ts +++ b/apps/editor/src/lib/plugin/follow-project-plugins.ts @@ -9,6 +9,7 @@ import { import { type ProjectService } from '@nanoforge-dev/editor-project'; import { type PluginListing, PluginsContract } from '@nanoforge-dev/editor-protocol'; import { type RpcClient } from '@nanoforge-dev/editor-rpc'; +import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; import type { NotificationService } from '@nanoforge-dev/editor-ui'; export const toDescriptor = ( @@ -35,6 +36,10 @@ export const toDescriptor = ( * The open project's own plugins (`.nanoforge/plugins`) join the plugin set while it is open. * Plugins that its packages suggest and that aren't installed are named in a notification, once * per project. + * + * On a hosted editor (`online`) a project is shared: anyone who can change it can change its + * plugins, which would run in the others' editors. They load only for the projects the user + * allowed (`plugins.trustedProjects`); otherwise a notification names them and offers to. */ export const followProjectPlugins = (options: { rpc: RpcClient; @@ -42,14 +47,52 @@ export const followProjectPlugins = (options: { plugins: PluginHost; notifications: NotificationService; logger: Logger; + settings: SettingsService; + online: boolean; }): Disposable => { - const { rpc, plugins, logger } = options; + const { rpc, plugins, logger, settings } = options; const api = rpc.api(PluginsContract); const suggested = new Set(); + const offered = new Set(); let listedFor: string | undefined; let queue = Promise.resolve(); + const trusted = (project: string) => + !options.online || settings.get(CoreSettings.pluginsTrustedProjects).includes(project); + + /** Keeps a hosted project's own plugins out until allowed, and offers to allow them. */ + const allowed = (project: string | undefined, listings: readonly PluginListing[]) => { + if (!project || trusted(project)) return listings; + const own = listings.filter((listing) => listing.source === 'project'); + if (own.length && !offered.has(project)) { + offered.add(project); + const names = own + .map((listing) => (listing.manifest as { name?: string } | null)?.name ?? listing.baseUrl) + .join(', '); + options.notifications.notify('warning', 'This project has its own plugins', { + detail: `${names}. They would run in your editor, and anyone who can change the project can change them.`, + actions: [ + { + title: 'Allow its plugins', + run: async () => { + const list = settings.get(CoreSettings.pluginsTrustedProjects); + await settings.set( + CoreSettings.pluginsTrustedProjects, + [...list, project], + 'account', + ); + listedFor = undefined; + follow(options.projects.current.get()?.id); + }, + }, + ], + timeout: 0, + }); + } + return listings.filter((listing) => listing.source !== 'project'); + }; + const suggest = async (project: string) => { if (suggested.has(project)) return; suggested.add(project); @@ -71,18 +114,20 @@ export const followProjectPlugins = (options: { }); }; - const follow = (project: string | undefined) => { + function follow(project: string | undefined) { if (project === listedFor) return; listedFor = project; queue = queue .then(async () => { const listings = await api.list(project ? { project } : null); if (listedFor !== project) return; - await plugins.update(listings.flatMap((listing) => toDescriptor(listing, logger) ?? [])); + await plugins.update( + allowed(project, listings).flatMap((listing) => toDescriptor(listing, logger) ?? []), + ); if (project) await suggest(project); }) .catch((error: unknown) => logger.error('Could not load the project plugins', error)); - }; + } return toDisposable(options.projects.current.subscribe((project) => follow(project?.id))); }; diff --git a/apps/editor/src/lib/runtime/follow-project-runtime.ts b/apps/editor/src/lib/runtime/follow-project-runtime.ts index df5c340a..4746d9ae 100644 --- a/apps/editor/src/lib/runtime/follow-project-runtime.ts +++ b/apps/editor/src/lib/runtime/follow-project-runtime.ts @@ -322,7 +322,8 @@ export const followProjectRuntime = (options: { const url = new URL(`/play/${project.id}`, location.origin); url.searchParams.set('app', app.id); const env = settings.get(RuntimeSettings.env); - if (Object.keys(env).length) url.hash = `env=${btoa(JSON.stringify(env))}`; + if (Object.keys(env).length) + url.hash = new URLSearchParams({ env: JSON.stringify(env) }).toString(); window.open(url, '_blank', 'noopener'); }), ], diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts index 1ee0c4e0..acf366f8 100644 --- a/apps/editor/src/lib/workspace/workspace-actions.ts +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -1,4 +1,8 @@ -import { DocumentServiceToken } from '@nanoforge-dev/editor-code'; +import { + CodeServiceToken, + DocumentServiceToken, + RENAME_PACKAGE_TRANSFORMER, +} from '@nanoforge-dev/editor-code'; import type { HistoryContext, HistoryService } from '@nanoforge-dev/editor-history'; import { type Container, @@ -258,6 +262,43 @@ export class WorkspaceActions implements Disposable { this._store.dispose(); } + /** + * The project's files for an operation. With the code worker, imports are rewritten by reading + * the code, through the open documents (saved after each change, as a multi-file operation must). + */ + private _io(project: ClientProject): WorkspaceIo { + const io = workspaceIo(project); + const code = this._options.services.tryGet(CodeServiceToken); + const documents = this._options.services.tryGet(DocumentServiceToken); + if (!code || !documents) return io; + const saved = async (path: string) => { + if (documents.get(path)) await documents.save(path); + }; + return { + ...io, + renameImports: async (path, from, to) => { + const command = await code.edit( + path, + RENAME_PACKAGE_TRANSFORMER, + { from, to }, + { + label: `Rename ${from}`, + }, + ); + if (!command) return undefined; + await command.do(); + await saved(path); + return { + undo: async () => { + await command.undo(); + await saved(path); + }, + isValid: async () => (await command.isValid?.()) ?? true, + }; + }, + }; + } + private _project(): ClientProject | undefined { return this._options.projects.current.get(); } @@ -281,7 +322,7 @@ export class WorkspaceActions implements Disposable { ): Promise { const project = this._project(); if (!project) return false; - const io = workspaceIo(project); + const io = this._io(project); let undo: Undo | undefined; try { await this._stack.push({ diff --git a/apps/editor/src/routes/(editor)/+page.svelte b/apps/editor/src/routes/(editor)/+page.svelte index 99d88360..57dcb45a 100644 --- a/apps/editor/src/routes/(editor)/+page.svelte +++ b/apps/editor/src/routes/(editor)/+page.svelte @@ -40,8 +40,18 @@ $effect(() => { if (!session.user) return; - editor.projects.recent().then((list) => (recent = list)); - if (session.mode === 'ONLINE') projectsApi.gateways(null).then((list) => (gateways = list)); + const failed = (reason: unknown) => { + error = reason instanceof Error ? reason.message : String(reason); + }; + editor.projects + .recent() + .then((list) => (recent = list)) + .catch(failed); + if (session.mode === 'ONLINE') + projectsApi + .gateways(null) + .then((list) => (gateways = list)) + .catch(failed); }); const run = async (label: string, task: () => Promise) => { diff --git a/apps/editor/src/routes/(editor)/load/+page.svelte b/apps/editor/src/routes/(editor)/load/+page.svelte index 9c4e7b47..6f10eca8 100644 --- a/apps/editor/src/routes/(editor)/load/+page.svelte +++ b/apps/editor/src/routes/(editor)/load/+page.svelte @@ -15,6 +15,10 @@ /** * Opens a project from a link: `/load?path=` (local editors, `nf editor `) or * `/load?gatewayId=` (hosted editors, "Open in editor" on the NanoForge website). + * + * Opening a local project runs its `nanoforge.config.ts` and its plugins, and any web page can + * link here: a folder that is not in the recent projects is opened only once the user says so. + * A hosted editor opens projects by their id only, never by path. */ const editor = getEditor(); let error = $state(); @@ -37,6 +41,16 @@ return undefined; }; + /** + * Whether a folder was opened here before (the link then needs no confirmation): its real path + * only, as recent projects keep it. A relative path always asks. + */ + const known = async (path: string): Promise => { + const trimmed = path.replace(/(.)\/+$/, '$1'); + const recent = await editor.projects.recent().catch(() => []); + return recent.some((project) => 'path' in project.ref && project.ref.path === trimmed); + }; + const describe = (reason: unknown, ref: ProjectRef): ErrorDescription => { const detail = errorMessage(reason); const name = 'path' in ref ? ref.path : 'this project'; @@ -101,14 +115,43 @@ return; } let disposed = false; - editor.projects - .open(ref) - .then(({ id }) => { - if (!disposed) void goto(`/project/${id}`, { replaceState: true }); - }) - .catch((reason: unknown) => { - if (!disposed) error = describe(reason, ref); + const open = () => + editor.projects + .open(ref) + .then(({ id }) => { + if (!disposed) void goto(`/project/${id}`, { replaceState: true }); + }) + .catch((reason: unknown) => { + if (!disposed) error = describe(reason, ref); + }); + if (!('path' in ref)) void open(); + else if (editor.session.mode === 'ONLINE') { + error = { + title: 'Projects open by their id here', + hint: 'This editor is hosted: open the project from your projects on the NanoForge website.', + actions: [{ ...back, primary: true }], + }; + } else { + void known(ref.path).then((trusted) => { + if (disposed) return; + if (trusted) return void open(); + error = { + title: `Open ${ref.path}?`, + hint: 'Opening a project runs its nanoforge.config.ts and its plugins on this computer. Open it only if you trust where it comes from.', + actions: [ + { + label: 'Open project', + primary: true, + run: () => { + error = undefined; + void open(); + }, + }, + back, + ], + }; }); + } return () => { disposed = true; }; diff --git a/apps/editor/src/routes/play/[id]/+page.svelte b/apps/editor/src/routes/play/[id]/+page.svelte index faec046a..89209774 100644 --- a/apps/editor/src/routes/play/[id]/+page.svelte +++ b/apps/editor/src/routes/play/[id]/+page.svelte @@ -19,7 +19,7 @@ const overrides = (): Record => { try { const encoded = new URLSearchParams(location.hash.slice(1)).get('env'); - return encoded ? (JSON.parse(atob(encoded)) as Record) : {}; + return encoded ? (JSON.parse(encoded) as Record) : {}; } catch { return {}; } diff --git a/docs/api/README.md b/docs/api/README.md index d6971ce1..4a6c85f2 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -18,13 +18,16 @@ Nothing is asked for **per-user project settings** of hosted editors (`local.jso All with the `Api-Key` header. "Bearer" means `Authorization: Bearer ` of the signed-in user. -| Call | Auth | The editor reads | -| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | -| `POST /auth/refresh-token` | none | body `{ refreshToken }` → `{ accessToken, refreshToken, tokenExpiresAt }`; `401` signs the user out | -| `GET /projects` | Bearer | an array of projects: `id`, `code`, `name`, `description`, `gatewayProjectRegistryUrl`, `gatewayProjectRegistryMetadata.dir` | -| `GET /editor/projects/:projectId` | Bearer | the same fields plus `token` (to clone and push the project's git repository) | +| Call | Auth | The editor reads | +| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /users/me` | Bearer | `id`, `username`, `email`, `firstName`, `lastName`: who the cookie's access token belongs to (the editor trusts no token it did not ask about) | +| `POST /auth/refresh-token` | none | body `{ refreshToken }` → `{ accessToken, refreshToken, tokenExpiresAt }`; `401` signs the user out | +| `GET /projects` | Bearer | an array of projects: `id`, `code`, `name`, `description`, `gatewayProjectRegistryUrl`, `gatewayProjectRegistryMetadata.dir` | +| `GET /editor/projects/:projectId` | Bearer | the same fields plus `token` (to clone and push the project's git repository) | -A `401` on any authenticated call makes the editor refresh the token pair once and retry. +A `401` on any authenticated call makes the editor refresh the token pair once and retry (one refresh at a time per session: a rotated refresh token is spent by the first). + +In the API, `@Get('me')` must be declared before `@Get(':id')` in the users controller, or `GET /users/me` is read as the user of id `me`. Sign-in itself is not the editor's: the projects website sets the cookies the hosted editor reads. diff --git a/docs/docs/editor/play.mdx b/docs/docs/editor/play.mdx index 0b271c71..dc6a3ea8 100644 --- a/docs/docs/editor/play.mdx +++ b/docs/docs/editor/play.mdx @@ -19,8 +19,8 @@ Press **Play** in the top bar, or `F5`. The editor saves your files, builds the The game gets the keyboard when it starts. Press `Escape` to give it back to the editor. Stopping returns to the screen you were on. - The game runs in a **local** editor (`nf editor`). A hosted editor never builds or runs project - code. + The game runs in a **local** editor (`nf editor`). A hosted editor does not build or run project + code yet. ## Play modes diff --git a/docs/docs/editor/plugins-and-packages.mdx b/docs/docs/editor/plugins-and-packages.mdx index 2d69d0ea..a0eab47f 100644 --- a/docs/docs/editor/plugins-and-packages.mdx +++ b/docs/docs/editor/plugins-and-packages.mdx @@ -22,7 +22,7 @@ _File › Settings… › Plugins_. Search, select a plugin to read about it, then **Install**: - **For me**: in every project you open on this machine; -- **For this project**: in the project's `.nanoforge/plugins` folder, which can be committed so the whole team has it. +- **For this project**: in the project's `.nanoforge/plugins` folder, which can be committed so the whole team has it. On a hosted editor, a project's own plugins load only after you choose **Allow its plugins** for that project: anyone who can change the project can change them. A plugin starts working after a reload of the editor: the page offers **Reload now**. When a newer version exists, **Update** shows instead of _Installed_. diff --git a/docs/docs/getting-started/create-your-first-project.mdx b/docs/docs/getting-started/create-your-first-project.mdx index c498fd5e..9b3f31c1 100644 --- a/docs/docs/getting-started/create-your-first-project.mdx +++ b/docs/docs/getting-started/create-your-first-project.mdx @@ -25,7 +25,7 @@ nf new # asks for a name, a language, and whether the game has nf editor my-game # starts the editor and opens the project in your browser ``` -`nf editor` without a path opens the list of recent projects. A local editor works on the folder itself: what you change in the editor is on your disk at once, and what you change on disk shows in the editor. +`nf editor` without a path opens the list of recent projects. The first time a folder is opened, the editor asks before opening it: opening a project runs its `nanoforge.config.ts` and its plugins. A local editor works on the folder itself: what you change in the editor is on your disk at once, and what you change on disk shows in the editor. A local editor can do what a hosted one cannot: run the game, use Git with your own credentials, and install plugins and packages. diff --git a/examples/breakout/apps/client/src/scenes/levels.ts b/examples/breakout/apps/client/src/scenes/levels.ts index 9502eec5..7b058e4a 100644 --- a/examples/breakout/apps/client/src/scenes/levels.ts +++ b/examples/breakout/apps/client/src/scenes/levels.ts @@ -780,7 +780,7 @@ export class Level3 extends Level { ), ); const brick23 = registry.spawnEntity(); - registry.addComponent(brick23, new Position(793, 232)); + registry.addComponent(brick23, new Position(793, 237)); registry.addComponent(brick23, new Box(141, 26)); registry.addComponent(brick23, new Brick(2, 20)); registry.addComponent( diff --git a/packages/code/src/catalog/catalog-service.ts b/packages/code/src/catalog/catalog-service.ts index 8772c2d8..ea51f24a 100644 --- a/packages/code/src/catalog/catalog-service.ts +++ b/packages/code/src/catalog/catalog-service.ts @@ -59,6 +59,10 @@ export class CatalogService implements Disposable { private _timer: ReturnType | undefined; private _running: Promise = Promise.resolve(); private _disposed = false; + /** Called once nothing is scheduled nor running (`whenIdle`). */ + private _idle: (() => void)[] = []; + /** Owners that did not register in time: not waited for again until the plugins change. */ + private _late = new Set(); /** The catalog starts (and starts the code worker) when first read. */ private _active = false; @@ -84,6 +88,7 @@ export class CatalogService implements Disposable { const next = ownerPlugins(plugins); if (next === owners) return; owners = next; + this._late.clear(); this._scheduleAll(); }), ); @@ -122,18 +127,17 @@ export class CatalogService implements Disposable { } /** Resolves once pending extractions are done. */ - async whenIdle(): Promise { - while (this._timer !== undefined || this._dirty.size) { - await new Promise((resolve) => setTimeout(resolve, 20)); - await this._running; - } - await this._running; + whenIdle(): Promise { + if (this._timer === undefined && !this._dirty.size) return this._running; + return new Promise((resolve) => this._idle.push(resolve)); } dispose(): void { this._disposed = true; clearTimeout(this._timer); + this._timer = undefined; this._store.dispose(); + this._settle(); } private _activate(): void { @@ -164,10 +168,19 @@ export class CatalogService implements Disposable { this._timer = undefined; this._running = this._running .then(() => this._run()) - .catch((error: unknown) => this._options.logger?.error('Catalog update failed', error)); + .catch((error: unknown) => this._options.logger?.error('Catalog update failed', error)) + .finally(() => this._settle()); }, delay); } + /** Wakes `whenIdle` callers when nothing is left to do. */ + private _settle(): void { + if (!this._disposed && (this._timer !== undefined || this._dirty.size)) return; + const waiting = this._idle; + this._idle = []; + for (const resolve of waiting) resolve(); + } + private async _run(): Promise { if (this._disposed || !this._dirty.size) return; const all = this._dirty.has('*'); @@ -204,20 +217,40 @@ export class CatalogService implements Disposable { }); } - /** Waits (a few seconds at most) for the worker to register the owners plugins declare. */ + /** + * Waits (a few seconds at most) for the worker to register the owners plugins declare, woken + * by each worker plugin load. Owners that never came are not waited for again until the plugin + * set changes. + */ private async _expectedOwners(): Promise { const plugins = this._options.plugins; if (!plugins) return; - const expected = ownerPlugins(plugins).split(',').filter(Boolean); + const expected = ownerPlugins(plugins) + .split(',') + .filter((name) => name && !this._late.has(name)); const deadline = Date.now() + EXPECTED_OWNERS_TIMEOUT_MS; - while (Date.now() < deadline) { + for (;;) { const registered = new Set( (await this._options.code.itemOwners()).map((owner) => owner.name), ); - if (expected.every((name) => registered.has(name))) return; - await new Promise((resolve) => setTimeout(resolve, 100)); + const missing = expected.filter((name) => !registered.has(name)); + if (!missing.length) return; + const remaining = deadline - Date.now(); + if (remaining <= 0 || this._disposed) { + for (const name of missing) this._late.add(name); + this._options.logger?.warn(`Item owners not registered in time: ${missing.join(', ')}`); + return; + } + await new Promise((resolve) => { + const timer = setTimeout(done, remaining); + const subscription = this._options.code.onDidChangeOwners(done); + function done() { + clearTimeout(timer); + subscription.dispose(); + resolve(); + } + }); } - this._options.logger?.warn(`Item owners not registered in time: ${expected.join(', ')}`); } /** Apps, shared libraries and installed packages (walking `include`), with their files. */ diff --git a/packages/code/src/document/document-service.ts b/packages/code/src/document/document-service.ts index a35190aa..53850780 100644 --- a/packages/code/src/document/document-service.ts +++ b/packages/code/src/document/document-service.ts @@ -164,16 +164,19 @@ export class DocumentService implements Disposable { * An undoable edit. Undo/redo check that the document still has the content the edit * produced; otherwise the step is dropped by the history. It carries a preview of the changed * lines and can be serialized (history kept across reloads, see `restoreEditCommand`). + * With `basedOn` (the `fingerprint` of the text the edits were computed on), the edit fails + * when the text changed since: offsets would land elsewhere. */ editCommand( uri: string, edits: readonly TextEdit[], - options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + options: { label: string; origin?: CommandOrigin; mergeKey?: string; basedOn?: string }, ): HistoryCommand { return this._editCommand( { uri, forward: [...edits], inverse: [], expected: '' }, options, false, + options.basedOn, ); } @@ -208,6 +211,7 @@ export class DocumentService implements Disposable { state: EditState, options: { label: string; origin?: CommandOrigin; mergeKey?: string }, applied: boolean, + basedOn?: string, ): HistoryCommand { const run = async (operations: readonly TextEdit[]) => { const before = await this.getText(state.uri); @@ -224,6 +228,8 @@ export class DocumentService implements Disposable { ...(state.preview && { preview: state.preview }), do: async () => { if (applied) return; + if (basedOn !== undefined && fingerprint(await this.getText(state.uri)) !== basedOn) + throw new Error(`${state.uri} changed while the edit was prepared: try again`); const { result, before, after } = await run(state.forward); state.inverse = result; state.preview = previewOf(state.uri, before, after); diff --git a/packages/code/src/document/match-glob.ts b/packages/code/src/document/match-glob.ts index b3247bea..5ef59935 100644 --- a/packages/code/src/document/match-glob.ts +++ b/packages/code/src/document/match-glob.ts @@ -3,8 +3,10 @@ import type { ExtensionRegistry } from '@nanoforge-dev/editor-kernel'; import { DOCUMENT_EDITORS } from './document-contributions'; import type { DocumentEditor } from './document.type'; +/** `{a,b}` alternatives; a `,` or `}` outside braces is a plain character. */ const globToRegex = (glob: string): RegExp => { let source = ''; + let depth = 0; for (let i = 0; i < glob.length; i++) { const char = glob[i]!; if (char === '*' && glob[i + 1] === '*') { @@ -17,12 +19,16 @@ const globToRegex = (glob: string): RegExp => { } } else if (char === '*') source += '[^/]*'; else if (char === '?') source += '[^/]'; - else if (char === '{') source += '(?:'; - else if (char === '}') source += ')'; - else if (char === ',') source += '|'; - else source += /[.+^$()|[\]\\]/.test(char) ? `\\${char}` : char; + else if (char === '{') { + depth++; + source += '(?:'; + } else if (char === '}' && depth) { + depth--; + source += ')'; + } else if (char === ',' && depth) source += '|'; + else source += /[.+^$()|[\]\\{}]/.test(char) ? `\\${char}` : char; } - return new RegExp(`^${source}$`); + return new RegExp(`^${source}${')'.repeat(depth)}$`); }; const globs = new Map(); diff --git a/packages/code/src/engine/code-engine.ts b/packages/code/src/engine/code-engine.ts index 6de6a2d4..50a4fe92 100644 --- a/packages/code/src/engine/code-engine.ts +++ b/packages/code/src/engine/code-engine.ts @@ -9,6 +9,8 @@ import type { CodeDiagnostic, CodeSymbol, SourceText, TextEdit } from './engine. import { type MetaRequest, type MetaResponse, extractMeta } from './extract-meta'; import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './literals'; import { joinPosix, matchesPathPattern, moduleSpecifierFor } from './module-specifier'; +import { renamePackage } from './rename-package'; +import { RENAME_PACKAGE_TRANSFORMER } from './rename-package.const'; const ROOT = '/project/'; @@ -55,7 +57,9 @@ export class CodeEngine { */ private _scopes: Map | undefined; - constructor(private readonly _options: CodeEngineOptions = {}) {} + constructor(private readonly _options: CodeEngineOptions = {}) { + this.registerTransformer(RENAME_PACKAGE_TRANSFORMER, renamePackage); + } setFiles(files: readonly SourceText[]): void { for (const { path, text } of files) { @@ -370,7 +374,14 @@ export class CodeEngine { roots.found.push(root); } this.setFiles(files); - } catch {} + } catch (error) { + roots.asked.delete(dir); + this._options.onTypesFailed?.( + packageName, + path, + error instanceof Error ? error.message : String(error), + ); + } } } } diff --git a/packages/code/src/engine/code-engine.type.ts b/packages/code/src/engine/code-engine.type.ts index c8204906..ee6418d2 100644 --- a/packages/code/src/engine/code-engine.type.ts +++ b/packages/code/src/engine/code-engine.type.ts @@ -42,4 +42,9 @@ export type Transformer = (context: TransformContext, op: unknown) => void; export interface CodeEngineOptions { /** Fetches type declarations (`node_modules//…`) of a bare import, once per module. */ readonly resolveTypes?: (module: string, from: string) => Promise; + /** + * The types of a module could not be fetched (its imports then show as errors in Problems). + * It is asked again for the next check of a file importing it. + */ + readonly onTypesFailed?: (module: string, from: string, message: string) => void; } diff --git a/packages/code/src/engine/index.ts b/packages/code/src/engine/index.ts index 57b16fc0..a14ec724 100644 --- a/packages/code/src/engine/index.ts +++ b/packages/code/src/engine/index.ts @@ -8,3 +8,4 @@ export * from './engine.type'; export * from './code.exception'; export * from './worker-sdk'; export * from './code-file.const'; +export * from './rename-package.const'; diff --git a/packages/code/src/engine/rename-package.const.ts b/packages/code/src/engine/rename-package.const.ts new file mode 100644 index 00000000..88d1df30 --- /dev/null +++ b/packages/code/src/engine/rename-package.const.ts @@ -0,0 +1,5 @@ +/** + * Core transformer pointing the imports of a package (`@pong/shared`, `@pong/shared/x`) at a + * new name; op `{ from, to }`. Kept apart from the engine: the main thread needs the id too. + */ +export const RENAME_PACKAGE_TRANSFORMER = 'core.renamePackage'; diff --git a/packages/code/src/engine/rename-package.ts b/packages/code/src/engine/rename-package.ts new file mode 100644 index 00000000..82a4a828 --- /dev/null +++ b/packages/code/src/engine/rename-package.ts @@ -0,0 +1,34 @@ +import { Node, SyntaxKind } from 'ts-morph'; + +import type { TransformContext } from './code-engine.type'; + +/** Whether a string literal is the module of an import, an export, `import()` or `require()`. */ +const isModuleSpecifier = (node: Node): boolean => { + const parent = node.getParent(); + if (!parent) return false; + if (Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent)) + return parent.getModuleSpecifier() === node; + if (Node.isExternalModuleReference(parent)) return true; + if (Node.isLiteralTypeNode(parent)) return Node.isImportTypeNode(parent.getParent()); + if (Node.isCallExpression(parent)) { + const callee = parent.getExpression(); + return ( + parent.getArguments()[0] === node && + (callee.getKind() === SyntaxKind.ImportKeyword || + (Node.isIdentifier(callee) && callee.getText() === 'require')) + ); + } + return false; +}; + +/** Points the module specifiers of package `from` (and its sub-paths) at `to`. */ +export const renamePackage = (context: TransformContext, op: unknown): void => { + const { from, to } = op as { from: string; to: string }; + for (const literal of context.file.getDescendantsOfKind(SyntaxKind.StringLiteral)) { + const value = literal.getLiteralValue(); + if (value !== from && !value.startsWith(`${from}/`)) continue; + if (!isModuleSpecifier(literal)) continue; + const quote = literal.getText()[0]; + context.edit.replace(literal, `${quote}${to}${value.slice(from.length)}${quote}`); + } +}; diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index fa8c706d..e1921c15 100644 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -6,5 +6,6 @@ export * from './diagnostics'; export * from './document'; export type * from './engine'; export * from './engine/code-file.const'; +export * from './engine/rename-package.const'; export * from './engine/engine.type'; export * from './engine/code.exception'; diff --git a/packages/code/src/service/code-service.ts b/packages/code/src/service/code-service.ts index 2fa57256..cb6813f3 100644 --- a/packages/code/src/service/code-service.ts +++ b/packages/code/src/service/code-service.ts @@ -2,6 +2,8 @@ import type { CommandOrigin, HistoryCommand } from '@nanoforge-dev/editor-histor import { type Disposable, DisposableStore, + Emitter, + type Event, type Logger, createToken, } from '@nanoforge-dev/editor-kernel'; @@ -10,7 +12,7 @@ import { CodeContract } from '@nanoforge-dev/editor-protocol'; import type { RpcClient } from '@nanoforge-dev/editor-rpc'; import type { DiagnosticsService } from '../diagnostics/diagnostics-service'; -import type { DocumentService } from '../document/document-service'; +import { type DocumentService, fingerprint } from '../document/document-service'; import { CODE_FILE } from '../engine/code-file.const'; import type { CodeDiagnostic, CodeSymbol, TextEdit } from '../engine/engine.type'; import type { MetaRequest, MetaResponse } from '../engine/extract-meta'; @@ -68,12 +70,26 @@ export class CodeService implements Disposable { private _projectChecks = 0; private _projectCheckRun = 0; private _projectCheckTimer: ReturnType | undefined; + private readonly _onDidChangeOwners = new Emitter(); + + /** A worker plugin was loaded or unloaded (item owners may have changed). */ + readonly onDidChangeOwners: Event = this._onDidChangeOwners.event; constructor(private readonly _options: CodeServiceOptions) {} - /** The engine, started (and the project mirrored) on first call. */ + /** + * The engine, started (and the project mirrored) on first call. A start that fails, or a + * worker that crashes, is forgotten: the next call starts a new worker. + */ engine(): Promise> { - this._engine ??= this._start(); + if (!this._engine) { + const started = new DisposableStore(); + const engine = this._start(started); + this._engine = engine; + engine.catch(() => { + if (this._engine === engine) this._stop(started); + }); + } return this._engine; } @@ -97,8 +113,15 @@ export class CodeService implements Disposable { op: unknown, options: { label: string; origin?: CommandOrigin; mergeKey?: string }, ): Promise { - const edits = await this.transform(path, transformerId, op); - return edits.length ? this._options.documents.editCommand(path, edits, options) : undefined; + const engine = await this.engine(); + const text = await this._options.documents.getText(path); + await engine.setFiles([{ path, text }]); + const edits = await engine.transform(path, transformerId, op); + if (!edits.length) return undefined; + return this._options.documents.editCommand(path, edits, { + ...options, + basedOn: fingerprint(text), + }); } /** Checks files now and publishes TypeScript problems. */ @@ -190,12 +213,20 @@ export class CodeService implements Disposable { /** Loads a plugin's worker entry (`entry.worker`); reloading replaces its registrations. */ async loadWorkerPlugin(owner: string, url: string): Promise { this._plugins.set(owner, url); - if (this._engine) await (await this._engine).loadPlugin(owner, url); + try { + if (this._engine) await (await this._engine).loadPlugin(owner, url); + } finally { + this._onDidChangeOwners.fire(); + } } async unloadWorkerPlugin(owner: string): Promise { this._plugins.delete(owner); - if (this._engine) (await this._engine).unloadPlugin(owner); + try { + if (this._engine) await (await this._engine).unloadPlugin(owner); + } finally { + this._onDidChangeOwners.fire(); + } } dispose(): void { @@ -203,6 +234,7 @@ export class CodeService implements Disposable { clearTimeout(this._projectCheckTimer); this._projectCheckRun++; this._store.dispose(); + this._onDidChangeOwners.dispose(); this._endpoint?.terminate?.(); } @@ -213,19 +245,38 @@ export class CodeService implements Disposable { return engine; } - private async _start(): Promise> { + /** Stops a worker that failed, and what was set up for it. */ + private _stop(started: DisposableStore): void { + this._engine = undefined; + this._store.delete(started); + started.dispose(); + this._endpoint?.terminate?.(); + this._endpoint = undefined; + } + + private async _start(started: DisposableStore): Promise> { mark('nf:code:created'); const endpoint = this._options.createWorker(); this._endpoint = endpoint; + this._store.add(started); + const onError = () => { + if (this._endpoint === endpoint) this._stop(started); + }; + endpoint.addEventListener('error', onError); + started.add({ dispose: () => endpoint.removeEventListener('error', onError) }); const host: EngineHostApi = { resolveTypes: (module, from) => this._options.rpc .api(CodeContract) .typeFiles({ project: this._options.projectId, from, module }), + typesFailed: (module, from, message) => + this._options.logger?.warn( + `The types of ${module} (imported by ${from}) could not be read: ${message}`, + ), }; - this._store.add({ dispose: expose(host, endpoint) }); + started.add({ dispose: expose(host, endpoint) }); const engine = wrap(endpoint); - this._store.add({ dispose: () => engine.dispose() }); + started.add({ dispose: () => engine.dispose() }); const syncTsconfig = async () => engine.setTsconfig( @@ -235,7 +286,7 @@ export class CodeService implements Disposable { ); await syncTsconfig(); mark('nf:code:ready', 'nf:code:created'); - this._store.add( + started.add( this._options.fs.onDidChange((changes) => { if (changes.some((change) => change.path === TSCONFIG)) void syncTsconfig().catch((error: unknown) => @@ -257,7 +308,7 @@ export class CodeService implements Disposable { await engine.setFiles(batch); } mark('nf:code:mirrored', 'nf:code:created'); - this._store.add( + started.add( this._options.fs.onDidChange((changes) => { void (async () => { const deleted = changes @@ -288,7 +339,7 @@ export class CodeService implements Disposable { ); }), ); - this._store.add( + started.add( this._options.documents.onDidChange(({ uri }) => { if (!isCodeFile(uri)) return; void this._options.documents @@ -307,6 +358,7 @@ export class CodeService implements Disposable { this._options.logger?.error(`Worker plugin ${owner} failed`, error), ); } + if (this._plugins.size) this._onDidChangeOwners.fire(); return engine; } diff --git a/packages/code/src/worker-rpc/serve-engine.ts b/packages/code/src/worker-rpc/serve-engine.ts index 788d42cf..0de93f28 100644 --- a/packages/code/src/worker-rpc/serve-engine.ts +++ b/packages/code/src/worker-rpc/serve-engine.ts @@ -24,6 +24,7 @@ export interface EngineApi { /** Services the main thread offers to the worker. */ export interface EngineHostApi { resolveTypes(module: string, from: string): Promise; + typesFailed(module: string, from: string, message: string): void; } export interface ServeOptions { @@ -39,6 +40,8 @@ export const serveEngine = ( const host = wrap(endpoint); const engine = new CodeEngine({ resolveTypes: (module, from) => host.resolveTypes(module, from), + onTypesFailed: (module, from, message) => + void host.typesFailed(module, from, message).catch(() => undefined), }); const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); const api: EngineApi = { diff --git a/packages/code/src/worker-rpc/worker-rpc.ts b/packages/code/src/worker-rpc/worker-rpc.ts index a7a7c43e..aab0964c 100644 --- a/packages/code/src/worker-rpc/worker-rpc.ts +++ b/packages/code/src/worker-rpc/worker-rpc.ts @@ -5,10 +5,20 @@ export interface MessageEndpoint { postMessage(message: unknown): void; addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + addEventListener(type: 'error' | 'messageerror', listener: (event: Event) => void): void; removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + removeEventListener(type: 'error' | 'messageerror', listener: (event: Event) => void): void; start?(): void; } +/** A worker failed (crashed, or sent what could not be read): its calls will never answer. */ +export class WorkerFailedError extends Error { + constructor(message: string) { + super(message); + this.name = 'WorkerFailedError'; + } +} + type AnyFunction = (...args: never[]) => unknown; /** The async view of an API object. */ @@ -61,21 +71,42 @@ export const expose = (api: object, endpoint: MessageEndpoint): (() => void) => return () => endpoint.removeEventListener('message', listener); }; -/** Calls the API exposed on the other side of the endpoint. */ +/** + * Calls the API exposed on the other side of the endpoint. When the endpoint reports an error + * (the worker crashed) every pending call fails with a `WorkerFailedError`, as does a call that + * gets no answer within `timeoutMs` (default 2 minutes). + */ export const wrap = ( endpoint: MessageEndpoint, + options: { timeoutMs?: number } = {}, ): Remote & { dispose(): void } => { let nextId = 1; const pending = new Map< number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } >(); + const failAll = (error: Error) => { + for (const call of pending.values()) { + clearTimeout(call.timer); + call.reject(error); + } + pending.clear(); + }; + const onError = (event: Event) => { + const detail = (event as ErrorEvent).message; + failAll(new WorkerFailedError(`The code worker failed${detail ? `: ${detail}` : ''}`)); + }; const listener = (event: MessageEvent) => { const message = event.data as Response; if (message?.rpc !== 'result') return; const call = pending.get(message.id); if (!call) return; pending.delete(message.id); + clearTimeout(call.timer); if (message.error) { call.reject(Object.assign(new Error(message.error.message), message.error)); } else { @@ -83,21 +114,29 @@ export const wrap = ( } }; endpoint.addEventListener('message', listener); + endpoint.addEventListener('error', onError); + endpoint.addEventListener('messageerror', onError); endpoint.start?.(); + const timeoutMs = options.timeoutMs ?? 120_000; return new Proxy({} as Remote & { dispose(): void }, { get: (_target, property) => { if (property === 'dispose') { return () => { endpoint.removeEventListener('message', listener); - for (const call of pending.values()) call.reject(new Error('Worker connection closed')); - pending.clear(); + endpoint.removeEventListener('error', onError); + endpoint.removeEventListener('messageerror', onError); + failAll(new Error('Worker connection closed')); }; } if (typeof property !== 'string' || property === 'then') return undefined; return (...args: unknown[]) => new Promise((resolve, reject) => { const id = nextId++; - pending.set(id, { resolve, reject }); + const timer = setTimeout(() => { + pending.delete(id); + reject(new WorkerFailedError(`The code worker did not answer ${property} in time`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); endpoint.postMessage({ rpc: 'call', id, method: property, args } satisfies Request); }); }, diff --git a/packages/code/test/document/document-service.spec.ts b/packages/code/test/document/document-service.spec.ts index be035b0e..fdf79f76 100644 --- a/packages/code/test/document/document-service.spec.ts +++ b/packages/code/test/document/document-service.spec.ts @@ -98,4 +98,11 @@ describe('matchesGlob', () => { expect(matchesGlob('src/**/*.ts', 'src/a/b/main.ts')).toBe(true); expect(matchesGlob('src/*.ts', 'src/a/main.ts')).toBe(false); }); + + it('reads a comma outside braces as a plain character', () => { + expect(matchesGlob('a,b', 'a,b')).toBe(true); + expect(matchesGlob('a,b', 'a')).toBe(false); + expect(matchesGlob('a,b', 'xb')).toBe(false); + expect(matchesGlob('src/{a,b}.ts', 'src/b.ts')).toBe(true); + }); }); diff --git a/packages/code/test/engine/code-engine.spec.ts b/packages/code/test/engine/code-engine.spec.ts index 4aea5f66..eca05660 100644 --- a/packages/code/test/engine/code-engine.spec.ts +++ b/packages/code/test/engine/code-engine.spec.ts @@ -9,6 +9,7 @@ import { CODE_FILE, CodeEngine, CodeError, + RENAME_PACKAGE_TRANSFORMER, type TransformContext, literalToValue, valueToLiteral, @@ -46,6 +47,35 @@ const renameConst = ({ file, edit }: TransformContext, op: unknown) => { }; describe('CodeEngine', () => { + it('renames the imports of a package, not its look-alikes in comments and strings', () => { + const engine = new CodeEngine(); + const text = [ + '// from "@pong/shared"', + 'import { a } from "@pong/shared/a";', + "export * from '@pong/shared';", + 'const lazy = import("@pong/shared/b");', + 'const label = "@pong/shared";', + 'import tools from "@pong/shared-tools";', + '', + ].join('\n'); + engine.setFiles([{ path: 'src/main.ts', text }]); + const edits = engine.transform('src/main.ts', RENAME_PACKAGE_TRANSFORMER, { + from: '@pong/shared', + to: '@pong/common', + }); + expect(applyTextEdits(text, edits).text).toBe( + [ + '// from "@pong/shared"', + 'import { a } from "@pong/common/a";', + "export * from '@pong/common';", + 'const lazy = import("@pong/common/b");', + 'const label = "@pong/shared";', + 'import tools from "@pong/shared-tools";', + '', + ].join('\n'), + ); + }); + it('keeps every pong-network file byte-identical and a no-op transform yields no edit', () => { const engine = new CodeEngine(); const files = pong(); diff --git a/packages/code/test/service/code-service.spec.ts b/packages/code/test/service/code-service.spec.ts index 9dc35703..abc17293 100644 --- a/packages/code/test/service/code-service.spec.ts +++ b/packages/code/test/service/code-service.spec.ts @@ -154,6 +154,40 @@ describe('CodeService', () => { code.dispose(); }); + it('refuses an edit when the text changed after it was computed', async () => { + const { documents, code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const onDisk = readFileSync(join(workspace, 'pong', MAIN), 'utf8'); + const document = fakeDocument(MAIN, onDisk); + documents.register(document); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + const command = await code.edit( + MAIN, + 'test.rename-first-const', + { name: 'renamed' }, + { label: 'Rename' }, + ); + document.applyEdits([{ start: 0, end: 0, text: '// typed meanwhile\n' }], undefined); + await expect(stack.push(command!)).rejects.toThrow(/changed while the edit was prepared/); + expect(document.text()).toBe(`// typed meanwhile\n${onDisk}`); + code.dispose(); + }); + + it('starts a new worker after a failed start', async () => { + const { code } = setup(); + let starts = 0; + const options = (code as unknown as { _options: { createWorker: () => unknown } })._options; + const create = options.createWorker; + options.createWorker = () => { + if (++starts === 1) throw new Error('worker blocked'); + return create(); + }; + await expect(code.engine()).rejects.toThrow('worker blocked'); + await expect(code.symbols(MAIN)).resolves.toBeInstanceOf(Array); + expect(starts).toBe(2); + code.dispose(); + }); + it('edits closed files on disk, undoes, and drops undo after an outside change', async () => { const { code, history } = setup(); await code.loadWorkerPlugin('test/rename', 'memory://plugin'); diff --git a/packages/code/test/worker-rpc/worker-rpc.spec.ts b/packages/code/test/worker-rpc/worker-rpc.spec.ts new file mode 100644 index 00000000..889c80cb --- /dev/null +++ b/packages/code/test/worker-rpc/worker-rpc.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkerFailedError, wrap } from '../../src/worker-rpc/worker-rpc'; + +/** A worker that never answers, and can crash. */ +class SilentWorker extends EventTarget { + postMessage(): void {} + crash(): void { + this.dispatchEvent(Object.assign(new Event('error'), { message: 'out of memory' })); + } +} + +describe('wrap', () => { + it('fails pending calls when the worker crashes', async () => { + const worker = new SilentWorker(); + const remote = wrap<{ diagnostics(): string[] }>(worker as never); + const call = remote.diagnostics(); + worker.crash(); + await expect(call).rejects.toBeInstanceOf(WorkerFailedError); + await expect(call).rejects.toThrow(/out of memory/); + }); + + it('fails a call that gets no answer in time', async () => { + const remote = wrap<{ diagnostics(): string[] }>(new SilentWorker() as never, { + timeoutMs: 10, + }); + await expect(remote.diagnostics()).rejects.toThrow(/did not answer diagnostics/); + }); +}); diff --git a/packages/history/package.json b/packages/history/package.json index e1903225..8b850c23 100644 --- a/packages/history/package.json +++ b/packages/history/package.json @@ -42,8 +42,6 @@ "@nanoforge-dev/editor-kernel": "workspace:*" }, "devDependencies": { - "@nanoforge-dev/editor-project": "workspace:*", - "@nanoforge-dev/editor-server-core": "workspace:*", "typescript": "catalog:build", "vitest": "catalog:test" }, diff --git a/packages/history/src/command/composite-command.ts b/packages/history/src/command/composite-command.ts index e900408f..1f349e5c 100644 --- a/packages/history/src/command/composite-command.ts +++ b/packages/history/src/command/composite-command.ts @@ -3,7 +3,10 @@ import type { CommandOrigin, HistoryCommand } from './history-command.type'; export const sameOrigin = (a: CommandOrigin | undefined, b: CommandOrigin | undefined): boolean => a?.kind === b?.kind && a?.id === b?.id; -/** Runs children in order and undoes them in reverse. */ +/** + * Runs children in order and undoes them in reverse. When a child fails, the ones already run + * are undone (best effort) before the error goes on: a step is never left half applied. + */ export const compositeCommand = ( label: string, children: readonly HistoryCommand[], @@ -13,15 +16,14 @@ export const compositeCommand = ( ...(origin && { origin }), children, sizeBytes: children.reduce((total, child) => total + (child.sizeBytes ?? 0), 0), - async do() { - for (const child of children) await child.do(); - }, - async undo() { - for (const child of [...children].reverse()) await child.undo(); - }, - async redo() { - for (const child of children) await (child.redo ?? child.do).call(child); - }, + do: () => runAll(children, (child) => child.do()), + undo: () => + runAll( + [...children].reverse(), + (child) => child.undo(), + (child) => (child.redo ?? child.do).call(child), + ), + redo: () => runAll(children, (child) => (child.redo ?? child.do).call(child)), async isValid() { for (const child of children) if ((await child.isValid?.()) === false) return false; return true; @@ -31,15 +33,48 @@ export const compositeCommand = ( }, }); -/** Default merge of two commands with the same merge key: first undo, last redo. */ +/** + * Runs `step` on each command in order. If one throws, the ones already done are reverted with + * `revert` (undo by default), last first, and the error is rethrown. + */ +const runAll = async ( + commands: readonly HistoryCommand[], + step: (command: HistoryCommand) => Promise | void, + revert: (command: HistoryCommand) => Promise | void = (command) => command.undo(), +): Promise => { + const done: HistoryCommand[] = []; + try { + for (const command of commands) { + await step(command); + done.push(command); + } + } catch (error) { + for (const command of done.reverse()) { + try { + await revert(command); + } catch {} + } + throw error; + } +}; + +/** + * Default merge of two commands with the same merge key: one step whose undo reverts both (the + * last one first) and whose redo replays both. + */ export const mergeCommands = (previous: HistoryCommand, next: HistoryCommand): HistoryCommand => ({ label: next.label, ...(previous.origin && { origin: previous.origin }), sizeBytes: (previous.sizeBytes ?? 0) + (next.sizeBytes ?? 0), ...(next.mergeKey && { mergeKey: next.mergeKey }), do: () => next.do(), - undo: () => previous.undo(), - redo: () => (next.redo ?? next.do).call(next), + undo: () => + runAll( + [next, previous], + (command) => command.undo(), + (command) => (command.redo ?? command.do).call(command), + ), + redo: () => runAll([previous, next], (command) => (command.redo ?? command.do).call(command)), async isValid() { return (await previous.isValid?.()) !== false && (await next.isValid?.()) !== false; }, diff --git a/packages/history/src/file/file-history-tracker.ts b/packages/history/src/file/file-history-tracker.ts deleted file mode 100644 index 7845f38e..00000000 --- a/packages/history/src/file/file-history-tracker.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type Disposable, DisposableStore, type Event } from '@nanoforge-dev/editor-kernel'; - -import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; -import type { HistoryStack } from '../stack/history-stack'; -import { type TextEdit, applyTextEdits } from './apply-text-edits'; - -/** The file operations history needs (ProjectFs satisfies it). */ -export interface HistoryFileSystem { - readonly onDidChange: Event; - readText(path: string): Promise<{ text: string; hash: string }>; - write( - path: string, - content: string, - options: { expectedHash: string | null }, - ): Promise<{ hash: string }>; -} - -/** - * Tracks the hash each file should have after the editor's own edits. When a tracked file - * changes to anything else (hand edit, git pull…), the history of that context is invalidated. - */ -export class FileHistoryTracker implements Disposable { - private readonly _expected = new Map(); - /** Writes in progress per path: changes seen meanwhile may be their own echo. */ - private readonly _writing = new Map>(); - private readonly _store = new DisposableStore(); - - constructor( - private readonly _fs: HistoryFileSystem, - private readonly _stack: HistoryStack, - ) { - this._store.add( - _fs.onDidChange((changes) => { - for (const change of changes) - if (this._expected.has(change.path)) void this._check(change.path, change.type); - }), - ); - } - - expect(path: string, hash: string): void { - this._expected.set(path, hash); - } - - expectedHash(path: string): string | undefined { - return this._expected.get(path); - } - - /** - * An undoable edit of a file. It fails (and the step is dropped) if the file no longer has - * the content the edit produced. - */ - edit( - path: string, - edits: readonly TextEdit[], - options: { label: string; origin?: CommandOrigin; mergeKey?: string }, - ): HistoryCommand { - let inverse: TextEdit[] = []; - let forward = edits; - const apply = (operations: readonly TextEdit[]) => { - const task = write(operations); - this._writing.set( - path, - task.catch(() => undefined), - ); - return task.finally(() => { - if (this._writing.get(path) === task) this._writing.delete(path); - }); - }; - const write = async (operations: readonly TextEdit[]) => { - const current = await this._fs.readText(path); - const expected = this._expected.get(path); - if (expected !== undefined && current.hash !== expected) { - throw new Error(`${path} changed outside of the editor`); - } - const result = applyTextEdits(current.text, operations); - const { hash } = await this._fs.write(path, result.text, { expectedHash: current.hash }); - this._expected.set(path, hash); - return result.inverse; - }; - return { - label: options.label, - ...(options.origin && { origin: options.origin }), - ...(options.mergeKey && { mergeKey: options.mergeKey }), - sizeBytes: edits.reduce((total, edit) => total + edit.text.length * 2 + 16, 0), - do: async () => { - inverse = await apply(forward); - }, - undo: async () => { - forward = await apply(inverse); - }, - redo: async () => { - inverse = await apply(forward); - }, - isValid: async () => { - const expected = this._expected.get(path); - return expected === undefined || (await this._fs.readText(path)).hash === expected; - }, - }; - } - - dispose(): void { - this._store.dispose(); - } - - private async _check(path: string, type: string): Promise { - await this._writing.get(path); - const expected = this._expected.get(path); - if (expected === undefined) return; - const hash = - type === 'deleted' - ? null - : await this._fs.readText(path).then( - (file) => file.hash, - () => null, - ); - if (hash === expected) return; // echo of our own write - this._expected.delete(path); - await this._stack.invalidate('external-change'); - } -} diff --git a/packages/history/src/file/index.ts b/packages/history/src/file/index.ts index 617d3c57..9ddab5ee 100644 --- a/packages/history/src/file/index.ts +++ b/packages/history/src/file/index.ts @@ -1,2 +1 @@ export * from './apply-text-edits'; -export * from './file-history-tracker'; diff --git a/packages/history/src/service/history-service.ts b/packages/history/src/service/history-service.ts index 778838c6..44a3c1e3 100644 --- a/packages/history/src/service/history-service.ts +++ b/packages/history/src/service/history-service.ts @@ -113,8 +113,9 @@ export class HistoryService implements Disposable { /** * Keeps the history of the matching contexts in a store: restored when such a context is - * registered, saved when it changes. Only histories whose steps are all serializable are - * saved. + * registered (under the steps made meanwhile), saved when it changes and when the context + * goes away. Only histories whose steps are all serializable are saved; otherwise the stored + * history stays as it is. */ persist(match: (id: string) => boolean, store: HistoryStore): Disposable { const entry = { match, store }; @@ -161,17 +162,19 @@ export class HistoryService implements Disposable { private _persistContext(context: HistoryContext, store: HistoryStore): Disposable { let restored = false; let timer: ReturnType | undefined; + const write = () => { + timer = undefined; + const entries: PersistedEntry[] = []; + for (const entry of context.stack.state.past) { + const command = entry.command.serialize?.(); + if (!command) return; + entries.push({ command, origin: entry.origin, time: entry.time }); + } + void store.save(context.id, entries).catch(() => undefined); + }; const save = () => { clearTimeout(timer); - timer = setTimeout(() => { - const entries: PersistedEntry[] = []; - for (const entry of context.stack.state.past) { - const command = entry.command.serialize?.(); - if (!command) return; // not all serializable: keep the stored history as is - entries.push({ command, origin: entry.origin, time: entry.time }); - } - void store.save(context.id, entries).catch(() => undefined); - }, 300); + timer = setTimeout(write, 300); }; void store .load(context.id) @@ -183,24 +186,26 @@ export class HistoryService implements Disposable { entry.command.label, ), })); - if ( - commands.length && - commands.every((entry) => entry.command) && - !context.stack.state.past.length - ) { + if (commands.length && commands.every((entry) => entry.command)) { await context.stack.restore( commands as { command: HistoryCommand; origin: CommandOrigin; time: number }[], ); } }) .catch(() => undefined) - .finally(() => (restored = true)); + .finally(() => { + restored = true; + if (context.stack.state.past.length) save(); + }); const subscription = context.stack.onDidChange(() => { if (restored) save(); }); return { dispose: () => { - clearTimeout(timer); + if (timer !== undefined) { + clearTimeout(timer); + write(); + } subscription.dispose(); }, }; diff --git a/packages/history/src/stack/history-stack.ts b/packages/history/src/stack/history-stack.ts index 37e3f9a1..68afae56 100644 --- a/packages/history/src/stack/history-stack.ts +++ b/packages/history/src/stack/history-stack.ts @@ -100,22 +100,28 @@ export class HistoryStack { }); } - /** Replaces the stack with entries restored after a reload (already applied). */ + /** + * Puts back entries restored after a reload (already applied), before the steps recorded since + * the reload: an edit made while the history was loading stays on top of them. + */ restore( entries: readonly { command: HistoryCommand; origin: CommandOrigin; time: number }[], ): Promise { return this._serial(async () => { - this._drop([...this._past, ...this._future]); const local = this._options.localOrigin(); - this._past = entries.map((entry) => ({ + const restored = entries.map((entry) => ({ id: this._nextId++, command: entry.command, origin: entry.origin, undoable: sameOrigin(entry.origin, local), time: entry.time, })); - this._future = []; - this._savedAt = null; + if (!this._past.length) { + this._drop(this._future); + this._future = []; + this._savedAt = null; + } + this._past = [...restored, ...this._past]; this._trim(); this._changed(); }); diff --git a/packages/history/test/file/file-history-tracker.spec.ts b/packages/history/test/file/file-history-tracker.spec.ts deleted file mode 100644 index 90c4be50..00000000 --- a/packages/history/test/file/file-history-tracker.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { Emitter } from '@nanoforge-dev/editor-kernel'; - -import { FileHistoryTracker, type HistoryFileSystem } from '../../src'; -import { setup } from '../fixtures/history'; - -describe('FileHistoryTracker', () => { - const memoryFs = (initial: Record) => { - const files = new Map(Object.entries(initial)); - const hashOf = (text: string) => `h${text.length}:${text}`; - const changes = new Emitter<{ path: string; type: string }[]>(); - const fs: HistoryFileSystem & { - files: Map; - external(path: string, text: string): void; - } = { - files, - readText: async (path) => ({ text: files.get(path)!, hash: hashOf(files.get(path)!) }), - write: async (path, content, { expectedHash }) => { - if (hashOf(files.get(path)!) !== expectedHash) - throw Object.assign(new Error('conflict'), { code: 'CONFLICT' }); - files.set(path, content); - changes.fire([{ path, type: 'changed' }]); // echo, like the watcher - return { hash: hashOf(content) }; - }, - onDidChange: changes.event, - external(path, text) { - files.set(path, text); - changes.fire([{ path, type: 'changed' }]); - }, - }; - return fs; - }; - - it('undoes file edits and survives the echo of its own writes', async () => { - const fs = memoryFs({ 'main.ts': 'let x = 1;' }); - const { stack } = setup(); - const tracker = new FileHistoryTracker(fs, stack); - await stack.push( - tracker.edit('main.ts', [{ start: 8, end: 9, text: '42' }], { label: 'x = 42' }), - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(fs.files.get('main.ts')).toBe('let x = 42;'); - await stack.undo(); - expect(fs.files.get('main.ts')).toBe('let x = 1;'); - await stack.redo(); - expect(fs.files.get('main.ts')).toBe('let x = 42;'); - }); - - it('invalidates the context when the file is changed from outside', async () => { - const fs = memoryFs({ 'main.ts': 'let x = 1;' }); - const { history, stack } = setup(); - const invalidated = vi.fn(); - history.onDidInvalidate(invalidated); - const tracker = new FileHistoryTracker(fs, stack); - await stack.push( - tracker.edit('main.ts', [{ start: 8, end: 9, text: '2' }], { label: 'x = 2' }), - ); - fs.external('main.ts', 'let x = 2; // hand edit'); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(stack.state.canUndo).toBe(false); - expect(invalidated).toHaveBeenCalledWith({ contextId: 'scene', reason: 'external-change' }); - expect(fs.files.get('main.ts')).toBe('let x = 2; // hand edit'); - }); -}); diff --git a/packages/history/test/file/project-fs.spec.ts b/packages/history/test/file/project-fs.spec.ts deleted file mode 100644 index 18f88139..00000000 --- a/packages/history/test/file/project-fs.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, describe, expect, it } from 'vitest'; - -import { MemoryContentCache, MemoryTreeCache, ProjectService } from '@nanoforge-dev/editor-project'; -import { createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; -import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; - -import { FileHistoryTracker, HistoryService } from '../../src'; - -const workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-history-')); -cpSync( - join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), - join(workspace, 'pong'), - { - recursive: true, - }, -); -const editor = createEditorServer({ - env: loadEnv( - { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, - workspace, - ), - version: 'test', -}); -const rpc = createLoopbackClient(editor); -afterAll(() => { - rpc.dispose(); - editor.dispose(); - rmSync(workspace, { recursive: true, force: true }); -}); - -describe('file history over ProjectFs', () => { - it('undoes edits of a real project file, then drops history on an outside edit', async () => { - const project = await new ProjectService(rpc, { - contents: () => new MemoryContentCache(), - tree: () => new MemoryTreeCache(), - }).open({ path: 'pong' }); - const path = 'apps/client/src/main.ts'; - const history = new HistoryService(); - const { stack } = history.registerContext({ id: `file:${path}`, label: path }); - const tracker = new FileHistoryTracker(project.fs, stack); - - const original = (await project.fs.readText(path)).text; - await stack.push( - tracker.edit(path, [{ start: 0, end: 0, text: '// edited\n' }], { label: 'comment' }), - ); - await new Promise((resolve) => setTimeout(resolve, 300)); // watcher echo - expect(stack.state.canUndo).toBe(true); - await stack.undo(); - expect((await project.fs.readText(path)).text).toBe(original); - - await stack.redo(); - await new Promise((resolve) => setTimeout(resolve, 300)); - writeFileSync(join(workspace, 'pong', path), '// replaced by git pull\n'); - const start = Date.now(); - while (stack.state.canUndo && Date.now() - start < 2000) - await new Promise((r) => setTimeout(r, 10)); - expect(stack.state.canUndo).toBe(false); - }); -}); diff --git a/packages/history/test/service/history-service.spec.ts b/packages/history/test/service/history-service.spec.ts index 24932c04..6180e0d0 100644 --- a/packages/history/test/service/history-service.spec.ts +++ b/packages/history/test/service/history-service.spec.ts @@ -74,4 +74,42 @@ describe('history panel support', () => { await new Promise((resolve) => setTimeout(resolve, 400)); expect(saved.has('layout')).toBe(false); }); + + it('restores under an edit made while loading, and saves pending steps on dispose', async () => { + const saved = new Map(); + let release!: () => void; + const loading = new Promise((resolve) => (release = resolve)); + const values = { current: 0 }; + const command = (value: number, previous: number): HistoryCommand => ({ + label: `set ${value}`, + do: () => void (values.current = value), + undo: () => void (values.current = previous), + serialize: () => ({ type: 'set', label: `set ${value}`, data: { value, previous } }), + }); + saved.set('file:a.ts', [ + { command: command(1, 0).serialize!(), origin: { kind: 'local', id: 'local' }, time: 1 }, + ]); + const store = { + load: async (id: string) => { + await loading; + return saved.get(id) as never; + }, + save: async (id: string, entries: unknown) => void saved.set(id, entries), + }; + const service = new HistoryService(); + service.registerDeserializer('set', (data) => { + const { value, previous } = data as { value: number; previous: number }; + return command(value, previous); + }); + service.persist((id) => id.startsWith('file:'), store); + const context = service.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + values.current = 1; + await context.stack.push(command(2, 1)); + release(); + await vi.waitFor(() => expect(context.stack.state.past).toHaveLength(2)); + await context.stack.push(command(3, 2)); + context.dispose(); + expect(saved.get('file:a.ts')).toHaveLength(3); + await service.undo('file:a.ts'); + }); }); diff --git a/packages/history/test/stack/history-stack.spec.ts b/packages/history/test/stack/history-stack.spec.ts index 7797585a..9286f469 100644 --- a/packages/history/test/stack/history-stack.spec.ts +++ b/packages/history/test/stack/history-stack.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { HistoryCommand } from '../../src'; +import { type HistoryCommand, compositeCommand } from '../../src'; import { cell, setup } from '../fixtures/history'; describe('HistoryStack', () => { @@ -140,4 +140,37 @@ describe('HistoryStack', () => { expect(stack.state.past).toHaveLength(0); expect(invalidated).toHaveBeenCalledWith({ contextId: 'scene', reason: 'invalid-command' }); }); + + it('undoes a merged step entirely, the last change first', async () => { + const { stack } = setup(); + const text = { value: '' }; + const insert = (chars: string): HistoryCommand => ({ + label: 'type', + mergeKey: 'typing', + do: () => void (text.value += chars), + undo: () => void (text.value = text.value.slice(0, -chars.length)), + }); + await stack.push(insert('ab')); + await stack.push(insert('cd')); + expect(stack.state.past).toHaveLength(1); + await stack.undo(); + expect(text.value).toBe(''); + await stack.redo(); + expect(text.value).toBe('abcd'); + }); + + it('reverts the parts of a group already applied when one fails', async () => { + const { stack } = setup(); + const { state, set } = cell(0); + const failing: HistoryCommand = { + label: 'fail', + do: () => Promise.reject(new Error('disk full')), + undo: () => undefined, + }; + await expect(stack.push(compositeCommand('move', [set(1), failing]))).rejects.toThrow( + 'disk full', + ); + expect(state.value).toBe(0); + expect(stack.state.past).toHaveLength(0); + }); }); diff --git a/packages/kernel/src/context/when-tokenizer.ts b/packages/kernel/src/context/when-tokenizer.ts index bb1a52c8..2c824d40 100644 --- a/packages/kernel/src/context/when-tokenizer.ts +++ b/packages/kernel/src/context/when-tokenizer.ts @@ -41,6 +41,7 @@ export const tokenize = (source: string): Token[] => { i++; let flags = ''; while (/[a-z]/.test(source[i] ?? '')) flags += source[i++]; + flags = flags.replace(/[gy]/g, ''); try { tokens.push({ kind: 'regex', value: new RegExp(pattern, flags), pos: start }); } catch (error) { diff --git a/packages/kernel/src/plugin/plugin-host.ts b/packages/kernel/src/plugin/plugin-host.ts index d6dd02dc..30aaa8bf 100644 --- a/packages/kernel/src/plugin/plugin-host.ts +++ b/packages/kernel/src/plugin/plugin-host.ts @@ -32,6 +32,11 @@ interface PluginRecord { context?: DisposableStore; staticContributions: DisposableStore; pending?: Promise; + /** + * Deactivation in progress. An activation asked meanwhile is refused: waiting for it could + * deadlock with a dependent that is still activating. + */ + deactivating?: Promise; } export class PluginHost implements Disposable { @@ -220,6 +225,9 @@ export class PluginHost implements Disposable { if (record.status.kind !== 'ok') { return Promise.reject(new PluginActivationError(name, describeStatus(record.status))); } + if (record.deactivating) { + return Promise.reject(new PluginActivationError(name, 'it is being deactivated')); + } if (record.state === 'active') return Promise.resolve(); if (record.state === 'failed') return Promise.reject(new PluginActivationError(name, record.error)); @@ -229,27 +237,17 @@ export class PluginHost implements Disposable { return record.pending; } - /** Deactivates a plugin after its active dependents. */ - async deactivate(name: string): Promise { + /** + * Deactivates a plugin after its active dependents. An activation in progress finishes first; + * concurrent calls share the same deactivation. + */ + deactivate(name: string): Promise { const record = this._records.get(name); - if (!record || (record.state !== 'active' && record.state !== 'failed')) return; - for (const dependent of this._dependents(name)) await this.deactivate(dependent); - if (record.state === 'failed') { - record.state = 'inactive'; - record.error = undefined; - this._onDidChange.fire(); - return; - } - record.state = 'deactivating'; - this._onDidChange.fire(); - try { - await record.module?.deactivate?.(); - } catch (error) { - this._logger.error(`Plugin "${name}" failed to deactivate`, error); - } - this._cleanup(name, record); - record.state = 'inactive'; - this._onDidChange.fire(); + if (!record) return Promise.resolve(); + record.deactivating ??= this._deactivate(name, record).finally(() => { + record.deactivating = undefined; + }); + return record.deactivating; } /** @@ -291,6 +289,28 @@ export class PluginHost implements Disposable { this._onDidChange.dispose(); } + private async _deactivate(name: string, record: PluginRecord): Promise { + await record.pending?.catch(() => undefined); + if (record.state !== 'active' && record.state !== 'failed') return; + for (const dependent of this._dependents(name)) await this.deactivate(dependent); + if (record.state === 'failed') { + record.state = 'inactive'; + record.error = undefined; + this._onDidChange.fire(); + return; + } + record.state = 'deactivating'; + this._onDidChange.fire(); + try { + await record.module?.deactivate?.(); + } catch (error) { + this._logger.error(`Plugin "${name}" failed to deactivate`, error); + } + this._cleanup(name, record); + record.state = 'inactive'; + this._onDidChange.fire(); + } + private async _activate(name: string, record: PluginRecord): Promise { const { manifest } = record.descriptor; for (const dependency of Object.keys(manifest.dependencies)) { @@ -361,13 +381,15 @@ export class PluginHost implements Disposable { } } - /** Active or activating plugins depending (required or optional) on `name`. */ + /** Active, activating or failed plugins depending (required or optional) on `name`. */ private _dependents(name: string): string[] { return [...this._records] .filter(([, record]) => { const { dependencies, optionalDependencies } = record.descriptor.manifest; return ( - (record.state === 'active' || record.state === 'failed') && + (record.state === 'active' || + record.state === 'activating' || + record.state === 'failed') && (name in dependencies || name in optionalDependencies) ); }) diff --git a/packages/kernel/test/context/parse-when.spec.ts b/packages/kernel/test/context/parse-when.spec.ts index 03c1c312..6ada5759 100644 --- a/packages/kernel/test/context/parse-when.spec.ts +++ b/packages/kernel/test/context/parse-when.spec.ts @@ -39,6 +39,11 @@ describe('when clauses', () => { expect(run(source)).toBe(expected); }); + it.each(['g', 'y', 'gy'])('gives the same answer every time with the %s flag', (flags) => { + const clause = parseWhen(`resourceName =~ /component/${flags}`); + expect([1, 2, 3].map(() => clause.evaluate((key) => ctx[key]))).toEqual([true, true, true]); + }); + it('reports read keys', () => { expect([...parseWhen('a && (b == x || !c) && d in e').keys]).toEqual(['a', 'b', 'c', 'd', 'e']); }); diff --git a/packages/kernel/test/plugin/plugin-host.spec.ts b/packages/kernel/test/plugin/plugin-host.spec.ts index 2aa3f8b0..cb03ed62 100644 --- a/packages/kernel/test/plugin/plugin-host.spec.ts +++ b/packages/kernel/test/plugin/plugin-host.spec.ts @@ -163,6 +163,29 @@ describe('PluginHost', () => { expect(live.size).toBe(0); }); + it('refuses an activation asked while the plugin deactivates, and deactivates activating dependents', async () => { + let release!: () => void; + const slow = new Promise((resolve) => (release = resolve)); + const states: string[] = []; + const { host } = setup({ + '@n/base': { activate: () => undefined, deactivate: () => slow }, + '@n/ext': { + activate: () => void states.push('ext:activate'), + deactivate: () => void states.push('ext:deactivate'), + }, + }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + const deactivation = host.deactivate('@n/base'); + await expect(host.activate('@n/base')).rejects.toBeInstanceOf(PluginActivationError); + release(); + await deactivation; + expect(host.plugins.get().find((plugin) => plugin.name === '@n/base')?.state).toBe('inactive'); + expect(states).toEqual(['ext:activate', 'ext:deactivate']); + await host.activate('@n/base'); + expect(host.plugins.get().find((plugin) => plugin.name === '@n/base')?.state).toBe('active'); + host.dispose(); + }); + it('hot reloads a plugin and re-activates its dependents', async () => { let version = 1; const { host, services, loads } = setup({ diff --git a/packages/project/src/workspace/changes.ts b/packages/project/src/workspace/changes.ts index 6ba7e11b..73befce8 100644 --- a/packages/project/src/workspace/changes.ts +++ b/packages/project/src/workspace/changes.ts @@ -1,8 +1,17 @@ -import type { Undo, WorkspaceIo } from './workspace.type'; +import { WorkspaceError } from './workspace.exception'; +import type { ImportsEdit, Undo, WorkspaceIo } from './workspace.type'; + +interface Step { + readonly undo: Undo; + /** The file this step wrote, and the text it left there. */ + readonly written?: { readonly path: string; readonly text: string }; + /** An edit made elsewhere (`renameImports`), with its own check. */ + readonly edit?: { readonly path: string; readonly edit: ImportsEdit }; +} /** Records what an operation changed, to put it back in reverse order. */ export class Changes { - private readonly _undo: Undo[] = []; + private readonly _steps: Step[] = []; constructor(private readonly _io: WorkspaceIo) {} @@ -11,22 +20,43 @@ export class Changes { const before = this._io.exists(path) ? await this._io.read(path) : undefined; if (before === text) return; await this._io.write(path, text); - this._undo.push(async () => { - if (before !== undefined) await this._io.write(path, before); - else if (this._io.exists(path)) await this._io.remove(path); + this._steps.push({ + undo: async () => { + if (before !== undefined) await this._io.write(path, before); + else if (this._io.exists(path)) await this._io.remove(path); + }, + written: { path, text }, }); } + /** Rewrites a code file's imports of a package (`WorkspaceIo.renameImports`, else as text). */ + async renameImports( + path: string, + from: string, + to: string, + asText: (text: string) => string | undefined, + ): Promise { + if (!this._io.renameImports) { + const renamed = asText(await this._io.read(path)); + if (renamed !== undefined) await this.write(path, renamed); + return; + } + const edit = await this._io.renameImports(path, from, to); + if (edit) this._steps.push({ undo: () => edit.undo(), edit: { path, edit } }); + } + /** Removes a file or folder (to the trash). */ async remove(path: string): Promise { if (!this._io.exists(path)) return; - this._undo.push(await this._io.remove(path)); + this._steps.push({ undo: await this._io.remove(path) }); } /** Something new was made at `path` (a copy): undo removes it. */ created(path: string): void { - this._undo.push(async () => { - if (this._io.exists(path)) await this._io.remove(path); + this._steps.push({ + undo: async () => { + if (this._io.exists(path)) await this._io.remove(path); + }, }); } @@ -35,13 +65,41 @@ export class Changes { try { await operation(); } catch (error) { - await this.undo().catch(() => undefined); + await this._undo(false).catch(() => undefined); throw error; } - return () => this.undo(); + return () => this._undo(true); } - async undo(): Promise { - for (const undo of this._undo.splice(0).reverse()) await undo(); + /** + * Puts everything back, last change first. With `check`, nothing is undone when a file was + * edited since the operation (its edit would be lost); the steps stay for another try. A step + * that fails does not stop the others: the first failure is rethrown at the end. + */ + private async _undo(check: boolean): Promise { + if (check) { + const last = new Map(); + for (const { written } of this._steps) if (written) last.set(written.path, written.text); + const changed: string[] = []; + for (const [path, text] of last) { + if (!this._io.exists(path) || (await this._io.read(path)) !== text) changed.push(path); + } + for (const { edit } of this._steps) { + if (edit && !(await edit.edit.isValid())) changed.push(edit.path); + } + if (changed.length) + throw new WorkspaceError( + `Not undone: ${changed.join(', ')} changed since. Undo those edits first, or change it back by hand.`, + ); + } + const failures: unknown[] = []; + for (const step of this._steps.splice(0).reverse()) { + try { + await step.undo(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw failures[0]; } } diff --git a/packages/project/src/workspace/library-operations.ts b/packages/project/src/workspace/library-operations.ts index 9985b2cc..7f72d886 100644 --- a/packages/project/src/workspace/library-operations.ts +++ b/packages/project/src/workspace/library-operations.ts @@ -78,7 +78,11 @@ export const libraryImporters = async (io: WorkspaceIo, library: AppModel): Prom return importers.sort(); }; -/** A text with the imports of a library pointed at its new name; undefined when it has none. */ +/** + * A text with the imports of a library pointed at its new name; undefined when it has none. A + * text search: it also rewrites look-alikes in comments and strings, so it is only used without + * a code worker (`WorkspaceIo.renameImports`). + */ export const withRenamedImports = (text: string, from: string, to: string): string | undefined => { const renamed = text.replace( specifier(from), @@ -126,9 +130,10 @@ export const renameLibrary = ( }); } for (const path of io.files()) { - if (!CODE.test(path)) continue; - const renamed = withRenamedImports(await io.read(path), library.name, packageName); - if (renamed !== undefined) await changes.write(path, renamed); + if (!CODE.test(path) || !specifier(library.name).test(await io.read(path))) continue; + await changes.renameImports(path, library.name, packageName, (text) => + withRenamedImports(text, library.name, packageName), + ); } await editJson(io, changes, packageJson(library), (json) => { json.name = packageName; diff --git a/packages/project/src/workspace/workspace.type.ts b/packages/project/src/workspace/workspace.type.ts index e527d80b..a95b702c 100644 --- a/packages/project/src/workspace/workspace.type.ts +++ b/packages/project/src/workspace/workspace.type.ts @@ -21,6 +21,19 @@ export interface WorkspaceIo { remove(path: string): Promise<() => Promise>; /** Copies a file or a folder with everything in it. */ copy(from: string, to: string): Promise; + /** + * Points a code file's imports of package `from` at `to`, reading the code (not a text search: + * comments and strings stay as they are), through the open document if any. Resolves with the + * edit made, or undefined when the file has none. Without it, imports are rewritten as text. + */ + renameImports?(path: string, from: string, to: string): Promise; +} + +/** An edit of a file's imports, as `renameImports` made it. */ +export interface ImportsEdit { + undo(): Promise; + /** False when the file changed since: undoing would lose that change. */ + isValid(): Promise; } export type Undo = () => Promise; diff --git a/packages/project/test/workspace/library-operations.spec.ts b/packages/project/test/workspace/library-operations.spec.ts index 718202d0..a3f47e00 100644 --- a/packages/project/test/workspace/library-operations.spec.ts +++ b/packages/project/test/workspace/library-operations.spec.ts @@ -120,6 +120,52 @@ describe('shared libraries', () => { expect(withRenamedImports('const a = 1;', '@pong/shared', '@x/y')).toBeUndefined(); }); + it('does not undo over a file edited since, and keeps the step for another try', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/shared/a";\n'); + const library = app('libs/shared', '@pong/shared', 'lib'); + const undo = await renameLibrary(io, model(client, server, library), library, '@pong/common'); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/common/a";\n// hand edit\n'); + await expect(undo()).rejects.toThrow(/apps\/client\/src\/main.ts changed since/); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/common'); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/common/a";\n'); + await undo(); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/shared'); + }); + + it('rewrites imports through renameImports when the io reads code', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + const text = '// see "@pong/shared" docs\nimport { a } from "@pong/shared/a";\n'; + files.set('apps/client/src/main.ts', text); + const asked: string[] = []; + const reading = { + ...io, + renameImports: async (path: string, from: string, to: string) => { + asked.push(`${path}:${from}->${to}`); + files.set(path, files.get(path)!.replace(`from "${from}/a"`, `from "${to}/a"`)); + return { + undo: async () => void files.set(path, text), + isValid: async () => true, + }; + }, + }; + const library = app('libs/shared', '@pong/shared', 'lib'); + const undo = await renameLibrary( + reading, + model(client, server, library), + library, + '@pong/common', + ); + expect(asked).toEqual(['apps/client/src/main.ts:@pong/shared->@pong/common']); + expect(files.get('apps/client/src/main.ts')).toBe( + '// see "@pong/shared" docs\nimport { a } from "@pong/common/a";\n', + ); + await undo(); + expect(files.get('apps/client/src/main.ts')).toBe(text); + }); + it('refuses to remove a library that is imported, then removes it', async () => { const { io, files, snapshot } = project(); await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); diff --git a/packages/protocol/src/session/session.contract.ts b/packages/protocol/src/session/session.contract.ts index 38e2f486..54928d65 100644 --- a/packages/protocol/src/session/session.contract.ts +++ b/packages/protocol/src/session/session.contract.ts @@ -7,6 +7,5 @@ import { SessionInfo } from './session.schema'; export const SessionContract = defineContract('session', { methods: { info: { input: z.null(), output: SessionInfo, public: true }, - logout: { input: z.null(), output: z.null(), public: true }, }, }); diff --git a/packages/registry/src/archive/archive.ts b/packages/registry/src/archive/archive.ts index ed138d1e..d2b5bed3 100644 --- a/packages/registry/src/archive/archive.ts +++ b/packages/registry/src/archive/archive.ts @@ -1,6 +1,7 @@ import { strFromU8, unzipSync, zipSync } from 'fflate'; -import { createHash } from 'node:crypto'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { ItemManifest, MANIFEST_FILE } from '../item/item.schema'; @@ -9,6 +10,12 @@ import { RegistryError } from '../registry/registry.exception'; export const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); +/** Size of an archive, packed: a download past it is stopped. */ +export const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024; +/** What an archive may unpack to, in all and in files. */ +const MAX_UNPACKED_BYTES = 256 * 1024 * 1024; +const MAX_FILES = 20_000; + /** A zip entry that would be written outside of the target folder, or in an odd place. */ const unsafe = (path: string): boolean => path.startsWith('/') || @@ -16,14 +23,30 @@ const unsafe = (path: string): boolean => /^[A-Za-z]:/.test(path) || path.split('/').some((segment) => segment === '..' || segment === '.'); -/** The files of an archive by path (folders left out); rejects unsafe paths. */ +/** + * The files of an archive by path (folders left out); rejects unsafe paths, and archives that + * unpack to more than 256 MiB or 20,000 files (checked on the sizes the entries claim, before + * anything is allocated, then on what they gave). + */ export const readArchive = (bytes: Uint8Array): Map => { + if (bytes.length > MAX_ARCHIVE_BYTES) throw tooLarge(); let entries: Record; + let claimed = 0; + let count = 0; try { - entries = unzipSync(bytes); - } catch { + entries = unzipSync(bytes, { + filter: (file) => { + claimed += file.originalSize; + if (++count > MAX_FILES || claimed > MAX_UNPACKED_BYTES) throw tooLarge(); + return true; + }, + }); + } catch (error) { + if (error instanceof RegistryError) throw error; throw new RegistryError('INVALID', 'The archive is not a zip file'); } + const unpacked = Object.values(entries).reduce((total, content) => total + content.length, 0); + if (unpacked > MAX_UNPACKED_BYTES) throw tooLarge(); const files = new Map(); for (const [path, content] of Object.entries(entries)) { if (path.endsWith('/')) continue; @@ -34,6 +57,9 @@ export const readArchive = (bytes: Uint8Array): Map => { return files; }; +const tooLarge = () => + new RegistryError('INVALID', 'The archive is too large (256 MiB or 20,000 files at most)'); + /** The manifest at the root of an archive. */ export const manifestOf = (files: ReadonlyMap): ItemManifest => { const raw = files.get(MANIFEST_FILE); @@ -59,13 +85,17 @@ export const manifestOf = (files: ReadonlyMap): ItemManifest * last: a folder without it is an install that did not finish, and is installed again. * * Files are written in place rather than in a temporary folder renamed at the end: file - * watchers (the editor's) miss the content of a folder that appears through a rename. + * watchers (the editor's) miss the content of a folder that appears through a rename. The old + * folder is moved aside first (`.nf-`, a name the editor's watcher skips) and put back when + * the write fails (disk full), so a failed reinstall keeps the working copy. */ export const writeFolder = async ( files: ReadonlyMap, target: string, ): Promise => { - await rm(target, { recursive: true, force: true }); + const backup = `${target}.nf-${randomBytes(4).toString('hex')}`; + const previous = existsSync(target); + if (previous) await rename(target, backup); const ordered = [...files].sort( ([a], [b]) => Number(a === MANIFEST_FILE) - Number(b === MANIFEST_FILE), ); @@ -77,8 +107,10 @@ export const writeFolder = async ( } } catch (error) { await rm(target, { recursive: true, force: true }).catch(() => undefined); + if (previous) await rename(backup, target).catch(() => undefined); throw error; } + if (previous) await rm(backup, { recursive: true, force: true }).catch(() => undefined); }; /** Zips a folder with stable bytes (sorted paths, no dates), so its hash only follows its content. */ diff --git a/packages/registry/src/client/registry-client.ts b/packages/registry/src/client/registry-client.ts index 6f8bf91b..22a16977 100644 --- a/packages/registry/src/client/registry-client.ts +++ b/packages/registry/src/client/registry-client.ts @@ -1,5 +1,6 @@ import semver from 'semver'; +import { MAX_ARCHIVE_BYTES } from '../archive/archive'; import { type RegistryItem, SearchQuery, type SearchResult } from '../item/item.schema'; import { RegistryError } from '../registry/registry.exception'; import type { Registry } from '../registry/registry.type'; @@ -12,6 +13,8 @@ export interface RegistryClientOptions { readonly fetch?: typeof fetch; /** Headers of every request (reading needs none; a registry key to publish). */ readonly headers?: () => Record; + /** How long a request may take (default 60 s). */ + readonly timeoutMs?: number; } /** The registry over HTTP (the contract of `docs/api/registry.md`). */ @@ -38,8 +41,13 @@ export class RegistryClient implements Registry { async download(name: string, version: string): Promise { const found = (await this._packument(name)).versions[version]; if (!found) throw new RegistryError('NOT_FOUND', `${name}@${version} was not found`); - const response = await this._request(found.dist.file, `${name}@${version}`); - return new Uint8Array(await response.arrayBuffer()); + const subject = `${name}@${version}`; + const response = await this._request(found.dist.file, subject); + const limit = Math.min(found.dist.size ?? MAX_ARCHIVE_BYTES, MAX_ARCHIVE_BYTES); + const bytes = await readLimited(response, limit, subject); + if (found.dist.size !== undefined && bytes.length !== found.dist.size) + throw new RegistryError('INTEGRITY', `${subject} does not have the size the registry says`); + return bytes; } private async _packument(name: string): Promise { @@ -47,13 +55,24 @@ export class RegistryClient implements Registry { return this._parse(Packument, await this._json(response)); } + /** + * A request to the registry, or to where it keeps an archive (`dist.file` may be anywhere): + * http(s) only, and the registry's headers only go to the registry's own origin. + */ private async _request(path: string, subject?: string): Promise { + const base = new URL(this._options.baseUrl); + const url = new URL(path, base); + if (url.protocol !== 'https:' && url.protocol !== 'http:') + throw new RegistryError( + 'INVALID', + `The registry gave an address that is not http(s): ${url.protocol}`, + ); let response: Response; try { - response = await (this._options.fetch ?? fetch)( - new URL(path, this._options.baseUrl).toString(), - { headers: this._options.headers?.() ?? {} }, - ); + response = await (this._options.fetch ?? fetch)(url.toString(), { + headers: url.origin === base.origin ? (this._options.headers?.() ?? {}) : {}, + signal: AbortSignal.timeout(this._options.timeoutMs ?? 60_000), + }); } catch (error) { throw new RegistryError( 'UNAVAILABLE', @@ -91,3 +110,43 @@ export class RegistryClient implements Registry { return parsed.data as T; } } + +/** The body of a response, stopped as soon as it passes `limit` bytes. */ +const readLimited = async ( + response: Response, + limit: number, + subject: string, +): Promise => { + const declared = Number(response.headers.get('content-length') ?? NaN); + const tooLarge = () => new RegistryError('INVALID', `${subject} is larger than allowed`); + if (declared > limit) throw tooLarge(); + if (!response.body) return new Uint8Array(await response.arrayBuffer()); + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body.getReader(); + for (;;) { + let chunk; + try { + chunk = await reader.read(); + } catch (error) { + throw new RegistryError( + 'UNAVAILABLE', + `The download of ${subject} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (chunk.done) break; + total += chunk.value.length; + if (total > limit) { + await reader.cancel().catch(() => undefined); + throw tooLarge(); + } + chunks.push(chunk.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +}; diff --git a/packages/registry/src/install/install-item.ts b/packages/registry/src/install/install-item.ts index 3daaddb6..0d61e65f 100644 --- a/packages/registry/src/install/install-item.ts +++ b/packages/registry/src/install/install-item.ts @@ -52,7 +52,7 @@ export const installItem = async ( if (!name.success) throw new RegistryError('INVALID', `${request.name} is not a valid name`); const bytes = await registry.download(request.name, request.version); const hash = sha256(bytes); - if (request.sha256 && request.sha256 !== hash) { + if (request.sha256 !== undefined && request.sha256 !== hash) { throw new RegistryError( 'INTEGRITY', `${request.name}@${request.version} does not have the expected content (hash mismatch)`, diff --git a/packages/registry/src/item/item.schema.ts b/packages/registry/src/item/item.schema.ts index 3455b0e8..0ad71efd 100644 --- a/packages/registry/src/item/item.schema.ts +++ b/packages/registry/src/item/item.schema.ts @@ -1,3 +1,4 @@ +import semver from 'semver'; import { z } from 'zod'; /** `@scope/name`: the name of everything in the registry. */ @@ -9,9 +10,10 @@ export type ItemName = z.output; export const ItemType = z.enum(['plugin', 'package']); export type ItemType = z.output; +/** A version semver reads as is (no `v`, no leading zero, no empty pre-release part). */ export const ItemVersion = z .string() - .regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, 'expected a version like 1.2.3'); + .refine((version) => semver.valid(version) === version, 'expected a version like 1.2.3'); /** Other registry items an item needs, with version ranges. */ export const Dependencies = z.record(ItemName, z.string()); diff --git a/packages/registry/src/project/packages-file.schema.ts b/packages/registry/src/project/packages-file.schema.ts index a004fba7..878b6608 100644 --- a/packages/registry/src/project/packages-file.schema.ts +++ b/packages/registry/src/project/packages-file.schema.ts @@ -10,7 +10,7 @@ export const PackagesLock = z.object({ ItemName, z.object({ version: ItemVersion, - sha256: z.string(), + sha256: z.string().regex(/^[0-9a-f]{64}$/, 'expected a sha256 in hex'), dependencies: Dependencies.default({}), }), ) diff --git a/packages/registry/src/project/project-packages.ts b/packages/registry/src/project/project-packages.ts index 444ec463..ec90bf19 100644 --- a/packages/registry/src/project/project-packages.ts +++ b/packages/registry/src/project/project-packages.ts @@ -126,12 +126,15 @@ export class ProjectPackages { }); } - /** Installs what the lock lists and `nf_modules` lacks (a fresh clone). */ + /** + * Installs what the lock lists and `nf_modules` lacks or has in another version (a fresh + * clone, a teammate's bump). + */ restore(): Promise { return this._queue(async () => { const lock = await this._readLock(); for (const [name, entry] of Object.entries(lock)) { - if (existsSync(join(this._folder(name), MANIFEST_FILE))) continue; + if ((await this._installedVersion(name)) === entry.version) continue; await installItem(this._registry, { name, version: entry.version, @@ -145,6 +148,19 @@ export class ProjectPackages { }); } + /** The version of the installed copy of a package, from its manifest; undefined if none. */ + private async _installedVersion(name: string): Promise { + const text = await readFile(join(this._folder(name), MANIFEST_FILE), 'utf8').catch( + () => undefined, + ); + try { + const version = (JSON.parse(text ?? 'null') as { version?: unknown } | null)?.version; + return typeof version === 'string' ? version : undefined; + } catch { + return undefined; + } + } + private _folder(name: string): string { return join(this.root, MODULES_DIR, ...name.split('/')); } @@ -301,7 +317,13 @@ export class ProjectPackages { private async _readLock(): Promise { const text = await readFile(join(this.root, LOCK_FILE), 'utf8').catch(() => undefined); if (text === undefined) return {}; - const parsed = PackagesLock.safeParse(JSON.parse(text) as unknown); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new RegistryError('INVALID', `${LOCK_FILE} is not valid JSON`); + } + const parsed = PackagesLock.safeParse(json); if (!parsed.success) throw new RegistryError('INVALID', `${LOCK_FILE} is not valid`); return parsed.data.packages; } diff --git a/packages/registry/test/registry.spec.ts b/packages/registry/test/registry.spec.ts index a0f68671..8fb4181c 100644 --- a/packages/registry/test/registry.spec.ts +++ b/packages/registry/test/registry.spec.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DirectoryRegistry, + ItemVersion, LOCK_FILE, PACKAGES_FILE, ProjectPackages, @@ -265,6 +266,57 @@ describe('installItem', () => { expect(existsSync(join(workspace, 'refused'))).toBe(false); expect(existsSync(join(workspace, 'outside.txt'))).toBe(false); }); + + it('refuses archives that claim to unpack to too much, before unpacking them', async () => { + const target = join(workspace, 'bomb/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + const zip = zipSync({ + 'nanoforge.manifest.json': new Uint8Array(16), + 'big.bin': new Uint8Array(64), + }); + const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength); + for (let offset = 0; offset < zip.length - 4; offset++) { + const signature = view.getUint32(offset, true); + if (signature === 0x04034b50) view.setUint32(offset + 22, 0x7fffffff, true); + if (signature === 0x02014b50) view.setUint32(offset + 24, 0x7fffffff, true); + } + await expect( + installItem( + { + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => zip, + }, + request, + ), + ).rejects.toThrow(/too large/); + expect(existsSync(target)).toBe(false); + }); + + it('keeps the installed copy when a reinstall fails', async () => { + const target = join(workspace, 'kept/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + await installItem(registry, request); + const blocked = new Uint8Array(4); + await expect( + installItem( + { + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => + zipSync({ + 'nanoforge.manifest.json': new TextEncoder().encode( + JSON.stringify({ type: 'package', name: '@acme/shapes', version: '1.0.0' }), + ), + 'index.ts': blocked, + 'index.ts/inside.ts': blocked, + }), + }, + request, + ), + ).rejects.toThrow(); + expect(read(target, 'index.ts')).toBe('// 1.0.0\n'); + }); }); describe('ProjectPackages', () => { @@ -383,6 +435,32 @@ describe('ProjectPackages', () => { lock.packages['@acme/shapes']!.sha256 = 'f'.repeat(64); writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); await expect(packages.restore()).rejects.toBeInstanceOf(RegistryError); + lock.packages['@acme/shapes']!.sha256 = ''; + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await expect(packages.restore()).rejects.toMatchObject({ code: 'INVALID' }); + writeFileSync(join(root, LOCK_FILE), '{ not json'); + await expect(packages.restore()).rejects.toMatchObject({ code: 'INVALID' }); + }); + + it('restores the version the lock says when another one is installed', async () => { + const root = project('restore-bump'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + const lock = json(root, LOCK_FILE) as unknown as { + packages: Record; + }; + const bumped = await registry.get('@acme/shapes'); + const next = bumped.versions.find((entry) => entry.version === '1.1.0')!; + lock.packages['@acme/shapes'] = { + ...lock.packages['@acme/shapes']!, + version: '1.1.0', + sha256: next.sha256, + }; + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await packages.restore(); + expect(json(root, 'nf_modules/@acme/shapes/nanoforge.manifest.json')).toMatchObject({ + version: '1.1.0', + }); }); it('runs changes of one project one after the other', async () => { @@ -400,6 +478,15 @@ describe('ProjectPackages', () => { }); }); +describe('ItemVersion', () => { + it('takes only versions semver reads as they are', () => { + for (const version of ['1.0.0', '1.2.3-beta.1', '0.0.1']) + expect(ItemVersion.safeParse(version).success).toBe(true); + for (const version of ['01.0.0', '1.0.0-..', 'v1.0.0', '1.0']) + expect(ItemVersion.safeParse(version).success).toBe(false); + }); +}); + describe('tsconfig paths', () => { it('adds and removes entries, keeping comments', () => { const text = diff --git a/packages/rpc/src/router/rpc-session.ts b/packages/rpc/src/router/rpc-session.ts index dd207595..5f61e516 100644 --- a/packages/rpc/src/router/rpc-session.ts +++ b/packages/rpc/src/router/rpc-session.ts @@ -37,6 +37,7 @@ export class RpcSession implements Disposable { } catch { return; } + if (typeof message !== 'object' || message === null) return; switch (message.t) { case 'ping': this._write({ t: 'pong' }); diff --git a/packages/runtime/test/service/runtime-service.spec.ts b/packages/runtime/test/service/runtime-service.spec.ts index 32469d7c..90185ace 100644 --- a/packages/runtime/test/service/runtime-service.spec.ts +++ b/packages/runtime/test/service/runtime-service.spec.ts @@ -156,7 +156,7 @@ describe('RuntimeService', () => { expect(url).toMatch(new RegExp(`/runtime/${model.id}/apps%2Fclient/main\\.js\\?v=\\w+&run=`)); expect(options.env).toEqual({ PORT: '1' }); expect(options.files.get('/data.txt')).toMatch( - /^http:\/\/editor\.test\/runtime\/.+data\.txt\?v=\w+$/, + /^http:\/\/editor\.localhost\/runtime\/.+data\.txt\?v=\w+$/, ); expect(mounts()).toHaveLength(run); expect(mounts()[run - 1]!.dataset.nfGame).toBe('apps/client'); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5f882f5c..8aabda75 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -214,7 +214,6 @@ export { export { type CommandOrigin, type CommandPreview, - FileHistoryTracker, HISTORY_CONTEXT_KEY, type HistoryCommand, type HistoryContext, diff --git a/packages/sdk/test/index.spec.ts b/packages/sdk/test/index.spec.ts index 006b0dac..2e933aa7 100644 --- a/packages/sdk/test/index.spec.ts +++ b/packages/sdk/test/index.spec.ts @@ -47,7 +47,6 @@ describe('public API', () => { "EditorServices", "Emitter", "Events", - "FileHistoryTracker", "GitContract", "HISTORY_CONTEXT_KEY", "HistoryServiceToken", diff --git a/packages/server-core/src/api/api-client.ts b/packages/server-core/src/api/api-client.ts index f8bac13c..8176f17c 100644 --- a/packages/server-core/src/api/api-client.ts +++ b/packages/server-core/src/api/api-client.ts @@ -1,3 +1,6 @@ +import { z } from 'zod'; + +import type { User } from '@nanoforge-dev/editor-protocol'; import { RpcError, type RpcErrorCode } from '@nanoforge-dev/editor-rpc'; import type { EditorEnv } from '../env/editor-env.type'; @@ -26,21 +29,71 @@ export interface TokenResponse extends AuthTokens { tokenExpiresAt: string; } -export interface GatewayProject { - id: string; - code: string; - name: string; - description: string; - gatewayProjectRegistryUrl: string; - gatewayProjectRegistryMetadata: { dir: string | null }; - token: string; -} +/** A project of the API, as `/projects` and `/editor/projects/:id` answer it. */ +export const GatewayProjectSchema = z.object({ + id: z.string().min(1), + code: z.string(), + name: z.string(), + description: z + .string() + .nullish() + .transform((value) => value ?? ''), + gatewayProjectRegistryUrl: z + .string() + .url() + .refine((url) => /^https?:$/.test(new URL(url).protocol), 'must be an http(s) URL'), + gatewayProjectRegistryMetadata: z + .object({ + dir: z + .string() + .nullish() + .transform((value) => value ?? null), + }) + .nullish() + .transform((value) => value ?? { dir: null }), + token: z + .string() + .nullish() + .transform((value) => value ?? ''), +}); +export type GatewayProject = z.output; + +/** An entry of `/projects`: only what the project list shows. */ +export const GatewayProjectSummarySchema = z.object({ + id: z.string().min(1), + name: z.string(), + description: z + .string() + .nullish() + .transform((value) => value ?? ''), +}); +export type GatewayProjectSummary = z.output; + +const ApiUserSchema = z.object({ + id: z.union([z.string(), z.number()]).transform(String), + username: z.string().nullish(), + email: z.string().nullish(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), +}); + +const parseResponse = (schema: z.ZodType, value: unknown, what: string): T => { + const result = schema.safeParse(value); + if (!result.success) + throw new RpcError('UNAVAILABLE', `The NanoForge API sent an unexpected ${what}`, { + detail: result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + }); + return result.data; +}; /** * Client of api.nanoforge.eu. Authenticated calls refresh the access token once on 401 and * update the session tokens (the caller forwards them as cookies). */ export class ApiClient { + /** One refresh at a time per session: with rotation, a second one would use a spent token. */ + private readonly _refreshing = new WeakMap>(); + constructor( private readonly _env: EditorEnv, private readonly _fetch: typeof fetch = globalThis.fetch.bind(globalThis), @@ -49,10 +102,13 @@ export class ApiClient { async request(session: Session | null, path: string, request: ApiRequest = {}): Promise { const auth = request.auth ?? true; if (auth && !session?.tokens) throw new RpcError('UNAUTHORIZED', 'Not signed in'); - let response = await this._send(path, request, auth ? session!.tokens!.accessToken : null); + const sent = auth ? session!.tokens!.accessToken : null; + let response = await this._send(path, request, sent); if (auth && response.status === 401) { - await this.refresh(session!); - response = await this._send(path, request, session!.tokens!.accessToken); + if (session!.tokens?.accessToken === sent) await this.refresh(session!); + else await this._refreshing.get(session!); + if (!session!.tokens) throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + response = await this._send(path, request, session!.tokens.accessToken); } if (!response.ok) { const detail = await response.text().catch(() => ''); @@ -66,7 +122,43 @@ export class ApiClient { return response.status === 204 ? (undefined as T) : ((await response.json()) as T); } - async refresh(session: Session): Promise { + refresh(session: Session): Promise { + let refreshing = this._refreshing.get(session); + if (!refreshing) { + refreshing = this._refresh(session).finally(() => this._refreshing.delete(session)); + this._refreshing.set(session, refreshing); + } + return refreshing; + } + + /** The user the session's tokens belong to (refreshing them if needed); null when invalid. */ + async currentUser(session: Session): Promise { + if (!session.tokens) return null; + try { + const user = parseResponse(ApiUserSchema, await this.request(session, '/users/me'), 'user'); + const name = user.username || [user.firstName, user.lastName].filter(Boolean).join(' '); + return { + id: user.id, + name: name || user.email || 'NanoForge user', + ...(user.email && { email: user.email }), + }; + } catch (error) { + if (error instanceof RpcError && error.code === 'UNAUTHORIZED') return null; + throw error; + } + } + + async gatewayProjects(session: Session): Promise { + const projects = await this.request(session, '/projects'); + return parseResponse(z.array(GatewayProjectSummarySchema), projects, 'project list'); + } + + async gatewayProject(session: Session, id: string): Promise { + const project = await this.request(session, `/editor/projects/${encodeURIComponent(id)}`); + return parseResponse(GatewayProjectSchema, project, 'project'); + } + + private async _refresh(session: Session): Promise { const refreshToken = session.tokens?.refreshToken; if (!refreshToken) throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); const response = await this._send( @@ -87,14 +179,6 @@ export class ApiClient { return session.tokens; } - gatewayProjects(session: Session): Promise { - return this.request(session, '/projects'); - } - - gatewayProject(session: Session, id: string): Promise { - return this.request(session, `/editor/projects/${encodeURIComponent(id)}`); - } - private _send(path: string, request: ApiRequest, accessToken: string | null): Promise { const url = new URL(path, this._env.apiUrl); for (const [key, value] of Object.entries(request.query ?? {})) { diff --git a/packages/server-core/src/env/editor-env.type.ts b/packages/server-core/src/env/editor-env.type.ts index dda1edbe..d23d91e9 100644 --- a/packages/server-core/src/env/editor-env.type.ts +++ b/packages/server-core/src/env/editor-env.type.ts @@ -20,6 +20,8 @@ export interface EditorEnv { readonly cliPath: string | undefined; readonly gitPath: string; readonly allowedOrigins: readonly string[]; + /** Host names allowed in the `Host` header besides loopback ones. */ + readonly allowedHosts: readonly string[]; readonly devPlugins: readonly string[]; readonly bundledPluginsDir: string | undefined; } diff --git a/packages/server-core/src/env/env.schema.ts b/packages/server-core/src/env/env.schema.ts index 4b57da9d..e4f52502 100644 --- a/packages/server-core/src/env/env.schema.ts +++ b/packages/server-core/src/env/env.schema.ts @@ -53,6 +53,11 @@ export const EnvSchema = z GIT_PATH: optionalString, /** Extra origins allowed to call the server (dev proxies). */ ALLOWED_ORIGINS: list, + /** + * Host names the server answers to besides localhost (DNS rebinding guard), comma separated. + * A local editor reached by another name needs it; a hosted one checks it when it is set. + */ + ALLOWED_HOSTS: list, /** Directories of local plugins under development (their `dist/` is served). */ DEV_PLUGINS: list, /** Directory of the plugins bundled with the editor build. */ diff --git a/packages/server-core/src/env/load-env.ts b/packages/server-core/src/env/load-env.ts index 45388a5f..195392a1 100644 --- a/packages/server-core/src/env/load-env.ts +++ b/packages/server-core/src/env/load-env.ts @@ -36,6 +36,7 @@ export const loadEnv = ( cliPath: env.NF_CLI_PATH, gitPath: env.GIT_PATH ?? 'git', allowedOrigins: env.ALLOWED_ORIGINS, + allowedHosts: env.ALLOWED_HOSTS.map((host) => host.toLowerCase()), devPlugins: env.DEV_PLUGINS.map(absolute), bundledPluginsDir: env.BUNDLED_PLUGINS_DIR && absolute(env.BUNDLED_PLUGINS_DIR), }; diff --git a/packages/server-core/src/fs/fs-service.ts b/packages/server-core/src/fs/fs-service.ts index c450ac6f..380fa862 100644 --- a/packages/server-core/src/fs/fs-service.ts +++ b/packages/server-core/src/fs/fs-service.ts @@ -44,7 +44,19 @@ const mapError = (error: unknown, path: string): never => { } }; +/** + * A repository's own folder is never reached through the file API: writing hooks or config there + * runs code on the next git command, and its config may hold credentials. + */ +const isGitPath = (path: string) => + path.split(/[\\/]/).some((part) => part.toLowerCase() === '.git'); + +const assertAccessible = (path: string) => { + if (isGitPath(path)) throw new RpcError('FORBIDDEN', `${path} is inside a .git folder`); +}; + const assertWritable = (path: string) => { + assertAccessible(path); if (path === PACKAGES_DIR || path.startsWith(`${PACKAGES_DIR}/`)) { throw new RpcError( 'FORBIDDEN', @@ -85,6 +97,7 @@ export class ProjectFileSystem { } async stat(path: string): Promise { + assertAccessible(path); const absolute = await this.jail.resolve(path); try { const info = await stat(absolute); @@ -101,6 +114,7 @@ export class ProjectFileSystem { } async list(path: string, recursive = false): Promise { + assertAccessible(path); const absolute = await this.jail.resolve(path); await this.refreshIgnore(); const entries: FileEntry[] = []; @@ -112,7 +126,7 @@ export class ProjectFileSystem { return mapError(error, rel); } for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) { - if (entry.isSymbolicLink()) continue; // never follow links while listing + if (entry.isSymbolicLink() || isGitPath(entry.name)) continue; const childRel = rel ? `${rel}/${entry.name}` : entry.name; const info = await stat(join(dir, entry.name)).catch(() => undefined); if (!info) continue; @@ -129,6 +143,7 @@ export class ProjectFileSystem { } async read(path: string): Promise { + assertAccessible(path); const absolute = await this.jail.resolve(path); try { const info = await stat(absolute); @@ -209,6 +224,7 @@ export class ProjectFileSystem { } async copy(from: string, to: string, overwrite = false): Promise { + assertAccessible(from); assertWritable(to); const source = await this.jail.resolve(from); const target = await this.jail.resolve(to); @@ -266,4 +282,4 @@ export class ProjectFileSystem { } /** Heavy folders a recursive listing does not descend into. */ -const SKIPPED_IN_RECURSIVE_LIST = new Set(['node_modules', '.git']); +const SKIPPED_IN_RECURSIVE_LIST = new Set(['node_modules']); diff --git a/packages/server-core/src/fs/watcher.ts b/packages/server-core/src/fs/watcher.ts index 15bf1c64..96bb2a39 100644 --- a/packages/server-core/src/fs/watcher.ts +++ b/packages/server-core/src/fs/watcher.ts @@ -1,4 +1,6 @@ import { watch } from 'chokidar'; +import { lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, relative } from 'node:path'; import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; import type { FileChange } from '@nanoforge-dev/editor-protocol'; @@ -13,6 +15,12 @@ export const DEFAULT_IGNORED_SEGMENTS = [ '.svelte-kit', ]; +/** + * Temporary names (`.nf-`): atomic write files, and the folder a package reinstall + * moves aside (`writeFolder`), with everything in it. + */ +const TEMP_SEGMENT = /\.nf-[0-9a-f]{8}$/; + export interface WatcherOptions { /** Project-relative directories to ignore besides the defaults (build outputs…). */ ignoredPaths?: () => readonly string[]; @@ -34,6 +42,7 @@ export class ProjectWatcher { private _pending = new Map(); private _timer: ReturnType | undefined; private _ready: Promise = Promise.resolve(); + private _realRoot: string | undefined; /** Starts watching on the first listener and stops with the last one. */ readonly onDidChange: Event = this._onDidChange.event; @@ -58,14 +67,31 @@ export class ProjectWatcher { if (!rel || rel.startsWith('..')) return false; const segments = rel.split('/'); if (segments.some((segment) => DEFAULT_IGNORED_SEGMENTS.includes(segment))) return true; + if (this._leavesProject(absolute)) return true; if (rel === '.nanoforge/editor/trash' || rel.startsWith('.nanoforge/editor/trash/')) return true; - if (/\.nf-[0-9a-f]{8}$/.test(rel)) return true; // atomic write temp files + if (segments.some((segment) => TEMP_SEGMENT.test(segment))) return true; return (this._options.ignoredPaths?.() ?? []).some( (path) => rel === path || rel.startsWith(`${path}/`), ); }; + /** + * A link that leads outside of the project (a committed `link -> /`): never watched, or the + * watcher would crawl the host and stream outside file names to the client. Links are still + * followed otherwise (`followSymlinks: false` makes chokidar miss files in new folders). + */ + private _leavesProject(absolute: string): boolean { + try { + if (!lstatSync(absolute).isSymbolicLink()) return false; + this._realRoot ??= realpathSync(this._jail.root); + const rel = relative(this._realRoot, realpathSync(absolute)); + return rel.startsWith('..') || isAbsolute(rel); + } catch { + return false; + } + } + private _start(): void { const watcher = watch(this._jail.root, { ignoreInitial: true, diff --git a/packages/server-core/src/git/git-service.ts b/packages/server-core/src/git/git-service.ts index 12dd74d0..ca653d77 100644 --- a/packages/server-core/src/git/git-service.ts +++ b/packages/server-core/src/git/git-service.ts @@ -13,15 +13,9 @@ import { RpcError } from '@nanoforge-dev/editor-rpc'; import type { EditorEnv } from '../env/editor-env.type'; import { runProcess } from '../process/run-process'; import { KeyedMutex } from '../util/keyed-mutex'; -import { redact, withToken } from './git-token'; +import { authEnv, redact, withoutCredentials } from './git-token'; import { parseStatus } from './parse-status'; -export interface GitStatusEntry { - readonly path: string; - /** Two-letter porcelain code, e.g. ` M`, `??`. */ - readonly code: string; -} - /** Git CLI wrapper; operations on the same repository are serialized. */ export class GitService { private readonly _mutex = new KeyedMutex(); @@ -36,39 +30,24 @@ export class GitService { return existsSync(join(path, '.git')); } - /** Clones `url` into `path`, injecting a token for https remotes (GitHub style). */ + /** + * Clones `url` into `path`. A token for an https remote goes in an authorization header + * passed through the environment: never in the URL, which `ps` shows and `.git/config` keeps. + */ clone(url: string, path: string, token?: string): Promise { - const remote = token ? withToken(url, token) : url; - return this._mutex.run(path, async () => { - await this._git(this._env.fsRoot, ['clone', '--', remote, path]); - }); - } - - pull(path: string): Promise { return this._mutex.run(path, async () => { - await this._git(path, ['pull', '--ff-only']); + await this._git(this._env.fsRoot, ['clone', '--', url, path], true, authEnv(url, token)); }); } - status(path: string): Promise { + /** Fast-forwards a clone made by `clone`, with the same token. */ + pull(path: string, url: string, token?: string): Promise { return this._mutex.run(path, async () => { - const output = await this._git(path, ['status', '--porcelain=v1', '-z']); - return output - .split('\0') - .filter(Boolean) - .map((entry) => ({ code: entry.slice(0, 2), path: entry.slice(3) })); - }); - } - - /** Stages everything, commits when there are changes and pushes. Returns false when clean. */ - commitAndPush(path: string, message: string, branch = 'main'): Promise { - return this._mutex.run(path, async () => { - await this._git(path, ['add', '--all']); - const staged = await this._git(path, ['diff', '--cached', '--name-only']); - if (!staged.trim()) return false; - await this._git(path, ['commit', '-m', message]); - await this._git(path, ['push', '-u', 'origin', branch]); - return true; + const remote = (await this._git(path, ['remote', 'get-url', 'origin'])).trim(); + if (remote !== url && withoutCredentials(remote) === url) { + await this._git(path, ['remote', 'set-url', 'origin', url]); + } + await this._git(path, ['pull', '--ff-only'], true, authEnv(url, token)); }); } @@ -335,7 +314,7 @@ export class GitService { return this._mutex.run(path, async () => { const spec = revision === 'INDEX' ? `:${file}` : `${revision}:${file}`; const result = await this._exec(path, ['show', spec]); - return result.exitCode === 0 ? result.stdout : null; + return result.exitCode === 0 && !result.truncated ? result.stdout : null; }); } @@ -374,11 +353,18 @@ export class GitService { } /** + * Paths after `--` are literal (`levels/[id].ts` is not a glob), except for `stash`, which + * needs pathspec magic internally and is never given paths. * `network`: the command talks to a remote. It is stopped after a while (a remote that never * answers must not hold the repository's queue), and ssh never waits for a passphrase or a * host confirmation in the terminal the editor was started from. */ - private async _exec(cwd: string, args: string[], network = false) { + private async _exec( + cwd: string, + args: string[], + network = false, + extraEnv: Record = {}, + ) { const batchSsh = network && !process.env.GIT_SSH_COMMAND && @@ -389,8 +375,11 @@ export class GitService { env: { GIT_TERMINAL_PROMPT: '0', GIT_CEILING_DIRECTORIES: dirname(cwd), + ...(args[0] !== 'stash' && { GIT_LITERAL_PATHSPECS: '1' }), + GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...(batchSsh && { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }), + ...extraEnv, }, ...(network && { signal: AbortSignal.timeout(this._networkTimeoutMs) }), }).done; @@ -401,10 +390,15 @@ export class GitService { return result?.exitCode === 0 && result.stdout.trim() !== ''; } - private async _git(cwd: string, args: string[], network = false): Promise { + private async _git( + cwd: string, + args: string[], + network = false, + extraEnv?: Record, + ): Promise { let result; try { - result = await this._exec(cwd, args, network); + result = await this._exec(cwd, args, network, extraEnv); } catch (error) { if (network && (error as { name?: string }).name === 'AbortError') { throw new RpcError( diff --git a/packages/server-core/src/git/git-token.ts b/packages/server-core/src/git/git-token.ts index dc22608c..ea83b58b 100644 --- a/packages/server-core/src/git/git-token.ts +++ b/packages/server-core/src/git/git-token.ts @@ -1,9 +1,30 @@ -export const withToken = (url: string, token: string): string => { +/** + * Environment that makes git send `token` to `url`'s host as basic credentials (GitHub and GitLab + * style), through `GIT_CONFIG_*`: nothing on the command line, nothing saved in the clone. + */ +export const authEnv = (url: string, token: string | undefined): Record => { + if (!token || !URL.canParse(url)) return {}; const parsed = new URL(url); - if (parsed.protocol !== 'https:') return url; - parsed.username = 'oauth2'; - parsed.password = token; - return parsed.href; + if (parsed.protocol !== 'https:') return {}; + const credentials = Buffer.from(`oauth2:${token}`).toString('base64'); + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${parsed.origin}/.extraHeader`, + GIT_CONFIG_VALUE_0: `Authorization: Basic ${credentials}`, + }; +}; + +/** `url` without the user and password older clones kept in their remote. */ +export const withoutCredentials = (url: string): string => { + try { + const parsed = new URL(url); + if (!parsed.username && !parsed.password) return url; + parsed.username = ''; + parsed.password = ''; + return parsed.href; + } catch { + return url; + } }; /** Removes credentials from git output (clone errors echo the remote URL). */ diff --git a/packages/server-core/src/http/static-files.ts b/packages/server-core/src/http/static-files.ts index c6c0002b..798f6a14 100644 --- a/packages/server-core/src/http/static-files.ts +++ b/packages/server-core/src/http/static-files.ts @@ -30,7 +30,11 @@ export const fileResponse = async ( ): Promise => { const info = await stat(file).catch(() => undefined); if (!info?.isFile()) return undefined; - const headers = { 'content-type': contentType(file), 'cache-control': cacheControl }; + const headers = { + 'content-type': contentType(file), + 'cache-control': cacheControl, + 'x-content-type-options': 'nosniff', + }; const bun = (globalThis as { Bun?: { file(path: string): Blob } }).Bun; const body = bun ? bun.file(file) : new Uint8Array(await readFile(file)); return new Response(body, { headers }); diff --git a/packages/server-core/src/plugin/plugin-sources.ts b/packages/server-core/src/plugin/plugin-sources.ts index 40a47b94..0cefa99b 100644 --- a/packages/server-core/src/plugin/plugin-sources.ts +++ b/packages/server-core/src/plugin/plugin-sources.ts @@ -1,7 +1,7 @@ import { watch } from 'chokidar'; import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { Emitter, @@ -107,7 +107,10 @@ export class PluginSources { */ async suggestions(projectRoot: string): Promise { const byName = new Map(); + const visited = new Set(); const visit = async (dir: string, depth: number) => { + if (visited.has(dir)) return; + visited.add(dir); let manifest; try { const raw: unknown = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8')); @@ -124,12 +127,15 @@ export class PluginSources { byName.set(name, entry); } if (depth >= MAX_INCLUDE_DEPTH) return; - for (const include of manifest.include) await visit(join(dir, include), depth + 1); + for (const include of new Set(manifest.include)) { + const target = resolve(dir, include); + if (target !== dir) await visit(target, depth + 1); + } }; const root = join(projectRoot, PACKAGES_DIR); for (const scope of await directories(root)) { for (const name of await directories(join(root, scope))) - await visit(join(root, scope, name), 0); + await visit(resolve(root, scope, name), 0); } return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/packages/server-core/src/plugin/server-plugins.ts b/packages/server-core/src/plugin/server-plugins.ts index 350fe5e5..af8358ff 100644 --- a/packages/server-core/src/plugin/server-plugins.ts +++ b/packages/server-core/src/plugin/server-plugins.ts @@ -48,6 +48,8 @@ interface ActiveServerPlugin { /** Runs `entry.server` of enabled plugins inside the editor server (trusted plugins). */ export class ServerPluginHost implements Disposable { private readonly _active = new Map(); + /** Activations in progress: a second request for the same plugin waits for the first one. */ + private readonly _activating = new Map>(); constructor( private readonly _router: RpcRouter, @@ -59,10 +61,40 @@ export class ServerPluginHost implements Disposable { return [...this._active.keys()]; } - async activate(plugin: LocatedPlugin): Promise { - const manifest = plugin.manifest; - const entry = manifest?.entry.server; - if (!manifest || !entry || this._active.has(manifest.name)) return; + activate(plugin: LocatedPlugin): Promise { + const name = plugin.manifest?.name; + if (!name || !plugin.manifest?.entry.server || this._active.has(name)) return Promise.resolve(); + let activating = this._activating.get(name); + if (!activating) { + activating = this._activate(plugin).finally(() => this._activating.delete(name)); + this._activating.set(name, activating); + } + return activating; + } + + async deactivate(name: string): Promise { + await this._activating.get(name); + const plugin = this._active.get(name); + if (!plugin) return; + this._active.delete(name); + try { + await plugin.module.deactivate?.(); + } finally { + plugin.store.dispose(); + } + } + + dispose(): void { + for (const name of [...this._active.keys()]) { + this.deactivate(name).catch((error: unknown) => + this._logs.getLogger(`${name}(server)`).error('Server entry failed to deactivate', error), + ); + } + } + + private async _activate(plugin: LocatedPlugin): Promise { + const manifest = plugin.manifest!; + const entry = manifest.entry.server!; const logger = this._logs.getLogger(`${manifest.name}(server)`); const store = new DisposableStore(); const namespace = pluginNamespace(manifest.name); @@ -97,19 +129,4 @@ export class ServerPluginHost implements Disposable { logger.error('Server entry failed to activate', error); } } - - async deactivate(name: string): Promise { - const plugin = this._active.get(name); - if (!plugin) return; - this._active.delete(name); - try { - await plugin.module.deactivate?.(); - } finally { - plugin.store.dispose(); - } - } - - dispose(): void { - for (const name of [...this._active.keys()]) void this.deactivate(name); - } } diff --git a/packages/server-core/src/process/child-env.ts b/packages/server-core/src/process/child-env.ts new file mode 100644 index 00000000..52308628 --- /dev/null +++ b/packages/server-core/src/process/child-env.ts @@ -0,0 +1,92 @@ +const NAMES = new Set([ + 'PATH', + 'Path', + 'PATHEXT', + 'HOME', + 'USER', + 'USERNAME', + 'LOGNAME', + 'SHELL', + 'LANG', + 'LANGUAGE', + 'TERM', + 'TZ', + 'TMPDIR', + 'TMP', + 'TEMP', + 'CI', + 'NODE_ENV', + 'NODE_OPTIONS', + 'NODE_EXTRA_CA_CERTS', + 'FORCE_COLOR', + 'NO_COLOR', + 'SSH_AUTH_SOCK', + 'SSH_AGENT_PID', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', + 'SSH_ASKPASS', + 'GIT_AUTHOR_NAME', + 'GIT_AUTHOR_EMAIL', + 'GIT_COMMITTER_NAME', + 'GIT_COMMITTER_EMAIL', + 'GIT_SSL_CAINFO', + 'GIT_SSL_CAPATH', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'GNUPGHOME', + 'GPG_TTY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'ALL_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'all_proxy', + 'SystemRoot', + 'SYSTEMROOT', + 'ComSpec', + 'COMSPEC', + 'WINDIR', + 'APPDATA', + 'LOCALAPPDATA', + 'USERPROFILE', + 'HOMEDRIVE', + 'HOMEPATH', + 'ProgramFiles', + 'ProgramFiles(x86)', + 'ProgramData', + 'NF_CLI_PATH', +]); + +const PREFIXES = [ + 'LC_', + 'XDG_', + 'npm_config_', + 'NPM_CONFIG_', + 'PNPM_', + 'BUN_', + 'COREPACK_', + 'NANOFORGE_', +]; + +/** + * The part of the editor's environment its child processes (git, the CLI, game servers) get: what + * tools need to run, never the server's own settings (`SESSION_SECRET`, `API_KEY`, `PORT`…), nor + * the git variables that point at another repository (`GIT_DIR`, `GIT_WORK_TREE`…, set when the + * editor is started from a git hook), which would bypass `GIT_CEILING_DIRECTORIES`. + */ +export const childEnv = ( + extra: Record = {}, + source: Record = process.env, +): Record => { + const kept = Object.entries(source).filter( + ([key, value]) => + value !== undefined && + !(key in extra) && + (NAMES.has(key) || PREFIXES.some((prefix) => key.startsWith(prefix))), + ); + const added = Object.entries(extra).filter(([, value]) => value !== undefined); + return Object.fromEntries([...kept, ...added]) as Record; +}; diff --git a/packages/server-core/src/process/index.ts b/packages/server-core/src/process/index.ts index 9ab8edba..7bc2b15b 100644 --- a/packages/server-core/src/process/index.ts +++ b/packages/server-core/src/process/index.ts @@ -1,2 +1,3 @@ +export * from './child-env'; export * from './cli-service'; export * from './run-process'; diff --git a/packages/server-core/src/process/run-process.ts b/packages/server-core/src/process/run-process.ts index 37a64e93..792f0130 100644 --- a/packages/server-core/src/process/run-process.ts +++ b/packages/server-core/src/process/run-process.ts @@ -2,12 +2,21 @@ import { spawn } from 'node:child_process'; import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import { childEnv } from './child-env'; + +/** Output kept per stream in `ProcessResult`; past it, lines are still emitted. */ +const MAX_BUFFER = 16 * 1024 * 1024; +/** A line longer than this (binary output) is emitted in pieces. */ +const MAX_LINE = 64 * 1024; + export interface ProcessOptions { cwd: string; env?: Record; signal?: AbortSignal; /** Data written to stdin, then closed. */ input?: string; + /** Characters of output kept per stream (default 16 Mi). */ + maxBuffer?: number; } export interface OutputLine { @@ -25,9 +34,14 @@ export interface ProcessResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; + /** Some output was not kept (`maxBuffer`). */ + readonly truncated: boolean; } -/** Spawns a process without a shell; output is split into lines and buffered. */ +/** + * Spawns a process without a shell, with the editor's environment filtered (`childEnv`); output + * is split into lines and buffered. + */ export const runProcess = ( command: string, args: readonly string[], @@ -36,20 +50,30 @@ export const runProcess = ( const onLine = new Emitter(); const child = spawn(command, args, { cwd: options.cwd, - env: { ...process.env, ...options.env }, + env: childEnv(options.env), stdio: ['pipe', 'pipe', 'pipe'], ...(options.signal && { signal: options.signal }), }); + const maxBuffer = options.maxBuffer ?? MAX_BUFFER; const buffers = { stdout: '', stderr: '' }; const pending = { stdout: '', stderr: '' }; + let truncated = false; - const collect = (stream: 'stdout' | 'stderr') => (chunk: Buffer) => { - const text = chunk.toString('utf8'); - buffers[stream] += text; + const collect = (stream: 'stdout' | 'stderr') => (text: string) => { + const room = maxBuffer - buffers[stream].length; + if (text.length > room) truncated = true; + if (room > 0) buffers[stream] += text.length > room ? text.slice(0, room) : text; const lines = (pending[stream] + text).split(/\r?\n/); - pending[stream] = lines.pop() ?? ''; + let rest = lines.pop() ?? ''; for (const line of lines) onLine.fire({ stream, text: line }); + while (rest.length > MAX_LINE) { + onLine.fire({ stream, text: rest.slice(0, MAX_LINE) }); + rest = rest.slice(MAX_LINE); + } + pending[stream] = rest; }; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); child.stdout.on('data', collect('stdout')); child.stderr.on('data', collect('stderr')); if (options.input !== undefined) child.stdin.end(options.input); @@ -65,7 +89,12 @@ export const runProcess = ( if (pending[stream]) onLine.fire({ stream, text: pending[stream] }); } onLine.dispose(); - resolve({ exitCode: code ?? -1, stdout: buffers.stdout, stderr: buffers.stderr }); + resolve({ + exitCode: code ?? -1, + stdout: buffers.stdout, + stderr: buffers.stderr, + truncated, + }); }); }); diff --git a/packages/server-core/src/project/formatter.ts b/packages/server-core/src/project/formatter.ts index 99470dfa..f6e89dea 100644 --- a/packages/server-core/src/project/formatter.ts +++ b/packages/server-core/src/project/formatter.ts @@ -50,7 +50,10 @@ export class Formatter { } } - /** The Prettier installed for the file (closest node_modules), else undefined. */ + /** + * The Prettier installed for the file (closest node_modules), else undefined. Only a found one + * is kept: Prettier installed later is picked up by the next format. + */ private _resolveProject(absolute: string): Promise { const from = dirname(absolute); let prettier = this._projectPrettier.get(from); @@ -67,6 +70,10 @@ export class Formatter { } })(); this._projectPrettier.set(from, prettier); + void prettier.then((found) => { + if (!found && this._projectPrettier.get(from) === prettier) + this._projectPrettier.delete(from); + }); } return prettier; } diff --git a/packages/server-core/src/project/project-registry.ts b/packages/server-core/src/project/project-registry.ts index 52c0a7e5..b55ba2fb 100644 --- a/packages/server-core/src/project/project-registry.ts +++ b/packages/server-core/src/project/project-registry.ts @@ -1,10 +1,13 @@ +import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { type Disposable, DisposableStore, + Emitter, + type Event, type Logger, type Observable, ObservableValue, @@ -15,6 +18,7 @@ import { RpcError } from '@nanoforge-dev/editor-rpc'; import type { ApiClient } from '../api/api-client'; import type { EditorEnv } from '../env/editor-env.type'; import { NFIGNORE_FILE, ProjectFileSystem } from '../fs/fs-service'; +import { PathJail } from '../fs/path-jail'; import { ProjectWatcher } from '../fs/watcher'; import type { GitService } from '../git/git-service'; import type { Session } from '../session/session-store'; @@ -24,6 +28,7 @@ import { type ConfigLoader, executingConfigLoader, staticConfigLoader } from './ import { discoverProject } from './discovery'; const MAX_RECENT = 20; +const GATEWAY_ID = /^[\w-]{1,60}$/; const CONFIG_CHANGE = /(^|\/)(nanoforge\.config\.[mc]?[jt]s|package\.json)$/; /** An opened project: its files, watcher and live model. */ @@ -103,8 +108,15 @@ interface RecentStore { */ export class ProjectRegistry implements Disposable { private readonly _projects = new Map(); + /** When each open project was last asked for. */ + private readonly _lastUsed = new Map(); private readonly _opening = new KeyedMutex(); + private readonly _recentLock = new KeyedMutex(); private readonly _loader: ConfigLoader; + private readonly _onDidClose = new Emitter(); + + /** A project was closed (nobody uses it anymore): its runtime can stop too. */ + readonly onDidClose: Event = this._onDidClose.event; constructor( private readonly _env: EditorEnv, @@ -121,6 +133,7 @@ export class ProjectRegistry implements Disposable { ? await this._openLocal(ref.path) : await this._openGateway(session, ref.gatewayId); session.projects.add(project.id); + this._lastUsed.set(project.id, Date.now()); if (session.user) await this._remember(session.user, project); return project; } @@ -130,9 +143,35 @@ export class ProjectRegistry implements Disposable { const project = this._projects.get(id); const allowed = session.mode === 'OFFLINE' || session.projects.has(id); if (!project || !allowed) throw new RpcError('NOT_FOUND', `Project ${id} is not open`); + this._lastUsed.set(id, Date.now()); return project; } + /** Ids of the open projects. */ + get openIds(): string[] { + return [...this._projects.keys()]; + } + + /** + * Closes the projects nobody uses: no `inUse` (a connected editor holds it) and not asked for + * during `idleMs`. Each one keeps a watcher, so a hosted server must not keep them all. + */ + sweep(inUse: (id: string) => boolean, idleMs: number, now = Date.now()): void { + for (const id of this.openIds) { + if (!inUse(id) && now - (this._lastUsed.get(id) ?? 0) > idleMs) this.close(id); + } + } + + close(id: string): void { + const project = this._projects.get(id); + if (!project) return; + this._projects.delete(id); + this._lastUsed.delete(id); + project.dispose(); + this._logger.info(`Closed project ${project.name} (${id})`); + this._onDidClose.fire(id); + } + async recent(user: User): Promise { const store = await this._readRecent(); return (store[user.id] ?? []).map((entry) => ({ @@ -141,15 +180,16 @@ export class ProjectRegistry implements Disposable { })); } - async forget(user: User, id: string): Promise { - const store = await this._readRecent(); - store[user.id] = (store[user.id] ?? []).filter((entry) => entry.id !== id); - await this._writeRecent(store); + forget(user: User, id: string): Promise { + return this._updateRecent((store) => { + store[user.id] = (store[user.id] ?? []).filter((entry) => entry.id !== id); + }); } dispose(): void { for (const project of this._projects.values()) project.dispose(); this._projects.clear(); + this._onDidClose.dispose(); } private async _openLocal(path: string): Promise { @@ -171,18 +211,22 @@ export class ProjectRegistry implements Disposable { if (this._env.mode !== 'ONLINE') { throw new RpcError('FORBIDDEN', 'Gateway projects need an online editor'); } + if (!GATEWAY_ID.test(gatewayId)) { + throw new RpcError('BAD_REQUEST', `Not a project id: ${gatewayId.slice(0, 80)}`); + } const gateway = await this._api.gatewayProject(session, gatewayId); - const id = `gw-${gatewayId.replace(/[^\w-]/g, '').slice(0, 60)}`; + const id = `gw-${gatewayId}`; return this._opening.run(id, async () => { const root = join(this._env.fsRoot, id); + const url = gateway.gatewayProjectRegistryUrl; if (!this._git.isRepository(root)) { await mkdir(dirname(root), { recursive: true }); - await this._git.clone(gateway.gatewayProjectRegistryUrl, root, gateway.token); + await this._git.clone(url, root, gateway.token); } else if (!this._projects.has(id)) { - await this._git.pull(root); + await this._git.pull(root, url, gateway.token); } const subdir = gateway.gatewayProjectRegistryMetadata.dir; - const projectRoot = subdir ? join(root, subdir) : root; + const projectRoot = subdir ? await new PathJail(root).resolve(subdir) : root; return this._getOrCreate(id, projectRoot, { gatewayId }, gateway.name); }); } @@ -204,8 +248,7 @@ export class ProjectRegistry implements Disposable { }); } - private async _remember(user: User, project: OpenProject): Promise { - const store = await this._readRecent(); + private _remember(user: User, project: OpenProject): Promise { const entry = { id: project.id, name: project.name, @@ -213,11 +256,21 @@ export class ProjectRegistry implements Disposable { ref: project.ref, openedAt: new Date().toISOString(), }; - store[user.id] = [ - entry, - ...(store[user.id] ?? []).filter((item) => item.id !== project.id), - ].slice(0, MAX_RECENT); - await this._writeRecent(store); + return this._updateRecent((store) => { + store[user.id] = [ + entry, + ...(store[user.id] ?? []).filter((item) => item.id !== project.id), + ].slice(0, MAX_RECENT); + }); + } + + /** Read, change and write the recent projects one at a time: two opens never lose a list. */ + private _updateRecent(change: (store: RecentStore) => void): Promise { + return this._recentLock.run('recent', async () => { + const store = await this._readRecent(); + change(store); + await this._writeRecent(store); + }); } private get _recentFile(): string { @@ -236,6 +289,13 @@ export class ProjectRegistry implements Disposable { private async _writeRecent(store: RecentStore): Promise { await mkdir(this._env.dataDir, { recursive: true }); - await writeFile(this._recentFile, `${JSON.stringify(store, null, 2)}\n`); + const temp = `${this._recentFile}.${randomBytes(4).toString('hex')}.tmp`; + try { + await writeFile(temp, `${JSON.stringify(store, null, 2)}\n`); + await rename(temp, this._recentFile); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } } } diff --git a/packages/server-core/src/project/type-files.ts b/packages/server-core/src/project/type-files.ts index 6b04665b..3767ac27 100644 --- a/packages/server-core/src/project/type-files.ts +++ b/packages/server-core/src/project/type-files.ts @@ -1,18 +1,27 @@ import { existsSync } from 'node:fs'; -import { readFile, readdir, stat } from 'node:fs/promises'; -import { dirname, join, relative, sep } from 'node:path'; +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; const MAX_FILES = 400; const MAX_BYTES = 8 * 1024 * 1024; +const inside = (root: string, path: string) => { + const rel = relative(root, path); + return !rel.startsWith('..') && !isAbsolute(rel); +}; + /** * Finds `node_modules/` from `fromDir` up to `root` and returns its package.json and - * type declaration files (paths relative to `root`). + * type declaration files (paths relative to `root`). Links inside the package are never followed. + * The package folder itself may be a link (pnpm, `link:` dependencies) unless `contained`: then + * it must lead inside the project, so that a committed `node_modules/x -> /` reads nothing of + * the host (hosted editors). */ export const collectTypeFiles = async ( root: string, fromDir: string, module: string, + options: { contained?: boolean } = {}, ): Promise<{ path: string; text: string }[]> => { let packageDir: string | undefined; for (let dir = fromDir; ; dir = dirname(dir)) { @@ -24,11 +33,13 @@ export const collectTypeFiles = async ( if (dir === root || dirname(dir) === dir || relative(root, dir).startsWith('..')) break; } if (!packageDir || relative(root, packageDir).startsWith('..')) return []; + if (options.contained && !inside(await realpath(root), await realpath(packageDir))) return []; const files: { path: string; text: string }[] = []; let bytes = 0; const add = async (file: string) => { if (files.length >= MAX_FILES || bytes > MAX_BYTES) return; + if (!(await lstat(file)).isFile()) return; const text = await readFile(file, 'utf8'); bytes += text.length; files.push({ path: relative(root, file).split(sep).join('/'), text }); @@ -40,7 +51,11 @@ export const collectTypeFiles = async ( const path = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) await walk(path); - } else if (/\.d\.[cm]?ts$/.test(entry.name) && (await stat(path)).size < 2 * 1024 * 1024) { + } else if ( + entry.isFile() && + /\.d\.[cm]?ts$/.test(entry.name) && + (await lstat(path)).size < 2 * 1024 * 1024 + ) { await add(path); } } diff --git a/packages/server-core/src/rpc/implementation/code.implementation.ts b/packages/server-core/src/rpc/implementation/code.implementation.ts index e8b7e632..c7e76cd6 100644 --- a/packages/server-core/src/rpc/implementation/code.implementation.ts +++ b/packages/server-core/src/rpc/implementation/code.implementation.ts @@ -19,7 +19,7 @@ export const implementCodeRpc = ( typeFiles: async ({ project, from, module }, { session }) => { const open = projects.get(session, project); const fromDir = await open.fs.jail.resolve(from.split('/').slice(0, -1).join('/')); - return collectTypeFiles(open.root, fromDir, module); + return collectTypeFiles(open.root, fromDir, module, { contained: env.mode === 'ONLINE' }); }, format: async ({ project, path, text }, { session }) => { const open = projects.get(session, project); diff --git a/packages/server-core/src/rpc/implementation/session.implementation.ts b/packages/server-core/src/rpc/implementation/session.implementation.ts index 913a4e8c..940a8f0d 100644 --- a/packages/server-core/src/rpc/implementation/session.implementation.ts +++ b/packages/server-core/src/rpc/implementation/session.implementation.ts @@ -23,10 +23,6 @@ export const implementSessionRpc = ( ...(env.registryDir || env.registryUrl ? (['registry'] as const) : []), ], }), - logout: (_input, context) => { - context.responseCookies.push(...deps.sessions.logout(context.session)); - return null; - }, }, }); }; diff --git a/packages/server-core/src/runtime/build-orchestrator.ts b/packages/server-core/src/runtime/build-orchestrator.ts index a0d7e9bf..caacc295 100644 --- a/packages/server-core/src/runtime/build-orchestrator.ts +++ b/packages/server-core/src/runtime/build-orchestrator.ts @@ -31,6 +31,8 @@ export interface BuildableProject { // eslint-disable-next-line no-control-regex -- terminal color codes export const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; const REBUILD_DELAY_MS = 150; +/** A build that runs longer is stopped: it must not hold the build queue forever. */ +const BUILD_TIMEOUT_MS = 10 * 60_000; /** Apps the editor can build and run: clients and servers with an output directory. */ export const runnableApps = (model: ProjectModel): AppModel[] => @@ -80,6 +82,8 @@ export class BuildOrchestrator implements Disposable { private _watch: Disposable | undefined; private _timer: ReturnType | undefined; private _disposed = false; + /** Stops the build in progress when the orchestrator is disposed. */ + private readonly _abort = new AbortController(); readonly onEvent: Event = this._onEvent.event; @@ -87,6 +91,7 @@ export class BuildOrchestrator implements Disposable { private readonly _project: BuildableProject, private readonly _cli: CliService, private readonly _logger: Logger, + private readonly _buildTimeoutMs = BUILD_TIMEOUT_MS, ) {} get apps(): AppModel[] { @@ -150,6 +155,7 @@ export class BuildOrchestrator implements Disposable { dispose(): void { this._disposed = true; + this._abort.abort(); clearTimeout(this._timer); this._watch?.dispose(); this._onEvent.dispose(); @@ -187,6 +193,7 @@ export class BuildOrchestrator implements Disposable { const run = this._cli.run(['build', '--editor', '--directory', app.root || '.'], { cwd: this._project.root, env: { FORCE_COLOR: '0', NO_COLOR: '1' }, + signal: AbortSignal.any([this._abort.signal, AbortSignal.timeout(this._buildTimeoutMs)]), silent: true, }); const listener = run.onLine(({ stream, text }) => @@ -206,7 +213,10 @@ export class BuildOrchestrator implements Disposable { output = `${result.stdout}\n${result.stderr}`; } catch (error) { exitCode = -1; - output = `error: ${error instanceof Error ? error.message : String(error)}`; + const timedOut = (error as { name?: string }).name === 'AbortError' && !this._disposed; + output = timedOut + ? `error: The build of ${app.name} took more than ${Math.round(this._buildTimeoutMs / 60_000)} minutes and was stopped` + : `error: ${error instanceof Error ? error.message : String(error)}`; } finally { listener.dispose(); } diff --git a/packages/server-core/src/runtime/game-server.ts b/packages/server-core/src/runtime/game-server.ts index a122a737..2e6fe275 100644 --- a/packages/server-core/src/runtime/game-server.ts +++ b/packages/server-core/src/runtime/game-server.ts @@ -5,6 +5,8 @@ import { join } from 'node:path'; import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; import type { GameServerState, RuntimeEvent } from '@nanoforge-dev/editor-protocol'; +import { childEnv } from '../process/child-env'; + /** * Runs in the game process (Bun): receives the entry point, files and env over IPC, then * starts the game with an editor bridge whose `toEditor` events are sent back over IPC. @@ -55,7 +57,10 @@ send({ type: 'ready' }); const runners = new Map>(); -/** Writes the runner script once per data directory (the server itself may be bundled). */ +/** + * Writes the runner script once per data directory (the server itself may be bundled). A failed + * write is forgotten, so the next start tries again. + */ const ensureRunner = (dataDir: string): Promise => { let runner = runners.get(dataDir); if (!runner) { @@ -67,6 +72,9 @@ const ensureRunner = (dataDir: string): Promise => { return path; })(); runners.set(dataDir, runner); + runner.catch(() => { + if (runners.get(dataDir) === runner) runners.delete(dataDir); + }); } return runner; }; @@ -122,7 +130,7 @@ export class GameServerProcess { cwd: this._options.cwd, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], detached: process.platform !== 'win32', - env: { ...process.env, FORCE_COLOR: '0' }, + env: childEnv({ FORCE_COLOR: '0' }), }); this._child = child; this._setState('starting'); diff --git a/packages/server-core/src/runtime/project-runtime.ts b/packages/server-core/src/runtime/project-runtime.ts index f3f635fc..123e0cdf 100644 --- a/packages/server-core/src/runtime/project-runtime.ts +++ b/packages/server-core/src/runtime/project-runtime.ts @@ -18,6 +18,7 @@ import { RpcError } from '@nanoforge-dev/editor-rpc'; import type { EditorEnv } from '../env/editor-env.type'; import type { CliRun, CliService } from '../process/cli-service'; import type { OpenProject } from '../project/project-registry'; +import { KeyedMutex } from '../util/keyed-mutex'; import { ANSI, BuildOrchestrator } from './build-orchestrator'; import { BuildOutput } from './build-output'; import { loadGameEnv } from './game-env'; @@ -31,6 +32,8 @@ export const outputEntry = (app: AppModel): string => export class ProjectRuntime implements Disposable { readonly builds: BuildOrchestrator; private readonly _servers = new Map(); + /** Starts and stops of the same app run one at a time (two tabs, Restart during a start). */ + private readonly _serverLocks = new KeyedMutex(); private readonly _onEvent = new Emitter(); private readonly _store = new DisposableStore(); @@ -75,10 +78,35 @@ export class ProjectRuntime implements Disposable { } /** Starts the game server of an app (restarts it when running). */ - async startServer(appId: string, overrides: Record = {}): Promise { + startServer(appId: string, overrides: Record = {}): Promise { + return this._serverLocks.run(appId, () => this._startServer(appId, overrides)); + } + + stopServer(appId: string): Promise { + return this._serverLocks.run(appId, () => this._stopServer(appId)); + } + + sendServer(appId: string, event: string, args: readonly unknown[]): void { + const server = this._servers.get(appId); + if (!server) throw new RpcError('NOT_FOUND', `The game server of ${appId} is not running`); + server.send(event, args); + } + + servers(): { app: string; state: GameServerProcess['state'] }[] { + return [...this._servers].map(([app, server]) => ({ app, state: server.state })); + } + + dispose(): void { + for (const server of this._servers.values()) server.kill(); + this._servers.clear(); + this._store.dispose(); + this._onEvent.dispose(); + } + + private async _startServer(appId: string, overrides: Record): Promise { const app = this.app(appId); if (app.type !== 'server') throw new RpcError('BAD_REQUEST', `${app.name} is not a server`); - await this.stopServer(appId); + await this._stopServer(appId); const manifest = await this.manifest(appId); const outDir = this.builds.output(app).dir; const server = new GameServerProcess({ @@ -103,27 +131,10 @@ export class ProjectRuntime implements Disposable { this._logger.info(`Started the game server of ${app.name}`); } - async stopServer(appId: string): Promise { + private async _stopServer(appId: string): Promise { await this._servers.get(appId)?.stop(); } - sendServer(appId: string, event: string, args: readonly unknown[]): void { - const server = this._servers.get(appId); - if (!server) throw new RpcError('NOT_FOUND', `The game server of ${appId} is not running`); - server.send(event, args); - } - - servers(): { app: string; state: GameServerProcess['state'] }[] { - return [...this._servers].map(([app, server]) => ({ app, state: server.state })); - } - - dispose(): void { - for (const server of this._servers.values()) server.kill(); - this._servers.clear(); - this._store.dispose(); - this._onEvent.dispose(); - } - /** Streams a CLI run made in this project (other than builds) as a console task. */ private _followTask(run: CliRun): void { const inside = relative(this.project.root, run.cwd); @@ -163,6 +174,12 @@ export class RuntimeManager implements Disposable { return runtime; } + /** Stops the builds and game servers of a project that was closed. */ + close(projectId: string): void { + this._runtimes.get(projectId)?.dispose(); + this._runtimes.delete(projectId); + } + dispose(): void { for (const runtime of this._runtimes.values()) runtime.dispose(); this._runtimes.clear(); diff --git a/packages/server-core/src/server/create-editor-server.ts b/packages/server-core/src/server/create-editor-server.ts index 4b9142a6..624445e3 100644 --- a/packages/server-core/src/server/create-editor-server.ts +++ b/packages/server-core/src/server/create-editor-server.ts @@ -17,7 +17,7 @@ import { implementCoreRpc } from '../rpc/implement-core-rpc'; import type { BuildOutput } from '../runtime/build-output'; import { RuntimeManager } from '../runtime/project-runtime'; import { type RequestContext, authorize } from '../session/auth'; -import { serializeCookie } from '../session/cookies'; +import { parseCookies, serializeCookie } from '../session/cookies'; import { ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, @@ -30,6 +30,10 @@ import { ProjectLocalSettingsStore, } from '../settings/account-backends'; import type { EditorServer, EditorServerOptions } from './editor-server.type'; +import { hostAllowed, originAllowed } from './request-guard'; + +/** A project nobody has a connected editor on is closed after this long without a request. */ +const PROJECT_IDLE_MS = 30 * 60_000; const json = (status: number, body: unknown, headers: HeadersInit = {}) => new Response(JSON.stringify(body), { @@ -44,8 +48,8 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = if (!options.logger) store.add(logs.addSink(consoleSink)); const logger = logs.getLogger('server'); - const sessions = new SessionStore(env); const api = new ApiClient(env, options.fetch); + const sessions = new SessionStore(env, { verify: (session) => api.currentUser(session) }); const git = new GitService(env); const cli = new CliService(env); const projects = store.add(new ProjectRegistry(env, git, api, logs.getLogger('projects'))); @@ -78,21 +82,13 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = const services: ServerPluginServices = { projects, cli, git, api }; const serverPlugins = store.add(new ServerPluginHost(router, services, logs)); const staticJail = options.staticDir ? new PathJail(resolve(options.staticDir)) : undefined; - const pruneTimer = setInterval(() => sessions.prune(), 3600_000); - pruneTimer.unref?.(); - - /** Blocks cross-site requests: a local editor must not be driven by random web pages. */ - const originAllowed = (request: Request): boolean => { - const origin = request.headers.get('origin'); - if (!origin) return true; // non-browser clients - const host = request.headers.get('host') ?? new URL(request.url).host; - try { - const url = new URL(origin); - return url.host === host || env.allowedOrigins.includes(origin); - } catch { - return false; - } - }; + store.add(projects.onDidClose((id) => runtime.close(id))); + const inUse = (id: string) => sessions.holders(id).some((session) => session.sockets > 0); + const sweepTimer = setInterval(() => { + sessions.prune(); + projects.sweep(inUse, PROJECT_IDLE_MS); + }, 600_000); + sweepTimer.unref?.(); const withCookies = (response: Response, cookies: string[]): Response => { for (const cookie of cookies) response.headers.append('set-cookie', cookie); @@ -109,8 +105,9 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = }; const handleRpc = async (request: Request, path: string): Promise => { - const { session, setCookies } = sessions.resolve(request.headers.get('cookie')); - const before = session.tokens?.accessToken; + const cookie = request.headers.get('cookie'); + const before = parseCookies(cookie)[ACCESS_TOKEN_COOKIE]; + const { session, setCookies } = await sessions.resolve(cookie); const context: RequestContext = { session, responseCookies: [] }; const result = await router.handle(path, await request.text(), context, request.signal); const response = new Response(result.body, { @@ -125,7 +122,7 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = }; const handlePlugin = async (request: Request, pathname: string): Promise => { - const { session } = sessions.resolve(request.headers.get('cookie')); + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); const resolved = await plugins.resolveFile(pathname, (projectId) => { try { projects.get(session, projectId); @@ -135,17 +132,24 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = } }); if (!resolved) return json(404, { error: 'Not found' }); - const cache = - resolved.plugin.source === 'dev' ? 'no-cache' : 'public, max-age=31536000, immutable'; + const cache = { + dev: 'no-cache', + project: 'private, no-cache', + installed: 'public, max-age=31536000, immutable', + bundled: 'public, max-age=31536000, immutable', + }[resolved.plugin.source]; return (await fileResponse(resolved.file, cache)) ?? json(404, { error: 'Not found' }); }; - /** Build outputs of running games: `/runtime///`. */ + /** + * Build outputs of running games: `/runtime///`. Local editors only, like + * the runtime RPC: a hosted editor never builds nor runs a project's code. + */ const handleRuntime = async (request: Request, url: URL): Promise => { const [projectId, appSegment, ...rest] = url.pathname.slice('/runtime/'.length).split('/'); if (!projectId || !appSegment || !rest.length || env.mode !== 'OFFLINE') return json(404, { error: 'Not found' }); - const { session } = sessions.resolve(request.headers.get('cookie')); + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); let file: string | undefined; let output: BuildOutput; try { @@ -170,10 +174,10 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = const handleFiles = async (request: Request, url: URL): Promise => { const [projectId, ...rest] = url.pathname.slice('/files/'.length).split('/'); if (!projectId) return json(404, { error: 'Not found' }); - const { session } = sessions.resolve(request.headers.get('cookie')); - const path = rest.map(decodeURIComponent).join('/'); + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); const download = url.searchParams.has('download'); try { + const path = rest.map(decodeURIComponent).join('/'); const project = projects.get(session, projectId); const entry = await project.fs.stat(path); if (!entry || (path && project.fs.isIgnored(path, entry.kind === 'directory'))) { @@ -195,6 +199,7 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = } const response = await fileResponse(await project.fs.jail.resolve(path), 'no-cache'); if (!response) return json(404, { error: 'Not found' }); + response.headers.set('content-security-policy', 'sandbox'); if (download) response.headers.set('content-disposition', disposition(name)); return response; } catch { @@ -204,7 +209,12 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = const handleStatic = async (pathname: string): Promise => { if (!staticJail) return json(404, { error: 'Not found' }); - const rel = decodeURIComponent(pathname.slice(1)); + let rel: string; + try { + rel = decodeURIComponent(pathname.slice(1)); + } catch { + return json(404, { error: 'Not found' }); + } const file = await staticJail.resolve(rel).catch(() => undefined); const immutable = rel.startsWith('_app/immutable/'); const asset = @@ -239,9 +249,11 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = const { pathname } = url; try { if (pathname === '/healthz') return json(200, { ok: true, version: options.version }); + if (!hostAllowed(env, request.headers.get('host') ?? url.host)) + return json(421, { error: 'Unknown host' }); + if (!originAllowed(env, request)) return json(403, { error: 'Forbidden origin' }); if (pathname === RPC_WS_PATH) { - if (!originAllowed(request)) return json(403, { error: 'Forbidden origin' }); - const { session, setCookies } = sessions.resolve(request.headers.get('cookie')); + const { session, setCookies } = await sessions.resolve(request.headers.get('cookie')); const headers = new Headers(); for (const cookie of setCookies) headers.append('set-cookie', cookie); if (upgrade?.(request, { data: { session }, headers })) return undefined; @@ -249,7 +261,6 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = } if (pathname.startsWith(RPC_HTTP_PREFIX)) { if (request.method !== 'POST') return json(405, { error: 'Method not allowed' }); - if (!originAllowed(request)) return json(403, { error: 'Forbidden origin' }); return await handleRpc(request, pathname.slice(RPC_HTTP_PREFIX.length)); } if (request.method !== 'GET' && request.method !== 'HEAD') @@ -266,26 +277,29 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = websocket: { open(socket) { + socket.data.session.sockets++; socket.data.rpc = router.createSession( { session: socket.data.session, responseCookies: [] }, (message) => socket.send(message) > 0, ); }, message(socket, message) { - void socket.data.rpc?.receive( - typeof message === 'string' ? message : new TextDecoder().decode(message), - ); + socket.data.rpc + ?.receive(typeof message === 'string' ? message : new TextDecoder().decode(message)) + .catch((error: unknown) => logger.error('WebSocket message failed', error)); }, drain(socket) { socket.data.rpc?.drain(); }, close(socket) { + socket.data.session.sockets = Math.max(0, socket.data.session.sockets - 1); socket.data.rpc?.dispose(); }, }, dispose() { - clearInterval(pruneTimer); + clearInterval(sweepTimer); + sessions.dispose(); store.dispose(); }, }; diff --git a/packages/server-core/src/server/request-guard.ts b/packages/server-core/src/server/request-guard.ts new file mode 100644 index 00000000..54fefd56 --- /dev/null +++ b/packages/server-core/src/server/request-guard.ts @@ -0,0 +1,51 @@ +import type { EditorEnv } from '../env/editor-env.type'; + +const LOOPBACK = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/; +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']); + +const hostname = (host: string): string => { + const lower = host.trim().toLowerCase(); + if (lower.startsWith('[')) return lower.slice(0, lower.indexOf(']') + 1); + return lower.replace(/:\d+$/, ''); +}; + +/** + * Whether the `Host` header names this server. A page whose name was rebound to 127.0.0.1 sends + * its own name as `Host` (and as `Origin`, so the origin check alone passes): a local editor only + * answers to loopback names and `ALLOWED_HOSTS`. A hosted one checks `ALLOWED_HOSTS` when set. + */ +export const hostAllowed = (env: EditorEnv, host: string | null): boolean => { + if (!host) return false; + const name = hostname(host); + const allowed = [ + ...env.allowedHosts, + ...env.allowedOrigins.flatMap((origin) => { + try { + return [new URL(origin).hostname.toLowerCase()]; + } catch { + return []; + } + }), + ...(WILDCARD_HOSTS.has(env.host) ? [] : [env.host.toLowerCase()]), + ]; + if (LOOPBACK.test(name) || allowed.includes(name)) return true; + return env.mode === 'ONLINE' && !env.allowedHosts.length; +}; + +/** + * Whether a browser request comes from the editor's own pages. Requests without `Origin` + * (navigations, non-browser clients) pass, except cross-site subresource loads. + */ +export const originAllowed = (env: EditorEnv, request: Request): boolean => { + const origin = request.headers.get('origin'); + if (!origin) { + const site = request.headers.get('sec-fetch-site'); + return site !== 'cross-site' || request.headers.get('sec-fetch-mode') === 'navigate'; + } + const host = request.headers.get('host') ?? new URL(request.url).host; + try { + return new URL(origin).host === host || env.allowedOrigins.includes(origin); + } catch { + return false; + } +}; diff --git a/packages/server-core/src/session/session-store.ts b/packages/server-core/src/session/session-store.ts index dc4efe16..f322a79f 100644 --- a/packages/server-core/src/session/session-store.ts +++ b/packages/server-core/src/session/session-store.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { userInfo } from 'node:os'; +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; import type { User } from '@nanoforge-dev/editor-protocol'; import type { EditorEnv } from '../env/editor-env.type'; @@ -24,6 +25,8 @@ export interface Session { /** Projects this session opened (ONLINE access control). */ readonly projects: Set; lastSeen: number; + /** Open WebSockets of this session: its projects stay open while there is one. */ + sockets: number; } export interface SessionResolution { @@ -32,92 +35,150 @@ export interface SessionResolution { readonly setCookies: string[]; } -const IDLE_TTL_MS = 7 * 24 * 3600 * 1000; +export interface SessionStoreOptions { + /** + * Who an access token belongs to, asked to the API (`ApiClient.currentUser`). It may refresh + * the session's tokens; null when the tokens are not valid. + */ + readonly verify: (session: Session) => Promise; + /** Sessions kept at most; the least recently seen ones go first. */ + readonly maxSessions?: number; +} -/** Best-effort, unverified read of a JWT payload (display only; the API verifies tokens). */ -const decodeJwtUser = (token: string): User | null => { - try { - const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()) as { - sub?: string; - id?: string; - email?: string; - name?: string; - username?: string; - }; - const id = payload.sub ?? payload.id; - if (!id) return null; - return { - id: String(id), - name: payload.name ?? payload.username ?? payload.email ?? 'NanoForge user', - ...(payload.email && { email: payload.email }), - }; - } catch { - return null; - } -}; +const IDLE_TTL_MS = 7 * 24 * 3600 * 1000; +const MAX_SESSIONS = 10_000; + +/** What the store remembers of the tokens it checked, per session. */ +interface Verification { + /** The access token the user was verified with (after a refresh, the new one). */ + token: string; + /** The token a refresh replaced: requests sent before the new cookie arrived still carry it. */ + replaced?: string; + pending?: Promise; +} /** * In-memory sessions identified by a signed cookie. OFFLINE sessions belong to the local OS user; - * ONLINE sessions carry the API tokens found in the website cookies. + * ONLINE sessions carry the API tokens found in the website cookies, and their user is the one + * the API answers for them (a token is never trusted on its own). */ export class SessionStore { private readonly _sessions = new Map(); - - constructor(private readonly _env: EditorEnv) {} - - resolve(cookieHeader: string | null | undefined): SessionResolution { + private readonly _verifications = new Map(); + private readonly _onDidDrop = new Emitter(); + + /** A session was pruned or evicted: what it held can be released. */ + readonly onDidDrop: Event = this._onDidDrop.event; + + constructor( + private readonly _env: EditorEnv, + private readonly _options: SessionStoreOptions, + ) {} + + /** + * The session of a request. Without `create`, a request with no known session gets a + * throwaway one that is neither stored nor sent back (static files, plugin files…). + */ + async resolve( + cookieHeader: string | null | undefined, + options: { create?: boolean } = {}, + ): Promise { const cookies = parseCookies(cookieHeader); const setCookies: string[] = []; const id = cookies[SESSION_COOKIE] && unsign(cookies[SESSION_COOKIE], this._env.sessionSecret); let session = id ? this._sessions.get(id) : undefined; if (!session) { - session = this._create(); + session = this._new(); + if (options.create === false) return { session, setCookies }; + this._store(session); setCookies.push( serializeCookie(SESSION_COOKIE, sign(session.id, this._env.sessionSecret), { secure: this._env.production && this._env.mode === 'ONLINE', }), ); + } else { + this._sessions.delete(session.id); + this._sessions.set(session.id, session); } session.lastSeen = Date.now(); + if (session.mode === 'ONLINE') await this._authenticate(session, cookies); + return { session, setCookies }; + } - if (session.mode === 'ONLINE') { - const accessToken = cookies[ACCESS_TOKEN_COOKIE]; - const refreshToken = cookies[REFRESH_TOKEN_COOKIE]; - if (accessToken || refreshToken) { - session.tokens = { accessToken: accessToken ?? '', refreshToken: refreshToken ?? '' }; - session.user = (accessToken && decodeJwtUser(accessToken)) || session.user; - } else { - session.tokens = null; - session.user = null; - } + /** Drops sessions idle for more than a week. */ + prune(now = Date.now()): void { + for (const session of [...this._sessions.values()]) { + if (now - session.lastSeen > IDLE_TTL_MS && !session.sockets) this._drop(session); } - return { session, setCookies }; } - logout(session: Session): string[] { - this._sessions.delete(session.id); - return [serializeCookie(SESSION_COOKIE, '', { maxAge: 0 })]; + /** Sessions that opened a project. */ + holders(projectId: string): Session[] { + return [...this._sessions.values()].filter((session) => session.projects.has(projectId)); } - /** Drops sessions idle for more than a week. */ - prune(now = Date.now()): void { - for (const [id, session] of this._sessions) { - if (now - session.lastSeen > IDLE_TTL_MS) this._sessions.delete(id); + dispose(): void { + this._onDidDrop.dispose(); + } + + private async _authenticate(session: Session, cookies: Record): Promise { + const accessToken = cookies[ACCESS_TOKEN_COOKIE]; + const refreshToken = cookies[REFRESH_TOKEN_COOKIE]; + if (!accessToken && !refreshToken) { + session.tokens = null; + session.user = null; + this._verifications.delete(session.id); + return; } + const known = this._verifications.get(session.id); + if (known?.pending) return known.pending; + if (known && (accessToken === known.token || (accessToken && accessToken === known.replaced))) + return; + session.tokens = { accessToken: accessToken ?? '', refreshToken: refreshToken ?? '' }; + const verification: Verification = { token: accessToken ?? '' }; + verification.pending = this._options + .verify(session) + .catch(() => null) + .then((user) => { + session.user = user; + delete verification.pending; + if (!user || !session.tokens) { + this._verifications.delete(session.id); + return; + } + verification.token = session.tokens.accessToken; + if (accessToken && accessToken !== verification.token) verification.replaced = accessToken; + }); + this._verifications.set(session.id, verification); + return verification.pending; } - private _create(): Session { + private _new(): Session { const offline = this._env.mode === 'OFFLINE'; - const session: Session = { + return { id: randomUUID(), mode: this._env.mode, user: offline ? { id: 'local', name: localUserName() } : null, tokens: null, projects: new Set(), lastSeen: Date.now(), + sockets: 0, }; + } + + private _store(session: Session): void { this._sessions.set(session.id, session); - return session; + const max = this._options.maxSessions ?? MAX_SESSIONS; + for (const oldest of this._sessions.values()) { + if (this._sessions.size <= max) break; + if (oldest !== session && !oldest.sockets) this._drop(oldest); + } + } + + private _drop(session: Session): void { + this._sessions.delete(session.id); + this._verifications.delete(session.id); + this._onDidDrop.fire(session); } } diff --git a/packages/server-core/src/testing.ts b/packages/server-core/src/testing.ts index 93a831ca..6ba90043 100644 --- a/packages/server-core/src/testing.ts +++ b/packages/server-core/src/testing.ts @@ -6,7 +6,8 @@ import { RpcClient, type SocketLike } from '@nanoforge-dev/editor-rpc'; import type { EditorServer, ServerSocket, SocketData } from './server/editor-server.type'; -export const TEST_ORIGIN = 'http://editor.test'; +/** A loopback name: the server only answers to those and `ALLOWED_HOSTS` (DNS rebinding). */ +export const TEST_ORIGIN = 'http://editor.localhost'; class LoopbackSocket implements SocketLike { readyState = 0; diff --git a/packages/server-core/src/util/glob.ts b/packages/server-core/src/util/glob.ts index 222f8271..a7b36cf6 100644 --- a/packages/server-core/src/util/glob.ts +++ b/packages/server-core/src/util/glob.ts @@ -3,13 +3,32 @@ import { join } from 'node:path'; const IGNORED = new Set(['node_modules', '.git', '.nanoforge', 'dist']); -const segmentRegex = (segment: string) => - new RegExp( - `^${segment - .split('*') - .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) - .join('[^/]*')}$`, - ); +/** + * Whether `name` matches a segment pattern where `*` is any run of characters. Linear in time + * (the patterns come from the project's config: a regex of `a*a*a*b` backtracks). + */ +export const matchSegment = (pattern: string, name: string): boolean => { + let p = 0; + let n = 0; + let star = -1; + let resume = 0; + while (n < name.length) { + if (p < pattern.length && pattern[p] !== '*' && pattern[p] === name[n]) { + p++; + n++; + } else if (p < pattern.length && pattern[p] === '*') { + star = p++; + resume = n; + } else if (star >= 0) { + p = star + 1; + n = ++resume; + } else { + return false; + } + } + while (pattern[p] === '*') p++; + return p === pattern.length; +}; /** * Expands workspace `packages` patterns (`apps/*`, `packages/**`, `libs/shared`) into @@ -37,9 +56,8 @@ export const expandDirectoryGlobs = async ( await walk(join(relative, segment), rest); return; } - const regex = segmentRegex(segment); for (const child of await children(root, relative)) { - if (regex.test(child)) await walk(join(relative, child), rest); + if (matchSegment(segment, child)) await walk(join(relative, child), rest); } }; for (const pattern of patterns) { diff --git a/packages/server-core/test/fs/fs-service.spec.ts b/packages/server-core/test/fs/fs-service.spec.ts index 622a7417..cf44e48d 100644 --- a/packages/server-core/test/fs/fs-service.spec.ts +++ b/packages/server-core/test/fs/fs-service.spec.ts @@ -43,6 +43,23 @@ describe('ProjectFileSystem', () => { ]); }); + it('never reaches a .git folder', async () => { + const { root, fs } = project(); + mkdirSync(join(root, '.git/hooks'), { recursive: true }); + writeFileSync(join(root, '.git/config'), '[core]\n'); + expect((await fs.list('')).map((entry) => entry.path)).toEqual(['src']); + await expect(fs.read('.git/config')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(fs.write('.git/hooks/post-checkout', 'x')).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(fs.write('libs/x/.GIT/config', 'x')).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(fs.rename('src', '.git/src')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(fs.copy('.git', 'leak')).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(existsSync(join(root, '.git/hooks/post-checkout'))).toBe(false); + }); + it('writes atomically with optimistic concurrency', async () => { const { root, fs } = project(); const { hash } = await fs.read('src/main.ts'); @@ -157,4 +174,23 @@ describe('ProjectWatcher', () => { { type: 'created', kind: 'file', path: 'src/new.ts' }, ]); }); + + it('never follows a link that leaves the project', async () => { + const { root, fs } = project(); + const outside = mkdtempSync(join(tmpdir(), 'nf-outside-')); + roots.push(outside); + writeFileSync(join(outside, 'secret.txt'), 'x'); + symlinkSync(outside, join(root, 'link')); + const watcher = new ProjectWatcher(fs.jail); + const batches: FileChange[][] = []; + const subscription = watcher.onDidChange((changes) => batches.push(changes)); + await watcher.ready; + writeFileSync(join(outside, 'later.txt'), 'x'); + writeFileSync(join(root, 'src/new.ts'), 'x'); + const start = Date.now(); + while (!batches.length && Date.now() - start < 1000) await new Promise((r) => setTimeout(r, 5)); + await new Promise((r) => setTimeout(r, 150)); + subscription.dispose(); + expect(batches.flat().map((change) => change.path)).toEqual(['src/new.ts']); + }); }); diff --git a/packages/server-core/test/git/git-service.spec.ts b/packages/server-core/test/git/git-service.spec.ts index 929a3552..9abe737d 100644 --- a/packages/server-core/test/git/git-service.spec.ts +++ b/packages/server-core/test/git/git-service.spec.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { loadEnv } from '../../src/env/load-env'; import { GitService } from '../../src/git/git-service'; +import { authEnv, withoutCredentials } from '../../src/git/git-token'; import { parseStatus } from '../../src/git/parse-status'; let workspace: string; @@ -40,6 +41,45 @@ beforeAll(() => { }); afterAll(() => rmSync(workspace, { recursive: true, force: true })); +describe('git environment', () => { + it('reads paths literally and ignores an inherited GIT_DIR', async () => { + const root = await repository('literal'); + write(root, 'levels/[id].ts', 'x\n'); + write(root, 'levels/i.ts', 'i\n'); + write(root, 'levels/d.ts', 'd\n'); + const previous = process.env.GIT_DIR; + process.env.GIT_DIR = join(workspace, 'elsewhere'); + try { + await git.discard(root, ['levels/[id].ts']); + } finally { + if (previous === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = previous; + } + expect(existsSync(join(root, 'levels/[id].ts'))).toBe(false); + expect(existsSync(join(root, 'levels/i.ts'))).toBe(true); + expect(existsSync(join(root, 'levels/d.ts'))).toBe(true); + }); + + it('passes a token as an authorization header for its host only', () => { + expect(authEnv('https://git.test/ada/pong.git', 'secret')).toEqual({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://git.test/.extraHeader', + GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from('oauth2:secret').toString('base64')}`, + }); + expect(authEnv('git@git.test:ada/pong.git', 'secret')).toEqual({}); + expect(withoutCredentials('https://oauth2:secret@git.test/a.git')).toBe( + 'https://git.test/a.git', + ); + }); + + it('clones without saving a token in the remote', async () => { + const source = await repository('token-source'); + const target = join(workspace, 'token-clone'); + await git.clone(source, target, 'secret-token'); + expect(readFileSync(join(target, '.git/config'), 'utf8')).not.toContain('secret-token'); + }); +}); + describe('parseStatus', () => { it('reads the branch, the upstream counts and every kind of entry', () => { const output = [ diff --git a/packages/server-core/test/mocks/nanoforge-api.ts b/packages/server-core/test/mocks/nanoforge-api.ts index b339f381..693a6a63 100644 --- a/packages/server-core/test/mocks/nanoforge-api.ts +++ b/packages/server-core/test/mocks/nanoforge-api.ts @@ -1,6 +1,6 @@ /** * In-memory NanoForge API implementing what the editor calls (docs/api/README.md): the settings - * sync contract, token refresh and the project endpoints. Used as `fetch` by the ApiClient in + * sync contract, token refresh, the current user and the project endpoints. Used as `fetch` by the ApiClient in * tests, and as the default target of the contract tests (`test/contract`), which can run * against a staging API instead. */ @@ -82,6 +82,8 @@ export const createMockApi = (options: MockApiOptions = {}) => { return json(200, { revision: next.revision }); } } + if (url.pathname === '/users/me' && method === 'GET') + return json(200, { id: 'user-1', username: 'ada', email: 'ada@nanoforge.test' }); if (url.pathname === '/projects' && method === 'GET') return json(200, [PROJECT]); if (url.pathname === `/editor/projects/${PROJECT.id}` && method === 'GET') return json(200, PROJECT); diff --git a/packages/server-core/test/process/child-env.spec.ts b/packages/server-core/test/process/child-env.spec.ts new file mode 100644 index 00000000..bd4614e1 --- /dev/null +++ b/packages/server-core/test/process/child-env.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { childEnv } from '../../src/process/child-env'; + +describe('childEnv', () => { + it('keeps what tools need and drops the server settings', () => { + const env = childEnv( + { FORCE_COLOR: '0', HOME: undefined }, + { + PATH: '/usr/bin', + HOME: '/home/ada', + LC_ALL: 'C', + NANOFORGE_LEVEL: '2', + SESSION_SECRET: 's', + API_KEY: 'k', + PORT: '3000', + GIT_DIR: '/elsewhere', + }, + ); + expect(env).toEqual({ PATH: '/usr/bin', LC_ALL: 'C', NANOFORGE_LEVEL: '2', FORCE_COLOR: '0' }); + }); +}); diff --git a/packages/server-core/test/server/create-editor-server.spec.ts b/packages/server-core/test/server/create-editor-server.spec.ts index 559f3dec..62fd5ace 100644 --- a/packages/server-core/test/server/create-editor-server.spec.ts +++ b/packages/server-core/test/server/create-editor-server.spec.ts @@ -201,6 +201,30 @@ describe('editor server', () => { }), ); expect(forged!.status).toBe(403); + + const rebound = await editor.fetch( + new Request('http://evil.example/rpc/session.info', { + method: 'POST', + body: 'null', + headers: { origin: 'http://evil.example' }, + }), + ); + expect(rebound!.status).toBe(421); + expect((await get('/files/x/%E0')).status).toBe(404); + expect((await get('/%E0')).status).toBe(404); + }); + + it('survives WebSocket frames that are not messages', async () => { + const { id } = await client.api(ProjectsContract).open({ path: 'pong' }); + let rpc: { receive(raw: string): Promise } | undefined; + await editor.fetch(new Request(`${ORIGIN}/rpc/ws`), (_request, { data }) => { + editor.websocket.open({ data, send: () => 1, close: () => undefined }); + rpc = data.rpc; + return true; + }); + for (const frame of ['null', '1', '"x"', '[]']) + await expect(rpc!.receive(frame)).resolves.toBeUndefined(); + expect((await client.api(ProjectsContract).model({ id })).name).toBeTruthy(); }); it('serves project files and downloads folders as zip', async () => { @@ -213,6 +237,8 @@ describe('editor server', () => { }; const file = await fetchFile('apps/client/package.json'); expect(file.status).toBe(200); + expect(file.headers.get('content-security-policy')).toBe('sandbox'); + expect(file.headers.get('x-content-type-options')).toBe('nosniff'); expect(await file.json()).toMatchObject({ name: expect.any(String) }); const download = await fetchFile('apps/client/package.json?download'); diff --git a/packages/server-core/test/server/request-guard.spec.ts b/packages/server-core/test/server/request-guard.spec.ts new file mode 100644 index 00000000..eef03aee --- /dev/null +++ b/packages/server-core/test/server/request-guard.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { loadEnv } from '../../src/env/load-env'; +import { hostAllowed, originAllowed } from '../../src/server/request-guard'; + +const offline = loadEnv({ NODE_ENV: 'test', ALLOWED_HOSTS: 'editor.lan' }); +const online = (hosts = '') => + loadEnv({ NODE_ENV: 'test', PUBLIC_MODE: 'ONLINE', API_KEY: 'k', ALLOWED_HOSTS: hosts }); + +describe('hostAllowed', () => { + it('answers a local editor only by loopback names and ALLOWED_HOSTS', () => { + for (const host of ['localhost:3000', '127.0.0.1:4790', '[::1]:3000', 'editor.localhost']) + expect(hostAllowed(offline, host)).toBe(true); + expect(hostAllowed(offline, 'editor.lan:3000')).toBe(true); + expect(hostAllowed(offline, 'evil.example:3000')).toBe(false); + expect(hostAllowed(offline, null)).toBe(false); + }); + + it('checks a hosted editor when ALLOWED_HOSTS is set', () => { + expect(hostAllowed(online(), 'editor.nanoforge.eu')).toBe(true); + expect(hostAllowed(online('editor.nanoforge.eu'), 'editor.nanoforge.eu')).toBe(true); + expect(hostAllowed(online('editor.nanoforge.eu'), 'evil.example')).toBe(false); + }); +}); + +describe('originAllowed', () => { + const request = (headers: Record) => + new Request('http://localhost:3000/files/p/a.png', { headers }); + + it('lets same-origin requests, navigations and non-browser clients through', () => { + expect( + originAllowed(offline, request({ origin: 'http://localhost:3000', host: 'localhost:3000' })), + ).toBe(true); + expect(originAllowed(offline, request({}))).toBe(true); + expect( + originAllowed( + offline, + request({ 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }), + ), + ).toBe(true); + }); + + it('refuses other origins and cross-site loads', () => { + expect(originAllowed(offline, request({ origin: 'https://evil.example' }))).toBe(false); + expect(originAllowed(offline, request({ origin: 'null' }))).toBe(false); + expect( + originAllowed( + offline, + request({ 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'no-cors' }), + ), + ).toBe(false); + }); +}); diff --git a/packages/server-core/test/session/session-store.spec.ts b/packages/server-core/test/session/session-store.spec.ts new file mode 100644 index 00000000..5fac4d6f --- /dev/null +++ b/packages/server-core/test/session/session-store.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiClient } from '../../src/api/api-client'; +import { loadEnv } from '../../src/env/load-env'; +import { serializeCookie, sign } from '../../src/session/cookies'; +import { SESSION_COOKIE, type Session, SessionStore } from '../../src/session/session-store'; +import { createMockApi } from '../mocks/nanoforge-api'; + +const env = loadEnv({ + NODE_ENV: 'test', + PUBLIC_MODE: 'ONLINE', + API_KEY: 'test-key', + API_URL: 'https://api.test', + SESSION_SECRET: 'secret', +}); + +const setup = (maxSessions?: number) => { + const mock = createMockApi(); + const api = new ApiClient(env, mock.fetch); + const store = new SessionStore(env, { + verify: (session) => api.currentUser(session), + ...(maxSessions && { maxSessions }), + }); + return { mock, api, store }; +}; + +const cookieOf = (setCookies: string[]) => setCookies[0]!.split(';')[0]!; +const forgedJwt = `x.${Buffer.from(JSON.stringify({ sub: 'user-1', name: 'Ada' })).toString('base64url')}.x`; + +describe('SessionStore', () => { + it('signs in only with a token the API accepts', async () => { + const { store, mock } = setup(); + const forged = await store.resolve(`accessToken=${forgedJwt}`); + expect(forged.session.user).toBeNull(); + + const { session, setCookies } = await store.resolve( + 'accessToken=access-1; refreshToken=refresh-1', + ); + expect(session.user).toEqual({ id: 'user-1', name: 'ada', email: 'ada@nanoforge.test' }); + const calls = mock.calls.length; + await store.resolve(`${cookieOf(setCookies)}; accessToken=access-1; refreshToken=refresh-1`); + expect(mock.calls.length).toBe(calls); + }); + + it('refreshes once for parallel expired requests', async () => { + const { store, api, mock } = setup(); + const { session } = await store.resolve('accessToken=access-1; refreshToken=refresh-1'); + mock.expireAccessToken(); + await Promise.all([api.gatewayProjects(session), api.gatewayProjects(session)]); + expect(mock.calls.filter((call) => call === 'POST /auth/refresh-token')).toHaveLength(1); + expect(session.user?.id).toBe('user-1'); + }); + + it('keeps no session for requests that do not need one, and caps the others', async () => { + const { store } = setup(2); + const dropped: Session[] = []; + store.onDidDrop((session) => dropped.push(session)); + const throwaway = await store.resolve(null, { create: false }); + expect(throwaway.setCookies).toEqual([]); + const first = await store.resolve(null); + await store.resolve(null); + await store.resolve(null); + expect(dropped).toEqual([first.session]); + const again = await store.resolve( + serializeCookie(SESSION_COOKIE, sign(first.session.id, env.sessionSecret)), + ); + expect(again.session.id).not.toBe(first.session.id); + }); +}); diff --git a/packages/server-core/test/settings/account-backends.spec.ts b/packages/server-core/test/settings/account-backends.spec.ts index befe8ec8..b426271e 100644 --- a/packages/server-core/test/settings/account-backends.spec.ts +++ b/packages/server-core/test/settings/account-backends.spec.ts @@ -16,6 +16,7 @@ const session = (tokens: Session['tokens'] = null): Session => ({ tokens, projects: new Set(), lastSeen: 0, + sockets: 0, }); describe('FileAccountBackend', () => { diff --git a/packages/server-core/test/util/glob.spec.ts b/packages/server-core/test/util/glob.spec.ts new file mode 100644 index 00000000..e2d4b4fc --- /dev/null +++ b/packages/server-core/test/util/glob.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { matchSegment } from '../../src/util/glob'; + +describe('matchSegment', () => { + it('matches * as any run of characters', () => { + expect(matchSegment('*', 'client')).toBe(true); + expect(matchSegment('app-*', 'app-client')).toBe(true); + expect(matchSegment('*-lib', 'app-client')).toBe(false); + expect(matchSegment('a*b*c', 'axxbyyc')).toBe(true); + expect(matchSegment('a.b', 'aXb')).toBe(false); + }); + + it('stays linear on patterns that make a regex backtrack', () => { + const start = performance.now(); + expect(matchSegment(`${'a*'.repeat(30)}b`, 'a'.repeat(5000))).toBe(false); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); diff --git a/packages/settings/src/account-sync/account-sync-store.ts b/packages/settings/src/account-sync/account-sync-store.ts index c495bc2c..86273246 100644 --- a/packages/settings/src/account-sync/account-sync-store.ts +++ b/packages/settings/src/account-sync/account-sync-store.ts @@ -47,6 +47,8 @@ export class AccountSyncStore implements ScopeStore { private _timer: ReturnType | undefined; private _attempt = 0; private _disposed = false; + /** Bumped by every local write: a pull or a push that saw an older one must not drop it. */ + private _edits = 0; constructor( private readonly _remote: AccountRemote, @@ -83,6 +85,7 @@ export class AccountSyncStore implements ScopeStore { } async write(patch: SettingValues): Promise { + this._edits++; this._state = { ...this._state, local: applyPatch(this._state.local, patch), dirty: true }; this._values.set(this._state.local); await this._persist(); @@ -139,15 +142,36 @@ export class AccountSyncStore implements ScopeStore { } private async _pull(): Promise { + const edits = this._edits; const remote = await this._remote.get(); if (remote.revision === this._state.base.revision) return; + if (edits !== this._edits) { + this._rebase(remote); + } else { + this._state = { + base: remote, + local: { ...remote.values, ...this._localOnly(this._state.local) }, + dirty: false, + }; + } + this._values.set(this._state.local); + await this._persist(); + } + + /** + * Moves local edits on top of a newer remote document (three-way merge from the last common + * base); keys both sides changed become conflicts. The result still has to be pushed. + */ + private _rebase(remote: AccountDocument): void { + const local = this._synced(this._state.local); + const { values, conflicts } = merge3(this._state.base.values, local, remote.values); this._state = { base: remote, - local: { ...remote.values, ...this._localOnly(this._state.local) }, - dirty: false, + local: { ...values, ...this._localOnly(this._state.local) }, + dirty: true, }; - this._values.set(this._state.local); - await this._persist(); + if (conflicts.length) this._conflicts.set([...this._conflicts.get(), ...conflicts]); + this._rerun = true; } private async _push(attempt = 0): Promise { @@ -161,14 +185,9 @@ export class AccountSyncStore implements ScopeStore { } catch (error) { if (errorCode(error) !== 'CONFLICT' || attempt >= 3) throw error; const remote = (error as { data?: AccountDocument }).data ?? (await this._remote.get()); - const { values, conflicts } = merge3(this._state.base.values, sent, remote.values); - this._state = { - base: remote, - local: { ...values, ...this._localOnly(this._state.local) }, - dirty: true, - }; + this._rebase(remote); + this._rerun = false; this._values.set(this._state.local); - if (conflicts.length) this._conflicts.set([...this._conflicts.get(), ...conflicts]); await this._persist(); await this._push(attempt + 1); } diff --git a/packages/settings/src/definition/core-settings.ts b/packages/settings/src/definition/core-settings.ts index eed3d9b9..86b73c3f 100644 --- a/packages/settings/src/definition/core-settings.ts +++ b/packages/settings/src/definition/core-settings.ts @@ -101,4 +101,15 @@ export const CoreSettings = { category: 'Editor/Plugins', scopes: ['account'], }), + pluginsTrustedProjects: defineSetting({ + key: 'plugins.trustedProjects', + schema: z.array(z.string()), + default: [], + title: 'Projects allowed to run their plugins', + description: + 'On a hosted editor, the plugins a project keeps in .nanoforge/plugins run only in the projects listed here (by id). Anyone who can change the project can change its plugins.', + category: 'Editor/Plugins', + scopes: ['account'], + mergeStrategy: 'union', + }), } as const; diff --git a/packages/settings/src/service/settings-service.ts b/packages/settings/src/service/settings-service.ts index 793d38cb..84b576e6 100644 --- a/packages/settings/src/service/settings-service.ts +++ b/packages/settings/src/service/settings-service.ts @@ -40,10 +40,14 @@ export class SettingsService implements Disposable { this._store.add(registry.onDidChange((keys) => this._onDidChange.fire({ keys }))); } - /** Attaches the store of a scope (e.g. project stores when a project opens). */ + /** + * Attaches the store of a scope (e.g. project stores when a project opens). The scope is + * checked again once the store is initialized: another store may have been attached meanwhile. + */ async setStore(scope: WritableScope, store: ScopeStore): Promise { if (this._stores.has(scope)) throw new SettingsError(`A ${scope} store is already attached`); await store.initialize(); + if (this._stores.has(scope)) throw new SettingsError(`A ${scope} store is already attached`); this._stores.set(scope, store); this._snapshots.set(scope, {}); const subscription = store.values.subscribe((values) => this._storeChanged(scope, values)); diff --git a/packages/settings/src/setup/follow-current-project.ts b/packages/settings/src/setup/follow-current-project.ts index 06914253..b20a11c1 100644 --- a/packages/settings/src/setup/follow-current-project.ts +++ b/packages/settings/src/setup/follow-current-project.ts @@ -8,22 +8,35 @@ import type { ClientProject } from '@nanoforge-dev/editor-project'; import type { SettingsSetup } from './setup-settings'; -/** Keeps the project scopes attached to the current project of a window. */ +/** + * Keeps the project scopes attached to the current project of a window. Attachments run one + * after the other: a fast switch never has two projects' stores attaching at once. When + * attaching fails, `onError` hears of it and the project goes on with the other scopes. + */ export const followCurrentProject = ( setup: SettingsSetup, current: { subscribe(run: (project: ClientProject | undefined) => void): () => void }, + onError?: (error: unknown, project: ClientProject) => void, ): Disposable & { readonly attached: Observable } => { const attached = new MutableDisposable(); const attachedId = new ObservableValue(undefined); let generation = 0; + let queue = Promise.resolve(); const unsubscribe = current.subscribe((project) => { const token = ++generation; attached.clear(); attachedId.set(undefined); if (!project) return; - void setup.attachProject(project).then((disposable) => { - if (token !== generation) return disposable.dispose(); - attached.value = disposable; + queue = queue.then(async () => { + if (token !== generation) return; + try { + const disposable = await setup.attachProject(project); + if (token !== generation) return disposable.dispose(); + attached.value = disposable; + } catch (error) { + onError?.(error, project); + if (token !== generation) return; + } attachedId.set(project.id); }); }); diff --git a/packages/settings/src/setup/setup-settings.ts b/packages/settings/src/setup/setup-settings.ts index 459dfee1..3e4a7762 100644 --- a/packages/settings/src/setup/setup-settings.ts +++ b/packages/settings/src/setup/setup-settings.ts @@ -90,8 +90,13 @@ export const setupSettings = async (options: SettingsSetupOptions): Promise { store.dispose(); }); + it('keeps a write made while a pull is in flight', async () => { + const remote = new FakeRemote(); + const store = create(remote); + await store.initialize(); + remote.remoteEdit({ b: 2 }); + let release!: () => void; + const get = remote.get; + remote.get = async () => { + const document = await get(); + await new Promise((resolve) => (release = resolve)); + return document; + }; + const pulling = store.sync(); + await flush(); + await store.write({ a: 1 }); + release(); + await pulling; + remote.get = get; + expect(store.values.get()).toEqual({ a: 1, b: 2 }); + await store.sync(); + expect(remote.document.values).toEqual({ a: 1, b: 2 }); + store.dispose(); + }); + + it('merges a 409 with what was written while the push was in flight', async () => { + const remote = new FakeRemote(); + const store = create(remote); + await store.initialize(); + remote.remoteEdit({ b: 2 }); + const put = remote.put; + remote.put = async (base, values) => { + await store.write({ c: 3 }); + return put(base, values); + }; + await store.write({ a: 1 }); + await store.sync(); + remote.put = put; + await store.sync(); + expect(remote.document.values).toEqual({ a: 1, b: 2, c: 3 }); + store.dispose(); + }); + it('works as the account scope of the settings service', async () => { const remote = new FakeRemote(); const registry = new SettingsRegistry(); diff --git a/packages/settings/test/service/settings-service.spec.ts b/packages/settings/test/service/settings-service.spec.ts index 0efc4793..ff30e410 100644 --- a/packages/settings/test/service/settings-service.spec.ts +++ b/packages/settings/test/service/settings-service.spec.ts @@ -21,6 +21,24 @@ const setup = async () => { }; describe('SettingsService', () => { + it('refuses a store that finishes initializing after another one was attached', async () => { + const registry = new SettingsRegistry(); + registry.register(fontSize); + const service = new SettingsService(registry); + const slow = new MemoryScopeStore({ 'editor.fontSize': 11 }); + let release!: () => void; + const initialize = slow.initialize.bind(slow); + slow.initialize = async () => { + await new Promise((resolve) => (release = resolve)); + await initialize(); + }; + const first = service.setStore('project', slow); + await service.setStore('project', new MemoryScopeStore({ 'editor.fontSize': 12 })); + release(); + await expect(first).rejects.toThrow(/already attached/); + expect(service.get(fontSize)).toBe(12); + }); + it('applies scope precedence', async () => { const { service } = await setup(); expect(service.get(fontSize)).toBe(13); diff --git a/plugins/ecs/src/library/relocate-item.ts b/plugins/ecs/src/library/relocate-item.ts index a8722c3e..5381ef0f 100644 --- a/plugins/ecs/src/library/relocate-item.ts +++ b/plugins/ecs/src/library/relocate-item.ts @@ -4,6 +4,7 @@ import { type HistoryCommand, type PluginContext, ProjectServiceToken, + compositeCommand, } from '@nanoforge-dev/editor-sdk'; import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; @@ -75,24 +76,33 @@ export const relocateItem = async ( } const original = await documents.getText(from); const fs = project.fs; - const command: HistoryCommand = { - label: move ? `${title} to ${target.name}` : `${title} into ${target.name}`, - children: edits, + const textOf = (path: string) => documents.getText(path).catch(() => undefined); + let applied = false; + const file: HistoryCommand = { + label: `${move ? 'Move' : 'Copy'} ${from}`, do: async () => { if (move) await fs.rename(from, to); await fs.write(to, relocated.text); - for (const edit of edits) await edit.do(); + applied = true; }, undo: async () => { - for (const edit of [...edits].reverse()) await edit.undo(); if (move) { await fs.write(to, original); await fs.rename(to, from); } else { await fs.delete(to); } + applied = false; + }, + isValid: async () => { + if (applied) return (await textOf(to)) === relocated.text; + return move ? (await textOf(from)) === original : (await textOf(to)) === undefined; }, }; + const command = compositeCommand( + move ? `${title} to ${target.name}` : `${title} into ${target.name}`, + [file, ...edits], + ); try { await refactorsHistory(context).push(command); return true; diff --git a/plugins/scene/src/service/scene-steps.ts b/plugins/scene/src/service/scene-steps.ts index ea6a69a4..1fad12fb 100644 --- a/plugins/scene/src/service/scene-steps.ts +++ b/plugins/scene/src/service/scene-steps.ts @@ -4,6 +4,7 @@ import { type HistoryCommand, HistoryServiceToken, type PluginContext, + compositeCommand, } from '@nanoforge-dev/editor-sdk'; import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; @@ -33,19 +34,12 @@ export class SceneSteps { const command = await code.edit(path, transformer, op, { label: 'Scenes' }); return command ? saved(command, () => documents.save(path)) : undefined; } - /** Commands run in order, undone in reverse. */ + /** + * Commands run in order, undone in reverse; valid while each of them is (an edit by hand in + * between drops the step instead of undoing at the wrong place). + */ group(label: string, children: readonly HistoryCommand[]): HistoryCommand | undefined { - if (!children.length) return undefined; - return { - label, - children, - do: async () => { - for (const child of children) await child.do(); - }, - undo: async () => { - for (const child of [...children].reverse()) await child.undo(); - }, - }; + return children.length ? compositeCommand(label, children) : undefined; } /** * Pushes one undo step in the Scenes history, made of steps computed and run one after the @@ -70,6 +64,10 @@ export class SceneSteps { undo: async () => { for (const step of [...done].reverse()) await step.undo(); }, + isValid: async () => { + for (const step of done) if ((await step.isValid?.()) === false) return false; + return true; + }, }; try { const history = this.history(); diff --git a/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte b/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte index a8ed28db..6d0ccd9b 100644 --- a/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte +++ b/plugins/settings-ui/src/dialogs/settings/SettingsDialog.svelte @@ -96,7 +96,10 @@ const categories = categoryTree( host ? definitions.filter( - (definition) => definition !== pluginsDisabled && definition.key !== 'plugins.account', + (definition) => + definition !== pluginsDisabled && + definition.key !== 'plugins.account' && + definition.key !== 'plugins.trustedProjects', ) : definitions, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d8b8d79..41811609 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -510,12 +510,6 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: - '@nanoforge-dev/editor-project': - specifier: workspace:* - version: link:../project - '@nanoforge-dev/editor-server-core': - specifier: workspace:* - version: link:../server-core typescript: specifier: catalog:build version: 6.0.3 diff --git a/turbo.json b/turbo.json index 59ace577..a03cf1bc 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,8 @@ "inputs": [ "src/**", "static/**", + "server/**", + "scripts/**", "package.json", "tsconfig.json", "*.config.{js,ts}", From 49a7748e486309b34c1503408f391d9b51d0fc22 Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 16 Oct 2026 16:43:30 +0200 Subject: [PATCH 3/8] chore: add test setup, e2e tsconfig, rewrite plan and v2 changelog --- CHANGELOG-v2.md | 126 ++ CLAUDE.md | 34 +- README.md | 37 +- apps/editor/e2e/prepare-workspace.mjs | 12 +- apps/editor/e2e/tsconfig.json | 6 + apps/editor/e2e/workbench.spec.ts | 6 +- apps/editor/e2e/workspace.ts | 2 +- apps/editor/package.json | 5 +- apps/editor/playwright.config.ts | 4 +- docs/rewrite-plan.md | 1113 +++++++++++++++++ eslint.config.js | 105 +- package.json | 2 +- packages/code/package.json | 2 +- .../code/test/service/code-service.spec.ts | 6 +- packages/history/package.json | 2 +- packages/history/src/stack/history-stack.ts | 3 +- .../history/test/service/undo-target.spec.ts | 3 +- .../history/test/stack/history-stack.spec.ts | 3 +- packages/kernel/package.json | 2 +- .../kernel/src/command/command-registry.ts | 3 +- packages/kernel/src/plugin/resolve-plugins.ts | 3 +- .../test/context/context-key-service.spec.ts | 3 +- packages/kernel/test/di/container.spec.ts | 3 +- .../kernel/test/observable/observable.spec.ts | 6 +- packages/layout/package.json | 2 +- packages/meta/package.json | 2 +- packages/project/package.json | 5 +- packages/project/test/fs/project-fs.spec.ts | 3 +- packages/protocol/package.json | 2 +- packages/registry/package.json | 2 +- packages/rpc/package.json | 2 +- packages/rpc/src/router/rpc-router.ts | 41 +- packages/rpc/test/rpc.spec.ts | 31 +- packages/runtime/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server-core/package.json | 2 +- packages/server-core/src/project/discovery.ts | 3 +- .../server-core/test/rpc/registry.spec.ts | 2 + .../test/server/create-editor-server.spec.ts | 3 +- packages/settings/package.json | 2 +- .../account-sync/account-sync-store.spec.ts | 9 +- packages/settings/test/end-to-end.spec.ts | 3 +- packages/settings/test/merge/merge3.spec.ts | 6 +- .../test/service/settings-service.spec.ts | 3 +- .../test/store/json-file.scope-store.spec.ts | 3 +- packages/ui/package.json | 2 +- packages/ui/test/kit.svelte.spec.ts | 6 +- .../workbench/keybinding/keybindings.spec.ts | 6 +- packages/ui/vitest.config.ts | 2 + plugins/code-editor/package.json | 2 +- .../src/widgets/script/EditorGroupView.svelte | 2 +- plugins/command-palette/package.json | 2 +- plugins/console/package.json | 2 +- plugins/ecs/package.json | 3 +- .../widgets/inspector/ComponentCard.svelte | 154 +-- plugins/ecs/test/scene/build-scene.spec.ts | 1 - plugins/ecs/test/worker/e2e-game.spec.ts | 1 - plugins/ecs/test/worker/entry-file.spec.ts | 1 - plugins/ecs/test/worker/item-docs.spec.ts | 1 - plugins/ecs/test/worker/refactor.spec.ts | 1 - plugins/file-manager/package.json | 2 +- plugins/git/package.json | 2 +- plugins/history-panel/package.json | 2 +- plugins/inspectors/package.json | 2 +- plugins/packages/package.json | 2 +- plugins/scene/package.json | 2 +- plugins/scene/test/worker/edits.spec.ts | 1 - plugins/scene/test/worker/scenes.spec.ts | 1 - plugins/scene/test/worker/vars.spec.ts | 1 - plugins/settings-ui/package.json | 2 +- plugins/viewport/package.json | 2 +- pnpm-lock.yaml | 9 - scripts/engine-bridge.mjs | 9 +- scripts/example.mjs | 24 +- tooling/example-plugin/package.json | 2 +- tooling/vite-plugin/package.json | 2 +- turbo.json | 17 +- vitest.config.ts | 3 + vitest.global-setup.ts | 20 + vitest.setup.ts | 28 + 80 files changed, 1694 insertions(+), 246 deletions(-) create mode 100644 CHANGELOG-v2.md create mode 100644 apps/editor/e2e/tsconfig.json create mode 100644 docs/rewrite-plan.md create mode 100644 vitest.global-setup.ts create mode 100644 vitest.setup.ts diff --git a/CHANGELOG-v2.md b/CHANGELOG-v2.md new file mode 100644 index 00000000..4fecedf7 --- /dev/null +++ b/CHANGELOG-v2.md @@ -0,0 +1,126 @@ +# NanoForge Editor 1.0.0 + +`@nanoforge-dev/editor@1.0.0` and `@nanoforge-dev/editor-sdk@1.0.0`. + +The editor was rewritten from scratch. It keeps what the previous editor did (edit a NanoForge game in the browser, hosted or with `nf editor`) on a new base: everything visible is a plugin, the game's code is the only source of truth, and every change can be undone. + +This file describes the release. Nothing is published yet: the package versions, the tag and the publication are the release steps still to do. + +## What changes for you + +### The game is its code + +- The editor no longer keeps a save file of the scene (`.nanoforge/*.save.json`). Entities, components and systems are read from the app's `main.ts`, and written back to it when you change them in the panels. +- Code the editor cannot model (loops, helpers, conditions) is left untouched and shown read-only, with a link to its lines. +- Editing the code by hand and editing in the panels are the same thing: each follows the other. + +### A workbench you arrange + +- Four screens (Project, Scene, Script, Game) and panels in five docks. Drag tabs between docks, float them, close them, save layouts by name. +- A command palette (`Ctrl+Shift+P`) that also opens files (`Ctrl+P`), goes to a line (`Ctrl+G`) and to a symbol (`Ctrl+Shift+O`). +- Keyboard shortcuts you can change, with chords, conflict warnings, and the presets _VS Code_ and _Godot_. +- Light and dark themes. + +### Undo everywhere + +- Each area has its own history: each file, the Files panel, the layout, plugins. `Ctrl+Z` acts on what has focus. +- A History panel lists the steps, shows what each one changed, and jumps to any of them. +- File moves, renames and deletions can be undone. So can changes made from the panels to your code. + +### Files + +- A tree and a grid with image thumbnails; create from templates, rename, move, copy, duplicate, import by drop, download a folder as a `.zip`. +- `.nfignore` hides files from the whole editor. + +### Code editor + +- Monaco with the project's and the engine's types, go to definition into engine packages, rename, and errors for every file of the project in a Problems panel. +- Tabs and a split view. Unsaved changes survive a reload. +- Formatting with the project's own Prettier; save on delay or on focus loss; format and organize imports on save. +- A banner when a file with unsaved changes was modified on disk, with a side-by-side comparison. + +### Entities, components and systems + +- A Hierarchy and an Inspector per app (client, server), and a 2D Scene screen where entities are selected and dragged. +- A Components panel listing what an app can use: its own, shared libraries, installed packages. Create components and systems from templates. +- **Shared libraries**: code used by both the client and the server, imported by package name; move a component into one with its imports rewritten. +- The **Project screen** manages the project: add, rename and remove client apps, server apps and shared libraries, and choose which apps use each library. +- The author of a component chooses how it shows in the inspector (groups, presets such as vectors and colors, labels, hidden parameters) with documentation tags, or with the Component panel beside the code. +- Systems of an app listed in run order, reordered by drag. + +### Play + +- Play, pause, step, restart and stop, for the client, the server or both; a second client in another window. +- A Game screen with resolutions and device presets, zoom, a stats overlay, mute, screenshots, and a pop-out window. +- **Live mode**: while the game runs, the Hierarchy and the Inspector show the running world. Change values, add components, spawn entities, switch systems off; _Apply to code_ writes what you want to keep. Move entities by dragging them in the Scene screen, or over the running game with **Move entities** in the Game screen. +- A Console for the game, builds and the editor: logged objects expand, file paths are links, and game lines show where they were logged, through source maps. +- A Profiler (tick time, time per library and per system), a Network panel (packets, decoded payloads, throughput) and a World panel (the running entities, with a search). + +### Git + +- A VCS menu, a Commit panel (changes, unversioned files, rollback, amend, stash) and a Git panel (branches, commits, changed files), after IntelliJ's. +- Push with a preview of the commits, update, fetch, branches; file status marks in the Files panel; the branch in the status bar. +- Local editors only. + +### Settings + +- One dialog with search. Each setting can be saved to your account, this machine, the project (committed) or the project for you only. +- Import and export; the project's settings as a JSON file with completion. +- Account settings sync between devices once the API offers it (see below). + +### Plugins and packages + +- **Plugins** extend the editor. Install them from _Settings › Plugins_, for yourself or for a project. +- **Packages** bring components, systems and assets to a game. Install them from _File › Packages…_; they go to `nf_modules`, with a list and a lock file in the project. +- Both read a registry that the API does not provide yet: until then the marketplace says it is not available (a folder can stand in for it). + +### Scenes + +For games built on the engine's scene module (`@nanoforge-dev/scene`, engine branch `feat/scene`), a new built-in plugin: + +- the **Scenes** panel: the scene tree by parent, new, rename, move by drag and drop, set as initial, delete; selecting a scene makes the Hierarchy, Inspector, Systems and Scene screen edit its `setup`, with its parents' entities and systems read-only; +- the **Vars** panel: the scene vars declared in `scene-vars.ts`, their uses and the scenes that make them; add, rename (every key follows), edit, remove, declare a key used in code; +- while playing: the loaded scenes, _Load scene_ (with its params) and _Unload scene_, live var values, and the live entities grouped by scene. + +The `examples/breakout` project is a game made of scenes (`pnpm example breakout`). + +## For plugin authors + +- The ECS plugin's `ecs.sources` extension point: what its widgets edit for an app (a file and the function holding the spawns), and read-only locations shown with it. +- `DocumentService.save(uri)`, and `PromptOptions.optional`. + +- `@nanoforge-dev/editor-sdk` is the only package a plugin imports: services, commands, settings, history, code analysis, the runtime, and the editor's own components and theme. +- `nf create plugin --name @scope/name` writes a working plugin; `DEV_PLUGINS` loads it in the editor with hot reload. +- Extension points for panels, screens, menus, shortcuts, the status bar and toolbar, settings pages, themes, file templates, file actions, document editors, code generation targets, scene editors, game overlays, and inspector fields. +- A plugin can run code in the code worker (analyzers, transformers, item owners) and in the editor's server. +- The guide is in `docs/docs/plugins`, and a reference plugin in `examples/counter-plugin`. + +## Breaking changes + +- **Projects saved by the previous editor** as `.nanoforge/*.save.json` are not read. Their entities must be in `main.ts`. No importer is provided. +- **The previous editor's plugins and settings** do not carry over. +- **Engine**: play controls, stats, live mode and the inspectors need NanoForge engine 2.0 or later, and the game registers its editor library (`app.use(new EditorLibrary())`, from `@nanoforge-dev/editor-lib`). The editor offers to add it. A game without it still plays, without them. +- **`nf editor `** opens a project directly; without a path it lists recent projects. +- **Hosted editors** never build or run project code, and do not install plugins or packages. + +## Operating a hosted editor + +- `API_FEATURES` lists what the API offers (`settings`, `registry`). It is empty by default: account settings then stay on the editor's disk, and no registry is called. +- A hosted editor needs a persistent data volume (settings, and per-user project settings). +- What the editor asks of the API is in `docs/api`, with contract tests the API team can run. + +## Performance + +- The editor's page script went from 7 MB to 65 KB: the TypeScript compiler now loads only in the code worker. +- The code worker is ready about half a second after it is first needed on the pong-network example. +- Plugins load when first used; the code editor (Monaco) only when a file is opened. + +Measurements and how to reproduce them: `docs/performance.md`. + +## Known limits + +- The registry of plugins and packages, account settings sync, and the list of plugins following you between machines wait for the API (`docs/api`). +- Dragging entities inside the Game screen is not built (drag them in the Scene screen). +- The Git graph is a straight line per branch; no rebase, tags, cherry-pick or staging of part of a file. +- No debugger or breakpoints. +- The SDK and the plugin build tool are not on npm yet: plugin projects link them from the editor repository. diff --git a/CLAUDE.md b/CLAUDE.md index f7a46a57..1fbec2ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ The NanoForge editor, rewritten from scratch. The full plan, with status per pha ## Working rules - **Where to work.** - - This is the worktree `G-EIP-600/editor-v2`, branch `rewrite/v2`. + - This is the worktree `G-EIP-600/editor-v2`. Its branch changes with the work (`git branch --show-current`): start a branch of your own for new work rather than committing on the user's. - Never commit on the user's own branches or checkouts (`G-EIP-600/editor`, `engine`, `loader`, `cli`): use a worktree. - Never push or publish. That is the user's call. - **Phases.** @@ -50,17 +50,18 @@ pnpm dev # editor + built-in plugins in watch mode (hot reload) # E2E: against the production build. Build first, and rebuild after every change. cd apps/editor CHROME=/usr/bin/google-chrome-stable \ -NANOFORGE_ENGINE=/home/exelo/workspace/epitech/G6/G-EIP-600/engine-editor-bridge \ -NANOFORGE_CLI=/home/exelo/workspace/epitech/G6/G-EIP-600/cli-editor \ +NANOFORGE_ENGINE=/home/exelo/workspace/epitech/G6/G-EIP-600/engine \ +NANOFORGE_CLI=/home/exelo/workspace/epitech/G6/G-EIP-600/cli \ npx playwright test [spec…] --reporter=line ``` -- **Run e2e with `E2E_WORKSPACE=.workspace-agent`** (any folder name under `e2e/`). The default `e2e/.workspace` is wiped at each run, and the user's dev server (`FS_ROOT=apps/editor`) may have its projects open. Specs take their paths from `e2e/workspace.ts`. +- **Run e2e with `E2E_WORKSPACE=.workspace-agent`** (`.workspace` or `.workspace-`: anything else is refused, since the folder is wiped). The default `e2e/.workspace` is wiped at each run, and the user's dev server (`FS_ROOT=apps/editor`) may have its projects open. Specs take their paths from `e2e/workspace.ts`. - Without `NANOFORGE_ENGINE`, the tests that play the game (runtime, the viewport's "while playing", some code-editor tests) are **skipped**, not failed. Check the skipped count. - `NANOFORGE_CLI` (a built CLI checkout) is linked into the played game as its own CLI. Without it, the console test skips its source-map check (the game's published CLI writes no source maps). - Contract tests (`test/contract` in `server-core` and `registry`) run in `pnpm test` against the stand-ins, and against a real API with `CONTRACT_*` variables (`docs/api/README.md`). Never run them against a real API yourself. - `ANALYZE=1 npx vite build` (in `apps/editor`) prints the bundle analysis; `BENCH=1 npx playwright test` runs the startup measurements of `e2e/*.bench.ts` instead of the tests (`docs/performance.md`). - `E2E_EXTRA_PLUGINS=` adds a dev plugin to the e2e editor. +- Unit tests fail on an unexpected `console.error` (`vitest.setup.ts`): a test that expects one spies on it (`vi.spyOn(console, 'error').mockImplementation(() => {})`). - SDK public API snapshot: after adding SDK exports, run `cd packages/sdk && npx vitest run -u`, and check that the diff shows only what you added. - E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error. Known noise goes in its `IGNORED` list. - E2E specs open projects with `openProject(page, path)` (`e2e/helpers.ts`): `/load?path=` asks before opening a folder that is not an exact recent path, and a relative path always asks. @@ -102,11 +103,9 @@ npx playwright test [spec…] --reporter=line - `scene` (the Scenes and Vars panels, for apps with `@nanoforge-dev/scene`; spec `docs/specs/scene.md`). It plugs into the ECS widgets through `ecs.sources`: they edit the selected scene's `setup` instead of `main` - **`tooling/`**: - `vite-plugin`: builds plugins; - - `example-plugin`: `@acme/hello`, the dev plugin loaded by the e2e tests; - - `spike-plugin`. -- **`examples/pong-game`**: a game project to try the editor on. `pnpm example` (`scripts/example.mjs`) links its engine libraries from `NANOFORGE_ENGINE` (default `../engine-editor-bridge`) and starts the built editor on port 4800 with `FS_ROOT=examples`. It has a shared library (`libs/shared`, `@pong-game/shared`). The ECS scene test has its own trimmed copy (`plugins/ecs/test/fixtures/pong-game`). -- **`examples/breakout`**: a client-only game made of scenes (`@nanoforge-dev/scene`, engine branch `feat/scene`): `NANOFORGE_ENGINE=../engine-scene pnpm example breakout`. Its entities and systems all belong to scenes (`GameScene`), so the Scene screen and Hierarchy show none: use Play and the World panel. -- **`examples/counter-plugin`**: a standalone plugin, outside the workspace, as `nf create plugin` writes it (the CLI command is in `../cli-editor`). + - `example-plugin`: `@acme/hello`, the dev plugin loaded by the e2e tests. +- **`examples/breakout`**: a client-only game made of scenes (`@nanoforge-dev/scene`, engine `modules/scene`), the default of `pnpm example` (`scripts/example.mjs`): it links the engine libraries from `NANOFORGE_ENGINE` (default `../engine`, shared with the e2e setup through `engineRoot`) and starts the built editor on port 4800 with `FS_ROOT=examples`. Its entities and systems all belong to scenes (`GameScene`), so the Scene screen and Hierarchy show none: use Play and the World panel. +- **`examples/counter-plugin`**: a standalone plugin, outside the workspace, as `nf create plugin` writes it (the CLI command is in `../cli`). - **`docs/docs`**: the user documentation and the plugin authoring guide (Mintlify pages, copied to the docs site). ### Manifests: packages and plugins (ADR 0003) @@ -160,14 +159,7 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - The engine's `EditorBridge` (`modules/editor/src/internal/editor-bridge.ts`) buffers the game's console output until `welcome`. The runtime service checks compatibility (legacy, older or newer engine). - **Logs:** everything shown in the Console goes through `LoggerService`. The source name gives the group: `game:client`, `game:server`, `build:`, `cli:`, a plugin's name, anything else is the editor. Logged values use the `LogValue` tree, made by kernel `serializeLogValue` only: the client runner hands it to the game (`RunOptions.editor.serializeLogValue`), and the engine calls it when a line is logged. The engine has no serializer of its own. - **Moving entities in the Game screen** (ECS plugin, `GameMoveOverlay.svelte`): the _Move entities_ tool (`ecs.moveInGame`) draws the scene shapes of the client's live entities over the game with the `viewport` mapping, and a drag is a live edit (`live.setField`), like the Scene's. graphics-2d's old drag (`DrawableCircle2D`… emitting `move-component`) was removed from the engine (`11f4c98`, breaking). -- **Unpushed branches in other repos:** - - | Repo | Branch | Worktree | - | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | - | engine | `feat/core-editor-bridge` (one commit on engine 2.0.0, rebased by the user 2026-10-02; old base in `backup/core-editor-bridge-pre-rebase`) | `../engine-editor-bridge` | - | loader | `feat/server-editor-ipc` | `../loader-editor` | - | cli | `feat/editor-open-project` (`nf editor [path] --port`, `nf create plugin`) | `../cli-editor` | - | engine | `feat/scene` (`modules/scene`, `@nanoforge-dev/scene`; from `feat/core-editor-bridge`) | `../engine-scene` | +- **Engine, loader and CLI:** the editor bridge (engine #445), the scene module (engine #447), the loader's editor IPC (loader #113) and `nf editor [path] --port` / `nf create plugin` (CLI #241, #242) are merged in their `main`. Use built checkouts of `../engine` and `../cli`. ## Conventions that matter @@ -179,7 +171,7 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - split a file past about 300 lines (a Svelte file's `