@vgai/editor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +35 -0
- package/dist/a/b/c/test-nested.json +1 -0
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
- package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
- package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
- package/dist/assets/browserAll-CEkWYlFk.js +1 -0
- package/dist/assets/collision-world-DniMB0qq.glb +0 -0
- package/dist/assets/create-runtime-DYIWr3va.js +1 -0
- package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
- package/dist/assets/esbuild-BHljloGq.wasm +0 -0
- package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
- package/dist/assets/game-8SDZPPgH.js +1 -0
- package/dist/assets/game-BI111o5f.js +10 -0
- package/dist/assets/game-CevorxyV.js +1 -0
- package/dist/assets/game-Di1H0gFj.js +1 -0
- package/dist/assets/game-Di7XlywD.js +1 -0
- package/dist/assets/game-DjnCaw8R.js +1 -0
- package/dist/assets/game-Q9A0aXSV.js +1 -0
- package/dist/assets/game-pt_1lQLv.js +1 -0
- package/dist/assets/game-source-CvidN7AB.js +1 -0
- package/dist/assets/game-xsOho_wg.js +83 -0
- package/dist/assets/index-Bp5cecD6.js +12 -0
- package/dist/assets/index-CPVV6pjw.js +1 -0
- package/dist/assets/index-CpOnYyeD.js +213 -0
- package/dist/assets/index-Cs8xwv3e.js +1 -0
- package/dist/assets/index-DL7RcQ0n.js +27 -0
- package/dist/assets/index-Dr8H_6Rl.js +1 -0
- package/dist/assets/index-_bCaipUk.js +13650 -0
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
- package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
- package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
- package/dist/assets/scene-query-BivAHhJu.js +1 -0
- package/dist/assets/stats.module--VATS4Kh.js +1 -0
- package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
- package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
- package/dist/index.html +37 -0
- package/dist/ingest/racing-game/UPSTREAM.md +68 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/index.css +1 -0
- package/dist/ingest/racing-game/images/bronze.png +0 -0
- package/dist/ingest/racing-game/images/gold.png +0 -0
- package/dist/ingest/racing-game/images/silver.png +0 -0
- package/dist/ingest/racing-game/index.html +15 -0
- package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
- package/dist/ingest/racing-game/models/track-draco.glb +0 -0
- package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
- package/dist/ingest/racing-game/rg-entry.js +1 -0
- package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
- package/dist/ingest/racing-game/textures/cursor.svg +3 -0
- package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
- package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
- package/dist/ingest/racing-game/textures/mask.svg +11 -0
- package/dist/ingest/racing-game/textures/no_sound.png +0 -0
- package/dist/ingest/racing-game/three-r139.module.js +50617 -0
- package/dist/ingest/racing-game/vendor.js +179 -0
- package/dist/ingest/racing-game/vgai.game.json +20 -0
- package/dist/ingest/simcity/UPSTREAM.md +65 -0
- package/dist/ingest/simcity/favicon.ico +0 -0
- package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
- package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
- package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
- package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
- package/dist/ingest/simcity/icons/bulldozer.png +0 -0
- package/dist/ingest/simcity/icons/calendar.png +0 -0
- package/dist/ingest/simcity/icons/factory-color.png +0 -0
- package/dist/ingest/simcity/icons/factory.png +0 -0
- package/dist/ingest/simcity/icons/house-color.png +0 -0
- package/dist/ingest/simcity/icons/house.png +0 -0
- package/dist/ingest/simcity/icons/job.png +0 -0
- package/dist/ingest/simcity/icons/office.png +0 -0
- package/dist/ingest/simcity/icons/pause-color.png +0 -0
- package/dist/ingest/simcity/icons/pause.png +0 -0
- package/dist/ingest/simcity/icons/person.png +0 -0
- package/dist/ingest/simcity/icons/play-color.png +0 -0
- package/dist/ingest/simcity/icons/play.png +0 -0
- package/dist/ingest/simcity/icons/power-color.png +0 -0
- package/dist/ingest/simcity/icons/power-line-color.png +0 -0
- package/dist/ingest/simcity/icons/power.png +0 -0
- package/dist/ingest/simcity/icons/road-color.png +0 -0
- package/dist/ingest/simcity/icons/road.png +0 -0
- package/dist/ingest/simcity/icons/select-color.png +0 -0
- package/dist/ingest/simcity/icons/select.png +0 -0
- package/dist/ingest/simcity/icons/store-color.png +0 -0
- package/dist/ingest/simcity/index.html +94 -0
- package/dist/ingest/simcity/index.js +58 -0
- package/dist/ingest/simcity/main.css +233 -0
- package/dist/ingest/simcity/models/armored-truck.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
- package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
- package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
- package/dist/ingest/simcity/models/building-cafe.glb +0 -0
- package/dist/ingest/simcity/models/building-casino.glb +0 -0
- package/dist/ingest/simcity/models/building-cinema.glb +0 -0
- package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
- package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
- package/dist/ingest/simcity/models/building-office-big.glb +0 -0
- package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
- package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
- package/dist/ingest/simcity/models/building-office.glb +0 -0
- package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
- package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
- package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
- package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
- package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger.glb +0 -0
- package/dist/ingest/simcity/models/car-police.glb +0 -0
- package/dist/ingest/simcity/models/car-taxi.glb +0 -0
- package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
- package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
- package/dist/ingest/simcity/models/car-veteran.glb +0 -0
- package/dist/ingest/simcity/models/construction-small.glb +0 -0
- package/dist/ingest/simcity/models/data-center.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory.glb +0 -0
- package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
- package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
- package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
- package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
- package/dist/ingest/simcity/models/truck.glb +0 -0
- package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
- package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
- package/dist/ingest/simcity/textures/base.png +0 -0
- package/dist/ingest/simcity/textures/grid.png +0 -0
- package/dist/ingest/simcity/textures/specular.png +0 -0
- package/dist/ingest/simcity/three-r155.module.js +51466 -0
- package/dist/ingest/simcity/vgai.game.json +20 -0
- package/dist/ingest/tanks/UPSTREAM.md +69 -0
- package/dist/ingest/tanks/index.html +214 -0
- package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
- package/dist/ingest/tanks/tanks-entry.js +92 -0
- package/dist/ingest/tanks/three-r170.module.js +54571 -0
- package/dist/ingest/tanks/vgai.game.json +20 -0
- package/dist/inputmaps/default.inputmap.json +41 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/dist/scenes/untitled.vscn.json +29 -0
- package/dist/textures/waternormals.jpg +0 -0
- package/dist-server/packaged.mjs +18155 -0
- package/package.json +36 -0
- package/server/asset-library-routes.ts +833 -0
- package/server/canonical-path.ts +50 -0
- package/server/cloud-asset-catalog.ts +101 -0
- package/server/dev.ts +350 -0
- package/server/editor-server.ts +1537 -0
- package/server/editor-sse.ts +73 -0
- package/server/game-globals-shadow.ts +58 -0
- package/server/local-asset-catalog.ts +163 -0
- package/server/packaged.ts +571 -0
- package/server/prod.ts +75 -0
- package/server/project-optimize-deps-entries.ts +149 -0
- package/server/project-view.ts +65 -0
- package/server/server-utils.ts +430 -0
- package/server/session-registry.ts +93 -0
- package/src/EditorContext.tsx +87 -0
- package/src/achieved-tier.ts +126 -0
- package/src/action-registry.ts +268 -0
- package/src/adapter-resolver.ts +1858 -0
- package/src/asset-events.ts +100 -0
- package/src/authoring/active-adapter.ts +98 -0
- package/src/authoring/active-systems.ts +50 -0
- package/src/authoring/boundary-authoring-adapter.ts +178 -0
- package/src/authoring/composite-authoring-adapter.ts +891 -0
- package/src/authoring/design-time-layers.ts +776 -0
- package/src/authoring/edit-mode-authoring.ts +643 -0
- package/src/authoring/embed-only-adapter.ts +57 -0
- package/src/authoring/ephemeral-persistence.ts +26 -0
- package/src/authoring/eyedropper-session.ts +69 -0
- package/src/authoring/first-party-hierarchy-menu.ts +74 -0
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
- package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
- package/src/authoring/first-party-inspector/register.tsx +108 -0
- package/src/authoring/ingest-authoring-adapter.ts +475 -0
- package/src/authoring/ingest-capture.ts +94 -0
- package/src/authoring/ingest-game-adapter.ts +291 -0
- package/src/authoring/ingest-iframe-adapter.ts +38 -0
- package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
- package/src/authoring/ingest-overlay.ts +193 -0
- package/src/authoring/layered-pick.ts +87 -0
- package/src/authoring/mount-failure-report.ts +112 -0
- package/src/authoring/overlay-report.ts +52 -0
- package/src/authoring/project-scenes.ts +111 -0
- package/src/authoring/react-dom-authoring-adapter.ts +795 -0
- package/src/authoring/react-inspector-section.tsx +1041 -0
- package/src/authoring/react-world-authoring-adapter.ts +2400 -0
- package/src/authoring/root-activation.ts +61 -0
- package/src/authoring/scene-ui-layer.ts +485 -0
- package/src/authoring/story-overlay.ts +48 -0
- package/src/authoring/ui-authoring-adapter.ts +325 -0
- package/src/authoring/ui-hierarchy-menu.ts +40 -0
- package/src/authoring/ui-inspector-sections.tsx +156 -0
- package/src/authoring/undo-timeline.ts +145 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
- package/src/authoring/viewport-pick-context.ts +31 -0
- package/src/authoring/viewport-raycast.ts +80 -0
- package/src/authoring/world-overlay.ts +76 -0
- package/src/authoring/world-pan-state.ts +115 -0
- package/src/authoring/world-session-state.ts +102 -0
- package/src/authoring/world2d-authoring-adapter.ts +313 -0
- package/src/browser-external-watch.ts +98 -0
- package/src/browser-play-entry.ts +112 -0
- package/src/browser-project-scripts.ts +112 -0
- package/src/browser-transpile.ts +246 -0
- package/src/browser-watch-reload.ts +100 -0
- package/src/command-listener.ts +326 -0
- package/src/components/AlignToolbar.tsx +189 -0
- package/src/components/AppRoot.tsx +73 -0
- package/src/components/AssetBrowser.tsx +502 -0
- package/src/components/BottomPanel.tsx +326 -0
- package/src/components/BuildPanel.tsx +455 -0
- package/src/components/CameraInfo.tsx +56 -0
- package/src/components/CommandPalette.tsx +265 -0
- package/src/components/ConsolePanel.tsx +272 -0
- package/src/components/DataPanel.tsx +1268 -0
- package/src/components/DefaultEditorLayout.tsx +509 -0
- package/src/components/FolderTree.tsx +179 -0
- package/src/components/GameHierarchy.tsx +1349 -0
- package/src/components/Inspector.tsx +671 -0
- package/src/components/InspectorToolSection.tsx +74 -0
- package/src/components/MountFailureBanner.tsx +110 -0
- package/src/components/NetworkSection.tsx +109 -0
- package/src/components/OnlineAssetBrowser.tsx +448 -0
- package/src/components/OverlayReportBanner.tsx +122 -0
- package/src/components/PlayBar.tsx +356 -0
- package/src/components/PrefabSaveDialog.tsx +201 -0
- package/src/components/ProjectHeader.tsx +174 -0
- package/src/components/ProjectScreen.tsx +1004 -0
- package/src/components/RightPanel.tsx +166 -0
- package/src/components/RootActivateDialog.tsx +105 -0
- package/src/components/SaveDialog.tsx +197 -0
- package/src/components/SaveStatus.tsx +69 -0
- package/src/components/StatsOverlay.tsx +35 -0
- package/src/components/ToolHost.tsx +80 -0
- package/src/components/Toolbar.tsx +307 -0
- package/src/components/ViewportOverlay.tsx +234 -0
- package/src/components/ViewportPanel.tsx +369 -0
- package/src/components/WorldContextMenu.tsx +257 -0
- package/src/components/WorldSelectionOverlay.tsx +2003 -0
- package/src/components/WorldTextEditor.tsx +86 -0
- package/src/components/asset-viewers/AssetViewer.tsx +38 -0
- package/src/components/asset-viewers/AudioViewer.tsx +149 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
- package/src/components/asset-viewers/ImageViewer.tsx +65 -0
- package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
- package/src/components/asset-viewers/ModelViewer.tsx +180 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
- package/src/components/data-csv.ts +301 -0
- package/src/components/entity-name-commit.ts +18 -0
- package/src/components/inspector-property-grouping.ts +65 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
- package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
- package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
- package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/components/inspector-widgets/FontPicker.tsx +196 -0
- package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
- package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
- package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
- package/src/components/inspector-widgets/color-utils.ts +202 -0
- package/src/components/inspector-widgets/gallery-main.tsx +167 -0
- package/src/components/inspector-widgets/index.ts +22 -0
- package/src/components/inspector-widgets/shared.tsx +135 -0
- package/src/components/inspectors/AnimationSection.tsx +143 -0
- package/src/components/inspectors/AudioSection.tsx +215 -0
- package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
- package/src/components/inspectors/CameraSection.tsx +105 -0
- package/src/components/inspectors/ComponentsSection.tsx +358 -0
- package/src/components/inspectors/JointsSection.tsx +255 -0
- package/src/components/inspectors/LightSection.tsx +116 -0
- package/src/components/inspectors/MaterialSection.tsx +1189 -0
- package/src/components/inspectors/MeshSection.tsx +99 -0
- package/src/components/inspectors/NavigationSection.tsx +58 -0
- package/src/components/inspectors/ParticleSection.tsx +681 -0
- package/src/components/inspectors/PhysicsSection.tsx +326 -0
- package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
- package/src/components/inspectors/RenderingSection.tsx +109 -0
- package/src/components/inspectors/ShadowSection.tsx +84 -0
- package/src/components/inspectors/SplineSection.tsx +191 -0
- package/src/components/inspectors/TransformSection.tsx +191 -0
- package/src/components/inspectors/json-schema-fields.ts +281 -0
- package/src/components/inspectors/multi-edit-utils.ts +49 -0
- package/src/components/inspectors/zod-fields.ts +184 -0
- package/src/components/primitives/ColorInput.tsx +109 -0
- package/src/components/primitives/JsonInput.tsx +98 -0
- package/src/components/primitives/NumberInput.tsx +164 -0
- package/src/components/primitives/Panel.tsx +68 -0
- package/src/components/primitives/SectionHeader.tsx +45 -0
- package/src/components/primitives/Tooltip.tsx +82 -0
- package/src/components/primitives/Vec3Input.tsx +62 -0
- package/src/components/world-overlay-gestures.ts +1438 -0
- package/src/doctor/data-checks.ts +285 -0
- package/src/doctor/report.ts +386 -0
- package/src/editor-api.ts +700 -0
- package/src/editor-console.ts +194 -0
- package/src/editor-hotkeys.ts +441 -0
- package/src/editor-layers.ts +2 -0
- package/src/editor-mode.ts +28 -0
- package/src/editor-store.ts +2225 -0
- package/src/editor-viewport.ts +1924 -0
- package/src/entity-factory.ts +221 -0
- package/src/game-globals-prelude.ts +27 -0
- package/src/gated-globals.ts +131 -0
- package/src/generated/setup-by-example.generated.ts +53 -0
- package/src/gizmo-registry.ts +608 -0
- package/src/handle-system.ts +221 -0
- package/src/hierarchy-menu-registry.ts +70 -0
- package/src/hierarchy-rows.ts +73 -0
- package/src/hosted-example.ts +160 -0
- package/src/hotkeys.ts +142 -0
- package/src/ingest/discovery-react.ts +132 -0
- package/src/ingest/discovery.ts +205 -0
- package/src/ingest/discovery2d.ts +204 -0
- package/src/ingest/esm-entry-resolution.ts +102 -0
- package/src/ingest/games/bloom-composer/game.js +99 -0
- package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
- package/src/ingest/games/embed-bundle/embed.html +10 -0
- package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
- package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
- package/src/ingest/games/games-fps/game.js +479 -0
- package/src/ingest/games/games-fps/vgai.game.json +24 -0
- package/src/ingest/games/iframe-reachable/game-source.js +58 -0
- package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
- package/src/ingest/games/interactive-cubes/game.js +139 -0
- package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
- package/src/ingest/games/mismatched-three/fake-three.js +38 -0
- package/src/ingest/games/mismatched-three/game.js +10 -0
- package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
- package/src/ingest/games/own-three-bundled/game.js +43 -0
- package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
- package/src/ingest/games/pointerlock-maze/game.js +288 -0
- package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
- package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
- package/src/ingest/games-2d/bunnymark/game.ts +88 -0
- package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy/game.ts +148 -0
- package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
- package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
- package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
- package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
- package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
- package/src/ingest/host-namespace-registry.ts +196 -0
- package/src/ingest/types.ts +127 -0
- package/src/ingest-hierarchy-rows.ts +207 -0
- package/src/ingest-mode.ts +2092 -0
- package/src/ingest-siblings.ts +466 -0
- package/src/initial-scene.ts +194 -0
- package/src/inspector-section-registry.ts +117 -0
- package/src/interactive-edit-scope.ts +34 -0
- package/src/main.tsx +23 -0
- package/src/manifest-project.ts +43 -0
- package/src/model-thumbnail.ts +156 -0
- package/src/module-mode.ts +513 -0
- package/src/navmesh-handler.ts +94 -0
- package/src/packaged-runtime.ts +49 -0
- package/src/play-mode.ts +1148 -0
- package/src/playground/main.ts +206 -0
- package/src/project-manager.ts +173 -0
- package/src/scene-diff.ts +112 -0
- package/src/scene-serializer.ts +144 -0
- package/src/scene-sync.ts +587 -0
- package/src/scene-ui-mode/active-scene-ui.ts +58 -0
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
- package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
- package/src/script-loader.ts +143 -0
- package/src/storage/fsa-storage.ts +55 -0
- package/src/storage/handle-storage.ts +150 -0
- package/src/storage/handle-store.ts +116 -0
- package/src/storage/http-storage.ts +137 -0
- package/src/storage/index.ts +147 -0
- package/src/storage/mem-storage.ts +135 -0
- package/src/storage/opfs-storage.ts +25 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/seed.ts +106 -0
- package/src/storage/types.ts +110 -0
- package/src/tool-loader.ts +291 -0
- package/src/ui-editor/inspector.tsx +563 -0
- package/src/ui-editor/node-ops.ts +266 -0
- package/src/ui-editor/overlay.tsx +380 -0
- package/src/ui-editor/palette.tsx +114 -0
- package/src/ui-editor/react-store.ts +78 -0
- package/src/ui-editor/timeline-panel.tsx +443 -0
- package/src/ui-editor/ui-edit-store.ts +552 -0
- package/src/ui-editor/ui-prefab.ts +95 -0
- package/src/ui-source/inspect.ts +544 -0
- package/src/ui-source/oid-transform.ts +133 -0
- package/src/ui-source/source-write-backend.ts +203 -0
- package/src/ui-source/tailwind-map.ts +174 -0
- package/src/ui-source/writer.ts +1175 -0
- package/src/widgets/index.ts +73 -0
- package/template/.claude/skills/editor/SKILL.md +252 -0
- package/template/CLAUDE.md +512 -0
- package/template/LICENSE +30 -0
- package/template/emit-schemas.ts +42 -0
- package/template/index.html +17 -0
- package/template/package.json +53 -0
- package/template/project.json +9 -0
- package/template/public/audio/ambient-arena.wav +0 -0
- package/template/public/audio/ambient-music.wav +0 -0
- package/template/public/audio/ambient-nature.wav +0 -0
- package/template/public/audio/ambient-wind.wav +0 -0
- package/template/public/inputmaps/default.inputmap.json +41 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/template/public/scenes/default.vscn.json +43 -0
- package/template/public/scenes/untitled.vscn.json +29 -0
- package/template/public/textures/waternormals.jpg +0 -0
- package/template/server/colyseus-setup.ts +85 -0
- package/template/server/main.ts +34 -0
- package/template/server/rooms/arena-room.ts +420 -0
- package/template/server/rooms/game-room.ts +162 -0
- package/template/server/rooms.ts +15 -0
- package/template/server/tsconfig.json +19 -0
- package/template/src/data/README.md +40 -0
- package/template/src/data/assets.ts +31 -0
- package/template/src/data/tuning.data.json +5 -0
- package/template/src/data/tuning.schema.json +21 -0
- package/template/src/data/tuning.schema.ts +34 -0
- package/template/src/data/tuning.ts +24 -0
- package/template/src/main.ts +157 -0
- package/template/src/scripts/components/data-spinner.ts +45 -0
- package/template/src/scripts/components/scene-camera.ts +40 -0
- package/template/src/scripts/main.ts +94 -0
- package/template/src/scripts/registry.ts +46 -0
- package/template/src/tools/README.md +14 -0
- package/template/src/tools/example.tool.tsx +91 -0
- package/template/src/tools/spin.tool.tsx +101 -0
- package/template/src/ui/Button.tsx +27 -0
- package/template/src/ui/DefaultHud.tsx +40 -0
- package/template/src/ui/KeyHint.tsx +25 -0
- package/template/src/ui/Panel.tsx +52 -0
- package/template/src/ui/RebindPanel.tsx +129 -0
- package/template/src/ui/Slider.tsx +28 -0
- package/template/src/ui/StatusDot.tsx +22 -0
- package/template/src/ui/Toggle.tsx +19 -0
- package/template/src/ui/game-state.tsx +37 -0
- package/template/src/ui/index.ts +7 -0
- package/template/src/ui/mount-default-hud.tsx +19 -0
- package/template/src/ui/mount-ui.ts +15 -0
- package/template/tsconfig.json +36 -0
- package/template/validate-asset-content.ts +78 -0
- package/template/validate-manifest.ts +55 -0
- package/template/validate-scenes.ts +211 -0
- package/template/vgai.game.json +21 -0
- package/template/vite.config.ts +128 -0
- package/vite-plugin-example-assets.ts +60 -0
- package/vite-plugin-game-globals.ts +46 -0
- package/vite-plugin-game-static.ts +71 -0
- package/vite-plugin-project-game-static.ts +84 -0
- package/vite-plugin-react-game-static.ts +98 -0
- package/vite-plugin-react-world-provider.ts +81 -0
- package/vite-plugin-react-world-runtime.ts +76 -0
- package/vite-plugin-ui-oid.ts +561 -0
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DATA dock tab (W2, docs/DATA-TOOLS-DESIGN.md §2.4) — lists every
|
|
3
|
+
* `src/data/*.data.json` of the open project and edits it in place.
|
|
4
|
+
*
|
|
5
|
+
* Architecture (spec §2.1): widgets are generated from the EMITTED
|
|
6
|
+
* `*.schema.json` (JSON Schema, the interchange artifact) via
|
|
7
|
+
* `jsonSchemaToFieldDescriptors` — the editor never executes the project's
|
|
8
|
+
* `.schema.ts`. The two blessed shapes render differently:
|
|
9
|
+
* - **singleton** → a form, one widget row per field (same look as the
|
|
10
|
+
* component inspector — same `FieldDescriptor` IR, same widget primitives).
|
|
11
|
+
* - **table** (`z.record`) → a grid: one row per key in the data file, one
|
|
12
|
+
* column per row-schema field, every cell a widget.
|
|
13
|
+
*
|
|
14
|
+
* Writes (D1, §2.5): every widget commit POSTs the whole updated file to
|
|
15
|
+
* `/__editor/data-file` immediately — no local buffering, no save button;
|
|
16
|
+
* undo is git. The `"$schema"` key is held out of editing everywhere and
|
|
17
|
+
* re-serialized first so git diffs stay minimal.
|
|
18
|
+
*
|
|
19
|
+
* Table rows can be added/removed (W6b) — see `TableEditor` below, which also
|
|
20
|
+
* carries CSV export/import (W6c, `data-csv.ts`) — a spreadsheet-friendly
|
|
21
|
+
* bulk-edit path for the same table. The empty state (`EmptyState`, W6d)
|
|
22
|
+
* teaches the create-a-data-asset recipe with a "Copy starter files" button;
|
|
23
|
+
* no file-creating scaffold button this wave (deferred — needs a
|
|
24
|
+
* purpose-scoped write verb, a security-surface decision). Still pending:
|
|
25
|
+
* adding a whole new FIELD to a schema (a `.schema.ts` code change, not an
|
|
26
|
+
* editor concern).
|
|
27
|
+
*
|
|
28
|
+
* Cross-asset refs (§2.2): a field declared with `dataRef(target)`
|
|
29
|
+
* (`@engine/data/data-ref`) renders as a `stem#key` PICKER instead of free
|
|
30
|
+
* text — `RefOptionsContext` reduces this panel's already-loaded `files`
|
|
31
|
+
* state into `stem -> key[]` once per change and threads it to `Widget`'s
|
|
32
|
+
* `'ref'` case (deep in `FieldList`/`TableEditor`) without prop-drilling.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
createContext,
|
|
37
|
+
Fragment,
|
|
38
|
+
useCallback,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useMemo,
|
|
42
|
+
useRef,
|
|
43
|
+
useState,
|
|
44
|
+
} from 'react';
|
|
45
|
+
import { connectDataFileEvents } from '../asset-events';
|
|
46
|
+
import { csvToTable, tableToCsv } from './data-csv';
|
|
47
|
+
import {
|
|
48
|
+
type DataAssetShape,
|
|
49
|
+
type DataFieldDescriptor,
|
|
50
|
+
defaultValueForDescriptor,
|
|
51
|
+
describeDataSchema,
|
|
52
|
+
} from './inspectors/json-schema-fields';
|
|
53
|
+
import { ColorInput } from './primitives/ColorInput';
|
|
54
|
+
import { NumberInput } from './primitives/NumberInput';
|
|
55
|
+
import { Tooltip } from './primitives/Tooltip';
|
|
56
|
+
|
|
57
|
+
/** One file as served by GET /__editor/data-files (raw text, client parses). */
|
|
58
|
+
interface ServerDataFile {
|
|
59
|
+
path: string;
|
|
60
|
+
content: string;
|
|
61
|
+
schema: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One file after client-side parsing + schema classification. */
|
|
65
|
+
interface LoadedDataFile {
|
|
66
|
+
/** Project-relative path, e.g. `src/data/tuning.data.json`. */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Parsed data object (`"$schema"` entry included), or null on parse error. */
|
|
69
|
+
data: Record<string, unknown> | null;
|
|
70
|
+
/**
|
|
71
|
+
* The file's own top-level key order, read from the RAW TEXT (D1
|
|
72
|
+
* diff-contract, #6): a JS object's own `Object.keys` order is not the
|
|
73
|
+
* file's textual order — integer-LIKE keys ("1", "2", "10") are always
|
|
74
|
+
* enumerated numerically-ascending by the engine, regardless of where they
|
|
75
|
+
* sat in the source JSON, so a table keyed by numeric strings would get a
|
|
76
|
+
* whole-file reorder on the very first edit if we ever serialized from
|
|
77
|
+
* `Object.keys(data)`. Captured once at load, then carried forward
|
|
78
|
+
* (new/removed keys folded in) by `nextKeyOrder` on every commit — see
|
|
79
|
+
* `writeFile`. `undefined` only when `data` is null (unparseable file).
|
|
80
|
+
*/
|
|
81
|
+
keyOrder?: string[];
|
|
82
|
+
/** Widget plan derived from the emitted schema; null = no/unusable schema. */
|
|
83
|
+
shape: DataAssetShape | null;
|
|
84
|
+
hasSchema: boolean;
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every loaded file's own top-level keys, by asset stem — the `'ref'` widget
|
|
90
|
+
* kind's picker options. `DataPanel` already fetches and parses every data
|
|
91
|
+
* file's raw text (`GET /__editor/data-files`); this just reduces that same
|
|
92
|
+
* state into `stem -> key[]` once per `files` change and threads it to
|
|
93
|
+
* `Widget` (deep inside `FieldList`/`TableEditor`) via context instead of a
|
|
94
|
+
* prop through every intermediate component — the SAME shape a `dataRef`
|
|
95
|
+
* field's `refTarget` addresses (`json-schema-fields.ts`).
|
|
96
|
+
*/
|
|
97
|
+
const RefOptionsContext = createContext<ReadonlyMap<string, string[]>>(new Map());
|
|
98
|
+
|
|
99
|
+
const inputStyle: React.CSSProperties = {
|
|
100
|
+
width: '100%',
|
|
101
|
+
background: '#1e1e1e',
|
|
102
|
+
color: '#c5c8ce',
|
|
103
|
+
border: '1px solid #333',
|
|
104
|
+
borderRadius: 2,
|
|
105
|
+
padding: '2px 4px',
|
|
106
|
+
fontSize: 12,
|
|
107
|
+
boxSizing: 'border-box',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const labelStyle: React.CSSProperties = {
|
|
111
|
+
fontSize: 11,
|
|
112
|
+
color: '#8c8c8c',
|
|
113
|
+
width: 140,
|
|
114
|
+
flexShrink: 0,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Scan a (valid-JSON) string literal starting at the opening quote; returns the index just past the closing quote. */
|
|
118
|
+
function skipJsonString(text: string, i: number): number {
|
|
119
|
+
let j = i + 1;
|
|
120
|
+
while (j < text.length) {
|
|
121
|
+
const ch = text[j];
|
|
122
|
+
if (ch === '\\') {
|
|
123
|
+
j += 2;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === '"') return j + 1;
|
|
127
|
+
j++;
|
|
128
|
+
}
|
|
129
|
+
return j;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Scan one JSON value (string/object/array/number/bool/null) starting at `i`; returns the index just past it. Assumes well-formed JSON (only ever called on text `JSON.parse` already accepted). */
|
|
133
|
+
function skipJsonValue(text: string, i: number): number {
|
|
134
|
+
const ch = text[i];
|
|
135
|
+
if (ch === '"') return skipJsonString(text, i);
|
|
136
|
+
if (ch === '{' || ch === '[') {
|
|
137
|
+
const close = ch === '{' ? '}' : ']';
|
|
138
|
+
let depth = 1;
|
|
139
|
+
let j = i + 1;
|
|
140
|
+
while (j < text.length && depth > 0) {
|
|
141
|
+
const c = text[j];
|
|
142
|
+
if (c === '"') {
|
|
143
|
+
j = skipJsonString(text, j);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === '{' || c === '[') depth++;
|
|
147
|
+
else if (c === close || c === '}' || c === ']') depth--;
|
|
148
|
+
j++;
|
|
149
|
+
}
|
|
150
|
+
return j;
|
|
151
|
+
}
|
|
152
|
+
// number / true / false / null — run until a structural character.
|
|
153
|
+
let j = i;
|
|
154
|
+
while (j < text.length && !/[,}\]\s]/.test(text[j]!)) j++;
|
|
155
|
+
return j;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read a top-level JSON object's key order straight off the SOURCE TEXT
|
|
160
|
+
* (D1 diff-contract, #6) — `JSON.parse` builds a JS object whose own
|
|
161
|
+
* enumeration order already reindexed any integer-like keys, so recovering
|
|
162
|
+
* "the order the file had on disk" has to happen before that reindexing, on
|
|
163
|
+
* the raw characters. Only ever called with text that `JSON.parse` already
|
|
164
|
+
* accepted, so this can assume well-formed JSON throughout.
|
|
165
|
+
*/
|
|
166
|
+
function extractTopLevelKeyOrder(text: string): string[] {
|
|
167
|
+
const keys: string[] = [];
|
|
168
|
+
let i = 0;
|
|
169
|
+
const n = text.length;
|
|
170
|
+
const skipWs = () => {
|
|
171
|
+
while (i < n && /\s/.test(text[i]!)) i++;
|
|
172
|
+
};
|
|
173
|
+
skipWs();
|
|
174
|
+
if (text[i] !== '{') return keys;
|
|
175
|
+
i++;
|
|
176
|
+
skipWs();
|
|
177
|
+
while (i < n && text[i] !== '}') {
|
|
178
|
+
if (text[i] !== '"') break;
|
|
179
|
+
const keyEnd = skipJsonString(text, i);
|
|
180
|
+
const key = JSON.parse(text.slice(i, keyEnd)) as string;
|
|
181
|
+
if (!keys.includes(key)) keys.push(key);
|
|
182
|
+
i = keyEnd;
|
|
183
|
+
skipWs();
|
|
184
|
+
if (text[i] !== ':') break;
|
|
185
|
+
i++;
|
|
186
|
+
skipWs();
|
|
187
|
+
i = skipJsonValue(text, i);
|
|
188
|
+
skipWs();
|
|
189
|
+
if (text[i] === ',') {
|
|
190
|
+
i++;
|
|
191
|
+
skipWs();
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
return keys;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseServerFile(f: ServerDataFile): LoadedDataFile {
|
|
200
|
+
let data: Record<string, unknown> | null = null;
|
|
201
|
+
let error: string | undefined;
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(f.content) as unknown;
|
|
204
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
205
|
+
data = parsed as Record<string, unknown>;
|
|
206
|
+
} else {
|
|
207
|
+
error = 'Data file is not a JSON object.';
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
error = `Data file is not valid JSON: ${e}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let shape: DataAssetShape | null = null;
|
|
214
|
+
if (f.schema !== null) {
|
|
215
|
+
try {
|
|
216
|
+
shape = describeDataSchema(JSON.parse(f.schema));
|
|
217
|
+
} catch {
|
|
218
|
+
/* malformed emitted schema — fall through to the no-schema message */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const out: LoadedDataFile = { path: f.path, data, shape, hasSchema: f.schema !== null };
|
|
223
|
+
if (data) {
|
|
224
|
+
try {
|
|
225
|
+
out.keyOrder = extractTopLevelKeyOrder(f.content);
|
|
226
|
+
} catch {
|
|
227
|
+
out.keyOrder = Object.keys(data); // defensive fallback — never block loading over this
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (error !== undefined) out.error = error;
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Fold a commit's resulting keys into a stable order: `prevOrder` (whatever
|
|
236
|
+
* this file's keys were last serialized in — from disk on first load, from
|
|
237
|
+
* the previous commit after that) survives for every key still present, and
|
|
238
|
+
* any BRAND NEW key (add-row, a freshly-set nested field) is appended at the
|
|
239
|
+
* end in `Object.keys` order. This is what makes #6's fix stick across a
|
|
240
|
+
* SESSION of edits, not just the first one: each commit's output order
|
|
241
|
+
* becomes the next commit's `prevOrder`.
|
|
242
|
+
*/
|
|
243
|
+
function nextKeyOrder(prevOrder: string[] | undefined, obj: Record<string, unknown>): string[] {
|
|
244
|
+
const existing = prevOrder ?? Object.keys(obj);
|
|
245
|
+
const kept = [...new Set(existing)].filter((k) => k in obj);
|
|
246
|
+
const added = Object.keys(obj).filter((k) => !existing.includes(k));
|
|
247
|
+
return [...kept, ...added];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Serialize per D1's diff-minimal contract: `"$schema"` first, then every
|
|
252
|
+
* other key in `keyOrder` (falling back to `Object.keys(obj)` if the caller
|
|
253
|
+
* has none — e.g. a brand-new file), 2-space indent, trailing newline.
|
|
254
|
+
*
|
|
255
|
+
* Deliberately NOT `JSON.stringify(orderedObject, null, 2)` on a
|
|
256
|
+
* spread-reordered plain object (#6, D1 diff-contract): building a plain
|
|
257
|
+
* object and letting the engine enumerate its keys reorders any INTEGER-LIKE
|
|
258
|
+
* key ("1", "2", "10") to numeric-ascending regardless of insertion order —
|
|
259
|
+
* so a table keyed by numeric strings would get its whole file reordered on
|
|
260
|
+
* the very first value edit. Emitting the object manually, key by key, in
|
|
261
|
+
* the exact order the caller hands us, sidesteps that reindexing entirely.
|
|
262
|
+
*/
|
|
263
|
+
function serializeDataFile(obj: Record<string, unknown>, keyOrder?: string[]): string {
|
|
264
|
+
const hasSchema = '$schema' in obj;
|
|
265
|
+
const rest = (keyOrder ?? Object.keys(obj)).filter((k) => k !== '$schema' && k in obj);
|
|
266
|
+
const order = hasSchema ? ['$schema', ...rest] : rest;
|
|
267
|
+
if (order.length === 0) return '{}\n';
|
|
268
|
+
const lines = order.map((k) => {
|
|
269
|
+
const valueJson = JSON.stringify(obj[k], null, 2);
|
|
270
|
+
return ` ${JSON.stringify(k)}: ${valueJson.split('\n').join('\n ')}`;
|
|
271
|
+
});
|
|
272
|
+
return `{\n${lines.join(',\n')}\n}\n`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Immutable set-at-path (W2 hardening, path-aware commits): spreads at every
|
|
277
|
+
* level the path touches, so untouched sibling keys — AND their original
|
|
278
|
+
* declaration order — survive unchanged (D1's diff-minimal contract; only
|
|
279
|
+
* the leaf's value actually changes in the serialized JSON). A path
|
|
280
|
+
* segment whose current value isn't an object (e.g. the field didn't exist
|
|
281
|
+
* yet, or a schema/data mismatch) starts that level fresh from `{}` rather
|
|
282
|
+
* than throwing — the write always succeeds, matching the panel's other
|
|
283
|
+
* commit paths (`setCell` has the same defensive fallback).
|
|
284
|
+
*/
|
|
285
|
+
function setDeep(
|
|
286
|
+
obj: Record<string, unknown>,
|
|
287
|
+
path: string[],
|
|
288
|
+
value: unknown,
|
|
289
|
+
): Record<string, unknown> {
|
|
290
|
+
const [head, ...rest] = path;
|
|
291
|
+
if (head === undefined) return obj;
|
|
292
|
+
if (rest.length === 0) return { ...obj, [head]: value };
|
|
293
|
+
const child = obj[head];
|
|
294
|
+
const childObj =
|
|
295
|
+
child && typeof child === 'object' && !Array.isArray(child)
|
|
296
|
+
? (child as Record<string, unknown>)
|
|
297
|
+
: {};
|
|
298
|
+
return { ...obj, [head]: setDeep(childObj, rest, value) };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function DataPanel({ active }: { active: boolean }) {
|
|
302
|
+
const [files, setFiles] = useState<LoadedDataFile[]>([]);
|
|
303
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
304
|
+
|
|
305
|
+
const refresh = useCallback(async () => {
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch('/__editor/data-files');
|
|
308
|
+
if (!res.ok) return;
|
|
309
|
+
const { files: raw } = (await res.json()) as { files: ServerDataFile[] };
|
|
310
|
+
setFiles(raw.map(parseServerFile));
|
|
311
|
+
} catch {
|
|
312
|
+
/* server unreachable — keep whatever we last had */
|
|
313
|
+
}
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
// Re-scan whenever the tab is shown (D3: discovery IS the folder scan —
|
|
317
|
+
// never a cached manifest, so activating the tab always reflects disk).
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (active) void refresh();
|
|
320
|
+
}, [active, refresh]);
|
|
321
|
+
|
|
322
|
+
// W6a: a NEW `src/data/*.data.json`/`*.schema.json` (or a deleted one)
|
|
323
|
+
// appears/disappears in the master list without switching tabs — the
|
|
324
|
+
// server's `src/`-scoped watcher broadcasts add/unlink only (a `change`
|
|
325
|
+
// event is deliberately excluded: it would echo this panel's own
|
|
326
|
+
// `/__editor/data-file` POST writes back into a refetch, clobbering
|
|
327
|
+
// in-flight drafts). `refresh` is the same rescan the tab-activation
|
|
328
|
+
// effect above already runs.
|
|
329
|
+
useEffect(() => connectDataFileEvents(() => void refresh()), [refresh]);
|
|
330
|
+
|
|
331
|
+
const selected = files.find((f) => f.path === selectedPath) ?? files[0] ?? null;
|
|
332
|
+
|
|
333
|
+
// `'ref'` widget picker options (§2.2's designer payoff): every loaded
|
|
334
|
+
// file's own top-level keys, keyed by asset stem — recomputed only when
|
|
335
|
+
// `files` actually changes (a fresh fetch, or a local commit updating one
|
|
336
|
+
// file's `data` in place).
|
|
337
|
+
const refOptionsByStem = useMemo(() => {
|
|
338
|
+
const map = new Map<string, string[]>();
|
|
339
|
+
for (const f of files) {
|
|
340
|
+
if (!f.data) continue;
|
|
341
|
+
const stem = f.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
|
|
342
|
+
map.set(
|
|
343
|
+
stem,
|
|
344
|
+
Object.keys(f.data).filter((k) => k !== '$schema'),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return map;
|
|
348
|
+
}, [files]);
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* D1 (spec §2.5): commit → write the file IMMEDIATELY, no buffering. The
|
|
352
|
+
* local copy is updated in the same tick so widgets stay live; disk is the
|
|
353
|
+
* document. D5 resolution: this deliberately BYPASSES the play-mode overlay
|
|
354
|
+
* (scene-entity-only) — a running game receives the change through the
|
|
355
|
+
* shipped `.data.json` HMR path instead (see the `/__editor/data-file`
|
|
356
|
+
* route's doc comment in editor-server.ts).
|
|
357
|
+
*
|
|
358
|
+
* Takes the whole `LoadedDataFile` (not just its path, #6) so it can read
|
|
359
|
+
* that file's CURRENT `keyOrder` straight from the caller's own prop/state
|
|
360
|
+
* closure — no stale-closure risk from reaching into the panel's `files`
|
|
361
|
+
* state inside this `useCallback([])`. `nextKeyOrder` folds the commit's
|
|
362
|
+
* keys into that order once, and the result becomes this file's `keyOrder`
|
|
363
|
+
* going forward, so a whole session of edits keeps the file's original
|
|
364
|
+
* on-disk key order (integer-like keys included) stable throughout.
|
|
365
|
+
*/
|
|
366
|
+
const writeFile = useCallback((file: LoadedDataFile, nextData: Record<string, unknown>) => {
|
|
367
|
+
const order = nextKeyOrder(file.keyOrder, nextData);
|
|
368
|
+
const content = serializeDataFile(nextData, order);
|
|
369
|
+
setFiles((prev) =>
|
|
370
|
+
prev.map((f) => (f.path === file.path ? { ...f, data: nextData, keyOrder: order } : f)),
|
|
371
|
+
);
|
|
372
|
+
void fetch('/__editor/data-file', {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: { 'Content-Type': 'application/json' },
|
|
375
|
+
body: JSON.stringify({ path: file.path, content }),
|
|
376
|
+
});
|
|
377
|
+
}, []);
|
|
378
|
+
|
|
379
|
+
if (files.length === 0) {
|
|
380
|
+
return <EmptyState />;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<RefOptionsContext.Provider value={refOptionsByStem}>
|
|
385
|
+
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
|
386
|
+
{/* File list (master) */}
|
|
387
|
+
<div
|
|
388
|
+
style={{
|
|
389
|
+
width: 200,
|
|
390
|
+
flexShrink: 0,
|
|
391
|
+
borderRight: '1px solid #333',
|
|
392
|
+
overflowY: 'auto',
|
|
393
|
+
padding: '4px 0',
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
{files.map((f) => {
|
|
397
|
+
const isSelected = f.path === selected?.path;
|
|
398
|
+
const name = f.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
|
|
399
|
+
return (
|
|
400
|
+
<button
|
|
401
|
+
key={f.path}
|
|
402
|
+
type="button"
|
|
403
|
+
data-testid={`data-file-${name}`}
|
|
404
|
+
onClick={() => setSelectedPath(f.path)}
|
|
405
|
+
title={f.path}
|
|
406
|
+
style={{
|
|
407
|
+
display: 'block',
|
|
408
|
+
width: '100%',
|
|
409
|
+
textAlign: 'left',
|
|
410
|
+
background: isSelected ? '#2c2c2c' : 'transparent',
|
|
411
|
+
color: isSelected ? '#c5c8ce' : '#8c8c8c',
|
|
412
|
+
border: 'none',
|
|
413
|
+
borderLeft: isSelected ? '2px solid #579EFF' : '2px solid transparent',
|
|
414
|
+
padding: '4px 10px',
|
|
415
|
+
cursor: 'pointer',
|
|
416
|
+
fontSize: 12,
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
{name}
|
|
420
|
+
</button>
|
|
421
|
+
);
|
|
422
|
+
})}
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* Editor (detail) */}
|
|
426
|
+
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', padding: 10 }}>
|
|
427
|
+
{selected && <DataFileEditor file={selected} writeFile={writeFile} />}
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</RefOptionsContext.Provider>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A minimal, paste-ready `.schema.ts` + `.data.json` pair (W6d) — mirrors the
|
|
436
|
+
* shape of the shipped `tuning.*` trio (template/src/data/README.md), just
|
|
437
|
+
* renamed/generic so it's obviously a starting point to rename, not the real
|
|
438
|
+
* thing to keep verbatim. No file-creating server verb this wave (deferred —
|
|
439
|
+
* today's write routes are purpose-scoped, `src/data/**\/*.data.json` only;
|
|
440
|
+
* a general project-file write API needs its own security ruling), so this
|
|
441
|
+
* is copy-paste, not a button that writes to disk.
|
|
442
|
+
*/
|
|
443
|
+
const STARTER_FILES = `// src/data/example.schema.ts
|
|
444
|
+
import { z } from 'zod';
|
|
445
|
+
|
|
446
|
+
export const ExampleSchema = z.object({
|
|
447
|
+
speed: z.number().min(0).max(10).default(1).describe('Example tunable — rename me'),
|
|
448
|
+
label: z.string().default('hello').describe('Example string field'),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
export type Example = z.infer<typeof ExampleSchema>;
|
|
452
|
+
|
|
453
|
+
// src/data/example.data.json
|
|
454
|
+
{
|
|
455
|
+
"$schema": "./example.schema.json",
|
|
456
|
+
"speed": 1,
|
|
457
|
+
"label": "hello"
|
|
458
|
+
}
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* DATA tab empty state (W6d, docs/DATA-TOOLS-DESIGN.md §3.5): the project has
|
|
463
|
+
* no `src/data/*.data.json` yet. Teaches the concrete recipe rather than just
|
|
464
|
+
* naming the shape, plus a one-click "Copy starter files" so the first asset
|
|
465
|
+
* doesn't require memorizing the trio from scratch. Once the files exist +
|
|
466
|
+
* `npm run emit-schemas` has run, the new asset appears here automatically —
|
|
467
|
+
* no reload, W6a's src-watcher SSE channel picks it up.
|
|
468
|
+
*/
|
|
469
|
+
function EmptyState() {
|
|
470
|
+
const [copied, setCopied] = useState(false);
|
|
471
|
+
|
|
472
|
+
const copyStarter = () => {
|
|
473
|
+
void navigator.clipboard.writeText(STARTER_FILES).then(() => {
|
|
474
|
+
setCopied(true);
|
|
475
|
+
setTimeout(() => setCopied(false), 1500);
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
<div style={{ padding: 12, fontSize: 12, color: '#8c8c8c', maxWidth: 480 }}>
|
|
481
|
+
<p style={{ margin: '0 0 8px' }}>
|
|
482
|
+
No data assets yet — a data asset is a <code>src/data/*.data.json</code> file governed by a
|
|
483
|
+
Zod schema (docs/DATA-TOOLS-DESIGN.md §2).
|
|
484
|
+
</p>
|
|
485
|
+
<ol style={{ margin: '0 0 10px', paddingLeft: 18 }}>
|
|
486
|
+
<li>
|
|
487
|
+
Create <code>src/data/<name>.schema.ts</code> — a Zod object (or{' '}
|
|
488
|
+
<code>z.record(...)</code> for a table) exporting the schema.
|
|
489
|
+
</li>
|
|
490
|
+
<li>
|
|
491
|
+
Create <code>src/data/<name>.data.json</code> with a <code>"$schema"</code> line
|
|
492
|
+
pointing at the emitted schema, plus starting values.
|
|
493
|
+
</li>
|
|
494
|
+
<li>
|
|
495
|
+
Run <code>npm run emit-schemas</code> in the project — it writes the emitted{' '}
|
|
496
|
+
<code>.schema.json</code> and validates the data file against it.
|
|
497
|
+
</li>
|
|
498
|
+
</ol>
|
|
499
|
+
<p style={{ margin: '0 0 8px' }}>
|
|
500
|
+
See the project's <code>src/data/README.md</code> for the full trio doctrine and the shipped{' '}
|
|
501
|
+
<code>tuning.*</code> worked example.
|
|
502
|
+
</p>
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
data-testid="data-empty-copy-starter"
|
|
506
|
+
onClick={copyStarter}
|
|
507
|
+
style={{
|
|
508
|
+
background: '#2c2c2c',
|
|
509
|
+
color: '#c5c8ce',
|
|
510
|
+
border: '1px solid #444',
|
|
511
|
+
borderRadius: 2,
|
|
512
|
+
padding: '4px 10px',
|
|
513
|
+
fontSize: 12,
|
|
514
|
+
cursor: 'pointer',
|
|
515
|
+
}}
|
|
516
|
+
>
|
|
517
|
+
{copied ? 'Copied' : 'Copy starter files'}
|
|
518
|
+
</button>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function DataFileEditor({
|
|
524
|
+
file,
|
|
525
|
+
writeFile,
|
|
526
|
+
}: {
|
|
527
|
+
file: LoadedDataFile;
|
|
528
|
+
writeFile: (file: LoadedDataFile, nextData: Record<string, unknown>) => void;
|
|
529
|
+
}) {
|
|
530
|
+
if (file.error) {
|
|
531
|
+
return <Message text={`${file.path}: ${file.error}`} tone="error" />;
|
|
532
|
+
}
|
|
533
|
+
if (!file.hasSchema) {
|
|
534
|
+
// Teaching message, house style: name the fix (D4 — emitted schemas are
|
|
535
|
+
// the artifact the editor consumes; without one there are no widget types).
|
|
536
|
+
return (
|
|
537
|
+
<Message
|
|
538
|
+
text={`${file.path} has no emitted schema (${file.path.replace(/\.data\.json$/, '.schema.json')}). Run \`npm run emit-schemas\` in the project to generate it.`}
|
|
539
|
+
tone="warn"
|
|
540
|
+
/>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (!file.shape || !file.data) {
|
|
544
|
+
return (
|
|
545
|
+
<Message
|
|
546
|
+
text={`${file.path}: emitted schema is not one of the two blessed shapes (singleton object or z.record table — spec §2.1).`}
|
|
547
|
+
tone="warn"
|
|
548
|
+
/>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const data = file.data;
|
|
553
|
+
|
|
554
|
+
if (file.shape.shape === 'singleton') {
|
|
555
|
+
// Path-aware write (W2 hardening): a nested `group` field commits through
|
|
556
|
+
// the SAME immutable `setDeep`, so an edit three levels down (e.g.
|
|
557
|
+
// `enemyKinds.scout.hp`) only ever replaces the leaf — every other level
|
|
558
|
+
// is spread, preserving key order (D1's diff-minimal contract). This is
|
|
559
|
+
// what keeps a nested singleton's serialized shape an OBJECT at every
|
|
560
|
+
// level instead of ever collapsing a branch to a JSON string.
|
|
561
|
+
const onCommitAt = (path: string[], value: unknown) => {
|
|
562
|
+
writeFile(file, setDeep(data, path, value));
|
|
563
|
+
};
|
|
564
|
+
return (
|
|
565
|
+
<div style={{ maxWidth: 480 }}>
|
|
566
|
+
<FieldList fields={file.shape.fields} value={data} path={[]} onCommitAt={onCommitAt} />
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Table: one grid row per data key ("$schema" is plumbing, never a row),
|
|
572
|
+
// one column per row-schema field, plus row add/remove (W6b) — a separate
|
|
573
|
+
// component because add-row needs its own draft-key state (hooks can't
|
|
574
|
+
// live in this early-return-heavy function; see `TableEditor`'s comment).
|
|
575
|
+
return (
|
|
576
|
+
<TableEditor file={file} data={data} rowFields={file.shape.rowFields} writeFile={writeFile} />
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function Message({ text, tone }: { text: string; tone: 'error' | 'warn' }) {
|
|
581
|
+
return (
|
|
582
|
+
<div style={{ fontSize: 12, color: tone === 'error' ? '#FF6B6B' : '#FFD866', maxWidth: 560 }}>
|
|
583
|
+
{text}
|
|
584
|
+
</div>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Table-shape data-file editor (W6b, docs/DATA-TOOLS-DESIGN.md §7): the grid
|
|
590
|
+
* itself (one row per data key, one column per row-schema field — unchanged
|
|
591
|
+
* from W2) plus row add/remove. Pulled out of `DataFileEditor` because it's
|
|
592
|
+
* the only shape needing its own hook state (the add-row key draft + inline
|
|
593
|
+
* error) — `DataFileEditor` stays a plain function with early-return teaching
|
|
594
|
+
* branches (no conditional hooks).
|
|
595
|
+
*/
|
|
596
|
+
function TableEditor({
|
|
597
|
+
file,
|
|
598
|
+
data,
|
|
599
|
+
rowFields,
|
|
600
|
+
writeFile,
|
|
601
|
+
}: {
|
|
602
|
+
file: LoadedDataFile;
|
|
603
|
+
data: Record<string, unknown>;
|
|
604
|
+
rowFields: DataFieldDescriptor[];
|
|
605
|
+
writeFile: (file: LoadedDataFile, nextData: Record<string, unknown>) => void;
|
|
606
|
+
}) {
|
|
607
|
+
const rowKeys = Object.keys(data).filter((k) => k !== '$schema');
|
|
608
|
+
// Any successful edit through the grid/add-row clears a stale CSV import
|
|
609
|
+
// error block (W6c) — it describes the LAST import attempt, not the
|
|
610
|
+
// current state of the file.
|
|
611
|
+
const [importErrors, setImportErrors] = useState<string[]>([]);
|
|
612
|
+
|
|
613
|
+
const setCell = (rowKey: string, fieldName: string, value: unknown) => {
|
|
614
|
+
const row = data[rowKey];
|
|
615
|
+
const rowObj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
|
|
616
|
+
writeFile(file, { ...data, [rowKey]: { ...rowObj, [fieldName]: value } });
|
|
617
|
+
if (importErrors.length > 0) setImportErrors([]);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Remove: no confirm dialog (D1's "undo is git" stance) — `$schema`
|
|
621
|
+
// survives in `rest` (re-ordered first by `serializeDataFile`), and the
|
|
622
|
+
// spread preserves every other row's key order.
|
|
623
|
+
const removeRow = (rowKey: string) => {
|
|
624
|
+
const { [rowKey]: _removed, ...rest } = data;
|
|
625
|
+
writeFile(file, rest);
|
|
626
|
+
if (importErrors.length > 0) setImportErrors([]);
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const [newKey, setNewKey] = useState('');
|
|
630
|
+
const [addError, setAddError] = useState<string | null>(null);
|
|
631
|
+
|
|
632
|
+
const addRow = () => {
|
|
633
|
+
const trimmed = newKey.trim();
|
|
634
|
+
if (!trimmed) {
|
|
635
|
+
setAddError('Row key cannot be empty.');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (trimmed === '$schema') {
|
|
639
|
+
setAddError('"$schema" is reserved and cannot be a row key.');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (trimmed in data) {
|
|
643
|
+
setAddError(`Row "${trimmed}" already exists.`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const row = Object.fromEntries(rowFields.map((f) => [f.name, defaultValueForDescriptor(f)]));
|
|
647
|
+
writeFile(file, { ...data, [trimmed]: row }); // appends — key order preserved
|
|
648
|
+
setNewKey('');
|
|
649
|
+
setAddError(null);
|
|
650
|
+
if (importErrors.length > 0) setImportErrors([]);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// ---- CSV round-trip (W6c) ----
|
|
654
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
655
|
+
|
|
656
|
+
const exportCsv = () => {
|
|
657
|
+
const csv = tableToCsv(data, rowFields);
|
|
658
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
659
|
+
const url = URL.createObjectURL(blob);
|
|
660
|
+
const baseName = file.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
|
|
661
|
+
const a = document.createElement('a');
|
|
662
|
+
a.href = url;
|
|
663
|
+
a.download = `${baseName}.csv`;
|
|
664
|
+
a.click();
|
|
665
|
+
URL.revokeObjectURL(url);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const importCsv = async (fileList: FileList | null) => {
|
|
669
|
+
const picked = fileList?.[0];
|
|
670
|
+
if (fileInputRef.current) fileInputRef.current.value = ''; // allow re-picking the same file
|
|
671
|
+
if (!picked) return;
|
|
672
|
+
const text = await picked.text();
|
|
673
|
+
const { rows, errors } = csvToTable(text, rowFields);
|
|
674
|
+
if (errors.length > 0) {
|
|
675
|
+
setImportErrors(errors);
|
|
676
|
+
return; // atomic — nothing written
|
|
677
|
+
}
|
|
678
|
+
setImportErrors([]);
|
|
679
|
+
// Whole-table replace: a CSV IS the table. `$schema` is plumbing, carried
|
|
680
|
+
// over from the current file (never itself a CSV column).
|
|
681
|
+
const next: Record<string, unknown> = '$schema' in data ? { $schema: data['$schema'] } : {};
|
|
682
|
+
writeFile(file, { ...next, ...rows });
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<div>
|
|
687
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
data-testid="data-csv-export"
|
|
691
|
+
onClick={exportCsv}
|
|
692
|
+
title="Download this table as a CSV file"
|
|
693
|
+
style={{
|
|
694
|
+
background: '#2c2c2c',
|
|
695
|
+
color: '#c5c8ce',
|
|
696
|
+
border: '1px solid #444',
|
|
697
|
+
borderRadius: 2,
|
|
698
|
+
padding: '3px 10px',
|
|
699
|
+
fontSize: 12,
|
|
700
|
+
cursor: 'pointer',
|
|
701
|
+
}}
|
|
702
|
+
>
|
|
703
|
+
Export CSV
|
|
704
|
+
</button>
|
|
705
|
+
<button
|
|
706
|
+
type="button"
|
|
707
|
+
data-testid="data-csv-import"
|
|
708
|
+
onClick={() => fileInputRef.current?.click()}
|
|
709
|
+
title="Import replaces every row in this file (undo is git)."
|
|
710
|
+
style={{
|
|
711
|
+
background: '#2c2c2c',
|
|
712
|
+
color: '#c5c8ce',
|
|
713
|
+
border: '1px solid #444',
|
|
714
|
+
borderRadius: 2,
|
|
715
|
+
padding: '3px 10px',
|
|
716
|
+
fontSize: 12,
|
|
717
|
+
cursor: 'pointer',
|
|
718
|
+
}}
|
|
719
|
+
>
|
|
720
|
+
Import CSV
|
|
721
|
+
</button>
|
|
722
|
+
<input
|
|
723
|
+
ref={fileInputRef}
|
|
724
|
+
type="file"
|
|
725
|
+
accept=".csv,text/csv"
|
|
726
|
+
style={{ display: 'none' }}
|
|
727
|
+
onChange={(e) => void importCsv(e.target.files)}
|
|
728
|
+
/>
|
|
729
|
+
</div>
|
|
730
|
+
{importErrors.length > 0 && (
|
|
731
|
+
<div data-testid="data-csv-errors" style={{ marginBottom: 8 }}>
|
|
732
|
+
{importErrors.map((err) => (
|
|
733
|
+
<Message key={err} text={err} tone="error" />
|
|
734
|
+
))}
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
<div
|
|
738
|
+
style={{
|
|
739
|
+
display: 'grid',
|
|
740
|
+
gridTemplateColumns: `110px repeat(${rowFields.length}, minmax(100px, 200px)) 24px`,
|
|
741
|
+
gap: '3px 6px',
|
|
742
|
+
alignItems: 'center',
|
|
743
|
+
width: 'max-content',
|
|
744
|
+
}}
|
|
745
|
+
>
|
|
746
|
+
{/* Header row */}
|
|
747
|
+
<span style={{ ...labelStyle, width: 'auto' }} />
|
|
748
|
+
{rowFields.map((f) => {
|
|
749
|
+
const head = (
|
|
750
|
+
<span key={f.name} style={{ ...labelStyle, width: 'auto', fontWeight: 600 }}>
|
|
751
|
+
{f.name}
|
|
752
|
+
</span>
|
|
753
|
+
);
|
|
754
|
+
return f.description ? (
|
|
755
|
+
<Tooltip key={f.name} text={f.description} position="bottom">
|
|
756
|
+
{head}
|
|
757
|
+
</Tooltip>
|
|
758
|
+
) : (
|
|
759
|
+
head
|
|
760
|
+
);
|
|
761
|
+
})}
|
|
762
|
+
<span />
|
|
763
|
+
|
|
764
|
+
{rowKeys.map((rowKey) => {
|
|
765
|
+
const row = data[rowKey];
|
|
766
|
+
const rowObj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
|
|
767
|
+
return (
|
|
768
|
+
<Fragment key={rowKey}>
|
|
769
|
+
<span style={{ ...labelStyle, width: 'auto', color: '#b0b0b0', fontWeight: 600 }}>
|
|
770
|
+
{rowKey}
|
|
771
|
+
</span>
|
|
772
|
+
{rowFields.map((f) => (
|
|
773
|
+
<Widget
|
|
774
|
+
key={f.name}
|
|
775
|
+
field={f}
|
|
776
|
+
value={f.name in rowObj ? rowObj[f.name] : f.default}
|
|
777
|
+
onCommit={(v) => setCell(rowKey, f.name, v)}
|
|
778
|
+
testId={`data-cell-${rowKey}-${f.name}`}
|
|
779
|
+
/>
|
|
780
|
+
))}
|
|
781
|
+
<button
|
|
782
|
+
type="button"
|
|
783
|
+
data-testid={`data-row-remove-${rowKey}`}
|
|
784
|
+
title="Remove row (undo is git)"
|
|
785
|
+
onClick={() => removeRow(rowKey)}
|
|
786
|
+
style={{
|
|
787
|
+
background: 'transparent',
|
|
788
|
+
border: 'none',
|
|
789
|
+
color: '#8c8c8c',
|
|
790
|
+
cursor: 'pointer',
|
|
791
|
+
fontSize: 13,
|
|
792
|
+
lineHeight: 1,
|
|
793
|
+
padding: '2px 4px',
|
|
794
|
+
}}
|
|
795
|
+
>
|
|
796
|
+
×
|
|
797
|
+
</button>
|
|
798
|
+
</Fragment>
|
|
799
|
+
);
|
|
800
|
+
})}
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
|
|
804
|
+
<input
|
|
805
|
+
data-testid="data-row-add-key"
|
|
806
|
+
placeholder="new-row-key"
|
|
807
|
+
value={newKey}
|
|
808
|
+
onChange={(e) => {
|
|
809
|
+
setNewKey(e.target.value);
|
|
810
|
+
if (addError) setAddError(null);
|
|
811
|
+
}}
|
|
812
|
+
onKeyDown={(e) => {
|
|
813
|
+
if (e.key === 'Enter') addRow();
|
|
814
|
+
}}
|
|
815
|
+
style={{ ...inputStyle, width: 160 }}
|
|
816
|
+
/>
|
|
817
|
+
<button
|
|
818
|
+
type="button"
|
|
819
|
+
data-testid="data-row-add"
|
|
820
|
+
onClick={addRow}
|
|
821
|
+
style={{
|
|
822
|
+
background: '#2c2c2c',
|
|
823
|
+
color: '#c5c8ce',
|
|
824
|
+
border: '1px solid #444',
|
|
825
|
+
borderRadius: 2,
|
|
826
|
+
padding: '3px 10px',
|
|
827
|
+
fontSize: 12,
|
|
828
|
+
cursor: 'pointer',
|
|
829
|
+
}}
|
|
830
|
+
>
|
|
831
|
+
Add row
|
|
832
|
+
</button>
|
|
833
|
+
</div>
|
|
834
|
+
{addError && (
|
|
835
|
+
<div style={{ marginTop: 4 }}>
|
|
836
|
+
<Message text={addError} tone="error" />
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function FieldRow({
|
|
844
|
+
name,
|
|
845
|
+
description,
|
|
846
|
+
children,
|
|
847
|
+
}: {
|
|
848
|
+
name: string;
|
|
849
|
+
description?: string | undefined;
|
|
850
|
+
children: React.ReactNode;
|
|
851
|
+
}) {
|
|
852
|
+
const label = <span style={labelStyle}>{name}</span>;
|
|
853
|
+
return (
|
|
854
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 3 }}>
|
|
855
|
+
{description ? (
|
|
856
|
+
<Tooltip text={description} position="right">
|
|
857
|
+
{label}
|
|
858
|
+
</Tooltip>
|
|
859
|
+
) : (
|
|
860
|
+
label
|
|
861
|
+
)}
|
|
862
|
+
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
|
863
|
+
</div>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Recursive singleton-form renderer (W2 hardening §3.2): a `'group'`
|
|
869
|
+
* descriptor renders as an indented, bordered sub-form (a nested `FieldList`
|
|
870
|
+
* over its `children`) instead of falling back to a raw-JSON cell; every
|
|
871
|
+
* other kind renders the existing `Widget`. `path` accumulates the field
|
|
872
|
+
* names from the form root down to this row so `onCommitAt` can write
|
|
873
|
+
* exactly the touched leaf via `setDeep` — nothing above or beside it in the
|
|
874
|
+
* nested object is ever re-typed as a string.
|
|
875
|
+
*/
|
|
876
|
+
function FieldList({
|
|
877
|
+
fields,
|
|
878
|
+
value,
|
|
879
|
+
path,
|
|
880
|
+
onCommitAt,
|
|
881
|
+
}: {
|
|
882
|
+
fields: DataFieldDescriptor[];
|
|
883
|
+
value: Record<string, unknown>;
|
|
884
|
+
path: string[];
|
|
885
|
+
onCommitAt: (path: string[], value: unknown) => void;
|
|
886
|
+
}) {
|
|
887
|
+
return (
|
|
888
|
+
<>
|
|
889
|
+
{fields.map((f) => {
|
|
890
|
+
const fieldPath = [...path, f.name];
|
|
891
|
+
const raw = f.name in value ? value[f.name] : f.default;
|
|
892
|
+
const testId = `data-field-${fieldPath.join('.')}`;
|
|
893
|
+
|
|
894
|
+
if (f.kind === 'group') {
|
|
895
|
+
const childValue =
|
|
896
|
+
raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
897
|
+
? (raw as Record<string, unknown>)
|
|
898
|
+
: {};
|
|
899
|
+
return (
|
|
900
|
+
<div
|
|
901
|
+
key={f.name}
|
|
902
|
+
data-testid={testId}
|
|
903
|
+
style={{
|
|
904
|
+
border: '1px solid #333',
|
|
905
|
+
borderRadius: 3,
|
|
906
|
+
padding: '6px 8px',
|
|
907
|
+
marginBottom: 6,
|
|
908
|
+
marginLeft: Math.min(path.length, 3) * 10,
|
|
909
|
+
}}
|
|
910
|
+
>
|
|
911
|
+
<GroupHeader name={f.name} description={f.description} />
|
|
912
|
+
<FieldList
|
|
913
|
+
fields={f.children ?? []}
|
|
914
|
+
value={childValue}
|
|
915
|
+
path={fieldPath}
|
|
916
|
+
onCommitAt={onCommitAt}
|
|
917
|
+
/>
|
|
918
|
+
</div>
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return (
|
|
923
|
+
<FieldRow key={f.name} name={f.name} description={f.description}>
|
|
924
|
+
<Widget
|
|
925
|
+
field={f}
|
|
926
|
+
value={raw}
|
|
927
|
+
onCommit={(v) => onCommitAt(fieldPath, v)}
|
|
928
|
+
testId={testId}
|
|
929
|
+
/>
|
|
930
|
+
</FieldRow>
|
|
931
|
+
);
|
|
932
|
+
})}
|
|
933
|
+
</>
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function GroupHeader({ name, description }: { name: string; description?: string | undefined }) {
|
|
938
|
+
const label = (
|
|
939
|
+
<div style={{ fontSize: 11, color: '#a0a0a0', fontWeight: 600, marginBottom: 4 }}>{name}</div>
|
|
940
|
+
);
|
|
941
|
+
return description ? (
|
|
942
|
+
<Tooltip text={description} position="right">
|
|
943
|
+
{label}
|
|
944
|
+
</Tooltip>
|
|
945
|
+
) : (
|
|
946
|
+
label
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* One typed cell/field widget — the SAME primitives the component inspector
|
|
952
|
+
* renders `FieldDescriptor`s with (`ComponentsSection.renderWidget`), so the
|
|
953
|
+
* Data panel looks native. Number edits clamp to the schema's
|
|
954
|
+
* `minimum`/`maximum` (the JSON-Schema extra `DataFieldDescriptor` carries).
|
|
955
|
+
*/
|
|
956
|
+
function Widget({
|
|
957
|
+
field,
|
|
958
|
+
value,
|
|
959
|
+
onCommit,
|
|
960
|
+
testId,
|
|
961
|
+
}: {
|
|
962
|
+
field: DataFieldDescriptor;
|
|
963
|
+
value: unknown;
|
|
964
|
+
onCommit: (value: unknown) => void;
|
|
965
|
+
testId: string;
|
|
966
|
+
}) {
|
|
967
|
+
// Read unconditionally (hook rules) even though only the `'ref'` case below
|
|
968
|
+
// uses it — cheap, and `RefOptionsContext`'s default (empty map) makes a
|
|
969
|
+
// ref field outside the panel's own provider degrade to the "target not
|
|
970
|
+
// loaded" fallback rather than crash.
|
|
971
|
+
const refOptions = useContext(RefOptionsContext);
|
|
972
|
+
|
|
973
|
+
switch (field.kind) {
|
|
974
|
+
case 'number':
|
|
975
|
+
return <NumberCell field={field} value={value} onCommit={onCommit} testId={testId} />;
|
|
976
|
+
case 'boolean':
|
|
977
|
+
return (
|
|
978
|
+
<input
|
|
979
|
+
type="checkbox"
|
|
980
|
+
data-testid={testId}
|
|
981
|
+
checked={Boolean(value)}
|
|
982
|
+
onChange={(e) => onCommit(e.target.checked)}
|
|
983
|
+
/>
|
|
984
|
+
);
|
|
985
|
+
case 'color':
|
|
986
|
+
return (
|
|
987
|
+
<div data-testid={testId}>
|
|
988
|
+
<ColorInput value={typeof value === 'string' ? value : '#ffffff'} onChange={onCommit} />
|
|
989
|
+
</div>
|
|
990
|
+
);
|
|
991
|
+
case 'enum':
|
|
992
|
+
return (
|
|
993
|
+
<select
|
|
994
|
+
data-testid={testId}
|
|
995
|
+
value={value == null ? '' : String(value)}
|
|
996
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
997
|
+
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
998
|
+
>
|
|
999
|
+
{(field.options ?? []).map((opt) => (
|
|
1000
|
+
<option key={opt} value={opt}>
|
|
1001
|
+
{opt}
|
|
1002
|
+
</option>
|
|
1003
|
+
))}
|
|
1004
|
+
</select>
|
|
1005
|
+
);
|
|
1006
|
+
case 'string':
|
|
1007
|
+
return <StringCell value={value} onCommit={onCommit} testId={testId} />;
|
|
1008
|
+
case 'ref': {
|
|
1009
|
+
// The designer payoff (§2.2): a declared `dataRef(target)` field
|
|
1010
|
+
// renders as a `stem#key` PICKER, sourced from the target file's own
|
|
1011
|
+
// top-level keys (already loaded — every data file's raw text is
|
|
1012
|
+
// fetched by this same panel, see `RefOptionsContext` above), instead
|
|
1013
|
+
// of free text. Falls back to `StringCell` when the target file isn't
|
|
1014
|
+
// loaded (renamed/deleted/never existed — `vgai doctor`'s
|
|
1015
|
+
// `data-ref-missing-file` is the enforcement side of that) or the
|
|
1016
|
+
// current value carries a deeper `key.field` path (v1 scope: deep ref
|
|
1017
|
+
// addresses stay free-text, per the design note this module's history
|
|
1018
|
+
// carries).
|
|
1019
|
+
const targetStem = field.refTarget;
|
|
1020
|
+
const targetKeys = targetStem !== undefined ? refOptions.get(targetStem) : undefined;
|
|
1021
|
+
const current = typeof value === 'string' ? value : '';
|
|
1022
|
+
const hasDeepPath = current.includes('#') && (current.split('#')[1]?.includes('.') ?? false);
|
|
1023
|
+
if (targetStem === undefined || targetKeys === undefined || hasDeepPath) {
|
|
1024
|
+
return <StringCell value={value} onCommit={onCommit} testId={testId} />;
|
|
1025
|
+
}
|
|
1026
|
+
const options = targetKeys.map((k) => `${targetStem}#${k}`);
|
|
1027
|
+
const isDangling = current !== '' && !options.includes(current);
|
|
1028
|
+
return (
|
|
1029
|
+
<select
|
|
1030
|
+
data-testid={testId}
|
|
1031
|
+
value={current}
|
|
1032
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
1033
|
+
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
1034
|
+
>
|
|
1035
|
+
<option value="">—</option>
|
|
1036
|
+
{options.map((opt) => (
|
|
1037
|
+
<option key={opt} value={opt}>
|
|
1038
|
+
{opt}
|
|
1039
|
+
</option>
|
|
1040
|
+
))}
|
|
1041
|
+
{isDangling && <option value={current}>{current} (dangling)</option>}
|
|
1042
|
+
</select>
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
default:
|
|
1046
|
+
// 'group' (only reachable here from a TABLE cell — nested sub-forms
|
|
1047
|
+
// inside a grid cell are deferred, W6) and 'json' (arrays, unions,
|
|
1048
|
+
// anything past the group depth cap) both fall back to the
|
|
1049
|
+
// type-preserving `JsonCell`. This is the correctness floor (W2
|
|
1050
|
+
// hardening): `StringCell` commits raw text, which is only ever safe
|
|
1051
|
+
// for an actual `type: 'string'` field — every other degraded case
|
|
1052
|
+
// MUST go through a parse-or-refuse cell, never a bare string commit.
|
|
1053
|
+
return <JsonCell value={value} onCommit={onCommit} testId={testId} />;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Number field/cell: buffers a drag scrub into ONE commit at release, instead
|
|
1059
|
+
* of one per pointermove (#3). `NumberInput` still calls `onChange` on every
|
|
1060
|
+
* intermediate move (so the widget's OWN displayed number stays live-scrub
|
|
1061
|
+
* responsive) — but here that per-move callback only updates `live`, local
|
|
1062
|
+
* state that overrides the authoritative `value` prop for display purposes.
|
|
1063
|
+
* The actual `onCommit` (a `writeFile` → POST, non-atomic disk truncate-write
|
|
1064
|
+
* per D1) fires from `onChangeEnd` instead, which `NumberInput` invokes
|
|
1065
|
+
* exactly once per gesture: on pointerup after a real drag, or once for a
|
|
1066
|
+
* typed blur/Enter edit (already single-shot, unaffected either way).
|
|
1067
|
+
*/
|
|
1068
|
+
function NumberCell({
|
|
1069
|
+
field,
|
|
1070
|
+
value,
|
|
1071
|
+
onCommit,
|
|
1072
|
+
testId,
|
|
1073
|
+
}: {
|
|
1074
|
+
field: DataFieldDescriptor;
|
|
1075
|
+
value: unknown;
|
|
1076
|
+
onCommit: (value: unknown) => void;
|
|
1077
|
+
testId: string;
|
|
1078
|
+
}) {
|
|
1079
|
+
const clamp = (v: number) => {
|
|
1080
|
+
let out = v;
|
|
1081
|
+
if (field.min !== undefined) out = Math.max(field.min, out);
|
|
1082
|
+
if (field.max !== undefined) out = Math.min(field.max, out);
|
|
1083
|
+
return out;
|
|
1084
|
+
};
|
|
1085
|
+
const authoritative = typeof value === 'number' ? value : Number(value) || 0;
|
|
1086
|
+
const [live, setLive] = useState<number | null>(null);
|
|
1087
|
+
|
|
1088
|
+
// Once the authoritative (committed/file) value changes underneath us —
|
|
1089
|
+
// our own commit landing, another widget, a reload — drop the live
|
|
1090
|
+
// override so we go back to reflecting real state.
|
|
1091
|
+
useEffect(() => {
|
|
1092
|
+
setLive(null);
|
|
1093
|
+
}, [authoritative]);
|
|
1094
|
+
|
|
1095
|
+
return (
|
|
1096
|
+
<NumberInput
|
|
1097
|
+
value={live ?? authoritative}
|
|
1098
|
+
onChange={(v) => setLive(clamp(v))}
|
|
1099
|
+
onChangeEnd={(v) => {
|
|
1100
|
+
setLive(null);
|
|
1101
|
+
onCommit(clamp(v));
|
|
1102
|
+
}}
|
|
1103
|
+
testId={testId}
|
|
1104
|
+
/>
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Draft-buffered text cell: commits on blur/Enter, reverts on Escape — unlike
|
|
1110
|
+
* the inspector's keystroke-live `RawInput`, because HERE every commit is a
|
|
1111
|
+
* FILE write (D1) and per-keystroke writes would spam disk + HMR.
|
|
1112
|
+
*
|
|
1113
|
+
* `kind: 'string'` ONLY (W2 hardening) — a real string field can never be
|
|
1114
|
+
* widened by committing raw text, because the field already IS a string.
|
|
1115
|
+
* Non-string fallbacks render `JsonCell` instead (see `Widget`'s `default`).
|
|
1116
|
+
*/
|
|
1117
|
+
function StringCell({
|
|
1118
|
+
value,
|
|
1119
|
+
onCommit,
|
|
1120
|
+
testId,
|
|
1121
|
+
}: {
|
|
1122
|
+
value: unknown;
|
|
1123
|
+
onCommit: (value: unknown) => void;
|
|
1124
|
+
testId: string;
|
|
1125
|
+
}) {
|
|
1126
|
+
const display = value === null || value === undefined ? '' : String(value);
|
|
1127
|
+
const [draft, setDraft] = useState(display);
|
|
1128
|
+
// Escape below calls `.blur()` imperatively, which fires the native
|
|
1129
|
+
// blur/focusout event SYNCHRONOUSLY inside the same handler — so the
|
|
1130
|
+
// resulting `onBlur={commit}` closes over the PRE-revert `draft` (React
|
|
1131
|
+
// hasn't re-rendered yet) and would re-commit the just-discarded edit. Same
|
|
1132
|
+
// fix as `primitives/JsonInput.tsx`'s `revertingRef`: Escape sets it before
|
|
1133
|
+
// blurring, `commit()` checks it first and no-ops instead of racing it.
|
|
1134
|
+
const revertingRef = useRef(false);
|
|
1135
|
+
|
|
1136
|
+
// Re-sync when the underlying value changes (file re-list, another widget).
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
setDraft(display);
|
|
1139
|
+
}, [display]);
|
|
1140
|
+
|
|
1141
|
+
const commit = () => {
|
|
1142
|
+
if (revertingRef.current) {
|
|
1143
|
+
revertingRef.current = false;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (draft !== display) onCommit(draft);
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
return (
|
|
1150
|
+
<input
|
|
1151
|
+
data-testid={testId}
|
|
1152
|
+
value={draft}
|
|
1153
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
1154
|
+
onBlur={commit}
|
|
1155
|
+
onKeyDown={(e) => {
|
|
1156
|
+
if (e.key === 'Enter') {
|
|
1157
|
+
commit();
|
|
1158
|
+
(e.target as HTMLInputElement).blur();
|
|
1159
|
+
} else if (e.key === 'Escape') {
|
|
1160
|
+
revertingRef.current = true;
|
|
1161
|
+
setDraft(display);
|
|
1162
|
+
(e.target as HTMLInputElement).blur();
|
|
1163
|
+
}
|
|
1164
|
+
}}
|
|
1165
|
+
style={inputStyle}
|
|
1166
|
+
/>
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Type-preserving fallback cell (W2 hardening — the correctness floor, spec
|
|
1172
|
+
* §2.1). Used for anything the panel can't render a typed widget for: a
|
|
1173
|
+
* nested `group` past the depth cap, arrays, unions — and (until §2.1's
|
|
1174
|
+
* variant picker ships) discriminated unions.
|
|
1175
|
+
*
|
|
1176
|
+
* The invariant this cell exists to hold: **a commit must never widen a
|
|
1177
|
+
* typed value into a JSON string.** `commit()` runs `JSON.parse(draft)` and
|
|
1178
|
+
* hands `onCommit` the PARSED value, never the raw text. On parse failure it
|
|
1179
|
+
* REFUSES the commit outright — the draft stays exactly as typed, the input
|
|
1180
|
+
* shows an error state, and no write/POST fires — so a mid-edit invalid JSON
|
|
1181
|
+
* string can never reach disk (which is exactly how the live-observed
|
|
1182
|
+
* corruption happened before this cell existed: a raw string committed
|
|
1183
|
+
* straight over a typed object field).
|
|
1184
|
+
*/
|
|
1185
|
+
function JsonCell({
|
|
1186
|
+
value,
|
|
1187
|
+
onCommit,
|
|
1188
|
+
testId,
|
|
1189
|
+
}: {
|
|
1190
|
+
value: unknown;
|
|
1191
|
+
onCommit: (value: unknown) => void;
|
|
1192
|
+
testId: string;
|
|
1193
|
+
}) {
|
|
1194
|
+
const display = value === undefined ? '' : JSON.stringify(value);
|
|
1195
|
+
const [draft, setDraft] = useState(display);
|
|
1196
|
+
const [invalid, setInvalid] = useState(false);
|
|
1197
|
+
// Same race as `primitives/JsonInput.tsx`'s `revertingRef`: Escape's
|
|
1198
|
+
// imperative `.blur()` fires `onBlur={commit}` synchronously, before React
|
|
1199
|
+
// re-renders with the reverted draft — so `commit`'s closure still sees the
|
|
1200
|
+
// pre-revert (possibly invalid, always discarded) draft and would commit or
|
|
1201
|
+
// re-flag it right after Escape cleared it. This ref lets Escape tell the
|
|
1202
|
+
// nested commit to no-op instead of racing it.
|
|
1203
|
+
const revertingRef = useRef(false);
|
|
1204
|
+
|
|
1205
|
+
// Re-sync when the underlying value changes (file re-list, another widget).
|
|
1206
|
+
useEffect(() => {
|
|
1207
|
+
setDraft(display);
|
|
1208
|
+
setInvalid(false);
|
|
1209
|
+
}, [display]);
|
|
1210
|
+
|
|
1211
|
+
const commit = () => {
|
|
1212
|
+
if (revertingRef.current) {
|
|
1213
|
+
revertingRef.current = false;
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (draft === display) {
|
|
1217
|
+
setInvalid(false);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
let parsed: unknown;
|
|
1221
|
+
try {
|
|
1222
|
+
parsed = JSON.parse(draft);
|
|
1223
|
+
} catch {
|
|
1224
|
+
setInvalid(true); // refuse: keep the draft, no onCommit, no write
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
setInvalid(false);
|
|
1228
|
+
onCommit(parsed);
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
return (
|
|
1232
|
+
<div>
|
|
1233
|
+
<input
|
|
1234
|
+
data-testid={testId}
|
|
1235
|
+
value={draft}
|
|
1236
|
+
onChange={(e) => {
|
|
1237
|
+
setDraft(e.target.value);
|
|
1238
|
+
if (invalid) setInvalid(false);
|
|
1239
|
+
}}
|
|
1240
|
+
onBlur={commit}
|
|
1241
|
+
onKeyDown={(e) => {
|
|
1242
|
+
if (e.key === 'Enter') {
|
|
1243
|
+
commit();
|
|
1244
|
+
} else if (e.key === 'Escape') {
|
|
1245
|
+
revertingRef.current = true;
|
|
1246
|
+
setDraft(display);
|
|
1247
|
+
setInvalid(false);
|
|
1248
|
+
(e.target as HTMLInputElement).blur();
|
|
1249
|
+
}
|
|
1250
|
+
}}
|
|
1251
|
+
style={invalid ? { ...inputStyle, borderColor: '#FF6B6B', color: '#FF6B6B' } : inputStyle}
|
|
1252
|
+
title={
|
|
1253
|
+
invalid
|
|
1254
|
+
? 'Not valid JSON — nothing written. Fix the JSON or press Escape to revert.'
|
|
1255
|
+
: undefined
|
|
1256
|
+
}
|
|
1257
|
+
/>
|
|
1258
|
+
{invalid && (
|
|
1259
|
+
<div
|
|
1260
|
+
data-testid={`${testId}-error`}
|
|
1261
|
+
style={{ fontSize: 10, color: '#FF6B6B', marginTop: 2 }}
|
|
1262
|
+
>
|
|
1263
|
+
Not valid JSON — nothing written. Fix the JSON or press Escape to revert.
|
|
1264
|
+
</div>
|
|
1265
|
+
)}
|
|
1266
|
+
</div>
|
|
1267
|
+
);
|
|
1268
|
+
}
|