@vgai/editor 0.2.0 → 0.4.0-canary.20260715.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.
Files changed (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.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 +7697 -3435
  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 +51 -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 +1450 -293
  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 +794 -343
  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 +506 -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,82 @@
1
1
  import {
2
- faArrowsRotate,
2
+ faArrowLeft,
3
+ faBookOpen,
4
+ faBoxArchive,
5
+ faChevronRight,
3
6
  faCubes,
4
7
  faFile,
5
8
  faFileCode,
6
9
  faFileImage,
7
10
  faFolder,
11
+ faList,
8
12
  faMusic,
9
13
  faPaintbrush,
10
14
  faPuzzlePiece,
11
15
  faImage as faSceneIcon,
16
+ faTable,
17
+ faTableCellsLarge,
18
+ faTrash,
12
19
  } 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';
20
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
21
+ import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
22
+ import {
23
+ type AssetCapabilityKind,
24
+ assetCapabilities,
25
+ assetDocumentKind,
26
+ } from '../asset-workflow/asset-capabilities';
27
+ import { confirmAssetAction } from '../asset-workflow/asset-workflow-quality';
28
+ import {
29
+ PROJECT_ASSET_COMMANDS,
30
+ projectAssetBrowserStateKey,
31
+ } from '../asset-workflow/project-asset-commands';
32
+ import { inspectProjectAssetHealthFromStorage } from '../asset-workflow/project-asset-health';
33
+ import { executeProjectAssetOperation } from '../asset-workflow/project-asset-operations';
34
+ import {
35
+ loadProjectThumbnailManifest,
36
+ ThumbnailJobQueue,
37
+ } from '../asset-workflow/thumbnail-system';
38
+ import {
39
+ accent,
40
+ Button,
41
+ border,
42
+ EditorIcon,
43
+ EditorToolbar,
44
+ editorIcons,
45
+ IconButton,
46
+ Menu,
47
+ MenuItem,
48
+ radius,
49
+ Select,
50
+ StateSurface,
51
+ TextInput,
52
+ ToolbarGroup,
53
+ Tooltip,
54
+ text,
55
+ } from '../design-system';
56
+ import { useEditorStore, useHistoryService } from '../EditorContext';
57
+ import { type AssetEntry, listAssets, revealInFinder } from '../editor-api';
58
+ import { isEditableTarget, setActiveScope } from '../hotkeys';
59
+ import { getModelThumbnailRenderer, modelThumbnailFormat } from '../model-thumbnail';
60
+ import { getCurrentProject } from '../project-manager';
61
+ import { getStorageBackend } from '../storage';
62
+ import {
63
+ getProjectStoryModules,
64
+ refreshProjectStories,
65
+ subscribeProjectStoryModules,
66
+ } from '../stories/story-registry';
67
+ import { openAssetDocument } from './asset-documents';
68
+ import {
69
+ dataFilesVersion,
70
+ loadedDataFiles,
71
+ openDataDocument,
72
+ refreshDataFiles,
73
+ subscribeDataFiles,
74
+ } from './data-documents';
75
+ import { openStoryDocument } from './story-documents';
76
+ import './asset-workflow.css';
22
77
 
23
78
  // --- Helpers ---
24
79
 
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
80
  type AssetKind =
36
81
  | 'model'
37
82
  | 'image'
@@ -41,22 +86,18 @@ type AssetKind =
41
86
  | 'audio'
42
87
  | 'json'
43
88
  | 'folder'
89
+ | 'source'
44
90
  | 'unknown';
45
91
 
92
+ type BrowserKind = AssetKind | 'data' | 'story';
93
+ type ViewableAssetKind = Exclude<AssetKind, 'folder' | 'unknown'>;
94
+
46
95
  function getAssetKind(entry: AssetEntry): AssetKind {
47
96
  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';
97
+ return assetCapabilities(entry.name).kind as AssetCapabilityKind;
57
98
  }
58
99
 
59
- const assetIconColors: Record<AssetKind, string> = {
100
+ const assetIconColors: Record<BrowserKind, string> = {
60
101
  folder: '#e8a740',
61
102
  model: '#6bc5f7',
62
103
  scene: '#8b5cf6',
@@ -65,10 +106,13 @@ const assetIconColors: Record<AssetKind, string> = {
65
106
  audio: '#34d399',
66
107
  image: '#f472b6',
67
108
  json: '#fbbf24',
109
+ data: '#22c55e',
110
+ story: '#a78bfa',
68
111
  unknown: '#6b7280',
112
+ source: '#fb923c',
69
113
  };
70
114
 
71
- const assetIconMap: Record<AssetKind, typeof faFolder> = {
115
+ const assetIconMap: Record<BrowserKind, typeof faFolder> = {
72
116
  folder: faFolder,
73
117
  model: faCubes,
74
118
  scene: faSceneIcon,
@@ -77,39 +121,84 @@ const assetIconMap: Record<AssetKind, typeof faFolder> = {
77
121
  audio: faMusic,
78
122
  image: faFileImage,
79
123
  json: faFileCode,
124
+ data: faTable,
125
+ story: faBookOpen,
80
126
  unknown: faFile,
127
+ source: faBoxArchive,
81
128
  };
82
129
 
83
- function AssetIcon({ kind }: { kind: AssetKind }) {
130
+ function AssetIcon({ kind, size = 24 }: { kind: BrowserKind; size?: number }) {
84
131
  return (
85
- <FontAwesomeIcon
132
+ <EditorIcon
86
133
  icon={assetIconMap[kind]}
87
- style={{ fontSize: 24, color: assetIconColors[kind] }}
134
+ style={{ fontSize: size, color: assetIconColors[kind] }}
88
135
  />
89
136
  );
90
137
  }
91
-
92
138
  /** Renders an offscreen 3D preview thumbnail for .glb/.gltf model assets. */
139
+ const projectThumbnailQueue = new ThumbnailJobQueue<string>((url) => {
140
+ const format = modelThumbnailFormat(url);
141
+ if (!format) return Promise.reject(new Error(`No model preview loader for ${url}.`));
142
+ return getModelThumbnailRenderer().render({ url, format });
143
+ }, 2);
144
+
93
145
  function ModelThumbnail({ url }: { url: string }) {
94
146
  const [src, setSrc] = useState<string | null>(null);
95
147
  const [failed, setFailed] = useState(false);
148
+ const [manifestPath, setManifestPath] = useState<string | null>(null);
149
+ const [manifestRejected, setManifestRejected] = useState(false);
150
+
151
+ useEffect(() => {
152
+ setSrc(null);
153
+ setFailed(false);
154
+ setManifestPath(null);
155
+ setManifestRejected(false);
156
+ }, [url]);
96
157
 
97
158
  useEffect(() => {
98
159
  let cancelled = false;
99
- getModelThumbnailRenderer()
100
- .render(url)
160
+ let queued: ReturnType<typeof projectThumbnailQueue.request> | null = null;
161
+ void loadProjectThumbnailManifest()
162
+ .then((manifest) => {
163
+ if (cancelled) return;
164
+ const entry = manifest?.entries[url.replace(/^\/+/, '')];
165
+ if (
166
+ !manifestRejected &&
167
+ entry?.path &&
168
+ (entry.state === 'generated' || entry.state === 'upstream')
169
+ ) {
170
+ setManifestPath(entry.path.startsWith('/') ? entry.path : `/${entry.path}`);
171
+ return;
172
+ }
173
+ queued = projectThumbnailQueue.request(url, 100);
174
+ return queued.promise;
175
+ })
101
176
  .then((dataUrl) => {
102
- if (!cancelled) setSrc(dataUrl);
177
+ if (!cancelled && dataUrl) setSrc(dataUrl);
103
178
  })
104
179
  .catch(() => {
105
180
  if (!cancelled) setFailed(true);
106
181
  });
107
182
  return () => {
108
183
  cancelled = true;
184
+ queued?.cancel();
109
185
  };
110
- }, [url]);
186
+ }, [manifestRejected, url]);
111
187
 
112
188
  if (failed) return <AssetIcon kind="model" />;
189
+ if (manifestPath) {
190
+ return (
191
+ <img
192
+ src={manifestPath}
193
+ alt="cached model preview"
194
+ onError={() => {
195
+ setManifestPath(null);
196
+ setManifestRejected(true);
197
+ }}
198
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
199
+ />
200
+ );
201
+ }
113
202
  if (!src) {
114
203
  // Loading spinner
115
204
  return (
@@ -127,8 +216,8 @@ function ModelThumbnail({ url }: { url: string }) {
127
216
  style={{
128
217
  width: 16,
129
218
  height: 16,
130
- border: '2px solid #444',
131
- borderTopColor: '#6bc5f7',
219
+ border: `2px solid ${border[2]}`,
220
+ borderTopColor: assetIconColors.model,
132
221
  borderRadius: '50%',
133
222
  animation: 'editor-spin 0.8s linear infinite',
134
223
  }}
@@ -140,7 +229,7 @@ function ModelThumbnail({ url }: { url: string }) {
140
229
  <img
141
230
  src={src}
142
231
  alt="model preview"
143
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: 2 }}
232
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
144
233
  />
145
234
  );
146
235
  }
@@ -161,22 +250,803 @@ function formatSize(bytes: number): string {
161
250
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
162
251
  }
163
252
 
253
+ type ProjectAssetHealth = 'healthy' | 'source-only' | 'unsupported' | 'stale-import';
254
+
255
+ function projectAssetHealth(name: string, path: string): ProjectAssetHealth {
256
+ const capability = assetCapabilities(name);
257
+ if (!capability.importable) return 'unsupported';
258
+ if (!capability.runtimeReady) return 'source-only';
259
+ if (path.includes('/.generated/') || path.startsWith('.generated/')) return 'stale-import';
260
+ return 'healthy';
261
+ }
262
+
263
+ interface PersistedAssetBrowserState {
264
+ currentRoot: AssetRoot | null;
265
+ currentPath: string;
266
+ viewMode: AssetViewMode;
267
+ sort: AssetSort;
268
+ filter: AssetFilter;
269
+ density: number;
270
+ expanded: string[];
271
+ }
272
+
273
+ const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
274
+ currentRoot: null,
275
+ currentPath: '',
276
+ viewMode: 'grid',
277
+ sort: 'name',
278
+ filter: 'all',
279
+ density: 96,
280
+ expanded: ['public'],
281
+ };
282
+
283
+ function readAssetBrowserState(): PersistedAssetBrowserState {
284
+ try {
285
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
286
+ const parsed = JSON.parse(
287
+ localStorage.getItem(projectAssetBrowserStateKey(projectId)) ?? '{}',
288
+ ) as Partial<PersistedAssetBrowserState>;
289
+ return { ...DEFAULT_BROWSER_STATE, ...parsed };
290
+ } catch {
291
+ return DEFAULT_BROWSER_STATE;
292
+ }
293
+ }
294
+
164
295
  // --- Main Component ---
165
296
 
166
297
  interface AssetBrowserProps {
167
298
  onOpenScene?: (assetPath: string) => void;
168
299
  }
169
300
 
301
+ type AssetRoot = 'public' | 'data' | 'stories';
302
+ type AssetViewMode = 'grid' | 'list' | 'columns';
303
+ type AssetSort = 'name' | 'type' | 'size' | 'health';
304
+ type AssetFilter =
305
+ | 'all'
306
+ | 'runtime-ready'
307
+ | 'source-only'
308
+ | 'unsupported'
309
+ | 'imported'
310
+ | 'generated';
311
+
312
+ type BrowserEntry =
313
+ | {
314
+ source: 'root';
315
+ key: string;
316
+ name: string;
317
+ root: AssetRoot;
318
+ detail: string;
319
+ }
320
+ | { source: 'asset'; key: string; name: string; entry: AssetEntry }
321
+ | { source: 'data'; key: string; name: string; path: string }
322
+ | {
323
+ source: 'story';
324
+ key: string;
325
+ name: string;
326
+ modulePath: string;
327
+ storyName: string;
328
+ };
329
+
330
+ function browserEntryKind(item: BrowserEntry): BrowserKind {
331
+ if (item.source === 'root') return 'folder';
332
+ if (item.source === 'data') return 'data';
333
+ if (item.source === 'story') return 'story';
334
+ return getAssetKind(item.entry);
335
+ }
336
+
337
+ function assetRootLabel(root: AssetRoot): string {
338
+ if (root === 'data') return 'src/data';
339
+ return root;
340
+ }
341
+
342
+ function parentAssetLocation(
343
+ root: AssetRoot | null,
344
+ path: string,
345
+ ): readonly [AssetRoot | null, string] {
346
+ if (root === null) return [null, ''];
347
+ const separator = path.lastIndexOf('/');
348
+ if (separator < 0) return path ? [root, ''] : [null, ''];
349
+ return [root, path.slice(0, separator)];
350
+ }
351
+
352
+ function openProjectAssetEntry(
353
+ entry: AssetEntry,
354
+ options: {
355
+ currentRoot: AssetRoot;
356
+ currentPath: string;
357
+ navigate: (root: AssetRoot | null, path: string) => void;
358
+ onOpenScene?: (assetPath: string) => void;
359
+ openAsset: (url: string, kind: ViewableAssetKind) => void;
360
+ },
361
+ ): void {
362
+ const { currentRoot, currentPath, navigate, onOpenScene, openAsset } = options;
363
+ if (entry.type === 'directory') {
364
+ navigate(currentRoot, currentPath ? `${currentPath}/${entry.name}` : entry.name);
365
+ return;
366
+ }
367
+ const capability = assetCapabilities(entry.name);
368
+ const documentKind = assetDocumentKind(capability);
369
+ if ((documentKind === 'scene' || documentKind === 'prefab') && onOpenScene) {
370
+ onOpenScene(assetServingUrl(currentRoot, currentPath, entry.name));
371
+ return;
372
+ }
373
+ if (documentKind) openAsset(assetServingUrl(currentRoot, currentPath, entry.name), documentKind);
374
+ }
375
+
376
+ function browserEntryDetail(item: BrowserEntry): string {
377
+ if (item.source === 'root') return item.detail;
378
+ if (item.source === 'story') return item.modulePath;
379
+ if (item.source === 'data') return item.path;
380
+ return item.entry.type === 'file' ? formatSize(item.entry.size) : 'Folder';
381
+ }
382
+
383
+ function browserEntryTitle(item: BrowserEntry): string {
384
+ const detail = browserEntryDetail(item);
385
+ return detail ? `${item.name}\n${detail}` : item.name;
386
+ }
387
+
388
+ const GRID_ENTRY_STYLE: React.CSSProperties = {
389
+ width: '100%',
390
+ minWidth: 0,
391
+ padding: 4,
392
+ textAlign: 'center',
393
+ display: 'flex',
394
+ flexDirection: 'column',
395
+ alignItems: 'center',
396
+ gap: 0,
397
+ };
398
+
399
+ const LIST_ENTRY_STYLE: React.CSSProperties = {
400
+ width: '100%',
401
+ minWidth: 0,
402
+ minHeight: 28,
403
+ padding: '3px 6px',
404
+ textAlign: 'left',
405
+ display: 'flex',
406
+ flexDirection: 'row',
407
+ alignItems: 'center',
408
+ gap: 7,
409
+ };
410
+
411
+ function BrowserEntryVisual({
412
+ item,
413
+ kind,
414
+ assetUrl,
415
+ grid,
416
+ }: {
417
+ item: BrowserEntry;
418
+ kind: BrowserKind;
419
+ assetUrl: string;
420
+ grid: boolean;
421
+ }) {
422
+ if (!grid) return <AssetIcon kind={kind} size={15} />;
423
+ if (kind === 'image') {
424
+ return (
425
+ <img
426
+ src={assetUrl}
427
+ alt={item.name}
428
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
429
+ />
430
+ );
431
+ }
432
+ if (kind === 'model' || (item.source === 'asset' && modelThumbnailFormat(assetUrl))) {
433
+ return <ModelThumbnail url={assetUrl} />;
434
+ }
435
+ if (['audio', 'material', 'scene', 'prefab', 'json', 'data', 'story'].includes(kind)) {
436
+ return <TypedAssetThumbnail kind={kind} name={item.name} />;
437
+ }
438
+ return <AssetIcon kind={kind} />;
439
+ }
440
+
441
+ function TypedAssetThumbnail({ kind, name }: { kind: BrowserKind; name: string }) {
442
+ const color = assetIconColors[kind];
443
+ if (kind === 'audio') {
444
+ return (
445
+ <svg
446
+ role="img"
447
+ aria-label={`${name} waveform preview`}
448
+ viewBox="0 0 64 48"
449
+ width="52"
450
+ height="44"
451
+ >
452
+ <rect width="64" height="48" rx="4" fill="var(--vgai-bg-inset)" />
453
+ {Array.from({ length: 16 }, (_, index) => (
454
+ <rect
455
+ key={index}
456
+ x={index * 4 + 1}
457
+ y={24 - ((index * 13) % 18) / 2}
458
+ width="2"
459
+ height={((index * 13) % 18) + 3}
460
+ fill={color}
461
+ />
462
+ ))}
463
+ </svg>
464
+ );
465
+ }
466
+ if (kind === 'material') {
467
+ return (
468
+ <span
469
+ role="img"
470
+ aria-label={`${name} material preview`}
471
+ style={{
472
+ width: 44,
473
+ height: 44,
474
+ borderRadius: '50%',
475
+ background: `radial-gradient(circle at 32% 28%, white 0, ${color} 18%, var(--vgai-bg-inset) 72%)`,
476
+ boxShadow: 'inset -5px -7px 10px rgb(0 0 0 / 35%)',
477
+ }}
478
+ />
479
+ );
480
+ }
481
+ return (
482
+ <svg
483
+ role="img"
484
+ aria-label={`${name} ${kind} preview`}
485
+ viewBox="0 0 52 48"
486
+ width="48"
487
+ height="44"
488
+ >
489
+ <rect x="5" y="3" width="42" height="42" rx="4" fill="var(--vgai-bg-inset)" stroke={color} />
490
+ <path d="M13 15h26M13 23h18M13 31h22" stroke={color} strokeWidth="3" strokeLinecap="round" />
491
+ </svg>
492
+ );
493
+ }
494
+
495
+ function BrowserEntryCard({
496
+ item,
497
+ selected,
498
+ currentRoot,
499
+ currentPath,
500
+ onDragStart,
501
+ onSelect,
502
+ onOpen,
503
+ onContextMenu,
504
+ viewMode,
505
+ }: {
506
+ item: BrowserEntry;
507
+ selected: boolean;
508
+ currentRoot: AssetRoot | null;
509
+ currentPath: string;
510
+ onDragStart: (event: React.DragEvent, item: BrowserEntry) => void;
511
+ onSelect: (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => void;
512
+ onOpen: (item: BrowserEntry) => void;
513
+ onContextMenu: (event: React.MouseEvent, item: BrowserEntry) => void;
514
+ viewMode: AssetViewMode;
515
+ }) {
516
+ const kind = browserEntryKind(item);
517
+ const assetEntry = item.source === 'asset' ? item.entry : null;
518
+ const capability = assetEntry?.type === 'file' ? assetCapabilities(assetEntry.name) : null;
519
+ const assetUrl = assetServingUrl(currentRoot ?? 'public', currentPath, item.name);
520
+ const title = browserEntryTitle(item);
521
+ const cursor = assetEntry
522
+ ? assetEntry.type === 'directory' || capability?.placeable
523
+ ? capability?.placeable
524
+ ? 'grab'
525
+ : 'pointer'
526
+ : 'default'
527
+ : 'pointer';
528
+ const detail = browserEntryDetail(item);
529
+ const grid = viewMode === 'grid';
530
+
531
+ return (
532
+ <div
533
+ className="vgai-selectable"
534
+ data-selected={selected || undefined}
535
+ data-testid={item.source === 'root' ? `asset-root-${item.root}` : undefined}
536
+ role="option"
537
+ aria-selected={selected}
538
+ tabIndex={0}
539
+ draggable={assetEntry?.type === 'file' && capability?.placeable === true}
540
+ onDragStart={(event) => onDragStart(event, item)}
541
+ onClick={(event) => onSelect(item, event)}
542
+ onDoubleClick={() => onOpen(item)}
543
+ onContextMenu={(event) => onContextMenu(event, item)}
544
+ onKeyDown={(event) => {
545
+ if (event.key === 'Enter') onOpen(item);
546
+ else if (event.key === ' ') {
547
+ onSelect(item, event);
548
+ event.preventDefault();
549
+ }
550
+ }}
551
+ title={title}
552
+ style={{
553
+ ...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
554
+ borderRadius: radius.sm,
555
+ cursor,
556
+ border: selected ? `1px solid ${accent}` : '1px solid transparent',
557
+ userSelect: 'none',
558
+ }}
559
+ >
560
+ <div
561
+ style={{
562
+ ...(grid
563
+ ? { width: 48, height: 48, margin: '0 auto 4px' }
564
+ : { width: 18, height: 18, margin: 0 }),
565
+ display: 'flex',
566
+ alignItems: 'center',
567
+ justifyContent: 'center',
568
+ overflow: 'hidden',
569
+ borderRadius: radius.sm,
570
+ pointerEvents: 'none',
571
+ }}
572
+ >
573
+ <BrowserEntryVisual item={item} kind={kind} assetUrl={assetUrl} grid={grid} />
574
+ </div>
575
+ <div
576
+ data-testid="asset-entry-name"
577
+ style={{
578
+ ...(grid
579
+ ? {
580
+ fontSize: 10,
581
+ lineHeight: '13px',
582
+ minHeight: 26,
583
+ display: '-webkit-box',
584
+ WebkitBoxOrient: 'vertical',
585
+ WebkitLineClamp: 2,
586
+ }
587
+ : { fontSize: 11, lineHeight: '18px', flex: 1 }),
588
+ overflow: 'hidden',
589
+ textOverflow: 'ellipsis',
590
+ whiteSpace: grid ? 'normal' : 'nowrap',
591
+ color: text[1],
592
+ minWidth: 0,
593
+ }}
594
+ >
595
+ {item.name}
596
+ </div>
597
+ {!grid && (
598
+ <div
599
+ role="listbox"
600
+ aria-label="Project assets"
601
+ aria-multiselectable="true"
602
+ style={{
603
+ maxWidth: '45%',
604
+ overflow: 'hidden',
605
+ textOverflow: 'ellipsis',
606
+ whiteSpace: 'nowrap',
607
+ color: text[3],
608
+ fontSize: 10,
609
+ }}
610
+ >
611
+ {detail}
612
+ </div>
613
+ )}
614
+ {grid && capability && (
615
+ <div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', justifyContent: 'center' }}>
616
+ <span className="vgai-asset-badge">
617
+ {capability.runtimeReady ? 'Ready' : capability.importable ? 'Import' : 'Unsupported'}
618
+ </span>
619
+ {capability.animated && <span className="vgai-asset-badge">Animated</span>}
620
+ </div>
621
+ )}
622
+ </div>
623
+ );
624
+ }
625
+
626
+ interface AssetBrowserToolbarProps {
627
+ currentRoot: AssetRoot | null;
628
+ currentPath: string;
629
+ searchOpen: boolean;
630
+ searchQuery: string;
631
+ viewMode: AssetViewMode;
632
+ sort: AssetSort;
633
+ filter: AssetFilter;
634
+ density: number;
635
+ onNavigate: (root: AssetRoot | null, path: string) => void;
636
+ onSearchOpenChange: (open: boolean) => void;
637
+ onSearchQueryChange: (query: string) => void;
638
+ onViewModeChange: (mode: AssetViewMode) => void;
639
+ onSortChange: (sort: AssetSort) => void;
640
+ onFilterChange: (filter: AssetFilter) => void;
641
+ onDensityChange: (density: number) => void;
642
+ onCreateFolder: () => void;
643
+ onImport: () => void;
644
+ onDelete: () => void;
645
+ deleteDisabled: boolean;
646
+ onRefresh: () => void;
647
+ }
648
+
649
+ function AssetBrowserToolbar({
650
+ currentRoot,
651
+ currentPath,
652
+ searchOpen,
653
+ searchQuery,
654
+ viewMode,
655
+ sort,
656
+ filter,
657
+ density,
658
+ onNavigate,
659
+ onSearchOpenChange,
660
+ onSearchQueryChange,
661
+ onViewModeChange,
662
+ onSortChange,
663
+ onFilterChange,
664
+ onDensityChange,
665
+ onCreateFolder,
666
+ onImport,
667
+ onDelete,
668
+ deleteDisabled,
669
+ onRefresh,
670
+ }: AssetBrowserToolbarProps) {
671
+ const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
672
+ const [parentRoot, parentPath] = parentAssetLocation(currentRoot, currentPath);
673
+ const closeSearch = () => {
674
+ onSearchQueryChange('');
675
+ onSearchOpenChange(false);
676
+ };
677
+
678
+ return (
679
+ <EditorToolbar
680
+ label="Asset browser controls"
681
+ compact
682
+ style={{
683
+ gap: 6,
684
+ flexWrap: 'wrap',
685
+ flexShrink: 0,
686
+ }}
687
+ >
688
+ <Tooltip text="Back" position="top">
689
+ <IconButton
690
+ data-testid="asset-back"
691
+ aria-label="Back"
692
+ disabled={currentRoot === null}
693
+ onClick={() => onNavigate(parentRoot, parentPath)}
694
+ >
695
+ <EditorIcon icon={faArrowLeft} />
696
+ </IconButton>
697
+ </Tooltip>
698
+ {searchOpen ? (
699
+ <TextInput
700
+ type="text"
701
+ data-testid="asset-search-input"
702
+ aria-label="Search assets"
703
+ placeholder="Search assets..."
704
+ value={searchQuery}
705
+ onChange={(event) => onSearchQueryChange(event.target.value)}
706
+ onKeyDown={(event) => {
707
+ if (event.key === 'Escape') closeSearch();
708
+ }}
709
+ className="vgai-input"
710
+ autoFocus
711
+ style={{ flex: 1, minWidth: 60 }}
712
+ />
713
+ ) : currentRoot !== null ? (
714
+ <nav
715
+ data-testid="asset-breadcrumb"
716
+ aria-label="Asset location"
717
+ style={{
718
+ flex: 1,
719
+ display: 'flex',
720
+ alignItems: 'center',
721
+ gap: 2,
722
+ fontSize: 12,
723
+ minWidth: 0,
724
+ overflow: 'hidden',
725
+ whiteSpace: 'nowrap',
726
+ }}
727
+ >
728
+ {currentPath ? (
729
+ <Button
730
+ type="button"
731
+ variant="ghost"
732
+ size="compact"
733
+ onClick={() => onNavigate(currentRoot, '')}
734
+ style={{ flexShrink: 0 }}
735
+ >
736
+ {assetRootLabel(currentRoot)}
737
+ </Button>
738
+ ) : (
739
+ <span aria-current="location" style={{ color: text[1] }}>
740
+ {assetRootLabel(currentRoot)}
741
+ </span>
742
+ )}
743
+ {breadcrumbSegments.map((segment, index) => {
744
+ const path = breadcrumbSegments.slice(0, index + 1).join('/');
745
+ const isLast = index === breadcrumbSegments.length - 1;
746
+ return (
747
+ <span
748
+ key={path}
749
+ style={{
750
+ display: 'flex',
751
+ alignItems: 'center',
752
+ gap: 2,
753
+ minWidth: isLast ? 0 : undefined,
754
+ flexShrink: isLast ? 1 : 0,
755
+ }}
756
+ >
757
+ <span style={{ color: text[3] }}>/</span>
758
+ {isLast ? (
759
+ <span
760
+ aria-current="location"
761
+ title={segment}
762
+ style={{
763
+ color: text[1],
764
+ overflow: 'hidden',
765
+ textOverflow: 'ellipsis',
766
+ }}
767
+ >
768
+ {segment}
769
+ </span>
770
+ ) : (
771
+ <Button
772
+ type="button"
773
+ variant="ghost"
774
+ size="compact"
775
+ onClick={() => onNavigate(currentRoot, path)}
776
+ >
777
+ {segment}
778
+ </Button>
779
+ )}
780
+ </span>
781
+ );
782
+ })}
783
+ </nav>
784
+ ) : (
785
+ <div style={{ flex: 1 }} />
786
+ )}
787
+
788
+ <Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
789
+ <IconButton
790
+ data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
791
+ aria-label={searchOpen ? 'Close search' : 'Search assets'}
792
+ onClick={() => {
793
+ if (searchOpen) closeSearch();
794
+ else onSearchOpenChange(true);
795
+ }}
796
+ >
797
+ <EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
798
+ </IconButton>
799
+ </Tooltip>
800
+ <ToolbarGroup>
801
+ <Tooltip text="Grid view" position="top">
802
+ <IconButton
803
+ aria-label="Grid view"
804
+ data-testid="asset-view-grid"
805
+ aria-pressed={viewMode === 'grid'}
806
+ onClick={() => onViewModeChange('grid')}
807
+ >
808
+ <EditorIcon icon={faTableCellsLarge} />
809
+ </IconButton>
810
+ </Tooltip>
811
+ <Tooltip text="List view" position="top">
812
+ <IconButton
813
+ aria-label="List view"
814
+ data-testid="asset-view-list"
815
+ aria-pressed={viewMode === 'list'}
816
+ onClick={() => onViewModeChange('list')}
817
+ >
818
+ <EditorIcon icon={faList} />
819
+ </IconButton>
820
+ </Tooltip>
821
+ <Tooltip text="Column view" position="top">
822
+ <IconButton
823
+ aria-label="Column view"
824
+ data-testid="asset-view-columns"
825
+ aria-pressed={viewMode === 'columns'}
826
+ onClick={() => onViewModeChange('columns')}
827
+ >
828
+ <EditorIcon icon={faTable} />
829
+ </IconButton>
830
+ </Tooltip>
831
+ </ToolbarGroup>
832
+ <Select
833
+ className="vgai-select"
834
+ aria-label="Sort project assets"
835
+ value={sort}
836
+ onChange={(event) => onSortChange(event.target.value as AssetSort)}
837
+ >
838
+ <option value="name">Name</option>
839
+ <option value="type">Type</option>
840
+ <option value="size">Size</option>
841
+ <option value="health">Health</option>
842
+ </Select>
843
+ <Select
844
+ className="vgai-select"
845
+ aria-label="Filter project assets"
846
+ value={filter}
847
+ onChange={(event) => onFilterChange(event.target.value as AssetFilter)}
848
+ >
849
+ <option value="all">All</option>
850
+ <option value="runtime-ready">Runtime-ready</option>
851
+ <option value="source-only">Source-only</option>
852
+ <option value="unsupported">Unsupported</option>
853
+ <option value="imported">Imported</option>
854
+ <option value="generated">Generated</option>
855
+ </Select>
856
+ {viewMode === 'grid' && (
857
+ <TextInput
858
+ type="range"
859
+ aria-label="Project asset thumbnail size"
860
+ min="72"
861
+ max="160"
862
+ value={density}
863
+ onChange={(event) => onDensityChange(Number(event.target.value))}
864
+ />
865
+ )}
866
+ <Button
867
+ data-command-id={PROJECT_ASSET_COMMANDS.import.id}
868
+ variant="ghost"
869
+ size="comfortable"
870
+ onClick={onImport}
871
+ >
872
+ Import
873
+ </Button>
874
+ <Button
875
+ data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
876
+ variant="ghost"
877
+ size="comfortable"
878
+ onClick={onCreateFolder}
879
+ >
880
+ New folder
881
+ </Button>
882
+ <IconButton
883
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
884
+ aria-label="Delete selected assets"
885
+ disabled={deleteDisabled}
886
+ onClick={onDelete}
887
+ >
888
+ <EditorIcon icon={faTrash} />
889
+ </IconButton>
890
+ <Tooltip text="Refresh" position="top">
891
+ <IconButton
892
+ aria-label="Refresh assets"
893
+ onClick={(event) => {
894
+ event.stopPropagation();
895
+ onRefresh();
896
+ }}
897
+ >
898
+ <EditorIcon icon={editorIcons.action.refresh} />
899
+ </IconButton>
900
+ </Tooltip>
901
+ </EditorToolbar>
902
+ );
903
+ }
904
+
905
+ function ProjectAssetTree(props: {
906
+ currentRoot: AssetRoot | null;
907
+ currentPath: string;
908
+ expanded: ReadonlySet<string>;
909
+ onExpanded(path: string): void;
910
+ onNavigate(root: AssetRoot | null, path: string): void;
911
+ }) {
912
+ const [folders, setFolders] = useState(new Map<string, string[]>());
913
+ useEffect(() => {
914
+ for (const key of props.expanded) {
915
+ if (!key.startsWith('public') || folders.has(key)) continue;
916
+ const path = key === 'public' ? '' : key.slice('public/'.length);
917
+ void listAssets('public', path).then((entries) => {
918
+ setFolders((current) => {
919
+ const next = new Map(current);
920
+ next.set(
921
+ key,
922
+ entries.filter((entry) => entry.type === 'directory').map((entry) => entry.name),
923
+ );
924
+ return next;
925
+ });
926
+ });
927
+ }
928
+ }, [folders, props.expanded]);
929
+
930
+ const renderFolder = (path: string, depth: number): React.ReactNode => {
931
+ const key = path ? `public/${path}` : 'public';
932
+ const open = props.expanded.has(key);
933
+ return (
934
+ <div key={key}>
935
+ <div className="vgai-asset-tree-row" style={{ paddingLeft: 6 + depth * 14 }}>
936
+ <Button
937
+ type="button"
938
+ className="vgai-asset-tree-toggle"
939
+ aria-label={`${open ? 'Collapse' : 'Expand'} ${path.split('/').pop() || 'public'}`}
940
+ aria-expanded={open}
941
+ onClick={() => props.onExpanded(key)}
942
+ >
943
+ <EditorIcon
944
+ icon={faChevronRight}
945
+ style={{ transform: open ? 'rotate(90deg)' : undefined }}
946
+ />
947
+ </Button>
948
+ <Button
949
+ type="button"
950
+ className="vgai-asset-tree-target"
951
+ aria-current={
952
+ props.currentRoot === 'public' && props.currentPath === path ? 'page' : undefined
953
+ }
954
+ onClick={() => props.onNavigate('public', path)}
955
+ >
956
+ <EditorIcon icon={faFolder} />
957
+ {path.split('/').pop() || 'public'}
958
+ </Button>
959
+ </div>
960
+ {open &&
961
+ folders.get(key)?.map((name) => renderFolder(path ? `${path}/${name}` : name, depth + 1))}
962
+ </div>
963
+ );
964
+ };
965
+
966
+ return (
967
+ <aside className="vgai-asset-tree" aria-label="Project asset sources">
968
+ <div className="vgai-asset-facet-title">Project</div>
969
+ {renderFolder('', 0)}
970
+ <Button
971
+ type="button"
972
+ className="vgai-asset-tree-row"
973
+ onClick={() => props.onNavigate('data', '')}
974
+ >
975
+ <EditorIcon icon={faTable} /> src/data
976
+ </Button>
977
+ <Button
978
+ type="button"
979
+ className="vgai-asset-tree-row"
980
+ onClick={() => props.onNavigate('stories', '')}
981
+ >
982
+ <EditorIcon icon={faBookOpen} /> stories
983
+ </Button>
984
+ <div className="vgai-asset-facet-title">References</div>
985
+ <Button
986
+ type="button"
987
+ className="vgai-asset-tree-row"
988
+ onClick={() => props.onNavigate('public', 'asset-library/local')}
989
+ >
990
+ Imported
991
+ </Button>
992
+ <Button
993
+ type="button"
994
+ className="vgai-asset-tree-row"
995
+ onClick={() => props.onNavigate('public', '.generated')}
996
+ >
997
+ Generated
998
+ </Button>
999
+ </aside>
1000
+ );
1001
+ }
1002
+
170
1003
  export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
171
1004
  const store = useEditorStore();
172
- const [currentRoot, setCurrentRoot] = useState('public');
173
- const [currentPath, setCurrentPath] = useState('');
1005
+ const history = useHistoryService();
1006
+ const initial = useMemo(readAssetBrowserState, []);
1007
+ const dataVersion = useSyncExternalStore(subscribeDataFiles, dataFilesVersion, dataFilesVersion);
1008
+ const storyModules = useSyncExternalStore(
1009
+ subscribeProjectStoryModules,
1010
+ getProjectStoryModules,
1011
+ getProjectStoryModules,
1012
+ );
1013
+ const [currentRoot, setCurrentRoot] = useState<AssetRoot | null>(initial.currentRoot);
1014
+ const [currentPath, setCurrentPath] = useState(initial.currentPath);
1015
+ const [viewMode, setViewMode] = useState<AssetViewMode>(initial.viewMode);
1016
+ const [sort, setSort] = useState<AssetSort>(initial.sort);
1017
+ const [filter, setFilter] = useState<AssetFilter>(initial.filter);
1018
+ const [density, setDensity] = useState(initial.density);
1019
+ const [expanded, setExpanded] = useState(new Set(initial.expanded));
1020
+ const [searchOpen, setSearchOpen] = useState(false);
174
1021
  const [entries, setEntries] = useState<AssetEntry[]>([]);
175
1022
  const [searchQuery, setSearchQuery] = useState('');
176
- const [selectedFile, setSelectedFile] = useState<string | null>(null);
1023
+ const [selectedFiles, setSelectedFiles] = useState(new Set<string>());
1024
+ const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
1025
+ const [contextMenu, setContextMenu] = useState<{
1026
+ x: number;
1027
+ y: number;
1028
+ item: BrowserEntry;
1029
+ } | null>(null);
1030
+ const importInputRef = useRef<HTMLInputElement>(null);
177
1031
  const cacheRef = useRef(new Map<string, AssetEntry[]>());
178
1032
 
179
- const fetchEntries = useCallback(async (root: string, dir: string) => {
1033
+ useEffect(() => {
1034
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
1035
+ localStorage.setItem(
1036
+ projectAssetBrowserStateKey(projectId),
1037
+ JSON.stringify({
1038
+ currentRoot,
1039
+ currentPath,
1040
+ viewMode,
1041
+ sort,
1042
+ filter,
1043
+ density,
1044
+ expanded: [...expanded],
1045
+ } satisfies PersistedAssetBrowserState),
1046
+ );
1047
+ }, [currentPath, currentRoot, density, expanded, filter, sort, viewMode]);
1048
+
1049
+ const fetchEntries = useCallback(async (root: AssetRoot, dir: string) => {
180
1050
  const key = `${root}:${dir}`;
181
1051
  const cached = cacheRef.current.get(key);
182
1052
  if (cached) {
@@ -193,29 +1063,122 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
193
1063
  }, []);
194
1064
 
195
1065
  useEffect(() => {
196
- fetchEntries(currentRoot, currentPath);
1066
+ if (currentRoot === 'public') fetchEntries(currentRoot, currentPath);
197
1067
  }, [currentRoot, currentPath, fetchEntries]);
198
1068
 
199
- const navigate = useCallback((root: string, path: string) => {
1069
+ const navigate = useCallback((root: AssetRoot | null, path: string) => {
200
1070
  setCurrentRoot(root);
201
1071
  setCurrentPath(path);
202
- setSelectedFile(null);
1072
+ setSelectedFiles(new Set());
1073
+ setSelectionAnchor(null);
1074
+ clearSelectedAsset();
203
1075
  setSearchQuery('');
1076
+ setSearchOpen(false);
204
1077
  }, []);
205
1078
 
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;
1079
+ // Project data and CSF stories are typed content inside the Assets browser,
1080
+ // not permanent peer navigation modes. Their existing center documents
1081
+ // remain the editing/preview surfaces.
1082
+ const browserEntries = useMemo<BrowserEntry[]>(() => {
1083
+ if (currentRoot === null) {
1084
+ return [
1085
+ {
1086
+ source: 'root',
1087
+ key: 'root:public',
1088
+ name: 'public',
1089
+ root: 'public',
1090
+ detail: 'Models, images, scenes, audio, and other game assets',
1091
+ },
1092
+ {
1093
+ source: 'root',
1094
+ key: 'root:data',
1095
+ name: 'src/data',
1096
+ root: 'data',
1097
+ detail: 'Typed game data',
1098
+ },
1099
+ {
1100
+ source: 'root',
1101
+ key: 'root:stories',
1102
+ name: 'stories',
1103
+ root: 'stories',
1104
+ detail: 'React component stories',
1105
+ },
1106
+ ];
1107
+ }
1108
+ if (currentRoot === 'data') {
1109
+ return loadedDataFiles().map((file) => ({
1110
+ source: 'data',
1111
+ key: `data:${file.path}`,
1112
+ name: file.path.split('/').pop() ?? file.path,
1113
+ path: file.path,
1114
+ }));
1115
+ }
1116
+ if (currentRoot === 'stories') {
1117
+ return storyModules.flatMap((module_) =>
1118
+ module_.ok
1119
+ ? module_.stories.map((story) => ({
1120
+ source: 'story' as const,
1121
+ key: `story:${module_.modulePath}:${story.name}`,
1122
+ name: story.name,
1123
+ modulePath: module_.modulePath,
1124
+ storyName: story.name,
1125
+ }))
1126
+ : [],
1127
+ );
1128
+ }
1129
+ return entries.map((entry) => ({
1130
+ source: 'asset' as const,
1131
+ key: `asset:${currentPath}:${entry.name}`,
1132
+ name: entry.name,
1133
+ entry,
1134
+ }));
1135
+ }, [currentPath, currentRoot, dataVersion, entries, storyModules]);
1136
+ const filtered = useMemo(() => {
1137
+ const query = searchQuery.toLowerCase();
1138
+ const visible = browserEntries.filter((item) => {
1139
+ if (query && !item.name.toLowerCase().includes(query)) return false;
1140
+ if (filter === 'all' || item.source !== 'asset' || item.entry.type === 'directory')
1141
+ return true;
1142
+ const capability = assetCapabilities(item.name);
1143
+ const path = currentPath ? `${currentPath}/${item.name}` : item.name;
1144
+ if (filter === 'runtime-ready') return capability.runtimeReady;
1145
+ if (filter === 'source-only') return capability.importable && !capability.runtimeReady;
1146
+ if (filter === 'unsupported') return !capability.importable;
1147
+ if (filter === 'imported') return path.startsWith('asset-library/local/');
1148
+ return path.startsWith('.generated/');
1149
+ });
1150
+ return [...visible].sort((left, right) => {
1151
+ if (left.source === 'asset' && right.source === 'asset') {
1152
+ if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
1153
+ if (sort === 'size' && left.entry.size !== right.entry.size)
1154
+ return right.entry.size - left.entry.size;
1155
+ if (sort === 'type') {
1156
+ const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
1157
+ if (order !== 0) return order;
1158
+ }
1159
+ if (sort === 'health') {
1160
+ const leftHealth = projectAssetHealth(left.name, currentPath);
1161
+ const rightHealth = projectAssetHealth(right.name, currentPath);
1162
+ const order = leftHealth.localeCompare(rightHealth);
1163
+ if (order !== 0) return order;
1164
+ }
1165
+ }
1166
+ return left.name.localeCompare(right.name);
1167
+ });
1168
+ }, [browserEntries, currentPath, filter, searchQuery, sort]);
213
1169
 
214
1170
  // --- Drag start ---
215
1171
  const onDragStart = useCallback(
216
- (e: React.DragEvent, entry: AssetEntry) => {
217
- const kind = getAssetKind(entry);
218
- if (kind === 'folder') return;
1172
+ (e: React.DragEvent, item: BrowserEntry) => {
1173
+ if (item.source !== 'asset' || currentRoot !== 'public') return;
1174
+ const { entry } = item;
1175
+ const capability = assetCapabilities(entry.name);
1176
+ if (entry.type !== 'file' || !capability.placeable) {
1177
+ e.preventDefault();
1178
+ return;
1179
+ }
1180
+ const kind = assetDocumentKind(capability);
1181
+ if (!kind) return;
219
1182
  const path = assetScenePath(currentRoot, currentPath, entry.name);
220
1183
  const payload = { path, kind, name: entry.name };
221
1184
  // Set text/plain first — Chromium silently cancels drags that only
@@ -226,31 +1189,125 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
226
1189
  [currentRoot, currentPath],
227
1190
  );
228
1191
 
229
- // --- Double-click handler ---
230
- const onDoubleClick = useCallback(
231
- (entry: AssetEntry) => {
1192
+ // --- Single-click: §5.1 SELECTION (W2) — compact preview/metadata in the
1193
+ // Inspector via `asset-selection.ts`; never opens anything. ---
1194
+ const onSelectEntry = useCallback(
1195
+ (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => {
1196
+ let next = new Set<string>();
1197
+ if (event.shiftKey && selectionAnchor) {
1198
+ const anchorIndex = filtered.findIndex((entry) => entry.key === selectionAnchor);
1199
+ const itemIndex = filtered.findIndex((entry) => entry.key === item.key);
1200
+ if (anchorIndex >= 0 && itemIndex >= 0) {
1201
+ const [start, end] =
1202
+ anchorIndex < itemIndex ? [anchorIndex, itemIndex] : [itemIndex, anchorIndex];
1203
+ next = new Set(filtered.slice(start, end + 1).map((entry) => entry.key));
1204
+ }
1205
+ } else if (event.metaKey || event.ctrlKey) {
1206
+ next = new Set(selectedFiles);
1207
+ if (next.has(item.key)) next.delete(item.key);
1208
+ else next.add(item.key);
1209
+ setSelectionAnchor(item.key);
1210
+ } else {
1211
+ next.add(item.key);
1212
+ setSelectionAnchor(item.key);
1213
+ }
1214
+ setSelectedFiles(next);
1215
+ if (item.source !== 'asset' || currentRoot !== 'public') {
1216
+ clearSelectedAsset();
1217
+ return;
1218
+ }
1219
+ const { entry } = item;
232
1220
  if (entry.type === 'directory') {
233
- navigate(currentRoot, currentPath ? `${currentPath}/${entry.name}` : entry.name);
1221
+ clearSelectedAsset();
234
1222
  return;
235
1223
  }
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);
1224
+ const capability = assetCapabilities(entry.name);
1225
+ const kind = assetDocumentKind(capability) ?? 'unknown';
1226
+ setSelectedAsset({
1227
+ path: assetServingUrl(currentRoot, currentPath, entry.name),
1228
+ name: entry.name,
1229
+ kind,
1230
+ sizeBytes: entry.size,
1231
+ capabilities: capability,
1232
+ health: projectAssetHealth(entry.name, currentPath),
1233
+ selectionCount: next.size,
1234
+ ...(currentPath.startsWith('asset-library/local')
1235
+ ? { sourcePath: 'Asset Library provenance metadata' }
1236
+ : {}),
1237
+ });
1238
+ const path = currentPath ? `${currentPath}/${entry.name}` : entry.name;
1239
+ void inspectProjectAssetHealthFromStorage(getStorageBackend(), path)
1240
+ .then((inspection) => {
1241
+ const servingPath = assetServingUrl(currentRoot, currentPath, entry.name);
1242
+ if (getSelectedAsset()?.path !== servingPath) return;
1243
+ setSelectedAsset({
1244
+ path: servingPath,
1245
+ name: entry.name,
1246
+ kind,
1247
+ sizeBytes: entry.size,
1248
+ capabilities: capability,
1249
+ health:
1250
+ inspection.health.status === 'healthy'
1251
+ ? 'healthy'
1252
+ : inspection.health.codes.includes('stale-import')
1253
+ ? 'stale-import'
1254
+ : capability.runtimeReady
1255
+ ? 'unsupported'
1256
+ : 'source-only',
1257
+ healthCodes: inspection.health.codes,
1258
+ dependencies: inspection.dependencies,
1259
+ references: inspection.references,
1260
+ selectionCount: next.size,
1261
+ });
1262
+ })
1263
+ .catch(() => {});
1264
+ },
1265
+ [currentPath, currentRoot, filtered, selectedFiles, selectionAnchor],
1266
+ );
1267
+
1268
+ // --- Double-click / Enter: §5.1 OPEN — the full viewer as a CENTER
1269
+ // workspace document (W2; was a right-rail asset tab pre-W2). ---
1270
+ const onDoubleClick = useCallback(
1271
+ (item: BrowserEntry) => {
1272
+ if (item.source === 'root') {
1273
+ navigate(item.root, '');
1274
+ return;
1275
+ }
1276
+ if (item.source === 'data') {
1277
+ openDataDocument(store, item.path);
241
1278
  return;
242
1279
  }
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);
1280
+ if (item.source === 'story') {
1281
+ openStoryDocument(store, item.modulePath, item.storyName);
1282
+ return;
247
1283
  }
1284
+ openProjectAssetEntry(item.entry, {
1285
+ currentRoot: currentRoot ?? 'public',
1286
+ currentPath,
1287
+ navigate,
1288
+ ...(onOpenScene ? { onOpenScene } : {}),
1289
+ openAsset: (url, kind) => openAssetDocument(store, url, kind),
1290
+ });
248
1291
  },
249
1292
  [currentRoot, currentPath, navigate, onOpenScene, store],
250
1293
  );
251
1294
 
252
1295
  // --- Refresh ---
253
1296
  const onRefresh = useCallback(() => {
1297
+ if (currentRoot === null) {
1298
+ cacheRef.current.clear();
1299
+ void refreshDataFiles();
1300
+ void refreshProjectStories(getCurrentProject());
1301
+ return;
1302
+ }
1303
+ if (currentRoot === 'data') {
1304
+ void refreshDataFiles();
1305
+ return;
1306
+ }
1307
+ if (currentRoot === 'stories') {
1308
+ void refreshProjectStories(getCurrentProject());
1309
+ return;
1310
+ }
254
1311
  cacheRef.current.clear();
255
1312
  fetchEntries(currentRoot, currentPath);
256
1313
  }, [currentRoot, currentPath, fetchEntries]);
@@ -262,241 +1319,341 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
262
1319
  return () => window.removeEventListener('editor:assets-changed', handler);
263
1320
  }, [onRefresh]);
264
1321
 
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>
1322
+ useEffect(() => {
1323
+ const handler = (event: Event) => {
1324
+ const path = (event as CustomEvent<{ path?: string }>).detail?.path;
1325
+ if (!path) return;
1326
+ const capability = assetCapabilities(path);
1327
+ const kind = assetDocumentKind(capability);
1328
+ if (!kind) return;
1329
+ if ((kind === 'scene' || kind === 'prefab') && onOpenScene) onOpenScene(path);
1330
+ else openAssetDocument(store, path, kind);
1331
+ };
1332
+ window.addEventListener('editor:open-project-asset', handler);
1333
+ return () => window.removeEventListener('editor:open-project-asset', handler);
1334
+ }, [onOpenScene, store]);
1335
+
1336
+ const selectedItems = useMemo(
1337
+ () => browserEntries.filter((item) => selectedFiles.has(item.key)),
1338
+ [browserEntries, selectedFiles],
1339
+ );
1340
+ const selectedAssetPaths = useMemo(
1341
+ () =>
1342
+ selectedItems.flatMap((item) =>
1343
+ item.source === 'asset' && item.entry.type === 'file'
1344
+ ? [currentPath ? `${currentPath}/${item.name}` : item.name]
1345
+ : [],
1346
+ ),
1347
+ [currentPath, selectedItems],
1348
+ );
1349
+
1350
+ useEffect(() => {
1351
+ const available = new Set(browserEntries.map((item) => item.key));
1352
+ setSelectedFiles((current) => {
1353
+ const next = new Set([...current].filter((key) => available.has(key)));
1354
+ return next.size === current.size ? current : next;
1355
+ });
1356
+ }, [browserEntries]);
1357
+
1358
+ const finishOperation = useCallback(
1359
+ async (operation: Parameters<typeof executeProjectAssetOperation>[0]) => {
1360
+ await executeProjectAssetOperation(operation, { backend: getStorageBackend(), history });
1361
+ cacheRef.current.clear();
1362
+ if (currentRoot === 'public') await fetchEntries(currentRoot, currentPath);
1363
+ },
1364
+ [currentPath, currentRoot, fetchEntries, history],
1365
+ );
1366
+
1367
+ const deleteSelected = useCallback(async () => {
1368
+ if (selectedAssetPaths.length === 0) return;
1369
+ if (
1370
+ !confirmAssetAction(
1371
+ 'delete',
1372
+ `Delete ${selectedAssetPaths.length} selected asset(s)? This is undoable.`,
1373
+ )
1374
+ )
1375
+ return;
1376
+ await finishOperation({ type: 'delete', paths: selectedAssetPaths });
1377
+ }, [finishOperation, selectedAssetPaths]);
1378
+
1379
+ const createFolder = useCallback(async () => {
1380
+ if (currentRoot !== 'public') return;
1381
+ const name = window.prompt('Folder name');
1382
+ if (!name) return;
1383
+ await finishOperation({
1384
+ type: 'create-folder',
1385
+ path: currentPath ? `${currentPath}/${name}` : name,
1386
+ });
1387
+ }, [currentPath, currentRoot, finishOperation]);
1388
+
1389
+ const importFiles = useCallback(
1390
+ async (files: FileList | null) => {
1391
+ if (!files || currentRoot !== 'public') return;
1392
+ const imported = await Promise.all(
1393
+ [...files].map(async (file) => ({
1394
+ path: currentPath ? `${currentPath}/${file.name}` : file.name,
1395
+ bytes: new Uint8Array(await file.arrayBuffer()),
1396
+ })),
1397
+ );
1398
+ await finishOperation({ type: 'import', files: imported });
1399
+ },
1400
+ [currentPath, currentRoot, finishOperation],
1401
+ );
1402
+
1403
+ const renameItem = useCallback(
1404
+ async (item: BrowserEntry) => {
1405
+ if (item.source !== 'asset' || item.entry.type !== 'file') return;
1406
+ const nextName = window.prompt('Rename asset', item.name);
1407
+ if (!nextName || nextName === item.name) return;
1408
+ const source = currentPath ? `${currentPath}/${item.name}` : item.name;
1409
+ const destination = currentPath ? `${currentPath}/${nextName}` : nextName;
1410
+ await finishOperation({ type: 'move', source, destination });
1411
+ },
1412
+ [currentPath, finishOperation],
1413
+ );
1414
+
1415
+ const duplicateItem = useCallback(
1416
+ async (item: BrowserEntry) => {
1417
+ if (item.source !== 'asset' || item.entry.type !== 'file') return;
1418
+ const dot = item.name.lastIndexOf('.');
1419
+ const copyName =
1420
+ dot > 0 ? `${item.name.slice(0, dot)} copy${item.name.slice(dot)}` : `${item.name} copy`;
1421
+ const source = currentPath ? `${currentPath}/${item.name}` : item.name;
1422
+ const destination = currentPath ? `${currentPath}/${copyName}` : copyName;
1423
+ await finishOperation({ type: 'duplicate', source, destination });
1424
+ },
1425
+ [currentPath, finishOperation],
285
1426
  );
286
1427
 
1428
+ const toggleExpanded = useCallback((path: string) => {
1429
+ setExpanded((current) => {
1430
+ const next = new Set(current);
1431
+ if (next.has(path)) next.delete(path);
1432
+ else next.add(path);
1433
+ return next;
1434
+ });
1435
+ }, []);
1436
+
1437
+ useEffect(() => {
1438
+ const handler = (event: Event) => {
1439
+ const id = (event as CustomEvent<{ id?: string }>).detail?.id;
1440
+ const item = selectedItems[0];
1441
+ if (id === PROJECT_ASSET_COMMANDS.refresh.id) onRefresh();
1442
+ else if (id === PROJECT_ASSET_COMMANDS.import.id) importInputRef.current?.click();
1443
+ else if (id === PROJECT_ASSET_COMMANDS.createFolder.id) void createFolder();
1444
+ else if (id === PROJECT_ASSET_COMMANDS.delete.id) void deleteSelected();
1445
+ else if (id === PROJECT_ASSET_COMMANDS.rename.id && item) void renameItem(item);
1446
+ else if (id === PROJECT_ASSET_COMMANDS.duplicate.id && item) void duplicateItem(item);
1447
+ else if (id === PROJECT_ASSET_COMMANDS.copyPath.id && selectedAssetPaths[0])
1448
+ void navigator.clipboard.writeText(selectedAssetPaths[0]);
1449
+ else if (id === PROJECT_ASSET_COMMANDS.reveal.id)
1450
+ void revealInFinder(`public/${currentPath}`);
1451
+ };
1452
+ window.addEventListener('editor:project-asset-command', handler);
1453
+ return () => window.removeEventListener('editor:project-asset-command', handler);
1454
+ }, [
1455
+ createFolder,
1456
+ currentPath,
1457
+ deleteSelected,
1458
+ duplicateItem,
1459
+ onRefresh,
1460
+ renameItem,
1461
+ selectedAssetPaths,
1462
+ selectedItems,
1463
+ ]);
1464
+
287
1465
  return (
288
- <Panel
289
- name="Assets"
290
- actions={refreshAction}
1466
+ <div
291
1467
  onPointerDown={() => setActiveScope('asset-browser')}
292
- style={{ flex: 1, minHeight: 0 }}
1468
+ onKeyDown={(event) => {
1469
+ if (isEditableTarget(event.target)) return;
1470
+ if (event.key === 'Delete' || event.key === 'Backspace') {
1471
+ void deleteSelected();
1472
+ event.preventDefault();
1473
+ } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1474
+ const item = selectedItems[0];
1475
+ if (item) void duplicateItem(item);
1476
+ event.preventDefault();
1477
+ } else if (event.key === 'F2') {
1478
+ const item = selectedItems[0];
1479
+ if (item) void renameItem(item);
1480
+ event.preventDefault();
1481
+ }
1482
+ }}
1483
+ style={{ display: 'flex', flex: 1, minHeight: 0, position: 'relative' }}
293
1484
  >
294
- <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
295
- {/* Folder tree */}
1485
+ <ProjectAssetTree
1486
+ currentRoot={currentRoot}
1487
+ currentPath={currentPath}
1488
+ expanded={expanded}
1489
+ onExpanded={toggleExpanded}
1490
+ onNavigate={navigate}
1491
+ />
1492
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
1493
+ <TextInput
1494
+ ref={importInputRef}
1495
+ type="file"
1496
+ multiple
1497
+ aria-label="Import project assets"
1498
+ hidden
1499
+ onChange={(event) => {
1500
+ void importFiles(event.target.files);
1501
+ event.currentTarget.value = '';
1502
+ }}
1503
+ />
1504
+ <AssetBrowserToolbar
1505
+ currentRoot={currentRoot}
1506
+ currentPath={currentPath}
1507
+ searchOpen={searchOpen}
1508
+ searchQuery={searchQuery}
1509
+ viewMode={viewMode}
1510
+ sort={sort}
1511
+ filter={filter}
1512
+ density={density}
1513
+ onNavigate={navigate}
1514
+ onSearchOpenChange={setSearchOpen}
1515
+ onSearchQueryChange={setSearchQuery}
1516
+ onViewModeChange={setViewMode}
1517
+ onSortChange={setSort}
1518
+ onFilterChange={setFilter}
1519
+ onDensityChange={setDensity}
1520
+ onCreateFolder={() => void createFolder()}
1521
+ onImport={() => importInputRef.current?.click()}
1522
+ onDelete={() => void deleteSelected()}
1523
+ deleteDisabled={selectedAssetPaths.length === 0}
1524
+ onRefresh={onRefresh}
1525
+ />
1526
+
1527
+ {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
296
1528
  <div
297
1529
  style={{
298
- width: 180,
299
- borderRight: '1px solid #333',
1530
+ flex: 1,
300
1531
  overflowY: 'auto',
301
- scrollbarWidth: 'none',
302
- flexShrink: 0,
1532
+ padding: 8,
1533
+ display: viewMode === 'grid' ? 'grid' : 'flex',
1534
+ gridTemplateColumns:
1535
+ viewMode === 'grid' ? `repeat(auto-fill, minmax(${density}px, 1fr))` : undefined,
1536
+ gridAutoRows: viewMode === 'grid' ? 'min-content' : undefined,
1537
+ flexDirection: viewMode === 'grid' ? undefined : 'column',
1538
+ gap: viewMode === 'grid' ? 6 : 1,
1539
+ alignContent: 'start',
303
1540
  }}
304
1541
  >
305
- <FolderTree currentPath={currentPath} currentRoot={currentRoot} onNavigate={navigate} />
1542
+ {filtered.map((item) => (
1543
+ <BrowserEntryCard
1544
+ key={item.key}
1545
+ item={item}
1546
+ selected={selectedFiles.has(item.key)}
1547
+ currentRoot={currentRoot}
1548
+ currentPath={currentPath}
1549
+ onDragStart={onDragStart}
1550
+ onSelect={onSelectEntry}
1551
+ onOpen={onDoubleClick}
1552
+ onContextMenu={(event, selectedItem) => {
1553
+ event.preventDefault();
1554
+ if (!selectedFiles.has(selectedItem.key)) {
1555
+ onSelectEntry(selectedItem, event);
1556
+ }
1557
+ setContextMenu({ x: event.clientX, y: event.clientY, item: selectedItem });
1558
+ }}
1559
+ viewMode={viewMode}
1560
+ />
1561
+ ))}
1562
+ {filtered.length === 0 && (
1563
+ <StateSurface
1564
+ compact
1565
+ style={{ gridColumn: '1 / -1' }}
1566
+ icon={
1567
+ <EditorIcon
1568
+ icon={searchQuery ? editorIcons.action.search : editorIcons.content.folder}
1569
+ />
1570
+ }
1571
+ title={searchQuery ? 'No matching files' : 'Empty folder'}
1572
+ description={
1573
+ searchQuery
1574
+ ? 'Try a broader asset search.'
1575
+ : 'Add files to this folder to see them here.'
1576
+ }
1577
+ />
1578
+ )}
306
1579
  </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,
1580
+ </div>
1581
+ {contextMenu && (
1582
+ <Menu
1583
+ autoFocusFirst
1584
+ className="vgai-asset-context-menu"
1585
+ style={{ left: contextMenu.x, top: contextMenu.y }}
1586
+ onDismiss={() => setContextMenu(null)}
1587
+ >
1588
+ <MenuItem
1589
+ type="button"
1590
+ role="menuitem"
1591
+ data-command-id={PROJECT_ASSET_COMMANDS.open.id}
1592
+ onClick={() => {
1593
+ onDoubleClick(contextMenu.item);
1594
+ setContextMenu(null);
319
1595
  }}
320
1596
  >
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',
1597
+ Open <kbd>Enter</kbd>
1598
+ </MenuItem>
1599
+ <MenuItem
1600
+ type="button"
1601
+ role="menuitem"
1602
+ data-command-id={PROJECT_ASSET_COMMANDS.rename.id}
1603
+ onClick={() => {
1604
+ void renameItem(contextMenu.item);
1605
+ setContextMenu(null);
405
1606
  }}
406
1607
  >
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>
1608
+ Rename <kbd>F2</kbd>
1609
+ </MenuItem>
1610
+ <MenuItem
1611
+ type="button"
1612
+ role="menuitem"
1613
+ data-command-id={PROJECT_ASSET_COMMANDS.duplicate.id}
1614
+ onClick={() => {
1615
+ void duplicateItem(contextMenu.item);
1616
+ setContextMenu(null);
1617
+ }}
1618
+ >
1619
+ Duplicate <kbd>⌘D</kbd>
1620
+ </MenuItem>
1621
+ <MenuItem
1622
+ type="button"
1623
+ role="menuitem"
1624
+ data-command-id={PROJECT_ASSET_COMMANDS.copyPath.id}
1625
+ onClick={() => {
1626
+ void navigator.clipboard.writeText(contextMenu.item.name);
1627
+ setContextMenu(null);
1628
+ }}
1629
+ >
1630
+ Copy path
1631
+ </MenuItem>
1632
+ <MenuItem
1633
+ type="button"
1634
+ role="menuitem"
1635
+ data-command-id={PROJECT_ASSET_COMMANDS.reveal.id}
1636
+ onClick={() => {
1637
+ void revealInFinder(`public/${currentPath}`);
1638
+ setContextMenu(null);
1639
+ }}
1640
+ >
1641
+ Reveal in Finder
1642
+ </MenuItem>
1643
+ <MenuItem
1644
+ type="button"
1645
+ role="menuitem"
1646
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
1647
+ disabled={selectedAssetPaths.length === 0}
1648
+ onClick={() => {
1649
+ void deleteSelected();
1650
+ setContextMenu(null);
1651
+ }}
1652
+ >
1653
+ Delete <kbd>⌫</kbd>
1654
+ </MenuItem>
1655
+ </Menu>
1656
+ )}
1657
+ </div>
501
1658
  );
502
1659
  }