@vgai/editor 0.2.0 → 0.4.0-canary.20260715.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-CyiTVRi6.js +30 -0
  3. package/dist/assets/browserAll-fGZ_P1P8.js +1 -0
  4. package/dist/assets/create-runtime-BzSHBTYw.js +1 -0
  5. package/dist/assets/game-DQ7YHNS8.js +1 -0
  6. package/dist/assets/game-DX6Obomf.js +1 -0
  7. package/dist/assets/game-DoUJj8vY.js +1 -0
  8. package/dist/assets/{game-xsOho_wg.js → game-HqybI_X7.js} +3 -3
  9. package/dist/assets/game-NUAnyPkS.js +1 -0
  10. package/dist/assets/game-ck_dNIE6.js +1 -0
  11. package/dist/assets/{game-pt_1lQLv.js → game-hIf5LD4E.js} +1 -1
  12. package/dist/assets/game-loop-o2CcBMFY.js +1 -0
  13. package/dist/assets/{game-source-CvidN7AB.js → game-source-C8HM7gbP.js} +1 -1
  14. package/dist/assets/{game-BI111o5f.js → game-uR_lv3Mp.js} +3 -3
  15. package/dist/assets/index-B-HA1uu3.js +15412 -0
  16. package/dist/assets/{index-Bp5cecD6.js → index-CSBAZwMx.js} +2 -2
  17. package/dist/assets/{index-DL7RcQ0n.js → index-CoSRCKPS.js} +1 -1
  18. package/dist/assets/{index-Cs8xwv3e.js → index-D93bBuUl.js} +1 -1
  19. package/dist/assets/index-bu01fqch.css +1 -0
  20. package/dist/assets/ingest-siblings-CCkkE2h7.js +1 -0
  21. package/dist/assets/mount-manifest-B2ECssU2.js +1 -0
  22. package/dist/assets/react-18-Bmj05S8J.js +1 -0
  23. package/dist/assets/webworkerAll-BC6gMOLZ.js +1 -0
  24. package/dist/assets/world-documents-DXPcAEA0.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7718 -3436
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +71 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1236 -296
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +1116 -344
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +620 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -1,37 +1,66 @@
1
1
  import {
2
- faArrowsRotate,
2
+ faArrowLeft,
3
+ faBoxArchive,
3
4
  faCubes,
5
+ faEllipsis,
4
6
  faFile,
5
7
  faFileCode,
6
8
  faFileImage,
7
9
  faFolder,
10
+ faList,
8
11
  faMusic,
9
12
  faPaintbrush,
10
13
  faPuzzlePiece,
11
14
  faImage as faSceneIcon,
15
+ faTableCellsLarge,
12
16
  } from '@fortawesome/free-solid-svg-icons';
13
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
14
- import { useCallback, useEffect, useRef, useState } from 'react';
15
- import { useEditorStore } from '../EditorContext';
16
- import { type AssetEntry, listAssets } from '../editor-api';
17
- import { setActiveScope } from '../hotkeys';
18
- import { getModelThumbnailRenderer } from '../model-thumbnail';
19
- import { FolderTree } from './FolderTree';
20
- import { Panel } from './primitives/Panel';
21
- import { Tooltip } from './primitives/Tooltip';
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
19
+ import {
20
+ type AssetCapabilityKind,
21
+ assetCapabilities,
22
+ assetDocumentKind,
23
+ } from '../asset-workflow/asset-capabilities';
24
+ import { confirmAssetAction } from '../asset-workflow/asset-workflow-quality';
25
+ import {
26
+ PROJECT_ASSET_COMMANDS,
27
+ projectAssetBrowserStateKey,
28
+ } from '../asset-workflow/project-asset-commands';
29
+ import { inspectProjectAssetHealthFromStorage } from '../asset-workflow/project-asset-health';
30
+ import { executeProjectAssetOperation } from '../asset-workflow/project-asset-operations';
31
+ import {
32
+ loadProjectThumbnailManifest,
33
+ ThumbnailJobQueue,
34
+ } from '../asset-workflow/thumbnail-system';
35
+ import {
36
+ accent,
37
+ Button,
38
+ border,
39
+ EditorIcon,
40
+ EditorToolbar,
41
+ editorIcons,
42
+ IconButton,
43
+ Menu,
44
+ MenuItem,
45
+ radius,
46
+ Select,
47
+ StateSurface,
48
+ TextInput,
49
+ ToolbarGroup,
50
+ Tooltip,
51
+ text,
52
+ } from '../design-system';
53
+ import { useEditorStore, useHistoryService } from '../EditorContext';
54
+ import { type AssetEntry, listAssets, revealInFinder } from '../editor-api';
55
+ import { isEditableTarget, setActiveScope } from '../hotkeys';
56
+ import { getModelThumbnailRenderer, modelThumbnailFormat } from '../model-thumbnail';
57
+ import { getCurrentProject } from '../project-manager';
58
+ import { getStorageBackend } from '../storage';
59
+ import { openAssetDocument } from './asset-documents';
60
+ import './asset-workflow.css';
22
61
 
23
62
  // --- Helpers ---
24
63
 
25
- function getExtension(name: string): string {
26
- // Handle compound extensions like .vscn.json
27
- if (name.endsWith('.vscn.json')) return '.vscn.json';
28
- if (name.endsWith('.prefab.json')) return '.prefab.json';
29
- if (name.endsWith('.mat.json')) return '.mat.json';
30
- if (name.endsWith('.inputmap.json')) return '.inputmap.json';
31
- const dot = name.lastIndexOf('.');
32
- return dot >= 0 ? name.slice(dot) : '';
33
- }
34
-
35
64
  type AssetKind =
36
65
  | 'model'
37
66
  | 'image'
@@ -41,22 +70,18 @@ type AssetKind =
41
70
  | 'audio'
42
71
  | 'json'
43
72
  | 'folder'
73
+ | 'source'
44
74
  | 'unknown';
45
75
 
76
+ type BrowserKind = AssetKind;
77
+ type ViewableAssetKind = Exclude<AssetKind, 'folder' | 'unknown'>;
78
+
46
79
  function getAssetKind(entry: AssetEntry): AssetKind {
47
80
  if (entry.type === 'directory') return 'folder';
48
- const ext = getExtension(entry.name);
49
- if (['.glb', '.gltf'].includes(ext)) return 'model';
50
- if (['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif'].includes(ext)) return 'image';
51
- if (ext === '.vscn.json') return 'scene';
52
- if (ext === '.prefab.json') return 'prefab';
53
- if (ext === '.mat.json') return 'material';
54
- if (['.mp3', '.ogg', '.wav', '.flac'].includes(ext)) return 'audio';
55
- if (ext === '.json' || ext === '.inputmap.json') return 'json';
56
- return 'unknown';
81
+ return assetCapabilities(entry.name).kind as AssetCapabilityKind;
57
82
  }
58
83
 
59
- const assetIconColors: Record<AssetKind, string> = {
84
+ const assetIconColors: Record<BrowserKind, string> = {
60
85
  folder: '#e8a740',
61
86
  model: '#6bc5f7',
62
87
  scene: '#8b5cf6',
@@ -66,9 +91,10 @@ const assetIconColors: Record<AssetKind, string> = {
66
91
  image: '#f472b6',
67
92
  json: '#fbbf24',
68
93
  unknown: '#6b7280',
94
+ source: '#fb923c',
69
95
  };
70
96
 
71
- const assetIconMap: Record<AssetKind, typeof faFolder> = {
97
+ const assetIconMap: Record<BrowserKind, typeof faFolder> = {
72
98
  folder: faFolder,
73
99
  model: faCubes,
74
100
  scene: faSceneIcon,
@@ -78,38 +104,81 @@ const assetIconMap: Record<AssetKind, typeof faFolder> = {
78
104
  image: faFileImage,
79
105
  json: faFileCode,
80
106
  unknown: faFile,
107
+ source: faBoxArchive,
81
108
  };
82
109
 
83
- function AssetIcon({ kind }: { kind: AssetKind }) {
110
+ function AssetIcon({ kind, size = 24 }: { kind: BrowserKind; size?: number }) {
84
111
  return (
85
- <FontAwesomeIcon
112
+ <EditorIcon
86
113
  icon={assetIconMap[kind]}
87
- style={{ fontSize: 24, color: assetIconColors[kind] }}
114
+ style={{ fontSize: size, color: assetIconColors[kind] }}
88
115
  />
89
116
  );
90
117
  }
91
-
92
118
  /** Renders an offscreen 3D preview thumbnail for .glb/.gltf model assets. */
119
+ const projectThumbnailQueue = new ThumbnailJobQueue<string>((url) => {
120
+ const format = modelThumbnailFormat(url);
121
+ if (!format) return Promise.reject(new Error(`No model preview loader for ${url}.`));
122
+ return getModelThumbnailRenderer().render({ url, format });
123
+ }, 2);
124
+
93
125
  function ModelThumbnail({ url }: { url: string }) {
94
126
  const [src, setSrc] = useState<string | null>(null);
95
127
  const [failed, setFailed] = useState(false);
128
+ const [manifestPath, setManifestPath] = useState<string | null>(null);
129
+ const [manifestRejected, setManifestRejected] = useState(false);
130
+
131
+ useEffect(() => {
132
+ setSrc(null);
133
+ setFailed(false);
134
+ setManifestPath(null);
135
+ setManifestRejected(false);
136
+ }, [url]);
96
137
 
97
138
  useEffect(() => {
98
139
  let cancelled = false;
99
- getModelThumbnailRenderer()
100
- .render(url)
140
+ let queued: ReturnType<typeof projectThumbnailQueue.request> | null = null;
141
+ void loadProjectThumbnailManifest()
142
+ .then((manifest) => {
143
+ if (cancelled) return;
144
+ const entry = manifest?.entries[url.replace(/^\/+/, '')];
145
+ if (
146
+ !manifestRejected &&
147
+ entry?.path &&
148
+ (entry.state === 'generated' || entry.state === 'upstream')
149
+ ) {
150
+ setManifestPath(entry.path.startsWith('/') ? entry.path : `/${entry.path}`);
151
+ return;
152
+ }
153
+ queued = projectThumbnailQueue.request(url, 100);
154
+ return queued.promise;
155
+ })
101
156
  .then((dataUrl) => {
102
- if (!cancelled) setSrc(dataUrl);
157
+ if (!cancelled && dataUrl) setSrc(dataUrl);
103
158
  })
104
159
  .catch(() => {
105
160
  if (!cancelled) setFailed(true);
106
161
  });
107
162
  return () => {
108
163
  cancelled = true;
164
+ queued?.cancel();
109
165
  };
110
- }, [url]);
166
+ }, [manifestRejected, url]);
111
167
 
112
168
  if (failed) return <AssetIcon kind="model" />;
169
+ if (manifestPath) {
170
+ return (
171
+ <img
172
+ src={manifestPath}
173
+ alt="cached model preview"
174
+ onError={() => {
175
+ setManifestPath(null);
176
+ setManifestRejected(true);
177
+ }}
178
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
179
+ />
180
+ );
181
+ }
113
182
  if (!src) {
114
183
  // Loading spinner
115
184
  return (
@@ -127,8 +196,8 @@ function ModelThumbnail({ url }: { url: string }) {
127
196
  style={{
128
197
  width: 16,
129
198
  height: 16,
130
- border: '2px solid #444',
131
- borderTopColor: '#6bc5f7',
199
+ border: `2px solid ${border[2]}`,
200
+ borderTopColor: assetIconColors.model,
132
201
  borderRadius: '50%',
133
202
  animation: 'editor-spin 0.8s linear infinite',
134
203
  }}
@@ -140,7 +209,7 @@ function ModelThumbnail({ url }: { url: string }) {
140
209
  <img
141
210
  src={src}
142
211
  alt="model preview"
143
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: 2 }}
212
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
144
213
  />
145
214
  );
146
215
  }
@@ -161,22 +230,721 @@ function formatSize(bytes: number): string {
161
230
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
162
231
  }
163
232
 
233
+ type ProjectAssetHealth = 'healthy' | 'source-only' | 'unsupported' | 'stale-import';
234
+
235
+ function projectAssetHealth(name: string, path: string): ProjectAssetHealth {
236
+ const capability = assetCapabilities(name);
237
+ if (!capability.importable) return 'unsupported';
238
+ if (!capability.runtimeReady) return 'source-only';
239
+ if (path.includes('/.generated/') || path.startsWith('.generated/')) return 'stale-import';
240
+ return 'healthy';
241
+ }
242
+
243
+ interface PersistedAssetBrowserState {
244
+ currentPath: string;
245
+ viewMode: AssetViewMode;
246
+ sort: AssetSort;
247
+ filter: AssetFilter;
248
+ density: number;
249
+ }
250
+
251
+ const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
252
+ currentPath: '',
253
+ viewMode: 'grid',
254
+ sort: 'name',
255
+ filter: 'all',
256
+ density: 96,
257
+ };
258
+
259
+ function readAssetBrowserState(): PersistedAssetBrowserState {
260
+ try {
261
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
262
+ const parsed = JSON.parse(
263
+ localStorage.getItem(projectAssetBrowserStateKey(projectId)) ?? '{}',
264
+ ) as Partial<PersistedAssetBrowserState>;
265
+ const legacyViewMode = (parsed as Record<string, unknown>)['viewMode'];
266
+ const legacyRoot = (parsed as Record<string, unknown>)['currentRoot'];
267
+ return {
268
+ ...DEFAULT_BROWSER_STATE,
269
+ ...parsed,
270
+ // v1 briefly persisted synthetic `data` / `stories` roots and a
271
+ // project-root landing page. Assets now opens directly at the real
272
+ // public asset root, so those old locations migrate home once.
273
+ currentPath:
274
+ legacyRoot === undefined || legacyRoot === 'public' ? (parsed.currentPath ?? '') : '',
275
+ viewMode: legacyViewMode === 'columns' ? 'list' : legacyViewMode === 'list' ? 'list' : 'grid',
276
+ };
277
+ } catch {
278
+ return DEFAULT_BROWSER_STATE;
279
+ }
280
+ }
281
+
164
282
  // --- Main Component ---
165
283
 
166
284
  interface AssetBrowserProps {
167
285
  onOpenScene?: (assetPath: string) => void;
168
286
  }
169
287
 
288
+ type AssetRoot = 'public';
289
+ type AssetViewMode = 'grid' | 'list';
290
+ type AssetSort = 'name' | 'type' | 'size' | 'health';
291
+ type AssetFilter =
292
+ | 'all'
293
+ | 'runtime-ready'
294
+ | 'source-only'
295
+ | 'unsupported'
296
+ | 'imported'
297
+ | 'generated';
298
+
299
+ type BrowserEntry = { source: 'asset'; key: string; name: string; entry: AssetEntry };
300
+
301
+ function browserEntryKind(item: BrowserEntry): BrowserKind {
302
+ return getAssetKind(item.entry);
303
+ }
304
+
305
+ function parentAssetPath(path: string): string {
306
+ const separator = path.lastIndexOf('/');
307
+ return separator < 0 ? '' : path.slice(0, separator);
308
+ }
309
+
310
+ function openProjectAssetEntry(
311
+ entry: AssetEntry,
312
+ options: {
313
+ currentRoot: AssetRoot;
314
+ currentPath: string;
315
+ navigate: (root: AssetRoot, path: string) => void;
316
+ onOpenScene?: (assetPath: string) => void;
317
+ openAsset: (url: string, kind: ViewableAssetKind) => void;
318
+ },
319
+ ): void {
320
+ const { currentRoot, currentPath, navigate, onOpenScene, openAsset } = options;
321
+ if (entry.type === 'directory') {
322
+ navigate(currentRoot, currentPath ? `${currentPath}/${entry.name}` : entry.name);
323
+ return;
324
+ }
325
+ const capability = assetCapabilities(entry.name);
326
+ const documentKind = assetDocumentKind(capability);
327
+ if ((documentKind === 'scene' || documentKind === 'prefab') && onOpenScene) {
328
+ onOpenScene(assetServingUrl(currentRoot, currentPath, entry.name));
329
+ return;
330
+ }
331
+ if (documentKind) openAsset(assetServingUrl(currentRoot, currentPath, entry.name), documentKind);
332
+ }
333
+
334
+ function browserEntryDetail(item: BrowserEntry): string {
335
+ return item.entry.type === 'file' ? formatSize(item.entry.size) : 'Folder';
336
+ }
337
+
338
+ function browserEntryTitle(item: BrowserEntry): string {
339
+ const detail = browserEntryDetail(item);
340
+ return detail ? `${item.name}\n${detail}` : item.name;
341
+ }
342
+
343
+ function matchesAssetFilter(item: BrowserEntry, filter: AssetFilter, currentPath: string): boolean {
344
+ if (filter === 'all' || item.entry.type === 'directory') return true;
345
+ const capability = assetCapabilities(item.name);
346
+ const path = currentPath ? `${currentPath}/${item.name}` : item.name;
347
+ const predicates: Record<Exclude<AssetFilter, 'all'>, boolean> = {
348
+ 'runtime-ready': capability.runtimeReady,
349
+ 'source-only': capability.importable && !capability.runtimeReady,
350
+ unsupported: !capability.importable,
351
+ imported: path.startsWith('asset-library/local/'),
352
+ generated: path.startsWith('.generated/'),
353
+ };
354
+ return predicates[filter];
355
+ }
356
+
357
+ function compareAssetEntries(
358
+ left: BrowserEntry,
359
+ right: BrowserEntry,
360
+ sort: AssetSort,
361
+ currentPath: string,
362
+ ): number {
363
+ if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
364
+ if (sort === 'size' && left.entry.size !== right.entry.size)
365
+ return right.entry.size - left.entry.size;
366
+ if (sort === 'type') {
367
+ const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
368
+ if (order !== 0) return order;
369
+ }
370
+ if (sort === 'health') {
371
+ const order = projectAssetHealth(left.name, currentPath).localeCompare(
372
+ projectAssetHealth(right.name, currentPath),
373
+ );
374
+ if (order !== 0) return order;
375
+ }
376
+ return left.name.localeCompare(right.name);
377
+ }
378
+
379
+ const GRID_ENTRY_STYLE: React.CSSProperties = {
380
+ width: '100%',
381
+ minWidth: 0,
382
+ padding: 4,
383
+ textAlign: 'center',
384
+ display: 'flex',
385
+ flexDirection: 'column',
386
+ alignItems: 'center',
387
+ gap: 0,
388
+ };
389
+
390
+ const LIST_ENTRY_STYLE: React.CSSProperties = {
391
+ width: '100%',
392
+ minWidth: 0,
393
+ minHeight: 28,
394
+ padding: '3px 6px',
395
+ textAlign: 'left',
396
+ display: 'flex',
397
+ flexDirection: 'row',
398
+ alignItems: 'center',
399
+ gap: 7,
400
+ };
401
+
402
+ function BrowserEntryVisual({
403
+ item,
404
+ kind,
405
+ assetUrl,
406
+ grid,
407
+ }: {
408
+ item: BrowserEntry;
409
+ kind: BrowserKind;
410
+ assetUrl: string;
411
+ grid: boolean;
412
+ }) {
413
+ if (!grid) return <AssetIcon kind={kind} size={15} />;
414
+ if (kind === 'image') {
415
+ return (
416
+ <img
417
+ src={assetUrl}
418
+ alt={item.name}
419
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
420
+ />
421
+ );
422
+ }
423
+ if (kind === 'model' || modelThumbnailFormat(assetUrl)) {
424
+ return <ModelThumbnail url={assetUrl} />;
425
+ }
426
+ if (['audio', 'material', 'scene', 'prefab', 'json'].includes(kind)) {
427
+ return <TypedAssetThumbnail kind={kind} name={item.name} />;
428
+ }
429
+ return <AssetIcon kind={kind} />;
430
+ }
431
+
432
+ function TypedAssetThumbnail({ kind, name }: { kind: BrowserKind; name: string }) {
433
+ const color = assetIconColors[kind];
434
+ if (kind === 'audio') {
435
+ return (
436
+ <svg
437
+ role="img"
438
+ aria-label={`${name} waveform preview`}
439
+ viewBox="0 0 64 48"
440
+ width="52"
441
+ height="44"
442
+ >
443
+ <rect width="64" height="48" rx="4" fill="var(--vgai-bg-inset)" />
444
+ {Array.from({ length: 16 }, (_, index) => (
445
+ <rect
446
+ key={index}
447
+ x={index * 4 + 1}
448
+ y={24 - ((index * 13) % 18) / 2}
449
+ width="2"
450
+ height={((index * 13) % 18) + 3}
451
+ fill={color}
452
+ />
453
+ ))}
454
+ </svg>
455
+ );
456
+ }
457
+ if (kind === 'material') {
458
+ return (
459
+ <span
460
+ role="img"
461
+ aria-label={`${name} material preview`}
462
+ style={{
463
+ width: 44,
464
+ height: 44,
465
+ borderRadius: '50%',
466
+ background: `radial-gradient(circle at 32% 28%, white 0, ${color} 18%, var(--vgai-bg-inset) 72%)`,
467
+ boxShadow: 'inset -5px -7px 10px rgb(0 0 0 / 35%)',
468
+ }}
469
+ />
470
+ );
471
+ }
472
+ return (
473
+ <svg
474
+ role="img"
475
+ aria-label={`${name} ${kind} preview`}
476
+ viewBox="0 0 52 48"
477
+ width="48"
478
+ height="44"
479
+ >
480
+ <rect x="5" y="3" width="42" height="42" rx="4" fill="var(--vgai-bg-inset)" stroke={color} />
481
+ <path d="M13 15h26M13 23h18M13 31h22" stroke={color} strokeWidth="3" strokeLinecap="round" />
482
+ </svg>
483
+ );
484
+ }
485
+
486
+ function BrowserEntryCard({
487
+ item,
488
+ selected,
489
+ currentRoot,
490
+ currentPath,
491
+ onDragStart,
492
+ onSelect,
493
+ onOpen,
494
+ onContextMenu,
495
+ viewMode,
496
+ }: {
497
+ item: BrowserEntry;
498
+ selected: boolean;
499
+ currentRoot: AssetRoot;
500
+ currentPath: string;
501
+ onDragStart: (event: React.DragEvent, item: BrowserEntry) => void;
502
+ onSelect: (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => void;
503
+ onOpen: (item: BrowserEntry) => void;
504
+ onContextMenu: (event: React.MouseEvent, item: BrowserEntry) => void;
505
+ viewMode: AssetViewMode;
506
+ }) {
507
+ const kind = browserEntryKind(item);
508
+ const assetEntry = item.entry;
509
+ const capability = assetEntry.type === 'file' ? assetCapabilities(assetEntry.name) : null;
510
+ const assetUrl = assetServingUrl(currentRoot, currentPath, item.name);
511
+ const title = browserEntryTitle(item);
512
+ const cursor = assetEntry
513
+ ? assetEntry.type === 'directory' || capability?.placeable
514
+ ? capability?.placeable
515
+ ? 'grab'
516
+ : 'pointer'
517
+ : 'default'
518
+ : 'pointer';
519
+ const detail = browserEntryDetail(item);
520
+ const grid = viewMode === 'grid';
521
+
522
+ return (
523
+ <div
524
+ className="vgai-selectable"
525
+ data-selected={selected || undefined}
526
+ role="option"
527
+ aria-selected={selected}
528
+ tabIndex={0}
529
+ draggable={assetEntry?.type === 'file' && capability?.placeable === true}
530
+ onDragStart={(event) => onDragStart(event, item)}
531
+ onClick={(event) => onSelect(item, event)}
532
+ onDoubleClick={() => onOpen(item)}
533
+ onContextMenu={(event) => onContextMenu(event, item)}
534
+ onKeyDown={(event) => {
535
+ if (event.key === 'Enter') onOpen(item);
536
+ else if (event.key === ' ') {
537
+ onSelect(item, event);
538
+ event.preventDefault();
539
+ }
540
+ }}
541
+ title={title}
542
+ style={{
543
+ ...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
544
+ borderRadius: radius.sm,
545
+ cursor,
546
+ border: selected ? `1px solid ${accent}` : '1px solid transparent',
547
+ userSelect: 'none',
548
+ }}
549
+ >
550
+ <div
551
+ style={{
552
+ ...(grid
553
+ ? { width: 48, height: 48, margin: '0 auto 4px' }
554
+ : { width: 18, height: 18, margin: 0 }),
555
+ display: 'flex',
556
+ alignItems: 'center',
557
+ justifyContent: 'center',
558
+ overflow: 'hidden',
559
+ borderRadius: radius.sm,
560
+ pointerEvents: 'none',
561
+ }}
562
+ >
563
+ <BrowserEntryVisual item={item} kind={kind} assetUrl={assetUrl} grid={grid} />
564
+ </div>
565
+ <div
566
+ data-testid="asset-entry-name"
567
+ style={{
568
+ ...(grid
569
+ ? {
570
+ fontSize: 10,
571
+ lineHeight: '13px',
572
+ minHeight: 26,
573
+ display: '-webkit-box',
574
+ WebkitBoxOrient: 'vertical',
575
+ WebkitLineClamp: 2,
576
+ }
577
+ : { fontSize: 11, lineHeight: '18px', flex: 1 }),
578
+ overflow: 'hidden',
579
+ textOverflow: 'ellipsis',
580
+ whiteSpace: grid ? 'normal' : 'nowrap',
581
+ color: text[1],
582
+ minWidth: 0,
583
+ }}
584
+ >
585
+ {item.name}
586
+ </div>
587
+ {!grid && (
588
+ <div
589
+ role="listbox"
590
+ aria-label="Project assets"
591
+ aria-multiselectable="true"
592
+ style={{
593
+ maxWidth: '45%',
594
+ overflow: 'hidden',
595
+ textOverflow: 'ellipsis',
596
+ whiteSpace: 'nowrap',
597
+ color: text[3],
598
+ fontSize: 10,
599
+ }}
600
+ >
601
+ {detail}
602
+ </div>
603
+ )}
604
+ {grid && capability && (
605
+ <div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', justifyContent: 'center' }}>
606
+ <span className="vgai-asset-badge">
607
+ {capability.runtimeReady ? 'Ready' : capability.importable ? 'Import' : 'Unsupported'}
608
+ </span>
609
+ {capability.animated && <span className="vgai-asset-badge">Animated</span>}
610
+ </div>
611
+ )}
612
+ </div>
613
+ );
614
+ }
615
+
616
+ interface AssetBrowserToolbarProps {
617
+ currentPath: string;
618
+ searchOpen: boolean;
619
+ searchQuery: string;
620
+ viewMode: AssetViewMode;
621
+ sort: AssetSort;
622
+ filter: AssetFilter;
623
+ density: number;
624
+ onNavigate: (root: AssetRoot, path: string) => void;
625
+ onSearchOpenChange: (open: boolean) => void;
626
+ onSearchQueryChange: (query: string) => void;
627
+ onViewModeChange: (mode: AssetViewMode) => void;
628
+ onSortChange: (sort: AssetSort) => void;
629
+ onFilterChange: (filter: AssetFilter) => void;
630
+ onDensityChange: (density: number) => void;
631
+ onCreateFolder: () => void;
632
+ onImport: () => void;
633
+ onDelete: () => void;
634
+ deleteDisabled: boolean;
635
+ onRefresh: () => void;
636
+ }
637
+
638
+ function AssetBrowserToolbar({
639
+ currentPath,
640
+ searchOpen,
641
+ searchQuery,
642
+ viewMode,
643
+ sort,
644
+ filter,
645
+ density,
646
+ onNavigate,
647
+ onSearchOpenChange,
648
+ onSearchQueryChange,
649
+ onViewModeChange,
650
+ onSortChange,
651
+ onFilterChange,
652
+ onDensityChange,
653
+ onCreateFolder,
654
+ onImport,
655
+ onDelete,
656
+ deleteDisabled,
657
+ onRefresh,
658
+ }: AssetBrowserToolbarProps) {
659
+ const actionsRef = useRef<HTMLDivElement>(null);
660
+ const [actionsOpen, setActionsOpen] = useState(false);
661
+ const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
662
+ const parentPath = parentAssetPath(currentPath);
663
+ const closeSearch = () => {
664
+ onSearchQueryChange('');
665
+ onSearchOpenChange(false);
666
+ };
667
+ useEffect(() => {
668
+ if (!actionsOpen) return;
669
+ const close = (event: PointerEvent) => {
670
+ if (!actionsRef.current?.contains(event.target as Node)) setActionsOpen(false);
671
+ };
672
+ document.addEventListener('pointerdown', close);
673
+ return () => document.removeEventListener('pointerdown', close);
674
+ }, [actionsOpen]);
675
+
676
+ return (
677
+ <div className="vgai-project-assets-toolbar">
678
+ <EditorToolbar label="Asset navigation" compact className="vgai-project-assets-toolbar__row">
679
+ <Tooltip text="Back" position="top">
680
+ <IconButton
681
+ data-testid="asset-back"
682
+ aria-label="Back"
683
+ disabled={!currentPath}
684
+ onClick={() => onNavigate('public', parentPath)}
685
+ >
686
+ <EditorIcon icon={faArrowLeft} />
687
+ </IconButton>
688
+ </Tooltip>
689
+ {searchOpen ? (
690
+ <TextInput
691
+ type="text"
692
+ data-testid="asset-search-input"
693
+ aria-label="Search assets"
694
+ placeholder="Search assets..."
695
+ value={searchQuery}
696
+ onChange={(event) => onSearchQueryChange(event.target.value)}
697
+ onKeyDown={(event) => {
698
+ if (event.key === 'Escape') closeSearch();
699
+ }}
700
+ className="vgai-input"
701
+ autoFocus
702
+ style={{ flex: 1, minWidth: 60 }}
703
+ />
704
+ ) : (
705
+ <nav
706
+ data-testid="asset-breadcrumb"
707
+ aria-label="Asset location"
708
+ style={{
709
+ flex: 1,
710
+ display: 'flex',
711
+ alignItems: 'center',
712
+ gap: 2,
713
+ fontSize: 12,
714
+ minWidth: 0,
715
+ overflow: 'hidden',
716
+ whiteSpace: 'nowrap',
717
+ }}
718
+ >
719
+ {currentPath ? (
720
+ <Button
721
+ type="button"
722
+ variant="ghost"
723
+ size="compact"
724
+ onClick={() => onNavigate('public', '')}
725
+ style={{ flexShrink: 0 }}
726
+ >
727
+ Assets
728
+ </Button>
729
+ ) : (
730
+ <span aria-current="location" style={{ color: text[1] }}>
731
+ Assets
732
+ </span>
733
+ )}
734
+ {breadcrumbSegments.map((segment, index) => {
735
+ const path = breadcrumbSegments.slice(0, index + 1).join('/');
736
+ const isLast = index === breadcrumbSegments.length - 1;
737
+ return (
738
+ <span
739
+ key={path}
740
+ style={{
741
+ display: 'flex',
742
+ alignItems: 'center',
743
+ gap: 2,
744
+ minWidth: isLast ? 0 : undefined,
745
+ flexShrink: isLast ? 1 : 0,
746
+ }}
747
+ >
748
+ <span style={{ color: text[3] }}>/</span>
749
+ {isLast ? (
750
+ <span
751
+ aria-current="location"
752
+ title={segment}
753
+ style={{
754
+ color: text[1],
755
+ overflow: 'hidden',
756
+ textOverflow: 'ellipsis',
757
+ }}
758
+ >
759
+ {segment}
760
+ </span>
761
+ ) : (
762
+ <Button
763
+ type="button"
764
+ variant="ghost"
765
+ size="compact"
766
+ onClick={() => onNavigate('public', path)}
767
+ >
768
+ {segment}
769
+ </Button>
770
+ )}
771
+ </span>
772
+ );
773
+ })}
774
+ </nav>
775
+ )}
776
+
777
+ <Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
778
+ <IconButton
779
+ data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
780
+ aria-label={searchOpen ? 'Close search' : 'Search assets'}
781
+ onClick={() => {
782
+ if (searchOpen) closeSearch();
783
+ else onSearchOpenChange(true);
784
+ }}
785
+ >
786
+ <EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
787
+ </IconButton>
788
+ </Tooltip>
789
+ </EditorToolbar>
790
+ <EditorToolbar
791
+ label="Asset browser controls"
792
+ compact
793
+ className="vgai-project-assets-toolbar__row"
794
+ >
795
+ <ToolbarGroup>
796
+ <Tooltip text="Grid view" position="top">
797
+ <IconButton
798
+ aria-label="Grid view"
799
+ data-testid="asset-view-grid"
800
+ aria-pressed={viewMode === 'grid'}
801
+ onClick={() => onViewModeChange('grid')}
802
+ >
803
+ <EditorIcon icon={faTableCellsLarge} />
804
+ </IconButton>
805
+ </Tooltip>
806
+ <Tooltip text="List view" position="top">
807
+ <IconButton
808
+ aria-label="List view"
809
+ data-testid="asset-view-list"
810
+ aria-pressed={viewMode === 'list'}
811
+ onClick={() => onViewModeChange('list')}
812
+ >
813
+ <EditorIcon icon={faList} />
814
+ </IconButton>
815
+ </Tooltip>
816
+ </ToolbarGroup>
817
+ <Select
818
+ className="vgai-select vgai-project-assets-toolbar__select"
819
+ aria-label="Sort project assets"
820
+ value={sort}
821
+ onChange={(event) => onSortChange(event.target.value as AssetSort)}
822
+ >
823
+ <option value="name">Name</option>
824
+ <option value="type">Type</option>
825
+ <option value="size">Size</option>
826
+ <option value="health">Health</option>
827
+ </Select>
828
+ <Select
829
+ className="vgai-select vgai-project-assets-toolbar__select"
830
+ aria-label="Filter project assets"
831
+ value={filter}
832
+ onChange={(event) => onFilterChange(event.target.value as AssetFilter)}
833
+ >
834
+ <option value="all">All</option>
835
+ <option value="runtime-ready">Runtime-ready</option>
836
+ <option value="source-only">Source-only</option>
837
+ <option value="unsupported">Unsupported</option>
838
+ <option value="imported">Imported</option>
839
+ <option value="generated">Generated</option>
840
+ </Select>
841
+ <Tooltip text="Import files" position="top">
842
+ <IconButton
843
+ data-command-id={PROJECT_ASSET_COMMANDS.import.id}
844
+ aria-label="Import files"
845
+ onClick={onImport}
846
+ >
847
+ <EditorIcon icon={faBoxArchive} />
848
+ </IconButton>
849
+ </Tooltip>
850
+ <div ref={actionsRef} className="vgai-project-assets-toolbar__more">
851
+ <Tooltip text="More asset actions" position="top">
852
+ <IconButton
853
+ aria-label="More asset actions"
854
+ aria-expanded={actionsOpen}
855
+ aria-haspopup="menu"
856
+ onClick={() => setActionsOpen((value) => !value)}
857
+ >
858
+ <EditorIcon icon={faEllipsis} />
859
+ </IconButton>
860
+ </Tooltip>
861
+ {actionsOpen && (
862
+ <Menu
863
+ autoFocusFirst
864
+ className="vgai-project-assets-toolbar__menu"
865
+ onDismiss={() => setActionsOpen(false)}
866
+ >
867
+ <MenuItem
868
+ data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
869
+ onSelect={() => {
870
+ setActionsOpen(false);
871
+ onCreateFolder();
872
+ }}
873
+ >
874
+ New folder
875
+ </MenuItem>
876
+ <MenuItem
877
+ onSelect={() => {
878
+ setActionsOpen(false);
879
+ onDensityChange(density <= 96 ? 132 : 88);
880
+ }}
881
+ >
882
+ {density <= 96 ? 'Larger thumbnails' : 'Smaller thumbnails'}
883
+ </MenuItem>
884
+ <MenuItem
885
+ disabled={deleteDisabled}
886
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
887
+ onSelect={() => {
888
+ setActionsOpen(false);
889
+ onDelete();
890
+ }}
891
+ >
892
+ Delete selected
893
+ </MenuItem>
894
+ <MenuItem
895
+ onSelect={() => {
896
+ setActionsOpen(false);
897
+ onRefresh();
898
+ }}
899
+ >
900
+ Refresh
901
+ </MenuItem>
902
+ </Menu>
903
+ )}
904
+ </div>
905
+ </EditorToolbar>
906
+ </div>
907
+ );
908
+ }
909
+
170
910
  export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
171
911
  const store = useEditorStore();
172
- const [currentRoot, setCurrentRoot] = useState('public');
173
- const [currentPath, setCurrentPath] = useState('');
912
+ const history = useHistoryService();
913
+ const initial = useMemo(readAssetBrowserState, []);
914
+ const currentRoot: AssetRoot = 'public';
915
+ const [currentPath, setCurrentPath] = useState(initial.currentPath);
916
+ const [viewMode, setViewMode] = useState<AssetViewMode>(initial.viewMode);
917
+ const [sort, setSort] = useState<AssetSort>(initial.sort);
918
+ const [filter, setFilter] = useState<AssetFilter>(initial.filter);
919
+ const [density, setDensity] = useState(initial.density);
920
+ const [searchOpen, setSearchOpen] = useState(false);
174
921
  const [entries, setEntries] = useState<AssetEntry[]>([]);
175
922
  const [searchQuery, setSearchQuery] = useState('');
176
- const [selectedFile, setSelectedFile] = useState<string | null>(null);
923
+ const [selectedFiles, setSelectedFiles] = useState(new Set<string>());
924
+ const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
925
+ const [contextMenu, setContextMenu] = useState<{
926
+ x: number;
927
+ y: number;
928
+ item: BrowserEntry;
929
+ } | null>(null);
930
+ const importInputRef = useRef<HTMLInputElement>(null);
177
931
  const cacheRef = useRef(new Map<string, AssetEntry[]>());
178
932
 
179
- const fetchEntries = useCallback(async (root: string, dir: string) => {
933
+ useEffect(() => {
934
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
935
+ localStorage.setItem(
936
+ projectAssetBrowserStateKey(projectId),
937
+ JSON.stringify({
938
+ currentPath,
939
+ viewMode,
940
+ sort,
941
+ filter,
942
+ density,
943
+ } satisfies PersistedAssetBrowserState),
944
+ );
945
+ }, [currentPath, density, filter, sort, viewMode]);
946
+
947
+ const fetchEntries = useCallback(async (root: AssetRoot, dir: string) => {
180
948
  const key = `${root}:${dir}`;
181
949
  const cached = cacheRef.current.get(key);
182
950
  if (cached) {
@@ -196,26 +964,47 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
196
964
  fetchEntries(currentRoot, currentPath);
197
965
  }, [currentRoot, currentPath, fetchEntries]);
198
966
 
199
- const navigate = useCallback((root: string, path: string) => {
200
- setCurrentRoot(root);
967
+ const navigate = useCallback((_root: AssetRoot, path: string) => {
201
968
  setCurrentPath(path);
202
- setSelectedFile(null);
969
+ setSelectedFiles(new Set());
970
+ setSelectionAnchor(null);
971
+ clearSelectedAsset();
203
972
  setSearchQuery('');
973
+ setSearchOpen(false);
204
974
  }, []);
205
975
 
206
- // --- Breadcrumb ---
207
- const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
208
-
209
- // --- Filtered entries ---
210
- const filtered = searchQuery
211
- ? entries.filter((e) => e.name.toLowerCase().includes(searchQuery.toLowerCase()))
212
- : entries;
976
+ // Assets is a file browser over the project's actual public asset root.
977
+ // Data and React stories have their own semantic document/hierarchy entry
978
+ // points; presenting them here as synthetic folders made the browser lie
979
+ // about the filesystem and duplicated navigation.
980
+ const browserEntries = useMemo<BrowserEntry[]>(() => {
981
+ return entries.map((entry) => ({
982
+ source: 'asset' as const,
983
+ key: `asset:${currentPath}:${entry.name}`,
984
+ name: entry.name,
985
+ entry,
986
+ }));
987
+ }, [currentPath, entries]);
988
+ const filtered = useMemo(() => {
989
+ const query = searchQuery.toLowerCase();
990
+ const visible = browserEntries.filter((item) => {
991
+ if (query && !item.name.toLowerCase().includes(query)) return false;
992
+ return matchesAssetFilter(item, filter, currentPath);
993
+ });
994
+ return [...visible].sort((left, right) => compareAssetEntries(left, right, sort, currentPath));
995
+ }, [browserEntries, currentPath, filter, searchQuery, sort]);
213
996
 
214
997
  // --- Drag start ---
215
998
  const onDragStart = useCallback(
216
- (e: React.DragEvent, entry: AssetEntry) => {
217
- const kind = getAssetKind(entry);
218
- if (kind === 'folder') return;
999
+ (e: React.DragEvent, item: BrowserEntry) => {
1000
+ const { entry } = item;
1001
+ const capability = assetCapabilities(entry.name);
1002
+ if (entry.type !== 'file' || !capability.placeable) {
1003
+ e.preventDefault();
1004
+ return;
1005
+ }
1006
+ const kind = assetDocumentKind(capability);
1007
+ if (!kind) return;
219
1008
  const path = assetScenePath(currentRoot, currentPath, entry.name);
220
1009
  const payload = { path, kind, name: entry.name };
221
1010
  // Set text/plain first — Chromium silently cancels drags that only
@@ -226,25 +1015,89 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
226
1015
  [currentRoot, currentPath],
227
1016
  );
228
1017
 
229
- // --- Double-click handler ---
230
- const onDoubleClick = useCallback(
231
- (entry: AssetEntry) => {
232
- if (entry.type === 'directory') {
233
- navigate(currentRoot, currentPath ? `${currentPath}/${entry.name}` : entry.name);
234
- return;
1018
+ // --- Single-click: §5.1 SELECTION (W2) — compact preview/metadata in the
1019
+ // Inspector via `asset-selection.ts`; never opens anything. ---
1020
+ const onSelectEntry = useCallback(
1021
+ (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => {
1022
+ let next = new Set<string>();
1023
+ if (event.shiftKey && selectionAnchor) {
1024
+ const anchorIndex = filtered.findIndex((entry) => entry.key === selectionAnchor);
1025
+ const itemIndex = filtered.findIndex((entry) => entry.key === item.key);
1026
+ if (anchorIndex >= 0 && itemIndex >= 0) {
1027
+ const [start, end] =
1028
+ anchorIndex < itemIndex ? [anchorIndex, itemIndex] : [itemIndex, anchorIndex];
1029
+ next = new Set(filtered.slice(start, end + 1).map((entry) => entry.key));
1030
+ }
1031
+ } else if (event.metaKey || event.ctrlKey) {
1032
+ next = new Set(selectedFiles);
1033
+ if (next.has(item.key)) next.delete(item.key);
1034
+ else next.add(item.key);
1035
+ setSelectionAnchor(item.key);
1036
+ } else {
1037
+ next.add(item.key);
1038
+ setSelectionAnchor(item.key);
235
1039
  }
236
- const kind = getAssetKind(entry);
237
- // Open scene and prefab files in the editor
238
- if ((kind === 'scene' || kind === 'prefab') && onOpenScene) {
239
- const url = assetServingUrl(currentRoot, currentPath, entry.name);
240
- onOpenScene(url);
1040
+ setSelectedFiles(next);
1041
+ const { entry } = item;
1042
+ if (entry.type === 'directory') {
1043
+ clearSelectedAsset();
241
1044
  return;
242
1045
  }
243
- // Open other viewable asset types in a tab
244
- if (kind !== 'folder' && kind !== 'unknown') {
245
- const url = assetServingUrl(currentRoot, currentPath, entry.name);
246
- store.openAssetTab(url, kind);
247
- }
1046
+ const capability = assetCapabilities(entry.name);
1047
+ const kind = assetDocumentKind(capability) ?? 'unknown';
1048
+ setSelectedAsset({
1049
+ path: assetServingUrl(currentRoot, currentPath, entry.name),
1050
+ name: entry.name,
1051
+ kind,
1052
+ sizeBytes: entry.size,
1053
+ capabilities: capability,
1054
+ health: projectAssetHealth(entry.name, currentPath),
1055
+ selectionCount: next.size,
1056
+ ...(currentPath.startsWith('asset-library/local')
1057
+ ? { sourcePath: 'Asset Library provenance metadata' }
1058
+ : {}),
1059
+ });
1060
+ const path = currentPath ? `${currentPath}/${entry.name}` : entry.name;
1061
+ void inspectProjectAssetHealthFromStorage(getStorageBackend(), path)
1062
+ .then((inspection) => {
1063
+ const servingPath = assetServingUrl(currentRoot, currentPath, entry.name);
1064
+ if (getSelectedAsset()?.path !== servingPath) return;
1065
+ setSelectedAsset({
1066
+ path: servingPath,
1067
+ name: entry.name,
1068
+ kind,
1069
+ sizeBytes: entry.size,
1070
+ capabilities: capability,
1071
+ health:
1072
+ inspection.health.status === 'healthy'
1073
+ ? 'healthy'
1074
+ : inspection.health.codes.includes('stale-import')
1075
+ ? 'stale-import'
1076
+ : capability.runtimeReady
1077
+ ? 'unsupported'
1078
+ : 'source-only',
1079
+ healthCodes: inspection.health.codes,
1080
+ dependencies: inspection.dependencies,
1081
+ references: inspection.references,
1082
+ selectionCount: next.size,
1083
+ });
1084
+ })
1085
+ .catch(() => {});
1086
+ },
1087
+ [currentPath, currentRoot, filtered, selectedFiles, selectionAnchor],
1088
+ );
1089
+
1090
+ // --- Double-click / Enter: §5.1 OPEN — the full viewer as a CENTER
1091
+ // workspace document (W2; was a right-rail asset tab pre-W2). ---
1092
+ const onDoubleClick = useCallback(
1093
+ (item: BrowserEntry) => {
1094
+ openProjectAssetEntry(item.entry, {
1095
+ currentRoot,
1096
+ currentPath,
1097
+ navigate,
1098
+ ...(onOpenScene ? { onOpenScene } : {}),
1099
+ openAsset: (url, kind) => openAssetDocument(store, url, kind),
1100
+ });
248
1101
  },
249
1102
  [currentRoot, currentPath, navigate, onOpenScene, store],
250
1103
  );
@@ -262,241 +1115,328 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
262
1115
  return () => window.removeEventListener('editor:assets-changed', handler);
263
1116
  }, [onRefresh]);
264
1117
 
265
- const refreshAction = (
266
- <Tooltip text="Refresh" position="top">
267
- <button
268
- type="button"
269
- onClick={(e) => {
270
- e.stopPropagation();
271
- onRefresh();
272
- }}
273
- style={{
274
- background: 'none',
275
- border: 'none',
276
- color: '#8c8c8c',
277
- cursor: 'pointer',
278
- fontSize: 12,
279
- padding: '0 4px',
280
- }}
281
- >
282
- <FontAwesomeIcon icon={faArrowsRotate} />
283
- </button>
284
- </Tooltip>
1118
+ useEffect(() => {
1119
+ const handler = (event: Event) => {
1120
+ const path = (event as CustomEvent<{ path?: string }>).detail?.path;
1121
+ if (!path) return;
1122
+ const capability = assetCapabilities(path);
1123
+ const kind = assetDocumentKind(capability);
1124
+ if (!kind) return;
1125
+ if ((kind === 'scene' || kind === 'prefab') && onOpenScene) onOpenScene(path);
1126
+ else openAssetDocument(store, path, kind);
1127
+ };
1128
+ window.addEventListener('editor:open-project-asset', handler);
1129
+ return () => window.removeEventListener('editor:open-project-asset', handler);
1130
+ }, [onOpenScene, store]);
1131
+
1132
+ const selectedItems = useMemo(
1133
+ () => browserEntries.filter((item) => selectedFiles.has(item.key)),
1134
+ [browserEntries, selectedFiles],
1135
+ );
1136
+ const selectedAssetPaths = useMemo(
1137
+ () =>
1138
+ selectedItems.flatMap((item) =>
1139
+ item.entry.type === 'file' ? [currentPath ? `${currentPath}/${item.name}` : item.name] : [],
1140
+ ),
1141
+ [currentPath, selectedItems],
1142
+ );
1143
+
1144
+ useEffect(() => {
1145
+ const available = new Set(browserEntries.map((item) => item.key));
1146
+ setSelectedFiles((current) => {
1147
+ const next = new Set([...current].filter((key) => available.has(key)));
1148
+ return next.size === current.size ? current : next;
1149
+ });
1150
+ }, [browserEntries]);
1151
+
1152
+ const finishOperation = useCallback(
1153
+ async (operation: Parameters<typeof executeProjectAssetOperation>[0]) => {
1154
+ await executeProjectAssetOperation(operation, { backend: getStorageBackend(), history });
1155
+ cacheRef.current.clear();
1156
+ if (currentRoot === 'public') await fetchEntries(currentRoot, currentPath);
1157
+ },
1158
+ [currentPath, currentRoot, fetchEntries, history],
285
1159
  );
286
1160
 
1161
+ const deleteSelected = useCallback(async () => {
1162
+ if (selectedAssetPaths.length === 0) return;
1163
+ if (
1164
+ !confirmAssetAction(
1165
+ 'delete',
1166
+ `Delete ${selectedAssetPaths.length} selected asset(s)? This is undoable.`,
1167
+ )
1168
+ )
1169
+ return;
1170
+ await finishOperation({ type: 'delete', paths: selectedAssetPaths });
1171
+ }, [finishOperation, selectedAssetPaths]);
1172
+
1173
+ const createFolder = useCallback(async () => {
1174
+ if (currentRoot !== 'public') return;
1175
+ const name = window.prompt('Folder name');
1176
+ if (!name) return;
1177
+ await finishOperation({
1178
+ type: 'create-folder',
1179
+ path: currentPath ? `${currentPath}/${name}` : name,
1180
+ });
1181
+ }, [currentPath, currentRoot, finishOperation]);
1182
+
1183
+ const importFiles = useCallback(
1184
+ async (files: FileList | null) => {
1185
+ if (!files || currentRoot !== 'public') return;
1186
+ const imported = await Promise.all(
1187
+ [...files].map(async (file) => ({
1188
+ path: currentPath ? `${currentPath}/${file.name}` : file.name,
1189
+ bytes: new Uint8Array(await file.arrayBuffer()),
1190
+ })),
1191
+ );
1192
+ await finishOperation({ type: 'import', files: imported });
1193
+ },
1194
+ [currentPath, currentRoot, finishOperation],
1195
+ );
1196
+
1197
+ const renameItem = useCallback(
1198
+ async (item: BrowserEntry) => {
1199
+ if (item.entry.type !== 'file') return;
1200
+ const nextName = window.prompt('Rename asset', item.name);
1201
+ if (!nextName || nextName === item.name) return;
1202
+ const source = currentPath ? `${currentPath}/${item.name}` : item.name;
1203
+ const destination = currentPath ? `${currentPath}/${nextName}` : nextName;
1204
+ await finishOperation({ type: 'move', source, destination });
1205
+ },
1206
+ [currentPath, finishOperation],
1207
+ );
1208
+
1209
+ const duplicateItem = useCallback(
1210
+ async (item: BrowserEntry) => {
1211
+ if (item.entry.type !== 'file') return;
1212
+ const dot = item.name.lastIndexOf('.');
1213
+ const copyName =
1214
+ dot > 0 ? `${item.name.slice(0, dot)} copy${item.name.slice(dot)}` : `${item.name} copy`;
1215
+ const source = currentPath ? `${currentPath}/${item.name}` : item.name;
1216
+ const destination = currentPath ? `${currentPath}/${copyName}` : copyName;
1217
+ await finishOperation({ type: 'duplicate', source, destination });
1218
+ },
1219
+ [currentPath, finishOperation],
1220
+ );
1221
+
1222
+ useEffect(() => {
1223
+ const handler = (event: Event) => {
1224
+ const id = (event as CustomEvent<{ id?: string }>).detail?.id;
1225
+ const item = selectedItems[0];
1226
+ if (id === PROJECT_ASSET_COMMANDS.refresh.id) onRefresh();
1227
+ else if (id === PROJECT_ASSET_COMMANDS.import.id) importInputRef.current?.click();
1228
+ else if (id === PROJECT_ASSET_COMMANDS.createFolder.id) void createFolder();
1229
+ else if (id === PROJECT_ASSET_COMMANDS.delete.id) void deleteSelected();
1230
+ else if (id === PROJECT_ASSET_COMMANDS.rename.id && item) void renameItem(item);
1231
+ else if (id === PROJECT_ASSET_COMMANDS.duplicate.id && item) void duplicateItem(item);
1232
+ else if (id === PROJECT_ASSET_COMMANDS.copyPath.id && selectedAssetPaths[0])
1233
+ void navigator.clipboard.writeText(selectedAssetPaths[0]);
1234
+ else if (id === PROJECT_ASSET_COMMANDS.reveal.id)
1235
+ void revealInFinder(`public/${currentPath}`);
1236
+ };
1237
+ window.addEventListener('editor:project-asset-command', handler);
1238
+ return () => window.removeEventListener('editor:project-asset-command', handler);
1239
+ }, [
1240
+ createFolder,
1241
+ currentPath,
1242
+ deleteSelected,
1243
+ duplicateItem,
1244
+ onRefresh,
1245
+ renameItem,
1246
+ selectedAssetPaths,
1247
+ selectedItems,
1248
+ ]);
1249
+
287
1250
  return (
288
- <Panel
289
- name="Assets"
290
- actions={refreshAction}
1251
+ <div
291
1252
  onPointerDown={() => setActiveScope('asset-browser')}
292
- style={{ flex: 1, minHeight: 0 }}
1253
+ onKeyDown={(event) => {
1254
+ if (isEditableTarget(event.target)) return;
1255
+ if (event.key === 'Delete' || event.key === 'Backspace') {
1256
+ void deleteSelected();
1257
+ event.preventDefault();
1258
+ } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1259
+ const item = selectedItems[0];
1260
+ if (item) void duplicateItem(item);
1261
+ event.preventDefault();
1262
+ } else if (event.key === 'F2') {
1263
+ const item = selectedItems[0];
1264
+ if (item) void renameItem(item);
1265
+ event.preventDefault();
1266
+ }
1267
+ }}
1268
+ style={{
1269
+ display: 'flex',
1270
+ flexDirection: 'column',
1271
+ flex: 1,
1272
+ minHeight: 0,
1273
+ position: 'relative',
1274
+ }}
293
1275
  >
294
- <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
295
- {/* Folder tree */}
1276
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
1277
+ <TextInput
1278
+ ref={importInputRef}
1279
+ type="file"
1280
+ multiple
1281
+ aria-label="Import project assets"
1282
+ hidden
1283
+ onChange={(event) => {
1284
+ void importFiles(event.target.files);
1285
+ event.currentTarget.value = '';
1286
+ }}
1287
+ />
1288
+ <AssetBrowserToolbar
1289
+ currentPath={currentPath}
1290
+ searchOpen={searchOpen}
1291
+ searchQuery={searchQuery}
1292
+ viewMode={viewMode}
1293
+ sort={sort}
1294
+ filter={filter}
1295
+ density={density}
1296
+ onNavigate={navigate}
1297
+ onSearchOpenChange={setSearchOpen}
1298
+ onSearchQueryChange={setSearchQuery}
1299
+ onViewModeChange={setViewMode}
1300
+ onSortChange={setSort}
1301
+ onFilterChange={setFilter}
1302
+ onDensityChange={setDensity}
1303
+ onCreateFolder={() => void createFolder()}
1304
+ onImport={() => importInputRef.current?.click()}
1305
+ onDelete={() => void deleteSelected()}
1306
+ deleteDisabled={selectedAssetPaths.length === 0}
1307
+ onRefresh={onRefresh}
1308
+ />
1309
+
1310
+ {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
296
1311
  <div
297
1312
  style={{
298
- width: 180,
299
- borderRight: '1px solid #333',
1313
+ flex: 1,
300
1314
  overflowY: 'auto',
301
- scrollbarWidth: 'none',
302
- flexShrink: 0,
1315
+ padding: 8,
1316
+ display: viewMode === 'grid' ? 'grid' : 'flex',
1317
+ gridTemplateColumns:
1318
+ viewMode === 'grid' ? `repeat(auto-fill, minmax(${density}px, 1fr))` : undefined,
1319
+ gridAutoRows: viewMode === 'grid' ? 'min-content' : undefined,
1320
+ flexDirection: viewMode === 'grid' ? undefined : 'column',
1321
+ gap: viewMode === 'grid' ? 6 : 1,
1322
+ alignContent: 'start',
303
1323
  }}
304
1324
  >
305
- <FolderTree currentPath={currentPath} currentRoot={currentRoot} onNavigate={navigate} />
1325
+ {filtered.map((item) => (
1326
+ <BrowserEntryCard
1327
+ key={item.key}
1328
+ item={item}
1329
+ selected={selectedFiles.has(item.key)}
1330
+ currentRoot={currentRoot}
1331
+ currentPath={currentPath}
1332
+ onDragStart={onDragStart}
1333
+ onSelect={onSelectEntry}
1334
+ onOpen={onDoubleClick}
1335
+ onContextMenu={(event, selectedItem) => {
1336
+ event.preventDefault();
1337
+ if (!selectedFiles.has(selectedItem.key)) {
1338
+ onSelectEntry(selectedItem, event);
1339
+ }
1340
+ setContextMenu({ x: event.clientX, y: event.clientY, item: selectedItem });
1341
+ }}
1342
+ viewMode={viewMode}
1343
+ />
1344
+ ))}
1345
+ {filtered.length === 0 && (
1346
+ <StateSurface
1347
+ compact
1348
+ style={{ gridColumn: '1 / -1' }}
1349
+ icon={
1350
+ <EditorIcon
1351
+ icon={searchQuery ? editorIcons.action.search : editorIcons.content.folder}
1352
+ />
1353
+ }
1354
+ title={searchQuery ? 'No matching files' : 'Empty folder'}
1355
+ description={
1356
+ searchQuery
1357
+ ? 'Try a broader asset search.'
1358
+ : 'Add files to this folder to see them here.'
1359
+ }
1360
+ />
1361
+ )}
306
1362
  </div>
307
-
308
- {/* File area */}
309
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
310
- {/* Top bar: breadcrumb + search */}
311
- <div
312
- style={{
313
- padding: '4px 8px',
314
- borderBottom: '1px solid #333',
315
- display: 'flex',
316
- alignItems: 'center',
317
- gap: 8,
318
- flexShrink: 0,
1363
+ </div>
1364
+ {contextMenu && (
1365
+ <Menu
1366
+ autoFocusFirst
1367
+ className="vgai-asset-context-menu"
1368
+ style={{ left: contextMenu.x, top: contextMenu.y }}
1369
+ onDismiss={() => setContextMenu(null)}
1370
+ >
1371
+ <MenuItem
1372
+ type="button"
1373
+ role="menuitem"
1374
+ data-command-id={PROJECT_ASSET_COMMANDS.open.id}
1375
+ onClick={() => {
1376
+ onDoubleClick(contextMenu.item);
1377
+ setContextMenu(null);
319
1378
  }}
320
1379
  >
321
- {/* Breadcrumb */}
322
- <div
323
- style={{
324
- flex: 1,
325
- display: 'flex',
326
- alignItems: 'center',
327
- gap: 2,
328
- fontSize: 12,
329
- minWidth: 0,
330
- }}
331
- >
332
- {breadcrumbSegments.length === 0 ? (
333
- <span style={{ color: '#8c8c8c' }}>/</span>
334
- ) : (
335
- breadcrumbSegments.map((seg, i) => {
336
- const path = breadcrumbSegments.slice(0, i + 1).join('/');
337
- const isLast = i === breadcrumbSegments.length - 1;
338
- return (
339
- <span key={path} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
340
- {i === 0 && (
341
- <span
342
- onClick={() => navigate(currentRoot, '')}
343
- onKeyDown={(e) => {
344
- if (e.key === 'Enter') navigate(currentRoot, '');
345
- }}
346
- role="button"
347
- tabIndex={0}
348
- style={{ cursor: 'pointer', color: '#555' }}
349
- >
350
- /
351
- </span>
352
- )}
353
- {i > 0 && <span style={{ color: '#555' }}>/</span>}
354
- <span
355
- onClick={() => navigate(currentRoot, path)}
356
- onKeyDown={(e) => {
357
- if (e.key === 'Enter') navigate(currentRoot, path);
358
- }}
359
- role="button"
360
- tabIndex={0}
361
- style={{
362
- cursor: 'pointer',
363
- color: isLast ? '#c5c8ce' : '#8c8c8c',
364
- }}
365
- >
366
- {seg}
367
- </span>
368
- </span>
369
- );
370
- })
371
- )}
372
- </div>
373
-
374
- {/* Search */}
375
- <input
376
- type="text"
377
- placeholder="Search..."
378
- value={searchQuery}
379
- onChange={(e) => setSearchQuery(e.target.value)}
380
- style={{
381
- width: 140,
382
- background: '#1e1e1e',
383
- border: '1px solid #333',
384
- borderRadius: 3,
385
- color: '#c5c8ce',
386
- fontSize: 11,
387
- padding: '2px 6px',
388
- outline: 'none',
389
- }}
390
- />
391
- </div>
392
-
393
- {/* Grid */}
394
- <div
395
- style={{
396
- flex: 1,
397
- overflowY: 'auto',
398
- scrollbarWidth: 'none',
399
- padding: 8,
400
- display: 'grid',
401
- gridTemplateColumns: 'repeat(auto-fill, 80px)',
402
- gridAutoRows: 'min-content',
403
- gap: 6,
404
- alignContent: 'start',
1380
+ Open <kbd>Enter</kbd>
1381
+ </MenuItem>
1382
+ <MenuItem
1383
+ type="button"
1384
+ role="menuitem"
1385
+ data-command-id={PROJECT_ASSET_COMMANDS.rename.id}
1386
+ onClick={() => {
1387
+ void renameItem(contextMenu.item);
1388
+ setContextMenu(null);
405
1389
  }}
406
1390
  >
407
- {filtered.map((entry) => {
408
- const kind = getAssetKind(entry);
409
- const isSelected = selectedFile === entry.name;
410
- const isImage = kind === 'image';
411
- const isModel = kind === 'model';
412
- const imgUrl = isImage ? assetServingUrl(currentRoot, currentPath, entry.name) : null;
413
- const modelUrl = isModel
414
- ? assetServingUrl(currentRoot, currentPath, entry.name)
415
- : null;
416
-
417
- return (
418
- <div
419
- key={entry.name}
420
- draggable={entry.type === 'file'}
421
- onDragStart={(e) => onDragStart(e, entry)}
422
- onClick={() => setSelectedFile(entry.name)}
423
- onDoubleClick={() => onDoubleClick(entry)}
424
- title={`${entry.name}${entry.type === 'file' ? `\n${formatSize(entry.size)}` : ''}`}
425
- style={{
426
- width: 80,
427
- padding: 4,
428
- borderRadius: 4,
429
- cursor: entry.type === 'directory' ? 'pointer' : 'grab',
430
- background: isSelected ? '#579EFF33' : 'transparent',
431
- border: isSelected ? '1px solid #579EFF' : '1px solid transparent',
432
- textAlign: 'center',
433
- userSelect: 'none',
434
- }}
435
- onMouseEnter={(e) => {
436
- if (!isSelected) (e.currentTarget as HTMLDivElement).style.background = '#333';
437
- }}
438
- onMouseLeave={(e) => {
439
- if (!isSelected)
440
- (e.currentTarget as HTMLDivElement).style.background = 'transparent';
441
- }}
442
- >
443
- {/* Thumbnail or icon – pointer-events:none so mouse
444
- events hit the parent draggable div directly, which
445
- avoids Chrome failing to initiate drag from SVG children */}
446
- <div
447
- style={{
448
- width: 48,
449
- height: 48,
450
- margin: '0 auto 4px',
451
- display: 'flex',
452
- alignItems: 'center',
453
- justifyContent: 'center',
454
- overflow: 'hidden',
455
- borderRadius: 3,
456
- pointerEvents: 'none',
457
- }}
458
- >
459
- {imgUrl ? (
460
- <img
461
- src={imgUrl}
462
- alt={entry.name}
463
- style={{
464
- maxWidth: 48,
465
- maxHeight: 48,
466
- objectFit: 'contain',
467
- borderRadius: 2,
468
- }}
469
- />
470
- ) : modelUrl ? (
471
- <ModelThumbnail url={modelUrl} />
472
- ) : (
473
- <AssetIcon kind={kind} />
474
- )}
475
- </div>
476
- {/* Filename */}
477
- <div
478
- style={{
479
- fontSize: 10,
480
- lineHeight: '13px',
481
- overflow: 'hidden',
482
- textOverflow: 'ellipsis',
483
- whiteSpace: 'nowrap',
484
- color: '#c5c8ce',
485
- }}
486
- >
487
- {entry.name}
488
- </div>
489
- </div>
490
- );
491
- })}
492
- {filtered.length === 0 && (
493
- <div style={{ gridColumn: '1 / -1', color: '#666', fontSize: 12, padding: 16 }}>
494
- {searchQuery ? 'No matching files.' : 'Empty folder.'}
495
- </div>
496
- )}
497
- </div>
498
- </div>
499
- </div>
500
- </Panel>
1391
+ Rename <kbd>F2</kbd>
1392
+ </MenuItem>
1393
+ <MenuItem
1394
+ type="button"
1395
+ role="menuitem"
1396
+ data-command-id={PROJECT_ASSET_COMMANDS.duplicate.id}
1397
+ onClick={() => {
1398
+ void duplicateItem(contextMenu.item);
1399
+ setContextMenu(null);
1400
+ }}
1401
+ >
1402
+ Duplicate <kbd>⌘D</kbd>
1403
+ </MenuItem>
1404
+ <MenuItem
1405
+ type="button"
1406
+ role="menuitem"
1407
+ data-command-id={PROJECT_ASSET_COMMANDS.copyPath.id}
1408
+ onClick={() => {
1409
+ void navigator.clipboard.writeText(contextMenu.item.name);
1410
+ setContextMenu(null);
1411
+ }}
1412
+ >
1413
+ Copy path
1414
+ </MenuItem>
1415
+ <MenuItem
1416
+ type="button"
1417
+ role="menuitem"
1418
+ data-command-id={PROJECT_ASSET_COMMANDS.reveal.id}
1419
+ onClick={() => {
1420
+ void revealInFinder(`public/${currentPath}`);
1421
+ setContextMenu(null);
1422
+ }}
1423
+ >
1424
+ Reveal in Finder
1425
+ </MenuItem>
1426
+ <MenuItem
1427
+ type="button"
1428
+ role="menuitem"
1429
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
1430
+ disabled={selectedAssetPaths.length === 0}
1431
+ onClick={() => {
1432
+ void deleteSelected();
1433
+ setContextMenu(null);
1434
+ }}
1435
+ >
1436
+ Delete <kbd>⌫</kbd>
1437
+ </MenuItem>
1438
+ </Menu>
1439
+ )}
1440
+ </div>
501
1441
  );
502
1442
  }