@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,2843 @@
1
+ import {
2
+ faArrowLeft,
3
+ faChevronRight,
4
+ faEllipsis,
5
+ faFolder,
6
+ faFolderTree,
7
+ faList,
8
+ faTable,
9
+ faTableCellsLarge,
10
+ } from '@fortawesome/free-solid-svg-icons';
11
+ import {
12
+ Button,
13
+ EditorIcon,
14
+ EditorToolbar,
15
+ editorIcons,
16
+ IconButton,
17
+ Menu,
18
+ MenuItem,
19
+ radius,
20
+ StateSurface,
21
+ TextInput,
22
+ ThemeRootPortal,
23
+ ToolbarGroup,
24
+ Tooltip,
25
+ text,
26
+ themeVars,
27
+ } from '@volter/editor-sdk/widgets';
28
+ import type { AuthoringAdapter, AuthoringAssetSubject } from '@volter/editor-project/adapter';
29
+ import type { DocumentEntry } from '@volter/editor-project/adapter/adapter-module';
30
+ import {
31
+ useCallback,
32
+ useEffect,
33
+ useLayoutEffect,
34
+ useMemo,
35
+ useRef,
36
+ useState,
37
+ useSyncExternalStore,
38
+ } from 'react';
39
+ import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
40
+ import {
41
+ type AssetCapabilityKind,
42
+ assetCapabilities,
43
+ assetDocumentKind,
44
+ } from '../asset-workflow/asset-capabilities';
45
+ import { confirmAssetAction } from '../asset-workflow/asset-workflow-quality';
46
+ import { previewAssetAudio, stopAssetAudioPreview } from '../asset-workflow/audio-preview-player';
47
+ import { invalidateFolderPreviews } from '../asset-workflow/folder-preview';
48
+ import {
49
+ PROJECT_ASSET_COMMANDS,
50
+ projectContentBrowserStateKey,
51
+ } from '../asset-workflow/project-asset-commands';
52
+ import { inspectProjectAssetHealthFromStorage } from '../asset-workflow/project-asset-health';
53
+ import { executeProjectAssetOperation } from '../asset-workflow/project-asset-operations';
54
+ import {
55
+ ASSET_ROOTS,
56
+ type AssetRootId,
57
+ assetRootServingUrl,
58
+ projectAssetPath,
59
+ REFERENCE_ROOT,
60
+ } from '../asset-workflow/project-asset-roots';
61
+ import {
62
+ collectProjectContentAssets,
63
+ expandSpritesheetContentAssets,
64
+ type ProjectComponentEntry,
65
+ type ProjectContentAsset,
66
+ type ProjectContentFacet,
67
+ type ProjectContentSpritesheetFrame,
68
+ projectComponentPriority,
69
+ projectContentAssetFacet,
70
+ projectContentAssetPriority,
71
+ } from '../asset-workflow/project-content';
72
+ import { activeAuthoringVersion, subscribeActiveAuthoring } from '../authoring/active-adapter';
73
+ import { resolvePanelAuthoring } from '../authoring/panel-authoring';
74
+ import { contributedMenuItems } from '../chrome-registry';
75
+ import {
76
+ type ContentEntry,
77
+ type ContentEntrySource,
78
+ contentEntrySourceRegistryVersion,
79
+ contentEntrySources,
80
+ subscribeContentEntrySources,
81
+ } from '../content-entry-source-registry';
82
+ import { openRegisteredDocument } from '../document-open-registry';
83
+ import { type AssetEntry, listAssets, listProjectComponents, revealInFinder } from '../editor-api';
84
+ import { editorConsole } from '../editor-console';
85
+ import { useEditorStore, useHistoryService } from '../editor-runtime';
86
+ import type { AssetKind as DocumentAssetKind } from '../editor-shell-store';
87
+ import { hierarchyNodesBreadthFirst } from '../hierarchy-walk';
88
+ import { isEditableTarget, setActiveScope } from '../hotkeys';
89
+ import { modelThumbnailFormat } from '../model-thumbnail';
90
+ import { object3DDocumentWritePolicy } from '../object3d-document-write-policy';
91
+ import { projectAdapterFacet, subscribeProjectAdapter } from '../project-adapter';
92
+ import { getCurrentProject } from '../project-manager';
93
+ import { stageStore } from '../stage-store-registry';
94
+ import { getStorageBackend } from '../storage';
95
+ import { getGlobalToolContributions, subscribeToolContributions } from '../tool-loader';
96
+ import { reportUnacceptedAssetDrop, showTransientHint } from '../transient-hint';
97
+ import {
98
+ type AvailableWorkspaceDocument,
99
+ availableWorkspaceDocuments,
100
+ openAvailableWorkspaceDocument,
101
+ subscribeAvailableWorkspaceDocuments,
102
+ } from '../workspace-available-documents';
103
+ import {
104
+ activeWorkspaceDocumentId,
105
+ subscribeWorkspaceDocuments,
106
+ workspaceDocumentRegistryVersion,
107
+ } from '../workspace-document-registry';
108
+ import { openAssetDocument, openAuthoringAssetDocument } from './asset-documents';
109
+ import {
110
+ type AssetGlyphKind,
111
+ AssetIcon,
112
+ AudioAssetThumb,
113
+ ModelThumbnail,
114
+ TypedAssetThumbnail,
115
+ } from './asset-thumbnails';
116
+ import { SpritesheetSpriteView } from './asset-viewers/SpritesheetSpriteView';
117
+ import { DocumentThumbnail } from './DocumentThumbnail';
118
+ import { FolderPreviewTile } from './FolderPreviewTile';
119
+ import { openKindDocument, uneditedKindAssetKind } from './kind-documents';
120
+ import { openSceneTableEntry } from './scene-documents';
121
+
122
+ // --- Helpers ---
123
+
124
+ type AssetKind = AssetGlyphKind;
125
+
126
+ type BrowserKind = AssetKind;
127
+
128
+ function getAssetKind(entry: AssetEntry): AssetKind {
129
+ if (entry.type === 'directory') return 'folder';
130
+ return assetCapabilities(entry.name).kind as AssetCapabilityKind;
131
+ }
132
+
133
+ /** Build the serving URL for an asset (all assets are under public/). */
134
+ function assetServingUrl(root: AssetRoot, path: string, name: string): string {
135
+ return assetRootServingUrl(root, path ? `${path}/${name}` : name);
136
+ }
137
+
138
+ /** Build the src path as it would appear in scene files. Public only — a
139
+ * reference is never placed in a scene (it would not ship). */
140
+ function assetScenePath(_root: AssetRoot, path: string, name: string): string {
141
+ return path ? `/${path}/${name}` : `/${name}`;
142
+ }
143
+
144
+ function formatSize(bytes: number): string {
145
+ if (bytes < 1024) return `${bytes} B`;
146
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
147
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
148
+ }
149
+
150
+ type ProjectAssetHealth = 'healthy' | 'source-only' | 'unsupported';
151
+
152
+ function projectAssetHealth(name: string): ProjectAssetHealth {
153
+ const capability = assetCapabilities(name);
154
+ if (!capability.importable) return 'unsupported';
155
+ if (!capability.runtimeReady) return 'source-only';
156
+ return 'healthy';
157
+ }
158
+
159
+ type AssetTreeVisibility = 'auto' | 'shown' | 'hidden';
160
+ const ASSET_TREE_MIN_INLINE_SIZE = 640;
161
+
162
+ interface PersistedAssetBrowserState {
163
+ scope: ContentScope;
164
+ root: AssetRoot;
165
+ currentPath: string;
166
+ viewMode: AssetViewMode;
167
+ sort: AssetSort;
168
+ filter: AssetFilter;
169
+ density: number;
170
+ expanded: string[];
171
+ treeVisibility: AssetTreeVisibility;
172
+ facets: ProjectContentFacet[];
173
+ }
174
+
175
+ const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
176
+ scope: 'content',
177
+ root: 'public',
178
+ currentPath: '',
179
+ viewMode: 'grid',
180
+ sort: 'relevance',
181
+ filter: 'all',
182
+ density: 96,
183
+ expanded: ['public'],
184
+ treeVisibility: 'auto',
185
+ facets: ['component'],
186
+ };
187
+
188
+ function readAssetBrowserState(): PersistedAssetBrowserState {
189
+ try {
190
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
191
+ const parsed = JSON.parse(
192
+ localStorage.getItem(projectContentBrowserStateKey(projectId)) ?? '{}',
193
+ ) as Partial<PersistedAssetBrowserState>;
194
+ const persistedViewMode = (parsed as Record<string, unknown>)['viewMode'];
195
+ const persistedTreeVisibility = (parsed as Record<string, unknown>)['treeVisibility'];
196
+ const persistedScope = (parsed as Record<string, unknown>)['scope'];
197
+ const persistedRoot = (parsed as Record<string, unknown>)['root'];
198
+ return {
199
+ ...DEFAULT_BROWSER_STATE,
200
+ ...parsed,
201
+ root: persistedRoot === REFERENCE_ROOT ? REFERENCE_ROOT : 'public',
202
+ currentPath: parsed.currentPath ?? '',
203
+ scope: persistedScope === 'files' ? 'files' : 'content',
204
+ viewMode: persistedScope === 'files' && persistedViewMode === 'list' ? 'list' : 'grid',
205
+ treeVisibility:
206
+ persistedTreeVisibility === 'shown' || persistedTreeVisibility === 'hidden'
207
+ ? persistedTreeVisibility
208
+ : 'auto',
209
+ facets:
210
+ Array.isArray(parsed.facets) && parsed.facets.length > 0
211
+ ? [parsed.facets[0]!]
212
+ : ['component'],
213
+ };
214
+ } catch {
215
+ return DEFAULT_BROWSER_STATE;
216
+ }
217
+ }
218
+
219
+ // --- Main Component ---
220
+
221
+ export interface AssetBrowserServices {
222
+ readonly listAssets: typeof listAssets;
223
+ readonly listProjectComponents: typeof listProjectComponents;
224
+ }
225
+
226
+ const DEFAULT_ASSET_BROWSER_SERVICES: AssetBrowserServices = { listAssets, listProjectComponents };
227
+
228
+ interface AssetBrowserProps {
229
+ /** Explicit data seam for bounded production hosts; the app uses the real
230
+ * editor API by default. */
231
+ services?: AssetBrowserServices | undefined;
232
+ }
233
+
234
+ type AssetRoot = AssetRootId;
235
+
236
+ /** Why a reference row refuses an edit or a drop, in the user's terms. */
237
+ const REFERENCE_READ_ONLY_HINT =
238
+ "Reference material is not part of the built game — it stays in the project's references/ " +
239
+ 'folder and is never exported. Copy the file into public/ to use it in the game; edit or ' +
240
+ 'remove it where it lives.';
241
+
242
+ /** The asset media and the component chip; every other facet is an open document kind. */
243
+ const FIXED_FACETS: ReadonlySet<string> = new Set([
244
+ 'component',
245
+ 'scene',
246
+ 'canvas',
247
+ 'model',
248
+ 'image',
249
+ 'video',
250
+ 'audio',
251
+ 'other',
252
+ ]);
253
+
254
+ /** `model` → `Models`: a kind's chip, from its name. */
255
+ function facetLabel(facet: string): string {
256
+ const word = facet.charAt(0).toUpperCase() + facet.slice(1);
257
+ return word.endsWith('s') ? word : `${word}s`;
258
+ }
259
+
260
+ async function saveDocumentPreviewFraming(documentId: string, reset: boolean): Promise<void> {
261
+ if (!reset && activeWorkspaceDocumentId() !== documentId) {
262
+ throw new Error('Open this scene as the active document before using its current view.');
263
+ }
264
+ const camera = stageStore(documentId)?.cameraPose;
265
+ if (!reset && !camera) {
266
+ throw new Error('The active scene has no ready 3D authoring view to save.');
267
+ }
268
+ await object3DDocumentWritePolicy().saveThumbnailFraming(
269
+ `document:${documentId}`,
270
+ reset
271
+ ? undefined
272
+ : {
273
+ position: camera!.position.toArray(),
274
+ target: camera!.target.toArray(),
275
+ fov: camera!.fov,
276
+ },
277
+ );
278
+ }
279
+ type ContentScope = 'content' | 'files';
280
+ type AssetViewMode = 'grid' | 'list';
281
+ type AssetSort = 'relevance' | 'name' | 'type' | 'size' | 'modified' | 'health';
282
+ type AssetFilter =
283
+ | 'all'
284
+ | 'runtime-ready'
285
+ | 'source-only'
286
+ | 'unsupported'
287
+ | 'imported'
288
+ | 'generated';
289
+
290
+ type BrowserEntry =
291
+ | {
292
+ source: 'available';
293
+ key: string;
294
+ name: string;
295
+ path: string;
296
+ available: AvailableWorkspaceDocument;
297
+ }
298
+ | {
299
+ source: 'asset';
300
+ key: string;
301
+ name: string;
302
+ /** Root-relative path (the root is `root`). */
303
+ path: string;
304
+ /** Which asset root this row was listed from. `references` rows are read
305
+ * material: shown, previewed and opened, never placed or mutated here. */
306
+ root: AssetRoot;
307
+ entry: AssetEntry;
308
+ spritesheet?: ProjectContentSpritesheetFrame;
309
+ }
310
+ | {
311
+ /** A component a REGISTERED content source admitted
312
+ * (`content-entry-source-registry.ts`). The host knows the index row it
313
+ * depicts and nothing about why it is content: the source paints the
314
+ * tile and decides what opening it means. */
315
+ source: 'component';
316
+ key: string;
317
+ name: string;
318
+ path: string;
319
+ component: ProjectComponentEntry;
320
+ contentSource: ContentEntrySource;
321
+ contentEntry: ContentEntry;
322
+ authoringAsset?: { id: string; subject: AuthoringAssetSubject } | undefined;
323
+ }
324
+ | {
325
+ /** A DOCUMENT the adapter's finders produced under an open kind that is
326
+ * neither a scene nor a prefab (ARCHITECTURE-CORE §The project model,
327
+ * "Documents, not scenes") — a model, a page. Its facet IS its kind. */
328
+ source: 'document';
329
+ key: string;
330
+ name: string;
331
+ path: string;
332
+ document: DocumentEntry;
333
+ };
334
+
335
+ type PrefabMedium = '3d' | '2d' | 'ui';
336
+
337
+ interface ContentResultSection {
338
+ id: string;
339
+ label: string | null;
340
+ headingLevel: 'section' | 'category' | null;
341
+ entries: BrowserEntry[];
342
+ }
343
+
344
+ function prefabMedium(item: Extract<BrowserEntry, { source: 'component' }>): PrefabMedium {
345
+ if (item.component.surface === 'three') return '3d';
346
+ if (item.component.surface === 'canvas') return '2d';
347
+ return 'ui';
348
+ }
349
+
350
+ function prefabMediumSections(entries: readonly BrowserEntry[]): ContentResultSection[] {
351
+ const labels: Record<PrefabMedium, string> = { '3d': '3D', '2d': '2D', ui: 'UI' };
352
+ return (['3d', '2d', 'ui'] as const).flatMap((medium) => {
353
+ const mediumEntries = entries.filter(
354
+ (entry) => entry.source === 'component' && prefabMedium(entry) === medium,
355
+ );
356
+ return mediumEntries.length > 0
357
+ ? [
358
+ {
359
+ id: `prefabs-${medium}`,
360
+ label: labels[medium],
361
+ headingLevel: 'category' as const,
362
+ entries: mediumEntries,
363
+ },
364
+ ]
365
+ : [];
366
+ });
367
+ }
368
+
369
+ function groupContentResults(
370
+ entries: readonly BrowserEntry[],
371
+ scope: ContentScope,
372
+ facets: readonly ProjectContentFacet[],
373
+ ): ContentResultSection[] {
374
+ if (scope === 'files') {
375
+ return [{ id: 'files', label: null, headingLevel: null, entries: [...entries] }];
376
+ }
377
+ if (facets.length === 1 && facets[0] === 'component') {
378
+ return prefabMediumSections(entries);
379
+ }
380
+ return entries.length > 0
381
+ ? [{ id: 'content', label: null, headingLevel: null, entries: [...entries] }]
382
+ : [];
383
+ }
384
+
385
+ /**
386
+ * A directory listing kept WITH the path it was fetched for. `currentPath`
387
+ * (navigation intent) updates synchronously on navigate while the listing
388
+ * arrives later; deriving tile paths/URLs from `currentPath` + the previous
389
+ * entries paired a NEW path with STALE entries for a render, mounting folder
390
+ * preview tiles (and asset `<img>`s) for paths that do not exist — a burst of
391
+ * 404ing `?dir=` scans per navigation. All entry-derived rendering reads this
392
+ * atomic pair instead; during a navigation the previous pair keeps rendering
393
+ * (same content that was already on screen — no flash, no phantom fetches)
394
+ * until the new listing resolves.
395
+ */
396
+ interface FolderListing {
397
+ path: string;
398
+ entries: AssetEntry[];
399
+ /** Set when the listing FAILED. An empty `entries` with no reason means the
400
+ * folder is empty; with a reason it means we could not look, and the panel
401
+ * says so instead of drawing an empty folder over a backend error. */
402
+ failure?: string;
403
+ }
404
+
405
+ /** The Content chip an entry sits under: an asset's medium, a component's
406
+ * kind, or a document's OPEN kind — chrome from what the project holds. */
407
+ function browserEntryFacet(item: BrowserEntry): ProjectContentFacet {
408
+ if (item.source === 'available') return item.available.category;
409
+ if (item.source === 'component') return item.component.contentKind ?? 'component';
410
+ if (item.source === 'document') return item.document.kind;
411
+ return projectContentAssetFacet(item.name);
412
+ }
413
+
414
+ function browserEntryKind(item: BrowserEntry): BrowserKind {
415
+ if (item.source === 'component') return item.component.contentKind ?? 'component';
416
+ if (item.source === 'available' || item.source === 'document') return 'source';
417
+ return getAssetKind(item.entry);
418
+ }
419
+
420
+ function parentAssetPath(path: string): string {
421
+ const separator = path.lastIndexOf('/');
422
+ return separator < 0 ? '' : path.slice(0, separator);
423
+ }
424
+
425
+ function openProjectAssetEntry(
426
+ item: BrowserEntry,
427
+ options: {
428
+ currentRoot: AssetRoot;
429
+ currentPath: string;
430
+ navigate: (root: AssetRoot, path: string) => void;
431
+ openAsset: (url: string, kind: DocumentAssetKind) => void;
432
+ },
433
+ ): void {
434
+ if (item.source === 'component' || item.source === 'document' || item.source === 'available')
435
+ return;
436
+ const { entry } = item;
437
+ const { currentRoot, currentPath, navigate, openAsset } = options;
438
+ if (entry.type === 'directory') {
439
+ navigate(currentRoot, currentPath ? `${currentPath}/${entry.name}` : entry.name);
440
+ return;
441
+ }
442
+ const capability = assetCapabilities(entry.name);
443
+ const documentKind = assetDocumentKind(capability);
444
+ if (!documentKind) return;
445
+ if (item.spritesheet) {
446
+ const frame = item.path.includes('#')
447
+ ? item.path.slice(item.path.lastIndexOf('#') + 1)
448
+ : entry.name.replace(/\.png$/i, '');
449
+ openAsset(`/${item.spritesheet.sheetPath}#${frame}`, documentKind);
450
+ return;
451
+ }
452
+ openAsset(assetServingUrl(item.root, currentPath, entry.name), documentKind);
453
+ }
454
+
455
+ function browserEntryDetail(item: BrowserEntry): string {
456
+ if (item.source === 'component') {
457
+ return `Component · line ${item.component.line}`;
458
+ }
459
+ if (item.source === 'available') return item.path || item.available.descriptor.title;
460
+ if (item.source === 'document') return `${item.document.kind} · ${item.path}`;
461
+ if (item.entry.type !== 'file') return 'Folder';
462
+ // An UNREPORTED size is not a zero-byte file. `formatSize(0)` printed
463
+ // "0 B" over every backend that does not stat, which reads as a broken asset.
464
+ return item.entry.size === undefined ? 'Size unknown' : formatSize(item.entry.size);
465
+ }
466
+
467
+ function browserEntryTitle(item: BrowserEntry): string {
468
+ if (item.source === 'component') {
469
+ return `${item.name}\n${item.path}:${item.component.line}`;
470
+ }
471
+ if (item.source === 'available') return `${item.name}\n${item.path}`;
472
+ if (item.source === 'document') return `${item.name}\n${item.path}`;
473
+ const detail = browserEntryDetail(item);
474
+ return detail ? `${item.name}\n${detail}` : item.name;
475
+ }
476
+
477
+ function matchesAssetFilter(item: BrowserEntry, filter: AssetFilter, currentPath: string): boolean {
478
+ if (item.source === 'component' || item.source === 'document' || item.source === 'available')
479
+ return true;
480
+ if (filter === 'all' || item.entry.type === 'directory') return true;
481
+ const capability = assetCapabilities(item.name);
482
+ const path = currentPath ? `${currentPath}/${item.name}` : item.name;
483
+ const predicates: Record<Exclude<AssetFilter, 'all'>, boolean> = {
484
+ 'runtime-ready': capability.runtimeReady,
485
+ 'source-only': capability.importable && !capability.runtimeReady,
486
+ unsupported: !capability.importable,
487
+ imported: path.startsWith('asset-library/local/'),
488
+ generated: path.startsWith('.generated/'),
489
+ };
490
+ return predicates[filter];
491
+ }
492
+
493
+ function compareEntryNames(left: BrowserEntry, right: BrowserEntry): number {
494
+ return left.name.localeCompare(right.name) || left.path.localeCompare(right.path);
495
+ }
496
+
497
+ function entryRelevance(item: BrowserEntry): number {
498
+ if (item.source === 'asset') {
499
+ return projectContentAssetPriority({ path: item.path, root: item.root, entry: item.entry });
500
+ }
501
+ if (item.source === 'available' || item.source === 'document') return 10;
502
+ return projectComponentPriority(item.component) - 100;
503
+ }
504
+
505
+ function compareRelevance(left: BrowserEntry, right: BrowserEntry): number {
506
+ const leftPriority = entryRelevance(left);
507
+ const rightPriority = entryRelevance(right);
508
+ if (leftPriority !== rightPriority) return leftPriority - rightPriority;
509
+ if (left.source !== right.source) return left.source === 'component' ? -1 : 1;
510
+ if (left.source === 'component' && right.source === 'component') {
511
+ return compareEntryNames(left, right);
512
+ }
513
+ if (left.source !== 'asset' || right.source !== 'asset') return compareEntryNames(left, right);
514
+ return compareEntryNames(left, right);
515
+ }
516
+
517
+ /** Descending by value, with UNMEASURED (`undefined`) always last. */
518
+ function compareMeasured(left: number | undefined, right: number | undefined): number {
519
+ if (left === undefined) return right === undefined ? 0 : 1;
520
+ if (right === undefined) return -1;
521
+ return right - left;
522
+ }
523
+
524
+ function compareAssetFiles(
525
+ left: Extract<BrowserEntry, { source: 'asset' }>,
526
+ right: Extract<BrowserEntry, { source: 'asset' }>,
527
+ sort: AssetSort,
528
+ ): number {
529
+ if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
530
+ // An entry the backend did not measure sorts LAST in either metric rather
531
+ // than as 0 bytes / 1970 — which put unmeasured files at the bottom of
532
+ // "largest first" and the bottom of "most recent" as if they had been read.
533
+ if (sort === 'size' && left.entry.size !== right.entry.size)
534
+ return compareMeasured(left.entry.size, right.entry.size);
535
+ if (sort === 'modified' && left.entry.modifiedAt !== right.entry.modifiedAt)
536
+ return compareMeasured(left.entry.modifiedAt, right.entry.modifiedAt);
537
+ if (sort === 'type') {
538
+ const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
539
+ if (order !== 0) return order;
540
+ }
541
+ if (sort === 'health') {
542
+ const order = projectAssetHealth(left.name).localeCompare(projectAssetHealth(right.name));
543
+ if (order !== 0) return order;
544
+ }
545
+ return compareEntryNames(left, right);
546
+ }
547
+
548
+ function compareAssetEntries(left: BrowserEntry, right: BrowserEntry, sort: AssetSort): number {
549
+ if (sort === 'relevance') return compareRelevance(left, right);
550
+ if (sort === 'name') return compareEntryNames(left, right);
551
+ if (left.source !== right.source) return left.source === 'component' ? -1 : 1;
552
+ if (left.source !== 'asset' || right.source !== 'asset') {
553
+ return compareEntryNames(left, right);
554
+ }
555
+ return compareAssetFiles(left, right, sort);
556
+ }
557
+
558
+ function browserEntryMatches(
559
+ item: BrowserEntry,
560
+ options: {
561
+ query: string;
562
+ scope: ContentScope;
563
+ facets: readonly ProjectContentFacet[];
564
+ filter: AssetFilter;
565
+ shownPath: string;
566
+ },
567
+ ): boolean {
568
+ if (options.query && !`${item.name} ${item.path}`.toLowerCase().includes(options.query)) {
569
+ return false;
570
+ }
571
+ if (options.scope === 'content' && options.facets.length > 0) {
572
+ const facet = browserEntryFacet(item);
573
+ return options.facets.includes(facet);
574
+ }
575
+ return options.scope === 'content' || matchesAssetFilter(item, options.filter, options.shownPath);
576
+ }
577
+
578
+ const GRID_ENTRY_STYLE: React.CSSProperties = {
579
+ width: '100%',
580
+ minWidth: 0,
581
+ padding: 0,
582
+ textAlign: 'center',
583
+ display: 'flex',
584
+ flexDirection: 'column',
585
+ alignItems: 'center',
586
+ gap: 3,
587
+ };
588
+
589
+ const LIST_ENTRY_STYLE: React.CSSProperties = {
590
+ width: '100%',
591
+ minWidth: 0,
592
+ minHeight: 28,
593
+ padding: '3px 6px',
594
+ textAlign: 'left',
595
+ display: 'flex',
596
+ flexDirection: 'row',
597
+ alignItems: 'center',
598
+ gap: 7,
599
+ };
600
+
601
+ function liveAuthoringAssets(
602
+ adapter: AuthoringAdapter,
603
+ ): Map<string, { id: string; subject: AuthoringAssetSubject }> {
604
+ const assets = new Map<string, { id: string; subject: AuthoringAssetSubject }>();
605
+ if (!adapter.assetSubject) return assets;
606
+ const entries =
607
+ adapter.assetSubject.entries?.() ??
608
+ hierarchyNodesBreadthFirst(adapter.hierarchy).flatMap((node) => {
609
+ const subject = adapter.assetSubject?.get(node.id);
610
+ return subject ? [{ id: node.id, subject }] : [];
611
+ });
612
+ for (const { id, subject } of entries) {
613
+ const normalized = subject.sourcePath?.replaceAll('\\', '/').replace(/^\.\//, '') ?? '';
614
+ const srcMarker = normalized.lastIndexOf('/src/');
615
+ const source = srcMarker >= 0 ? normalized.slice(srcMarker + 1) : normalized;
616
+ assets.set(`${source}:${subject.name}`, { id, subject });
617
+ }
618
+ return assets;
619
+ }
620
+
621
+ const emptySubscribe = (): (() => void) => () => undefined;
622
+ const zeroSnapshot = (): number => 0;
623
+
624
+ function SpritesheetFrameThumb({
625
+ sheetUrl,
626
+ frame,
627
+ }: {
628
+ sheetUrl: string;
629
+ frame: ProjectContentSpritesheetFrame;
630
+ }) {
631
+ return (
632
+ <SpritesheetSpriteView
633
+ sheetUrl={sheetUrl}
634
+ frame={{
635
+ name: '',
636
+ x: frame.x,
637
+ y: frame.y,
638
+ width: frame.width,
639
+ height: frame.height,
640
+ rotated: frame.rotated,
641
+ sourceWidth: frame.sourceWidth,
642
+ sourceHeight: frame.sourceHeight,
643
+ trimX: frame.trimX,
644
+ trimY: frame.trimY,
645
+ }}
646
+ maxEdge={48}
647
+ />
648
+ );
649
+ }
650
+
651
+ function BrowserEntryVisual({
652
+ item,
653
+ kind,
654
+ assetUrl,
655
+ grid,
656
+ folderPath,
657
+ previewRevision,
658
+ }: {
659
+ item: BrowserEntry;
660
+ kind: BrowserKind;
661
+ assetUrl: string;
662
+ grid: boolean;
663
+ folderPath: string;
664
+ previewRevision: number;
665
+ }) {
666
+ const paintUrl = assetUrl;
667
+ if (kind === 'folder') {
668
+ // The list keeps the compact glyph (assets show flat icons there too);
669
+ // Browse grid folders composite their contents.
670
+ if (!grid) return <AssetIcon kind="folder" size={15} />;
671
+ return (
672
+ <FolderPreviewTile
673
+ root={item.source === 'asset' ? item.root : 'public'}
674
+ folderPath={folderPath}
675
+ revision={previewRevision}
676
+ />
677
+ );
678
+ }
679
+ if (kind === 'audio' && assetUrl) {
680
+ return <AudioAssetThumb url={paintUrl} name={item.name} />;
681
+ }
682
+ if (!grid) return <AssetIcon kind={kind} size={15} />;
683
+ if (item.source === 'available' && item.available.descriptor.preview) {
684
+ return (
685
+ <DocumentThumbnail
686
+ documentId={item.available.descriptor.id}
687
+ source={item.available.descriptor.preview}
688
+ previewRevision={previewRevision}
689
+ />
690
+ );
691
+ }
692
+ if (item.source === 'component') {
693
+ // The source that admitted this component paints it. The host does not
694
+ // know what the picture IS (a mounted portable story, a rendered turntable,
695
+ // whatever a later source brings) — only where it goes.
696
+ const { Thumbnail } = item.contentSource;
697
+ return <Thumbnail entry={item.contentEntry} previewRevision={previewRevision} />;
698
+ }
699
+ if (assetCapabilities(item.name).editor === 'environment') {
700
+ // Browsers cannot decode HDR/EXR through <img>; the real native-loader
701
+ // preview lives in the environment Asset Lab document.
702
+ return <TypedAssetThumbnail kind="image" name={item.name} />;
703
+ }
704
+ if (kind === 'image') {
705
+ if (item.source === 'asset' && item.spritesheet) {
706
+ return (
707
+ <SpritesheetFrameThumb
708
+ sheetUrl={`/${item.spritesheet.sheetPath}`}
709
+ frame={item.spritesheet}
710
+ />
711
+ );
712
+ }
713
+ return (
714
+ <img
715
+ src={paintUrl}
716
+ alt={item.name}
717
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
718
+ />
719
+ );
720
+ }
721
+ if (kind === 'video' && paintUrl) {
722
+ // The clip itself, one frame of it. `preload="metadata"` fetches enough for
723
+ // a frame and nothing more, there is no autoplay and no sound (a wall of
724
+ // tiles must not start playing at anyone), and the `#t=0.1` MEDIA FRAGMENT
725
+ // is what makes a frame actually appear: a bare metadata preload leaves the
726
+ // element painting black until something seeks it, which is the same "I
727
+ // can't see the asset" a text row would have been.
728
+ return (
729
+ <video
730
+ src={`${paintUrl}#t=0.1`}
731
+ muted
732
+ playsInline
733
+ preload="metadata"
734
+ style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
735
+ />
736
+ );
737
+ }
738
+ if (kind === 'model' || modelThumbnailFormat(assetUrl)) {
739
+ return <ModelThumbnail url={assetUrl} />;
740
+ }
741
+ if (['prefab', 'json'].includes(kind)) {
742
+ return <TypedAssetThumbnail kind={kind} name={item.name} />;
743
+ }
744
+ return <AssetIcon kind={kind} />;
745
+ }
746
+
747
+ function BrowserEntryCard({
748
+ item,
749
+ selected,
750
+ onDragStart,
751
+ onSelect,
752
+ onOpen,
753
+ onContextMenu,
754
+ viewMode,
755
+ previewRevision,
756
+ showPath,
757
+ }: {
758
+ item: BrowserEntry;
759
+ selected: boolean;
760
+ onDragStart: (event: React.DragEvent, item: BrowserEntry) => void;
761
+ onSelect: (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => void;
762
+ onOpen: (item: BrowserEntry) => void;
763
+ onContextMenu: (event: React.MouseEvent, item: BrowserEntry) => void;
764
+ viewMode: AssetViewMode;
765
+ previewRevision: number;
766
+ showPath: boolean;
767
+ }) {
768
+ const kind = browserEntryKind(item);
769
+ const assetEntry = item.source === 'asset' ? item.entry : null;
770
+ const capability = assetEntry?.type === 'file' ? assetCapabilities(assetEntry.name) : null;
771
+ const assetUrl = item.source === 'asset' ? assetRootServingUrl(item.root, item.path) : '';
772
+ const folderPath = item.path;
773
+ const title = browserEntryTitle(item);
774
+ const cursor = assetEntry
775
+ ? assetEntry.type === 'directory' || capability?.placeable
776
+ ? capability?.placeable
777
+ ? 'grab'
778
+ : 'pointer'
779
+ : 'default'
780
+ : 'pointer';
781
+ const itemDetail = browserEntryDetail(item);
782
+ const location =
783
+ item.source === 'component'
784
+ ? item.path
785
+ : item.source === 'asset'
786
+ ? projectAssetPath(item.root, parentAssetPath(item.path)).replace(/\/$/, '')
787
+ : parentAssetPath(item.path) || 'public';
788
+ const detail = showPath ? `${location} · ${itemDetail}` : itemDetail;
789
+ // Tile presentation (full preview box, name under it, badges) is shared by
790
+ // Both flat Content and Browse grid use tiles; only Browse list is compact.
791
+ const grid = viewMode !== 'list';
792
+
793
+ return (
794
+ <div
795
+ className="vgai-selectable vgai-project-asset-entry"
796
+ data-selected={selected || undefined}
797
+ data-view={viewMode}
798
+ role="option"
799
+ aria-selected={selected}
800
+ tabIndex={0}
801
+ draggable={
802
+ item.source === 'component' ||
803
+ (item.source === 'asset' &&
804
+ assetEntry?.type === 'file' &&
805
+ capability?.placeable === true) ||
806
+ // Draggable so the refusal can SPEAK (a non-draggable card fires no
807
+ // dragstart, and nothing else says why the drag did nothing).
808
+ (item.source === 'asset' && item.spritesheet !== undefined) ||
809
+ (item.source === 'asset' && item.root === REFERENCE_ROOT && assetEntry?.type === 'file')
810
+ }
811
+ onPointerEnter={() => {
812
+ if (kind === 'audio' && assetUrl) previewAssetAudio(assetUrl);
813
+ }}
814
+ onPointerLeave={() => {
815
+ if (kind === 'audio' && assetUrl) stopAssetAudioPreview(assetUrl);
816
+ }}
817
+ onDragStart={(event) => onDragStart(event, item)}
818
+ onDragEnd={reportUnacceptedAssetDrop}
819
+ onClick={(event) => onSelect(item, event)}
820
+ onDoubleClick={() => onOpen(item)}
821
+ onContextMenu={(event) => onContextMenu(event, item)}
822
+ onKeyDown={(event) => {
823
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
824
+ event.preventDefault();
825
+ const rect = event.currentTarget.getBoundingClientRect();
826
+ event.currentTarget.dispatchEvent(
827
+ new MouseEvent('contextmenu', {
828
+ bubbles: true,
829
+ cancelable: true,
830
+ clientX: rect.left,
831
+ clientY: rect.bottom,
832
+ }),
833
+ );
834
+ } else if (event.key === 'Enter') onOpen(item);
835
+ else if (event.key === ' ') {
836
+ onSelect(item, event);
837
+ event.preventDefault();
838
+ }
839
+ }}
840
+ title={title}
841
+ style={{
842
+ ...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
843
+ borderRadius: radius.sm,
844
+ cursor,
845
+ userSelect: 'none',
846
+ }}
847
+ >
848
+ <div
849
+ className="vgai-project-asset-entry__preview"
850
+ data-testid="asset-entry-preview"
851
+ style={{
852
+ ...(grid
853
+ ? {
854
+ width: '100%',
855
+ aspectRatio:
856
+ item.source === 'available' && item.available.descriptor.preview
857
+ ? '16 / 9'
858
+ : '1 / 1',
859
+ margin: '0 auto',
860
+ }
861
+ : { width: 18, height: 18, margin: 0 }),
862
+ display: 'flex',
863
+ alignItems: 'center',
864
+ justifyContent: 'center',
865
+ overflow: 'hidden',
866
+ borderRadius: radius.sm,
867
+ pointerEvents: 'none',
868
+ position: 'relative',
869
+ }}
870
+ >
871
+ <BrowserEntryVisual
872
+ item={item}
873
+ kind={kind}
874
+ assetUrl={assetUrl}
875
+ grid={grid}
876
+ folderPath={folderPath}
877
+ previewRevision={previewRevision}
878
+ />
879
+ {grid && (
880
+ <span
881
+ className="vgai-project-asset-entry__kind"
882
+ data-testid="asset-entry-kind"
883
+ title={`${kind} asset`}
884
+ >
885
+ <AssetIcon kind={kind} size={11} />
886
+ </span>
887
+ )}
888
+ </div>
889
+ <div
890
+ data-testid="asset-entry-name"
891
+ style={{
892
+ ...(grid
893
+ ? {
894
+ fontSize: 10,
895
+ lineHeight: '13px',
896
+ minHeight: 26,
897
+ display: '-webkit-box',
898
+ WebkitBoxOrient: 'vertical',
899
+ WebkitLineClamp: 2,
900
+ }
901
+ : { fontSize: 11, lineHeight: '18px', flex: 1 }),
902
+ overflow: 'hidden',
903
+ textOverflow: 'ellipsis',
904
+ whiteSpace: grid ? 'normal' : 'nowrap',
905
+ color: text[1],
906
+ minWidth: 0,
907
+ }}
908
+ >
909
+ {item.name}
910
+ </div>
911
+ {!grid && (
912
+ <div
913
+ role="listbox"
914
+ aria-label="Project assets"
915
+ aria-multiselectable="true"
916
+ style={{
917
+ maxWidth: '45%',
918
+ overflow: 'hidden',
919
+ textOverflow: 'ellipsis',
920
+ whiteSpace: 'nowrap',
921
+ color: text[3],
922
+ fontSize: 10,
923
+ }}
924
+ >
925
+ {detail}
926
+ </div>
927
+ )}
928
+ </div>
929
+ );
930
+ }
931
+
932
+ interface AssetBrowserToolbarProps {
933
+ scope: ContentScope;
934
+ facets: readonly ProjectContentFacet[];
935
+ /** The kinds the project holds — the only chips offered. */
936
+ availableFacets: ReadonlySet<ProjectContentFacet>;
937
+ currentPath: string;
938
+ searchOpen: boolean;
939
+ searchQuery: string;
940
+ viewMode: AssetViewMode;
941
+ density: number;
942
+ treeVisible: boolean;
943
+ onScopeChange: (scope: ContentScope) => void;
944
+ onFacetToggle: (facet: ProjectContentFacet) => void;
945
+ onFacetsClear: () => void;
946
+ currentRoot: AssetRoot;
947
+ onNavigate: (root: AssetRoot, path: string) => void;
948
+ onSearchOpenChange: (open: boolean) => void;
949
+ onSearchQueryChange: (query: string) => void;
950
+ onViewModeChange: (mode: AssetViewMode) => void;
951
+ onDensityChange: (density: number) => void;
952
+ onTreeVisibilityToggle: () => void;
953
+ onCreateFolder: () => void;
954
+ onImport: () => void;
955
+ onDelete: () => void;
956
+ deleteDisabled: boolean;
957
+ onRefresh: () => void;
958
+ generationTools: readonly { id: string; title: string }[];
959
+ onGenerate: () => void;
960
+ }
961
+
962
+ function AssetBrowserToolbar({
963
+ scope,
964
+ facets,
965
+ availableFacets,
966
+ currentPath,
967
+ currentRoot,
968
+ searchOpen,
969
+ searchQuery,
970
+ viewMode,
971
+ density,
972
+ treeVisible,
973
+ onScopeChange,
974
+ onFacetToggle,
975
+ onFacetsClear,
976
+ onNavigate,
977
+ onSearchOpenChange,
978
+ onSearchQueryChange,
979
+ onViewModeChange,
980
+ onDensityChange,
981
+ onTreeVisibilityToggle,
982
+ onCreateFolder,
983
+ onImport,
984
+ onDelete,
985
+ deleteDisabled,
986
+ onRefresh,
987
+ generationTools,
988
+ onGenerate,
989
+ }: AssetBrowserToolbarProps) {
990
+ const actionsRef = useRef<HTMLDivElement>(null);
991
+ const breadcrumbEndAnchoredRef = useRef(true);
992
+ const breadcrumbRef = useRef<HTMLElement>(null);
993
+ const [actionsOpen, setActionsOpen] = useState(false);
994
+ const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
995
+ const parentPath = parentAssetPath(currentPath);
996
+ // The root crumb names the root being browsed: the shipped files, or the
997
+ // project's reference material.
998
+ const rootLabel = currentRoot === REFERENCE_ROOT ? 'References' : 'Files';
999
+ const closeSearch = () => {
1000
+ onSearchQueryChange('');
1001
+ onSearchOpenChange(false);
1002
+ };
1003
+ useEffect(() => {
1004
+ if (!actionsOpen) return;
1005
+ const close = (event: PointerEvent) => {
1006
+ if (!actionsRef.current?.contains(event.target as Node)) setActionsOpen(false);
1007
+ };
1008
+ document.addEventListener('pointerdown', close);
1009
+ return () => document.removeEventListener('pointerdown', close);
1010
+ }, [actionsOpen]);
1011
+ useLayoutEffect(() => {
1012
+ const breadcrumb = breadcrumbRef.current;
1013
+ if (!breadcrumb) return;
1014
+ const scrollToEnd = () => {
1015
+ breadcrumb.scrollLeft = breadcrumb.scrollWidth;
1016
+ };
1017
+ breadcrumbEndAnchoredRef.current = true;
1018
+ scrollToEnd();
1019
+ const updateEndAnchor = () => {
1020
+ breadcrumbEndAnchoredRef.current =
1021
+ breadcrumb.scrollWidth - breadcrumb.clientWidth - breadcrumb.scrollLeft <= 1;
1022
+ };
1023
+ breadcrumb.addEventListener('scroll', updateEndAnchor, { passive: true });
1024
+ const resizeObserver =
1025
+ typeof ResizeObserver === 'undefined'
1026
+ ? null
1027
+ : new ResizeObserver(() => {
1028
+ if (breadcrumbEndAnchoredRef.current) scrollToEnd();
1029
+ });
1030
+ resizeObserver?.observe(breadcrumb);
1031
+ return () => {
1032
+ breadcrumb.removeEventListener('scroll', updateEndAnchor);
1033
+ resizeObserver?.disconnect();
1034
+ };
1035
+ }, [currentPath, searchOpen]);
1036
+
1037
+ return (
1038
+ <div className="vgai-project-assets-toolbar">
1039
+ <EditorToolbar label="Content display" compact className="vgai-project-assets-toolbar__row">
1040
+ <ToolbarGroup>
1041
+ <Tooltip text="Content grid" position="top">
1042
+ <IconButton
1043
+ aria-label="Content grid"
1044
+ data-testid="content-scope-content"
1045
+ aria-pressed={scope === 'content'}
1046
+ onClick={() => {
1047
+ if (scope !== 'content') onScopeChange('content');
1048
+ onViewModeChange('grid');
1049
+ }}
1050
+ >
1051
+ <EditorIcon icon={faTableCellsLarge} />
1052
+ </IconButton>
1053
+ </Tooltip>
1054
+ <Tooltip text="Browse grid" position="top">
1055
+ <IconButton
1056
+ aria-label="Browse grid"
1057
+ data-testid="content-scope-files"
1058
+ aria-pressed={scope === 'files' && viewMode === 'grid'}
1059
+ onClick={() => {
1060
+ if (scope !== 'files') onScopeChange('files');
1061
+ onViewModeChange('grid');
1062
+ }}
1063
+ >
1064
+ <EditorIcon icon={faTable} />
1065
+ </IconButton>
1066
+ </Tooltip>
1067
+ <Tooltip text="Browse list" position="top">
1068
+ <IconButton
1069
+ aria-label="Browse list"
1070
+ data-testid="asset-view-list"
1071
+ aria-pressed={scope === 'files' && viewMode === 'list'}
1072
+ onClick={() => {
1073
+ if (scope !== 'files') onScopeChange('files');
1074
+ onViewModeChange('list');
1075
+ }}
1076
+ >
1077
+ <EditorIcon icon={faList} />
1078
+ </IconButton>
1079
+ </Tooltip>
1080
+ </ToolbarGroup>
1081
+ <span style={{ flex: 1 }} />
1082
+ {searchOpen ? (
1083
+ <TextInput
1084
+ type="text"
1085
+ data-testid="asset-search-input"
1086
+ aria-label="Search content"
1087
+ placeholder="Search content..."
1088
+ value={searchQuery}
1089
+ onChange={(event) => onSearchQueryChange(event.target.value)}
1090
+ onKeyDown={(event) => {
1091
+ if (event.key === 'Escape') closeSearch();
1092
+ }}
1093
+ className="vgai-input"
1094
+ autoFocus
1095
+ style={{ flex: '0 1 160px', minWidth: 60 }}
1096
+ />
1097
+ ) : null}
1098
+
1099
+ <Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
1100
+ <IconButton
1101
+ data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
1102
+ aria-label={searchOpen ? 'Close search' : 'Search content'}
1103
+ onClick={() => {
1104
+ if (searchOpen) closeSearch();
1105
+ else onSearchOpenChange(true);
1106
+ }}
1107
+ >
1108
+ <EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
1109
+ </IconButton>
1110
+ </Tooltip>
1111
+ </EditorToolbar>
1112
+ <EditorToolbar
1113
+ label={scope === 'content' ? 'Content types' : 'Folder navigation'}
1114
+ compact
1115
+ className="vgai-project-assets-toolbar__row"
1116
+ >
1117
+ {scope === 'files' && (
1118
+ <>
1119
+ <Tooltip text={treeVisible ? 'Hide folders' : 'Show folders'} position="top">
1120
+ <IconButton
1121
+ data-testid="asset-tree-toggle"
1122
+ aria-label={treeVisible ? 'Hide folder tree' : 'Show folder tree'}
1123
+ aria-controls="project-asset-tree"
1124
+ aria-expanded={treeVisible}
1125
+ onClick={onTreeVisibilityToggle}
1126
+ >
1127
+ <EditorIcon icon={faFolderTree} />
1128
+ </IconButton>
1129
+ </Tooltip>
1130
+ <Tooltip text="Back" position="top">
1131
+ <IconButton
1132
+ data-testid="asset-back"
1133
+ aria-label="Back"
1134
+ disabled={!currentPath}
1135
+ onClick={() => onNavigate(currentRoot, parentPath)}
1136
+ >
1137
+ <EditorIcon icon={faArrowLeft} />
1138
+ </IconButton>
1139
+ </Tooltip>
1140
+ <nav
1141
+ ref={breadcrumbRef}
1142
+ data-testid="asset-breadcrumb"
1143
+ aria-label="Asset location"
1144
+ style={{
1145
+ flex: 1,
1146
+ display: 'flex',
1147
+ alignItems: 'center',
1148
+ gap: 2,
1149
+ fontSize: 12,
1150
+ minWidth: 0,
1151
+ overflowX: 'auto',
1152
+ overflowY: 'hidden',
1153
+ whiteSpace: 'nowrap',
1154
+ }}
1155
+ >
1156
+ {currentPath ? (
1157
+ <Button
1158
+ type="button"
1159
+ variant="ghost"
1160
+ size="compact"
1161
+ onClick={() => onNavigate(currentRoot, '')}
1162
+ style={{ flexShrink: 0 }}
1163
+ >
1164
+ {rootLabel}
1165
+ </Button>
1166
+ ) : (
1167
+ <span aria-current="location" style={{ color: text[1] }}>
1168
+ {rootLabel}
1169
+ </span>
1170
+ )}
1171
+ {breadcrumbSegments.map((segment, index) => {
1172
+ const path = breadcrumbSegments.slice(0, index + 1).join('/');
1173
+ const isLast = index === breadcrumbSegments.length - 1;
1174
+ return (
1175
+ <span
1176
+ key={path}
1177
+ style={{ display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}
1178
+ >
1179
+ <span style={{ color: text[3] }}>/</span>
1180
+ {isLast ? (
1181
+ <span aria-current="location" title={segment} style={{ color: text[1] }}>
1182
+ {segment}
1183
+ </span>
1184
+ ) : (
1185
+ <Button
1186
+ type="button"
1187
+ variant="ghost"
1188
+ size="compact"
1189
+ onClick={() => onNavigate(currentRoot, path)}
1190
+ >
1191
+ {segment}
1192
+ </Button>
1193
+ )}
1194
+ </span>
1195
+ );
1196
+ })}
1197
+ </nav>
1198
+ </>
1199
+ )}
1200
+ {scope === 'content' && (
1201
+ <ToolbarGroup className="vgai-content-filter-inline" aria-label="Content types">
1202
+ {searchOpen && (
1203
+ <Button
1204
+ variant="ghost"
1205
+ size="compact"
1206
+ className="vgai-content-filter-chip"
1207
+ data-testid="content-filter-all"
1208
+ aria-pressed={facets.length === 0}
1209
+ onClick={onFacetsClear}
1210
+ >
1211
+ All
1212
+ </Button>
1213
+ )}
1214
+ {[
1215
+ ...(
1216
+ [
1217
+ ['component', 'Prefabs'],
1218
+ ['scene', 'Scenes'],
1219
+ ['canvas', 'Canvases'],
1220
+ ['model', 'Models'],
1221
+ ['image', 'Images'],
1222
+ ['video', 'Videos'],
1223
+ ['audio', 'Audio'],
1224
+ ['other', 'Other'],
1225
+ ] as const
1226
+ ).filter(([facet]) => availableFacets.has(facet)),
1227
+ ...[...availableFacets]
1228
+ .filter((facet) => !FIXED_FACETS.has(facet))
1229
+ .sort()
1230
+ .map((facet): readonly [string, string] => [facet, facetLabel(facet)]),
1231
+ ].map(([facet, label]) => (
1232
+ <Button
1233
+ key={facet}
1234
+ variant="ghost"
1235
+ size="compact"
1236
+ className="vgai-content-filter-chip"
1237
+ data-testid={`content-filter-${facet}`}
1238
+ aria-pressed={facets.includes(facet)}
1239
+ onClick={() => onFacetToggle(facet)}
1240
+ >
1241
+ {label}
1242
+ </Button>
1243
+ ))}
1244
+ </ToolbarGroup>
1245
+ )}
1246
+ <div ref={actionsRef} className="vgai-project-assets-toolbar__more">
1247
+ <Tooltip text="More asset actions" position="top">
1248
+ <IconButton
1249
+ aria-label="More asset actions"
1250
+ aria-expanded={actionsOpen}
1251
+ aria-haspopup="menu"
1252
+ onClick={() => setActionsOpen((value) => !value)}
1253
+ >
1254
+ <EditorIcon icon={faEllipsis} />
1255
+ </IconButton>
1256
+ </Tooltip>
1257
+ {actionsOpen && (
1258
+ <Menu
1259
+ autoFocusFirst
1260
+ className="vgai-project-assets-toolbar__menu"
1261
+ onDismiss={() => setActionsOpen(false)}
1262
+ >
1263
+ <MenuItem
1264
+ data-command-id={PROJECT_ASSET_COMMANDS.import.id}
1265
+ onSelect={() => {
1266
+ setActionsOpen(false);
1267
+ onImport();
1268
+ }}
1269
+ >
1270
+ Import files…
1271
+ </MenuItem>
1272
+ <MenuItem
1273
+ data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
1274
+ onSelect={() => {
1275
+ setActionsOpen(false);
1276
+ onCreateFolder();
1277
+ }}
1278
+ >
1279
+ New folder
1280
+ </MenuItem>
1281
+ {generationTools.length > 0 && (
1282
+ <>
1283
+ <hr className="vgai-menu-separator" />
1284
+ <MenuItem
1285
+ onSelect={() => {
1286
+ setActionsOpen(false);
1287
+ onGenerate();
1288
+ }}
1289
+ >
1290
+ Create asset…
1291
+ </MenuItem>
1292
+ </>
1293
+ )}
1294
+ <MenuItem
1295
+ onSelect={() => {
1296
+ setActionsOpen(false);
1297
+ onDensityChange(density <= 96 ? 132 : 88);
1298
+ }}
1299
+ >
1300
+ {density <= 96 ? 'Larger thumbnails' : 'Smaller thumbnails'}
1301
+ </MenuItem>
1302
+ <MenuItem
1303
+ disabled={deleteDisabled}
1304
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
1305
+ onSelect={() => {
1306
+ setActionsOpen(false);
1307
+ onDelete();
1308
+ }}
1309
+ >
1310
+ Delete selected
1311
+ </MenuItem>
1312
+ <MenuItem
1313
+ onSelect={() => {
1314
+ setActionsOpen(false);
1315
+ onRefresh();
1316
+ }}
1317
+ >
1318
+ Refresh
1319
+ </MenuItem>
1320
+ </Menu>
1321
+ )}
1322
+ </div>
1323
+ </EditorToolbar>
1324
+ </div>
1325
+ );
1326
+ }
1327
+
1328
+ function ProjectAssetTree(props: {
1329
+ currentRoot: AssetRoot;
1330
+ currentPath: string;
1331
+ expanded: ReadonlySet<string>;
1332
+ hidden: boolean;
1333
+ revision: number;
1334
+ loadAssets: typeof listAssets;
1335
+ onExpanded(path: string): void;
1336
+ onNavigate(root: AssetRoot, path: string): void;
1337
+ }) {
1338
+ const [folders, setFolders] = useState(new Map<string, string[]>());
1339
+
1340
+ useEffect(() => {
1341
+ setFolders(new Map());
1342
+ }, [props.revision]);
1343
+
1344
+ useEffect(() => {
1345
+ for (const key of props.expanded) {
1346
+ if (folders.has(key)) continue;
1347
+ const root = ASSET_ROOTS.find(
1348
+ (candidate) => key === candidate || key.startsWith(`${candidate}/`),
1349
+ );
1350
+ if (!root) continue;
1351
+ const path = key === root ? '' : key.slice(`${root}/`.length);
1352
+ void props.loadAssets(root, path).then((listed) => {
1353
+ // A FAILED listing leaves this node UNRECORDED rather than recorded as
1354
+ // having no subfolders: the map's `has(key)` is the "already loaded"
1355
+ // gate, so recording an empty array would pin a folder open and empty
1356
+ // for the session. Not recording it means the next expand retries.
1357
+ if (!listed.ok) return;
1358
+ setFolders((current) => {
1359
+ const next = new Map(current);
1360
+ next.set(
1361
+ key,
1362
+ listed.entries.filter((entry) => entry.type === 'directory').map((entry) => entry.name),
1363
+ );
1364
+ return next;
1365
+ });
1366
+ });
1367
+ }
1368
+ }, [folders, props.expanded, props.loadAssets]);
1369
+
1370
+ const renderFolder = (root: AssetRoot, path: string, depth: number): React.ReactNode => {
1371
+ const key = path ? `${root}/${path}` : root;
1372
+ const open = props.expanded.has(key);
1373
+ const name = path.split('/').pop() || root;
1374
+ return (
1375
+ <div key={key}>
1376
+ <div className="vgai-asset-tree-row" style={{ paddingLeft: 6 + depth * 14 }}>
1377
+ <IconButton
1378
+ className="vgai-asset-tree-toggle"
1379
+ aria-label={`${open ? 'Collapse' : 'Expand'} ${name}`}
1380
+ aria-expanded={open}
1381
+ onClick={() => props.onExpanded(key)}
1382
+ >
1383
+ <EditorIcon
1384
+ icon={faChevronRight}
1385
+ style={{ transform: open ? 'rotate(90deg)' : undefined }}
1386
+ />
1387
+ </IconButton>
1388
+ <Button
1389
+ variant="ghost"
1390
+ size="compact"
1391
+ className="vgai-asset-tree-target"
1392
+ aria-current={
1393
+ props.currentRoot === root && props.currentPath === path ? 'page' : undefined
1394
+ }
1395
+ onClick={() => props.onNavigate(root, path)}
1396
+ >
1397
+ <EditorIcon icon={faFolder} />
1398
+ {name}
1399
+ </Button>
1400
+ </div>
1401
+ {open &&
1402
+ folders
1403
+ .get(key)
1404
+ ?.map((child) => renderFolder(root, path ? `${path}/${child}` : child, depth + 1))}
1405
+ </div>
1406
+ );
1407
+ };
1408
+
1409
+ return (
1410
+ <aside
1411
+ id="project-asset-tree"
1412
+ className="vgai-asset-tree"
1413
+ aria-label="Project asset sources"
1414
+ hidden={props.hidden}
1415
+ >
1416
+ <div className="vgai-asset-facet-title">Project</div>
1417
+ {renderFolder('public', '', 0)}
1418
+ <Button
1419
+ variant="ghost"
1420
+ size="compact"
1421
+ className="vgai-asset-tree-target"
1422
+ onClick={() => props.onNavigate('public', 'asset-library/local')}
1423
+ >
1424
+ Imported
1425
+ </Button>
1426
+ <Button
1427
+ variant="ghost"
1428
+ size="compact"
1429
+ className="vgai-asset-tree-target"
1430
+ onClick={() => props.onNavigate('public', '.generated')}
1431
+ >
1432
+ Generated
1433
+ </Button>
1434
+ {/* The project's own reference material — a real folder beside public/,
1435
+ browsed the same way. It is listed even when empty so the place to put
1436
+ a moodboard is discoverable, and it is never exported. */}
1437
+ <div className="vgai-asset-facet-title">References</div>
1438
+ {renderFolder(REFERENCE_ROOT, '', 0)}
1439
+ </aside>
1440
+ );
1441
+ }
1442
+
1443
+ function useAdaptiveAssetTree(initialVisibility: AssetTreeVisibility) {
1444
+ const browserRef = useRef<HTMLDivElement>(null);
1445
+ const [treeVisibility, setTreeVisibility] = useState(initialVisibility);
1446
+ const [hasTreeSpace, setHasTreeSpace] = useState(true);
1447
+
1448
+ useLayoutEffect(() => {
1449
+ const browser = browserRef.current;
1450
+ if (!browser) return;
1451
+ const updateAvailableSpace = (width: number) => {
1452
+ if (width > 0) setHasTreeSpace(width >= ASSET_TREE_MIN_INLINE_SIZE);
1453
+ };
1454
+ updateAvailableSpace(browser.getBoundingClientRect().width);
1455
+ if (typeof ResizeObserver === 'undefined') return;
1456
+ const observer = new ResizeObserver(([entry]) => {
1457
+ if (entry) updateAvailableSpace(entry.contentRect.width);
1458
+ });
1459
+ observer.observe(browser);
1460
+ return () => observer.disconnect();
1461
+ }, []);
1462
+
1463
+ const treeVisible = treeVisibility === 'shown' || (treeVisibility === 'auto' && hasTreeSpace);
1464
+ const toggleTreeVisibility = useCallback(() => {
1465
+ setTreeVisibility(treeVisible ? 'hidden' : 'shown');
1466
+ }, [treeVisible]);
1467
+
1468
+ return { browserRef, treeVisibility, treeVisible, toggleTreeVisibility };
1469
+ }
1470
+
1471
+ export function AssetBrowser({ services = DEFAULT_ASSET_BROWSER_SERVICES }: AssetBrowserProps) {
1472
+ const store = useEditorStore();
1473
+ const subscribeStore = typeof store.subscribe === 'function' ? store.subscribe : emptySubscribe;
1474
+ const readStoreVersion =
1475
+ typeof store.getSnapshot === 'function' ? store.getSnapshot : zeroSnapshot;
1476
+ const readShellVersion =
1477
+ typeof store.getShellSnapshot === 'function' ? store.getShellSnapshot : readStoreVersion;
1478
+ const storeVersion = useSyncExternalStore(subscribeStore, readShellVersion, readShellVersion);
1479
+ const adapterVersion = useSyncExternalStore(
1480
+ subscribeActiveAuthoring,
1481
+ activeAuthoringVersion,
1482
+ activeAuthoringVersion,
1483
+ );
1484
+ const workspaceDocumentVersion = useSyncExternalStore(
1485
+ subscribeWorkspaceDocuments,
1486
+ workspaceDocumentRegistryVersion,
1487
+ workspaceDocumentRegistryVersion,
1488
+ );
1489
+ const storeRef = useRef(store);
1490
+ storeRef.current = store;
1491
+ const history = useHistoryService();
1492
+ const toolContributions = useSyncExternalStore(
1493
+ subscribeToolContributions,
1494
+ getGlobalToolContributions,
1495
+ getGlobalToolContributions,
1496
+ );
1497
+ // ONE subscription for every registered content source AND the registry
1498
+ // itself: a source's own ledger settling (portable-story discovery finishing)
1499
+ // re-reads the Content scope without the panel knowing what moved.
1500
+ const contentSourceVersion = useSyncExternalStore(
1501
+ subscribeContentEntrySources,
1502
+ contentEntrySourceRegistryVersion,
1503
+ contentEntrySourceRegistryVersion,
1504
+ );
1505
+ const generationTools = toolContributions.filter(
1506
+ (item) => item.point === 'workspace.document' && item.tool?.generation?.role === 'submit',
1507
+ );
1508
+ const initial = useMemo(readAssetBrowserState, []);
1509
+ // Browse Files navigates ONE root at a time; Content shows them all at once.
1510
+ const [currentRoot, setCurrentRoot] = useState<AssetRoot>(initial.root);
1511
+ const [scope, setScope] = useState<ContentScope>(initial.scope);
1512
+ const [facets, setFacets] = useState<ProjectContentFacet[]>(initial.facets);
1513
+ const [currentPath, setCurrentPath] = useState(initial.currentPath);
1514
+ const [viewMode, setViewMode] = useState<AssetViewMode>(initial.viewMode);
1515
+ const [sort, setSort] = useState<AssetSort>(initial.scope === 'content' ? 'relevance' : 'name');
1516
+ const [filter, setFilter] = useState<AssetFilter>('all');
1517
+ const [density, setDensity] = useState(initial.density);
1518
+ const { browserRef, treeVisibility, treeVisible, toggleTreeVisibility } = useAdaptiveAssetTree(
1519
+ initial.treeVisibility,
1520
+ );
1521
+ const [searchOpen, setSearchOpen] = useState(false);
1522
+ const [listing, setListing] = useState<FolderListing>({
1523
+ path: initial.currentPath,
1524
+ entries: [],
1525
+ });
1526
+ const [contentAssets, setContentAssets] = useState<ProjectContentAsset[]>([]);
1527
+ const [components, setComponents] = useState<ProjectComponentEntry[]>([]);
1528
+ const availableDocuments = useSyncExternalStore(
1529
+ subscribeAvailableWorkspaceDocuments,
1530
+ availableWorkspaceDocuments,
1531
+ );
1532
+ const adapterFacet = useSyncExternalStore(
1533
+ subscribeProjectAdapter,
1534
+ projectAdapterFacet,
1535
+ projectAdapterFacet,
1536
+ );
1537
+ const [contentLoading, setContentLoading] = useState(false);
1538
+ /** Listings that FAILED during the last Content scan, by name and reason. */
1539
+ const [contentFailures, setContentFailures] = useState<string[]>([]);
1540
+ const [expanded, setExpanded] = useState(new Set(initial.expanded));
1541
+ const [treeRevision, setTreeRevision] = useState(0);
1542
+ const [previewRevision, setPreviewRevision] = useState(0);
1543
+ const [searchQuery, setSearchQuery] = useState('');
1544
+ const [selectedFiles, setSelectedFiles] = useState(new Set<string>());
1545
+ const [operationError, setOperationError] = useState<string | null>(null);
1546
+ /**
1547
+ * The asset browser's own inline prompt. `window.prompt` is banned in editor
1548
+ * UI (it blocks the whole tab and cannot be styled or driven), and this panel
1549
+ * still had three of them — folder name, rename, move-to-folder. One piece of
1550
+ * state serves all three: the question, the draft, and what to do with it.
1551
+ */
1552
+ const [prompt, setPrompt] = useState<{
1553
+ readonly title: string;
1554
+ readonly initial: string;
1555
+ readonly confirmLabel: string;
1556
+ readonly run: (value: string) => void | Promise<void>;
1557
+ } | null>(null);
1558
+ const [promptDraft, setPromptDraft] = useState('');
1559
+ const askFor = useCallback(
1560
+ (question: {
1561
+ readonly title: string;
1562
+ readonly initial: string;
1563
+ readonly confirmLabel: string;
1564
+ readonly run: (value: string) => void | Promise<void>;
1565
+ }): void => {
1566
+ setPromptDraft(question.initial);
1567
+ setPrompt(question);
1568
+ },
1569
+ [],
1570
+ );
1571
+ /** True while the panel is showing a drop target for OS files. */
1572
+ const [fileDropActive, setFileDropActive] = useState(false);
1573
+ const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
1574
+ const [contextMenu, setContextMenu] = useState<{
1575
+ x: number;
1576
+ y: number;
1577
+ item: BrowserEntry;
1578
+ } | null>(null);
1579
+ const importInputRef = useRef<HTMLInputElement>(null);
1580
+ const cacheRef = useRef(new Map<string, AssetEntry[]>());
1581
+ /** Latest fetch key; an older in-flight fetch resolving late must not win. */
1582
+ const listingRequestRef = useRef('');
1583
+
1584
+ useEffect(() => {
1585
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
1586
+ localStorage.setItem(
1587
+ projectContentBrowserStateKey(projectId),
1588
+ JSON.stringify({
1589
+ scope,
1590
+ root: currentRoot,
1591
+ facets,
1592
+ currentPath,
1593
+ viewMode,
1594
+ sort,
1595
+ filter,
1596
+ density,
1597
+ expanded: [...expanded],
1598
+ treeVisibility,
1599
+ } satisfies PersistedAssetBrowserState),
1600
+ );
1601
+ }, [
1602
+ currentPath,
1603
+ currentRoot,
1604
+ density,
1605
+ expanded,
1606
+ facets,
1607
+ filter,
1608
+ scope,
1609
+ sort,
1610
+ treeVisibility,
1611
+ viewMode,
1612
+ ]);
1613
+
1614
+ const fetchEntries = useCallback(
1615
+ async (root: AssetRoot, dir: string) => {
1616
+ const key = `${root}:${dir}`;
1617
+ listingRequestRef.current = key;
1618
+ // Path and entries land in ONE state update, only if this is still the
1619
+ // newest request — navigate A→B→A can otherwise apply B's late result.
1620
+ const apply = (entries: AssetEntry[], failure?: string) => {
1621
+ if (listingRequestRef.current !== key) return;
1622
+ setListing({ path: dir, entries, ...(failure === undefined ? {} : { failure }) });
1623
+ };
1624
+ const cached = cacheRef.current.get(key);
1625
+ if (cached) {
1626
+ apply(cached);
1627
+ return;
1628
+ }
1629
+ try {
1630
+ const listed = await services.listAssets(root, dir);
1631
+ // A FAILED listing is never cached — caching it would pin "this folder
1632
+ // is empty" for the rest of the session over a transient backend error.
1633
+ if (!listed.ok) {
1634
+ apply([], listed.reason);
1635
+ return;
1636
+ }
1637
+ cacheRef.current.set(key, listed.entries);
1638
+ apply(listed.entries);
1639
+ } catch (error) {
1640
+ apply([], error instanceof Error ? error.message : String(error));
1641
+ }
1642
+ },
1643
+ [services],
1644
+ );
1645
+
1646
+ useEffect(() => {
1647
+ fetchEntries(currentRoot, currentPath);
1648
+ }, [currentRoot, currentPath, fetchEntries]);
1649
+
1650
+ const fetchContent = useCallback(async () => {
1651
+ setContentLoading(true);
1652
+ try {
1653
+ const [listed, componentListing] = await Promise.all([
1654
+ collectProjectContentAssets(services.listAssets),
1655
+ services.listProjectComponents(),
1656
+ ]);
1657
+ const assets = await expandSpritesheetContentAssets(listed.assets, async (path) => {
1658
+ try {
1659
+ const response = await fetch(`/${path}`);
1660
+ return response.ok ? await response.text() : null;
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ });
1665
+ setContentAssets(assets);
1666
+ setComponents(componentListing.ok ? componentListing.entries : []);
1667
+ // An empty Content gallery over a FAILED scan is the fabrication this
1668
+ // panel is most likely to show an author: their project looks emptied.
1669
+ // Both halves report by name; neither is silently absorbed into `[]`.
1670
+ setContentFailures([
1671
+ ...listed.failedListings,
1672
+ ...(componentListing.ok ? [] : [`component index: ${componentListing.reason}`]),
1673
+ ]);
1674
+ } catch (error) {
1675
+ setContentAssets([]);
1676
+ setComponents([]);
1677
+ setContentFailures([error instanceof Error ? error.message : String(error)]);
1678
+ } finally {
1679
+ setContentLoading(false);
1680
+ }
1681
+ }, [services]);
1682
+
1683
+ useEffect(() => {
1684
+ if (scope === 'content') void fetchContent();
1685
+ }, [fetchContent, scope]);
1686
+
1687
+ const navigate = useCallback((root: AssetRoot, path: string) => {
1688
+ setCurrentRoot(root);
1689
+ setCurrentPath(path);
1690
+ setSelectedFiles(new Set());
1691
+ setSelectionAnchor(null);
1692
+ clearSelectedAsset();
1693
+ setSearchQuery('');
1694
+ setSearchOpen(false);
1695
+ }, []);
1696
+
1697
+ const changeScope = useCallback((nextScope: ContentScope) => {
1698
+ setScope(nextScope);
1699
+ setSort(nextScope === 'content' ? 'relevance' : 'name');
1700
+ setFilter('all');
1701
+ setSelectedFiles(new Set());
1702
+ setSelectionAnchor(null);
1703
+ clearSelectedAsset();
1704
+ }, []);
1705
+
1706
+ const selectFacet = useCallback((facet: ProjectContentFacet) => {
1707
+ setFacets([facet]);
1708
+ }, []);
1709
+
1710
+ const changeSearchOpen = useCallback((open: boolean) => {
1711
+ setSearchOpen(open);
1712
+ if (!open) setFacets((current) => (current.length > 0 ? current : ['component']));
1713
+ }, []);
1714
+
1715
+ // Content is a semantic, flattened projection over project-owned components
1716
+ // and public assets. Browse Files remains the literal public/ navigator; it
1717
+ // never invents synthetic folders for semantic concepts.
1718
+ //
1719
+ // Everything derived from the entries reads `shownPath` — the path the
1720
+ // rendered listing was actually fetched for — never `currentPath`, which
1721
+ // runs ahead of the listing during navigation (see FolderListing).
1722
+ const shownPath = listing.path;
1723
+ const authoredAssets = useMemo(
1724
+ () => liveAuthoringAssets(resolvePanelAuthoring(storeRef.current).adapter),
1725
+ [adapterVersion, storeVersion, workspaceDocumentVersion],
1726
+ );
1727
+ const activeContentSurface = useMemo(
1728
+ () => resolvePanelAuthoring(storeRef.current).surface,
1729
+ [adapterVersion, storeVersion, workspaceDocumentVersion],
1730
+ );
1731
+ const browserEntries = useMemo<BrowserEntry[]>(() => {
1732
+ if (scope === 'content') {
1733
+ // WHICH components are content, and why, belongs to whatever REGISTERED
1734
+ // a content source (`content-entry-source-registry.ts`). Source shape
1735
+ // alone cannot distinguish a reusable prefab from scene composition or
1736
+ // implementation detail, and the answer that can is a package's
1737
+ // (portable CSF today) — the host hands over its component index and
1738
+ // the focused surface and takes back rows.
1739
+ // A source answers about a NARROW ref (name, path, surface) — the shape
1740
+ // the Inspector's one-component question also has — so the grid pairs
1741
+ // each answer back with its own index row, which is what carries the
1742
+ // chip, the kind, the relevance and the detail line.
1743
+ const indexed = new Map(components.map((entry) => [`${entry.path}:${entry.name}`, entry]));
1744
+ const derivedComponents = contentEntrySources().flatMap((source) =>
1745
+ source.entries({ components, surface: activeContentSurface }).flatMap((contentEntry) => {
1746
+ const component = indexed.get(
1747
+ `${contentEntry.component.path}:${contentEntry.component.name}`,
1748
+ );
1749
+ // A source may only admit components it was GIVEN; one that answers
1750
+ // with something else is answering about a project this panel is not
1751
+ // showing, and the grid has no row for it.
1752
+ if (!component) return [];
1753
+ const key = `${component.path.replace(/^\.\//, '')}:${component.name}`;
1754
+ const authoringAsset = authoredAssets.get(key);
1755
+ return [
1756
+ {
1757
+ source: 'component' as const,
1758
+ key: `component:${source.id}:${contentEntry.id}`,
1759
+ name: component.name,
1760
+ path: component.path,
1761
+ component,
1762
+ contentSource: source,
1763
+ contentEntry,
1764
+ ...(authoringAsset ? { authoringAsset } : {}),
1765
+ },
1766
+ ];
1767
+ }),
1768
+ );
1769
+ const documentEntries = (adapterFacet?.scenes.entries ?? []).flatMap((document) => {
1770
+ if (document.kind === 'prefab') return [];
1771
+ if (
1772
+ document.kind === 'scene' &&
1773
+ availableDocuments.some(
1774
+ (item) =>
1775
+ (item.category === 'scene' || item.category === 'canvas') &&
1776
+ !!document.source?.path &&
1777
+ item.descriptor.provenance?.sourcePath?.replace(/^\.\//, '') ===
1778
+ document.source.path.replace(/^\.\//, ''),
1779
+ )
1780
+ )
1781
+ return [];
1782
+ if (document.kind !== 'scene' && !document.source) return [];
1783
+ return [
1784
+ {
1785
+ source: 'document' as const,
1786
+ key: `document:${document.id}`,
1787
+ name: document.label,
1788
+ path: document.source?.path ?? '',
1789
+ document,
1790
+ },
1791
+ ];
1792
+ });
1793
+ return [
1794
+ ...derivedComponents,
1795
+ ...documentEntries,
1796
+ ...availableDocuments.map((available) => ({
1797
+ source: 'available' as const,
1798
+ key: `available:${available.descriptor.id}`,
1799
+ name: available.descriptor.title,
1800
+ path: available.descriptor.provenance?.sourcePath ?? '',
1801
+ available,
1802
+ })),
1803
+ ...contentAssets.map(({ path, root, entry, spritesheet }) => ({
1804
+ source: 'asset' as const,
1805
+ key: `asset:${root}:${path}`,
1806
+ name: entry.name,
1807
+ path,
1808
+ root,
1809
+ entry,
1810
+ ...(spritesheet ? { spritesheet } : {}),
1811
+ })),
1812
+ ];
1813
+ }
1814
+ return listing.entries.map((entry) => {
1815
+ const path = listing.path ? `${listing.path}/${entry.name}` : entry.name;
1816
+ return {
1817
+ source: 'asset' as const,
1818
+ key: `asset:${currentRoot}:${path}`,
1819
+ name: entry.name,
1820
+ path,
1821
+ root: currentRoot,
1822
+ entry,
1823
+ };
1824
+ });
1825
+ }, [
1826
+ activeContentSurface,
1827
+ adapterFacet,
1828
+ availableDocuments,
1829
+ authoredAssets,
1830
+ components,
1831
+ contentAssets,
1832
+ // The registry's aggregate version: a source registering, or a registered
1833
+ // source's own ledger settling. `contentEntrySources()` is a stable array,
1834
+ // so this is what says the rows must be rebuilt.
1835
+ contentSourceVersion,
1836
+ currentRoot,
1837
+ listing,
1838
+ scope,
1839
+ ]);
1840
+ /** Why THIS scope's listing has nothing to show, when the reason is a
1841
+ * failure rather than an empty result. `null` = nothing failed. */
1842
+ const listingFailure: string | null =
1843
+ scope === 'content' ? (contentFailures[0] ?? null) : (listing.failure ?? null);
1844
+ const filtered = useMemo(() => {
1845
+ const query = searchQuery.toLowerCase();
1846
+ const visible = browserEntries.filter((item) =>
1847
+ browserEntryMatches(item, { query, scope, facets, filter, shownPath }),
1848
+ );
1849
+ const effectiveSort = scope === 'files' && sort === 'relevance' ? 'name' : sort;
1850
+ return [...visible].sort((left, right) => compareAssetEntries(left, right, effectiveSort));
1851
+ }, [browserEntries, facets, shownPath, filter, scope, searchQuery, sort]);
1852
+ // The Content chips are the kinds this project HOLDS (ARCHITECTURE-CORE
1853
+ // §The project model: chrome from what is declared, never a fixed list).
1854
+ const availableFacets = useMemo(() => {
1855
+ const present = new Set<ProjectContentFacet>();
1856
+ for (const item of browserEntries) present.add(browserEntryFacet(item));
1857
+ return present;
1858
+ }, [browserEntries]);
1859
+ const resultSections = useMemo(
1860
+ () => groupContentResults(filtered, scope, facets),
1861
+ [facets, filtered, scope],
1862
+ );
1863
+
1864
+ // --- Drag start ---
1865
+ const onDragStart = useCallback(
1866
+ (e: React.DragEvent, item: BrowserEntry) => {
1867
+ if (item.source === 'component') {
1868
+ // `component` IS the adapter's `AssetDropContext.item`, discriminator
1869
+ // included: every drop consumer spreads it as the item, and without
1870
+ // `kind: 'component'` the adapter read a prefab drag as a model path
1871
+ // and refused it ("is not a .glb/.gltf model" — runhuman pass 45).
1872
+ const payload = {
1873
+ path: item.path,
1874
+ kind: 'component',
1875
+ name: item.name,
1876
+ component: {
1877
+ kind: 'component' as const,
1878
+ name: item.component.name,
1879
+ sourcePath: item.component.path,
1880
+ exportKind: item.component.defaultExport ? 'default' : 'named',
1881
+ surface: item.component.surface,
1882
+ },
1883
+ };
1884
+ e.dataTransfer.setData('text/plain', item.name);
1885
+ e.dataTransfer.setData('application/x-editor-asset', JSON.stringify(payload));
1886
+ return;
1887
+ }
1888
+ if (item.source === 'document' || item.source === 'available') {
1889
+ // A found document has no bytes to drop or size to read; its path is
1890
+ // the whole payload, and selecting it selects its source.
1891
+ if ('dataTransfer' in e) {
1892
+ e.dataTransfer.setData('text/plain', item.path);
1893
+ return;
1894
+ }
1895
+ return;
1896
+ }
1897
+ const { entry } = item;
1898
+ const capability = assetCapabilities(entry.name);
1899
+ // A REFERENCE IS NOT A GAME ASSET. It is draggable so the refusal can
1900
+ // speak (a non-draggable card fires no dragstart at all) and says the one
1901
+ // thing the author needs: this file does not ship.
1902
+ if (item.root === REFERENCE_ROOT) {
1903
+ e.preventDefault();
1904
+ showTransientHint(`${entry.name} is reference material. ${REFERENCE_READ_ONLY_HINT}`);
1905
+ return;
1906
+ }
1907
+ // A SPRITE-SHEET FRAME IS NOT A FILE TO DROP. The Images facet lists
1908
+ // every frame of the project's atlases beside real image files; a frame
1909
+ // dragged into the 2D scene did nothing and said nothing ("not able to
1910
+ // drag an image onto the 2D scene view… part B did not work" — runhuman
1911
+ // pass 136). The frame is placed through the component its sheet
1912
+ // belongs to — a Prefab — so the drag refuses and says that.
1913
+ if (item.spritesheet) {
1914
+ e.preventDefault();
1915
+ showTransientHint(
1916
+ `${entry.name} is one frame of the ${item.spritesheet.sheetPath.split('/').pop() ?? 'sprite sheet'} ` +
1917
+ 'sprite sheet — it is placed through the Prefab that animates it, not on its own. ' +
1918
+ 'Drop a Prefab, or a standalone image file.',
1919
+ );
1920
+ return;
1921
+ }
1922
+ if (entry.type !== 'file' || !capability.placeable) {
1923
+ e.preventDefault();
1924
+ return;
1925
+ }
1926
+ const kind = assetDocumentKind(capability);
1927
+ if (!kind) return;
1928
+ const path = assetScenePath(currentRoot, parentAssetPath(item.path), entry.name);
1929
+ const payload = { path, kind, name: entry.name };
1930
+ // Set text/plain first — Chromium silently cancels drags that only
1931
+ // carry custom MIME types (no ghost, no drag/dragend events).
1932
+ e.dataTransfer.setData('text/plain', entry.name);
1933
+ e.dataTransfer.setData('application/x-editor-asset', JSON.stringify(payload));
1934
+ },
1935
+ [currentRoot],
1936
+ );
1937
+
1938
+ // --- Single-click: §5.1 SELECTION (W2) — compact preview/metadata in the
1939
+ // Inspector via `asset-selection.ts`; never opens anything. ---
1940
+ const onSelectEntry = useCallback(
1941
+ (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => {
1942
+ let next = new Set<string>();
1943
+ if (event.shiftKey && selectionAnchor) {
1944
+ const anchorIndex = filtered.findIndex((entry) => entry.key === selectionAnchor);
1945
+ const itemIndex = filtered.findIndex((entry) => entry.key === item.key);
1946
+ if (anchorIndex >= 0 && itemIndex >= 0) {
1947
+ const [start, end] =
1948
+ anchorIndex < itemIndex ? [anchorIndex, itemIndex] : [itemIndex, anchorIndex];
1949
+ next = new Set(filtered.slice(start, end + 1).map((entry) => entry.key));
1950
+ }
1951
+ } else if (event.metaKey || event.ctrlKey) {
1952
+ next = new Set(selectedFiles);
1953
+ if (next.has(item.key)) next.delete(item.key);
1954
+ else next.add(item.key);
1955
+ setSelectionAnchor(item.key);
1956
+ } else {
1957
+ next.add(item.key);
1958
+ setSelectionAnchor(item.key);
1959
+ }
1960
+ setSelectedFiles(next);
1961
+ if (item.source === 'component') {
1962
+ if (item.component.contentKind === 'image' && item.authoringAsset) {
1963
+ setSelectedAsset({
1964
+ path: item.authoringAsset.subject.sourcePath ?? item.path,
1965
+ name: item.name,
1966
+ kind: 'image',
1967
+ capabilities: assetCapabilities('svg'),
1968
+ health: 'healthy',
1969
+ selectionCount: next.size,
1970
+ });
1971
+ return;
1972
+ }
1973
+ setSelectedAsset({
1974
+ path: item.path,
1975
+ name: item.name,
1976
+ kind: 'component',
1977
+ health: 'healthy',
1978
+ selectionCount: next.size,
1979
+ componentPreview: {
1980
+ surface: item.component.surface,
1981
+ sourcePath: item.component.path,
1982
+ },
1983
+ });
1984
+ return;
1985
+ }
1986
+ if (item.source === 'document' || item.source === 'available') {
1987
+ // A found document selects its SOURCE: the kind's own asset inspector
1988
+ // (the mesh capability's, for a model) takes it from there.
1989
+ setSelectedAsset({
1990
+ path: `/${item.path}`,
1991
+ name: item.name,
1992
+ kind: 'source',
1993
+ capabilities: assetCapabilities(item.name),
1994
+ origin: 'project',
1995
+ health: 'healthy',
1996
+ selectionCount: next.size,
1997
+ });
1998
+ return;
1999
+ }
2000
+ const { entry } = item;
2001
+ if (entry.type === 'directory') {
2002
+ clearSelectedAsset();
2003
+ return;
2004
+ }
2005
+ const capability = assetCapabilities(entry.name);
2006
+ const kind = assetDocumentKind(capability) ?? 'unknown';
2007
+ const servingUrl = assetRootServingUrl(item.root, item.path);
2008
+ setSelectedAsset({
2009
+ path: servingUrl,
2010
+ name: entry.name,
2011
+ kind,
2012
+ ...(entry.size === undefined ? {} : { sizeBytes: entry.size }),
2013
+ capabilities: capability,
2014
+ health: projectAssetHealth(entry.name),
2015
+ selectionCount: next.size,
2016
+ ...(item.path.startsWith('asset-library/local')
2017
+ ? { sourcePath: 'Asset Library provenance metadata' }
2018
+ : {}),
2019
+ });
2020
+ // Health inspection reads the SHIPPED tree through the public-rooted
2021
+ // storage backend; reference material has no runtime health question to
2022
+ // answer, so its row keeps the measurement above.
2023
+ if (item.root !== 'public') return;
2024
+ const path = item.path;
2025
+ void inspectProjectAssetHealthFromStorage(getStorageBackend(), path)
2026
+ .then((inspection) => {
2027
+ const servingPath = servingUrl;
2028
+ if (getSelectedAsset()?.path !== servingPath) return;
2029
+ setSelectedAsset({
2030
+ path: servingPath,
2031
+ name: entry.name,
2032
+ kind,
2033
+ ...(entry.size === undefined ? {} : { sizeBytes: entry.size }),
2034
+ capabilities: capability,
2035
+ health:
2036
+ inspection.health.status === 'healthy'
2037
+ ? 'healthy'
2038
+ : capability.runtimeReady
2039
+ ? 'unsupported'
2040
+ : 'source-only',
2041
+ healthCodes: inspection.health.codes,
2042
+ dependencies: inspection.dependencies,
2043
+ references: inspection.references,
2044
+ selectionCount: next.size,
2045
+ });
2046
+ })
2047
+ .catch(() => {});
2048
+ },
2049
+ [filtered, selectedFiles, selectionAnchor],
2050
+ );
2051
+
2052
+ // --- Double-click / Enter: §5.1 OPEN — the full viewer as a CENTER
2053
+ // workspace document (W2; was a right-rail asset tab pre-W2). ---
2054
+ const onDoubleClick = useCallback(
2055
+ (item: BrowserEntry) => {
2056
+ if (item.source === 'component') {
2057
+ if (item.authoringAsset) {
2058
+ openAuthoringAssetDocument(store, item.authoringAsset.id, item.authoringAsset.subject);
2059
+ return;
2060
+ }
2061
+ // Double-click OPENS — the same promise every other row in this panel
2062
+ // makes. What the component opens AS is the source's answer, not the
2063
+ // host's (a `three` prefab's turntable, an isolated story document,
2064
+ // whatever a later source brings).
2065
+ item.contentSource.open(store, item.contentEntry, item.name);
2066
+ return;
2067
+ }
2068
+ if (item.source === 'available') {
2069
+ openAvailableWorkspaceDocument(item.available.descriptor.id);
2070
+ return;
2071
+ }
2072
+ if (item.source === 'document') {
2073
+ if (item.document.kind === 'scene') {
2074
+ const result = openSceneTableEntry(store, item.document.id);
2075
+ if (!result.ok) editorConsole.error(result.error, 'scene');
2076
+ return;
2077
+ }
2078
+ // The document opens in the editor registered for its KIND (a model
2079
+ // in the mesh editor); only a kind nothing edits opens as its source.
2080
+ if (!openKindDocument(store, item.document)) {
2081
+ openAssetDocument(store, `/${item.path}`, uneditedKindAssetKind(item.document));
2082
+ }
2083
+ return;
2084
+ }
2085
+ openProjectAssetEntry(item, {
2086
+ currentRoot,
2087
+ currentPath: item.source === 'asset' ? parentAssetPath(item.path) : '',
2088
+ navigate,
2089
+ openAsset: (url, kind) => openAssetDocument(store, url, kind),
2090
+ });
2091
+ },
2092
+ [currentRoot, navigate, store],
2093
+ );
2094
+
2095
+ // --- Refresh ---
2096
+ const onRefresh = useCallback(
2097
+ (changedPaths?: readonly string[]) => {
2098
+ setTreeRevision((revision) => revision + 1);
2099
+ cacheRef.current.clear();
2100
+ // Folder previews share the refresh triggers of the entry cache — never
2101
+ // per-render. Scope invalidation to the mutated subtrees when the change
2102
+ // event names them, drop everything otherwise (manual refresh).
2103
+ if (changedPaths?.length) {
2104
+ for (const changed of changedPaths) {
2105
+ invalidateFolderPreviews(changed);
2106
+ }
2107
+ } else {
2108
+ invalidateFolderPreviews();
2109
+ }
2110
+ setPreviewRevision((revision) => revision + 1);
2111
+ fetchEntries(currentRoot, currentPath);
2112
+ if (scope === 'content') void fetchContent();
2113
+ },
2114
+ [currentRoot, currentPath, fetchContent, fetchEntries, scope],
2115
+ );
2116
+
2117
+ // Auto-refresh when assets change (e.g. file move detected by server).
2118
+ // Some emitters name the mutated paths in `detail.paths` (public-relative,
2119
+ // e.g. `foo/bar.png` — see `projectAssetPath` in asset-editor-persistence);
2120
+ // those scope the folder-preview invalidation. No prefix stripping: a
2121
+ // top-level folder literally named `public` must not be mis-scoped.
2122
+ useEffect(() => {
2123
+ const handler = (event: Event) =>
2124
+ onRefresh((event as CustomEvent<{ paths?: string[] }>).detail?.paths);
2125
+ window.addEventListener('editor:assets-changed', handler);
2126
+ return () => window.removeEventListener('editor:assets-changed', handler);
2127
+ }, [onRefresh]);
2128
+
2129
+ useEffect(() => {
2130
+ const handler = (event: Event) => {
2131
+ const path = (event as CustomEvent<{ path?: string }>).detail?.path;
2132
+ if (!path) return;
2133
+ const capability = assetCapabilities(path);
2134
+ const kind = assetDocumentKind(capability);
2135
+ if (!kind) return;
2136
+ openAssetDocument(store, path, kind);
2137
+ };
2138
+ window.addEventListener('editor:open-project-asset', handler);
2139
+ return () => window.removeEventListener('editor:open-project-asset', handler);
2140
+ }, [store]);
2141
+
2142
+ const selectedItems = useMemo(
2143
+ () => browserEntries.filter((item) => selectedFiles.has(item.key)),
2144
+ [browserEntries, selectedFiles],
2145
+ );
2146
+ /** Public-relative paths of the selected FILES — the subjects of every
2147
+ * mutating operation, which is why reference rows are not among them. */
2148
+ const selectedAssetPaths = useMemo(
2149
+ () =>
2150
+ selectedItems.flatMap((item) =>
2151
+ item.source === 'asset' && item.entry.type === 'file' && item.root === 'public'
2152
+ ? [item.path]
2153
+ : [],
2154
+ ),
2155
+ [selectedItems],
2156
+ );
2157
+ /** Project-relative paths of every selected file, reference rows included:
2158
+ * copy-path and reveal-in-finder are about where the file IS. */
2159
+ const selectedProjectPaths = useMemo(
2160
+ () =>
2161
+ selectedItems.flatMap((item) =>
2162
+ item.source === 'asset' && item.entry.type === 'file'
2163
+ ? [projectAssetPath(item.root, item.path)]
2164
+ : [],
2165
+ ),
2166
+ [selectedItems],
2167
+ );
2168
+
2169
+ useEffect(() => {
2170
+ const available = new Set(browserEntries.map((item) => item.key));
2171
+ setSelectedFiles((current) => {
2172
+ const next = new Set([...current].filter((key) => available.has(key)));
2173
+ if (current.size > 0 && next.size === 0) {
2174
+ setSelectionAnchor(null);
2175
+ clearSelectedAsset();
2176
+ }
2177
+ return next.size === current.size ? current : next;
2178
+ });
2179
+ }, [browserEntries]);
2180
+
2181
+ const finishOperation = useCallback(
2182
+ async (operation: Parameters<typeof executeProjectAssetOperation>[0]) => {
2183
+ await executeProjectAssetOperation(operation, {
2184
+ backend: getStorageBackend(),
2185
+ history,
2186
+ });
2187
+ cacheRef.current.clear();
2188
+ if (currentRoot === 'public') await fetchEntries(currentRoot, currentPath);
2189
+ if (scope === 'content') await fetchContent();
2190
+ },
2191
+ [currentPath, currentRoot, fetchContent, fetchEntries, history, scope],
2192
+ );
2193
+
2194
+ const deleteSelected = useCallback(async () => {
2195
+ if (selectedAssetPaths.length === 0) return;
2196
+ if (
2197
+ !confirmAssetAction(
2198
+ 'delete',
2199
+ `Delete ${selectedAssetPaths.length} selected asset(s)? This is undoable.`,
2200
+ )
2201
+ )
2202
+ return;
2203
+ await finishOperation({ type: 'delete', paths: selectedAssetPaths });
2204
+ setSelectedFiles(new Set());
2205
+ setSelectionAnchor(null);
2206
+ clearSelectedAsset();
2207
+ }, [finishOperation, selectedAssetPaths]);
2208
+
2209
+ const moveSelected = useCallback(() => {
2210
+ if (selectedAssetPaths.length === 0) return;
2211
+ askFor({
2212
+ title: `Move ${selectedAssetPaths.length} selected to folder`,
2213
+ initial: currentPath,
2214
+ confirmLabel: 'Move',
2215
+ run: async (destinationFolder) => {
2216
+ const folder = destinationFolder.replace(/^\/+|\/+$/g, '');
2217
+ setOperationError(null);
2218
+ try {
2219
+ await finishOperation({
2220
+ type: 'move-many',
2221
+ moves: selectedAssetPaths.map((source) => ({
2222
+ source,
2223
+ destination: folder
2224
+ ? `${folder}/${source.split('/').pop()}`
2225
+ : source.split('/').pop()!,
2226
+ })),
2227
+ });
2228
+ } catch (error) {
2229
+ setOperationError(error instanceof Error ? error.message : String(error));
2230
+ }
2231
+ },
2232
+ });
2233
+ }, [askFor, currentPath, finishOperation, selectedAssetPaths]);
2234
+
2235
+ const createFolder = useCallback(() => {
2236
+ if (currentRoot !== 'public') return;
2237
+ askFor({
2238
+ title: 'New folder name',
2239
+ initial: '',
2240
+ confirmLabel: 'Create',
2241
+ run: async (name) => {
2242
+ if (!name) return;
2243
+ await finishOperation({
2244
+ type: 'create-folder',
2245
+ path: currentPath ? `${currentPath}/${name}` : name,
2246
+ });
2247
+ },
2248
+ });
2249
+ }, [askFor, currentPath, currentRoot, finishOperation]);
2250
+
2251
+ /**
2252
+ * IMPORT MUST END SOMEWHERE THE USER CAN SEE, or say why it did not.
2253
+ *
2254
+ * A tester picked images through this panel's own Import files action,
2255
+ * repeatedly, and nothing appeared anywhere — no file, no error, no hint
2256
+ * (runhuman pass 74: "it's not accepting any type of image... nothing
2257
+ * happens"). Two silences caused that: a throw from the operation went
2258
+ * nowhere, and a file imported while the CONTENT tab is showing lands in the
2259
+ * project's files, which that tab does not list — so a successful import was
2260
+ * invisible too. Now failures land in the panel's own error row, and success
2261
+ * switches to Files at the destination folder and names what arrived.
2262
+ */
2263
+ const importFiles = useCallback(
2264
+ async (files: FileList | null) => {
2265
+ if (!files || files.length === 0) return;
2266
+ // Import writes SHIPPED assets. From Content (no folder) or from a
2267
+ // non-public root (reference material, which nothing here writes), it
2268
+ // lands at the top of public/ rather than at a path in another root.
2269
+ const destinationFolder = scope === 'content' || currentRoot !== 'public' ? '' : currentPath;
2270
+ const imported = await Promise.all(
2271
+ [...files].map(async (file) => ({
2272
+ path: destinationFolder ? `${destinationFolder}/${file.name}` : file.name,
2273
+ bytes: new Uint8Array(await file.arrayBuffer()),
2274
+ })),
2275
+ );
2276
+ setOperationError(null);
2277
+ try {
2278
+ await finishOperation({ type: 'import', files: imported });
2279
+ } catch (error) {
2280
+ setOperationError(
2281
+ `Could not import ${imported.length === 1 ? imported[0]?.path : `${imported.length} files`}: ${
2282
+ error instanceof Error ? error.message : String(error)
2283
+ }`,
2284
+ );
2285
+ return;
2286
+ }
2287
+ if (scope === 'content') changeScope('files');
2288
+ navigate('public', destinationFolder);
2289
+ showTransientHint(
2290
+ imported.length === 1
2291
+ ? `Imported ${imported[0]?.path}`
2292
+ : `Imported ${imported.length} files`,
2293
+ );
2294
+ },
2295
+ [changeScope, currentPath, currentRoot, finishOperation, navigate, scope],
2296
+ );
2297
+
2298
+ const renameItem = useCallback(
2299
+ (item: BrowserEntry) => {
2300
+ if (item.source !== 'asset' || item.entry.type !== 'file') return;
2301
+ if (item.root !== 'public') {
2302
+ showTransientHint(`${item.name} is reference material. ${REFERENCE_READ_ONLY_HINT}`);
2303
+ return;
2304
+ }
2305
+ askFor({
2306
+ title: `Rename ${item.name}`,
2307
+ initial: item.name,
2308
+ confirmLabel: 'Rename',
2309
+ run: async (nextName) => {
2310
+ if (!nextName || nextName === item.name) return;
2311
+ const source = item.path;
2312
+ const parent = parentAssetPath(item.path);
2313
+ const destination = parent ? `${parent}/${nextName}` : nextName;
2314
+ await finishOperation({ type: 'move', source, destination });
2315
+ },
2316
+ });
2317
+ },
2318
+ [askFor, finishOperation],
2319
+ );
2320
+
2321
+ const duplicateItem = useCallback(
2322
+ async (item: BrowserEntry) => {
2323
+ if (item.source !== 'asset' || item.entry.type !== 'file') return;
2324
+ if (item.root !== 'public') {
2325
+ showTransientHint(`${item.name} is reference material. ${REFERENCE_READ_ONLY_HINT}`);
2326
+ return;
2327
+ }
2328
+ const dot = item.name.lastIndexOf('.');
2329
+ const copyName =
2330
+ dot > 0 ? `${item.name.slice(0, dot)} copy${item.name.slice(dot)}` : `${item.name} copy`;
2331
+ const source = item.path;
2332
+ const parent = parentAssetPath(item.path);
2333
+ const destination = parent ? `${parent}/${copyName}` : copyName;
2334
+ await finishOperation({ type: 'duplicate', source, destination });
2335
+ },
2336
+ [finishOperation],
2337
+ );
2338
+
2339
+ const toggleExpanded = useCallback((path: string) => {
2340
+ setExpanded((current) => {
2341
+ const next = new Set(current);
2342
+ if (next.has(path)) next.delete(path);
2343
+ else next.add(path);
2344
+ return next;
2345
+ });
2346
+ }, []);
2347
+
2348
+ useEffect(() => {
2349
+ const handler = (event: Event) => {
2350
+ const id = (event as CustomEvent<{ id?: string }>).detail?.id;
2351
+ const item = selectedItems[0];
2352
+ if (id === PROJECT_ASSET_COMMANDS.refresh.id) onRefresh();
2353
+ else if (id === PROJECT_ASSET_COMMANDS.import.id) importInputRef.current?.click();
2354
+ else if (id === PROJECT_ASSET_COMMANDS.createFolder.id) void createFolder();
2355
+ else if (id === PROJECT_ASSET_COMMANDS.move.id) void moveSelected();
2356
+ else if (id === PROJECT_ASSET_COMMANDS.delete.id) void deleteSelected();
2357
+ else if (id === PROJECT_ASSET_COMMANDS.rename.id && item) void renameItem(item);
2358
+ else if (id === PROJECT_ASSET_COMMANDS.duplicate.id && item) void duplicateItem(item);
2359
+ else if (id === PROJECT_ASSET_COMMANDS.copyPath.id && selectedProjectPaths[0])
2360
+ void navigator.clipboard.writeText(selectedProjectPaths[0]);
2361
+ else if (id === PROJECT_ASSET_COMMANDS.reveal.id && selectedProjectPaths[0])
2362
+ void revealInFinder(selectedProjectPaths[0]);
2363
+ };
2364
+ window.addEventListener('editor:project-asset-command', handler);
2365
+ return () => window.removeEventListener('editor:project-asset-command', handler);
2366
+ }, [
2367
+ createFolder,
2368
+ currentPath,
2369
+ deleteSelected,
2370
+ duplicateItem,
2371
+ moveSelected,
2372
+ onRefresh,
2373
+ renameItem,
2374
+ selectedItems,
2375
+ selectedProjectPaths,
2376
+ ]);
2377
+
2378
+ const previewItem =
2379
+ contextMenu?.item.source === 'available' && contextMenu.item.available.descriptor.preview
2380
+ ? contextMenu.item
2381
+ : null;
2382
+
2383
+ return (
2384
+ <div
2385
+ ref={browserRef}
2386
+ className="vgai-asset-browser"
2387
+ data-tree-visible={scope === 'files' && treeVisible}
2388
+ onPointerDown={() => setActiveScope('asset-browser')}
2389
+ onKeyDown={(event) => {
2390
+ if (isEditableTarget(event.target)) return;
2391
+ if (event.key === 'Delete' || event.key === 'Backspace') {
2392
+ void deleteSelected();
2393
+ event.preventDefault();
2394
+ } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
2395
+ const item = selectedItems[0];
2396
+ if (item) void duplicateItem(item);
2397
+ event.preventDefault();
2398
+ } else if (event.key === 'F2') {
2399
+ const item = selectedItems[0];
2400
+ if (item) void renameItem(item);
2401
+ event.preventDefault();
2402
+ }
2403
+ }}
2404
+ // DROP FILES FROM THE DESKTOP. Import existed only behind a toolbar
2405
+ // button and a hidden <input type=file>; dragging an image in from the
2406
+ // OS — the first thing anyone tries — did nothing at all. The drop lands
2407
+ // in the same `importFiles` the button uses, so the write path, the
2408
+ // destination folder and the refusals are identical.
2409
+ onDragOver={(event) => {
2410
+ if (currentRoot !== 'public' || !event.dataTransfer.types.includes('Files')) return;
2411
+ event.preventDefault();
2412
+ event.dataTransfer.dropEffect = 'copy';
2413
+ if (!fileDropActive) setFileDropActive(true);
2414
+ }}
2415
+ onDragLeave={(event) => {
2416
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
2417
+ setFileDropActive(false);
2418
+ }}
2419
+ onDrop={(event) => {
2420
+ if (currentRoot !== 'public' || !event.dataTransfer.types.includes('Files')) return;
2421
+ event.preventDefault();
2422
+ setFileDropActive(false);
2423
+ void importFiles(event.dataTransfer.files);
2424
+ }}
2425
+ style={{
2426
+ display: 'flex',
2427
+ flex: 1,
2428
+ minHeight: 0,
2429
+ position: 'relative',
2430
+ ...(fileDropActive
2431
+ ? { outline: `2px dashed ${themeVars.accent.default}`, outlineOffset: -2 }
2432
+ : {}),
2433
+ }}
2434
+ >
2435
+ <ProjectAssetTree
2436
+ currentRoot={currentRoot}
2437
+ currentPath={currentPath}
2438
+ expanded={expanded}
2439
+ hidden={scope !== 'files' || !treeVisible}
2440
+ revision={treeRevision}
2441
+ loadAssets={services.listAssets}
2442
+ onExpanded={toggleExpanded}
2443
+ onNavigate={navigate}
2444
+ />
2445
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
2446
+ <TextInput
2447
+ ref={importInputRef}
2448
+ type="file"
2449
+ multiple
2450
+ aria-label="Import project assets"
2451
+ hidden
2452
+ onChange={(event) => {
2453
+ void importFiles(event.target.files);
2454
+ event.currentTarget.value = '';
2455
+ }}
2456
+ />
2457
+ <AssetBrowserToolbar
2458
+ availableFacets={availableFacets}
2459
+ scope={scope}
2460
+ facets={facets}
2461
+ currentPath={currentPath}
2462
+ currentRoot={currentRoot}
2463
+ searchOpen={searchOpen}
2464
+ searchQuery={searchQuery}
2465
+ viewMode={viewMode}
2466
+ density={density}
2467
+ treeVisible={treeVisible}
2468
+ onScopeChange={changeScope}
2469
+ onFacetToggle={selectFacet}
2470
+ onFacetsClear={() => setFacets([])}
2471
+ onNavigate={navigate}
2472
+ onSearchOpenChange={changeSearchOpen}
2473
+ onSearchQueryChange={setSearchQuery}
2474
+ onViewModeChange={setViewMode}
2475
+ onDensityChange={setDensity}
2476
+ onTreeVisibilityToggle={toggleTreeVisibility}
2477
+ onCreateFolder={() => void createFolder()}
2478
+ onImport={() => importInputRef.current?.click()}
2479
+ onDelete={() => void deleteSelected()}
2480
+ deleteDisabled={selectedAssetPaths.length === 0}
2481
+ onRefresh={onRefresh}
2482
+ generationTools={generationTools}
2483
+ onGenerate={() => {
2484
+ // The `generation` VIEW ADDRESS (`EditorView['document']`), served
2485
+ // by whichever package registered an opener for it; nothing
2486
+ // registered means nothing opens, and the button is only offered
2487
+ // when a generation tool contribution exists to feed it.
2488
+ openRegisteredDocument('generation', store, { kind: 'generation', id: 'create' });
2489
+ }}
2490
+ />
2491
+
2492
+ {operationError && (
2493
+ <div role="alert" className="vgai-asset-operation-error">
2494
+ {operationError}
2495
+ </div>
2496
+ )}
2497
+ {prompt && (
2498
+ <form
2499
+ className="vgai-asset-inline-prompt"
2500
+ style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px' }}
2501
+ onSubmit={(event) => {
2502
+ event.preventDefault();
2503
+ const question = prompt;
2504
+ setPrompt(null);
2505
+ void question.run(promptDraft.trim());
2506
+ }}
2507
+ >
2508
+ <span style={{ fontSize: 12 }}>{prompt.title}</span>
2509
+ <TextInput
2510
+ autoFocus
2511
+ aria-label={prompt.title}
2512
+ value={promptDraft}
2513
+ onChange={(event) => setPromptDraft(event.target.value)}
2514
+ onKeyDown={(event) => {
2515
+ if (event.key === 'Escape') setPrompt(null);
2516
+ }}
2517
+ />
2518
+ <Button type="submit" size="compact">
2519
+ {prompt.confirmLabel}
2520
+ </Button>
2521
+ <Button type="button" variant="ghost" size="compact" onClick={() => setPrompt(null)}>
2522
+ Cancel
2523
+ </Button>
2524
+ </form>
2525
+ )}
2526
+ {selectedAssetPaths.length > 0 && (
2527
+ <EditorToolbar label="Selected asset actions" compact>
2528
+ <span>{selectedAssetPaths.length} selected</span>
2529
+ <Button variant="ghost" size="compact" onClick={() => void moveSelected()}>
2530
+ Move selected
2531
+ </Button>
2532
+ <Button
2533
+ variant="ghost"
2534
+ size="compact"
2535
+ aria-label="Delete selected assets"
2536
+ onClick={() => void deleteSelected()}
2537
+ >
2538
+ Delete
2539
+ </Button>
2540
+ </EditorToolbar>
2541
+ )}
2542
+
2543
+ {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
2544
+ <div
2545
+ className="vgai-project-assets-results"
2546
+ data-view={viewMode}
2547
+ data-testid="project-asset-results"
2548
+ role="listbox"
2549
+ aria-label={scope === 'content' ? 'Project content' : 'Project files'}
2550
+ aria-multiselectable="true"
2551
+ style={{
2552
+ flex: 1,
2553
+ overflowY: 'auto',
2554
+ padding: 8,
2555
+ display: viewMode === 'list' ? 'flex' : 'grid',
2556
+ gridTemplateColumns:
2557
+ viewMode === 'grid'
2558
+ ? `repeat(auto-fill, minmax(${Math.round(density * 0.75)}px, 1fr))`
2559
+ : undefined,
2560
+ gridAutoRows: viewMode === 'list' ? undefined : 'min-content',
2561
+ flexDirection: viewMode === 'list' ? 'column' : undefined,
2562
+ gap: viewMode === 'list' ? 1 : 6,
2563
+ alignContent: 'start',
2564
+ }}
2565
+ >
2566
+ {resultSections.map((section) => (
2567
+ <div
2568
+ key={section.id}
2569
+ role="group"
2570
+ aria-label={section.label ?? undefined}
2571
+ className="vgai-content-result-section"
2572
+ >
2573
+ {section.label && (
2574
+ <div
2575
+ role="presentation"
2576
+ className="vgai-content-section-heading"
2577
+ data-level={section.headingLevel ?? undefined}
2578
+ data-testid={`content-section-${section.id}`}
2579
+ >
2580
+ <span>{section.label}</span>
2581
+ </div>
2582
+ )}
2583
+ {section.entries.map((item) => (
2584
+ <BrowserEntryCard
2585
+ key={item.key}
2586
+ item={item}
2587
+ selected={selectedFiles.has(item.key)}
2588
+ onDragStart={onDragStart}
2589
+ onSelect={onSelectEntry}
2590
+ onOpen={onDoubleClick}
2591
+ onContextMenu={(event, selectedItem) => {
2592
+ event.preventDefault();
2593
+ if (selectedItem.source === 'component') return;
2594
+ if (!selectedFiles.has(selectedItem.key)) {
2595
+ onSelectEntry(selectedItem, event);
2596
+ }
2597
+ setContextMenu({ x: event.clientX, y: event.clientY, item: selectedItem });
2598
+ }}
2599
+ viewMode={viewMode}
2600
+ previewRevision={previewRevision}
2601
+ showPath={scope === 'content'}
2602
+ />
2603
+ ))}
2604
+ </div>
2605
+ ))}
2606
+ {filtered.length === 0 && (
2607
+ <StateSurface
2608
+ compact
2609
+ style={{ gridColumn: '1 / -1' }}
2610
+ icon={
2611
+ <EditorIcon
2612
+ icon={
2613
+ listingFailure
2614
+ ? editorIcons.status.warning
2615
+ : searchQuery
2616
+ ? editorIcons.action.search
2617
+ : editorIcons.content.folder
2618
+ }
2619
+ />
2620
+ }
2621
+ // A listing that FAILED is NOT an empty folder, and this is the
2622
+ // surface where the difference matters most: "Empty folder" over
2623
+ // a backend error reads as the author's files being gone.
2624
+ title={
2625
+ contentLoading
2626
+ ? 'Finding project content…'
2627
+ : listingFailure
2628
+ ? 'Listing failed'
2629
+ : searchQuery
2630
+ ? 'No matching content'
2631
+ : scope === 'content'
2632
+ ? 'No content in this type'
2633
+ : 'Empty folder'
2634
+ }
2635
+ description={
2636
+ contentLoading
2637
+ ? 'Scanning components and assets across the project.'
2638
+ : listingFailure
2639
+ ? `Listing failed: ${listingFailure}`
2640
+ : searchQuery
2641
+ ? 'Try a broader search or another content type.'
2642
+ : scope === 'content'
2643
+ ? 'Choose another type or import an asset.'
2644
+ : 'Add files to this folder to see them here.'
2645
+ }
2646
+ />
2647
+ )}
2648
+ </div>
2649
+ </div>
2650
+ {previewItem && contextMenu && (
2651
+ <ThemeRootPortal>
2652
+ <Menu
2653
+ autoFocusFirst
2654
+ className="vgai-asset-context-menu"
2655
+ style={{ left: contextMenu.x, top: contextMenu.y }}
2656
+ onDismiss={() => setContextMenu(null)}
2657
+ >
2658
+ <MenuItem
2659
+ type="button"
2660
+ role="menuitem"
2661
+ onClick={() => {
2662
+ onDoubleClick(previewItem);
2663
+ setContextMenu(null);
2664
+ }}
2665
+ >
2666
+ Open <kbd>Enter</kbd>
2667
+ </MenuItem>
2668
+ {previewItem.available.category === 'scene' && (
2669
+ <>
2670
+ <MenuItem
2671
+ type="button"
2672
+ role="menuitem"
2673
+ onClick={() => {
2674
+ const id = previewItem.available.descriptor.id;
2675
+ setContextMenu(null);
2676
+ void saveDocumentPreviewFraming(id, false)
2677
+ .then(() => setPreviewRevision((value) => value + 1))
2678
+ .catch((cause: unknown) =>
2679
+ setOperationError(cause instanceof Error ? cause.message : String(cause)),
2680
+ );
2681
+ }}
2682
+ >
2683
+ Use current view for preview
2684
+ </MenuItem>
2685
+ <MenuItem
2686
+ type="button"
2687
+ role="menuitem"
2688
+ onClick={() => {
2689
+ const id = previewItem.available.descriptor.id;
2690
+ setContextMenu(null);
2691
+ void saveDocumentPreviewFraming(id, true)
2692
+ .then(() => setPreviewRevision((value) => value + 1))
2693
+ .catch((cause: unknown) =>
2694
+ setOperationError(cause instanceof Error ? cause.message : String(cause)),
2695
+ );
2696
+ }}
2697
+ >
2698
+ Reset preview view
2699
+ </MenuItem>
2700
+ </>
2701
+ )}
2702
+ <MenuItem
2703
+ type="button"
2704
+ role="menuitem"
2705
+ onClick={() => {
2706
+ setContextMenu(null);
2707
+ setPreviewRevision((value) => value + 1);
2708
+ }}
2709
+ >
2710
+ Refresh preview
2711
+ </MenuItem>
2712
+ </Menu>
2713
+ </ThemeRootPortal>
2714
+ )}
2715
+ {contextMenu && contextMenu.item.source === 'asset' && (
2716
+ // Portaled to the theme root (W6): the menu's `position: fixed`
2717
+ // would resolve against a backdrop-filtered floating card and be
2718
+ // clipped by its overflow — see `ThemeRootPortal`'s rationale.
2719
+ <ThemeRootPortal>
2720
+ <Menu
2721
+ autoFocusFirst
2722
+ className="vgai-asset-context-menu"
2723
+ style={{ left: contextMenu.x, top: contextMenu.y }}
2724
+ onDismiss={() => setContextMenu(null)}
2725
+ >
2726
+ <MenuItem
2727
+ type="button"
2728
+ role="menuitem"
2729
+ data-command-id={PROJECT_ASSET_COMMANDS.open.id}
2730
+ onClick={() => {
2731
+ onDoubleClick(contextMenu.item);
2732
+ setContextMenu(null);
2733
+ }}
2734
+ >
2735
+ Open <kbd>Enter</kbd>
2736
+ </MenuItem>
2737
+ <MenuItem
2738
+ type="button"
2739
+ role="menuitem"
2740
+ data-command-id={PROJECT_ASSET_COMMANDS.rename.id}
2741
+ onClick={() => {
2742
+ void renameItem(contextMenu.item);
2743
+ setContextMenu(null);
2744
+ }}
2745
+ >
2746
+ Rename <kbd>F2</kbd>
2747
+ </MenuItem>
2748
+ <MenuItem
2749
+ type="button"
2750
+ role="menuitem"
2751
+ data-command-id={PROJECT_ASSET_COMMANDS.duplicate.id}
2752
+ onClick={() => {
2753
+ void duplicateItem(contextMenu.item);
2754
+ setContextMenu(null);
2755
+ }}
2756
+ >
2757
+ Duplicate <kbd>⌘D</kbd>
2758
+ </MenuItem>
2759
+ <MenuItem
2760
+ type="button"
2761
+ role="menuitem"
2762
+ data-command-id={PROJECT_ASSET_COMMANDS.move.id}
2763
+ disabled={selectedAssetPaths.length === 0}
2764
+ title={selectedAssetPaths.length ? 'Move selected assets' : 'Select a file to move'}
2765
+ onClick={() => {
2766
+ void moveSelected();
2767
+ setContextMenu(null);
2768
+ }}
2769
+ >
2770
+ Move selected…
2771
+ </MenuItem>
2772
+ <MenuItem
2773
+ type="button"
2774
+ role="menuitem"
2775
+ data-command-id={PROJECT_ASSET_COMMANDS.copyPath.id}
2776
+ disabled={!selectedProjectPaths[0]}
2777
+ onClick={() => {
2778
+ if (selectedProjectPaths[0]) {
2779
+ void navigator.clipboard.writeText(selectedProjectPaths[0]);
2780
+ }
2781
+ setContextMenu(null);
2782
+ }}
2783
+ >
2784
+ Copy path
2785
+ </MenuItem>
2786
+ {/* A package's own items on this asset (`@volter/editor-sdk/chrome`,
2787
+ menu `asset`): invoked with the asset's project path. `Open in
2788
+ Asset Budget` is one of them (`@vgai/game`'s
2789
+ `asset-budget-asset.menu.ts`) — it was a built-in row here
2790
+ until the budget became that package's document. */}
2791
+ {contributedMenuItems('asset').map((item) => {
2792
+ const file =
2793
+ contextMenu.item.source === 'asset' && contextMenu.item.entry.type === 'file';
2794
+ const subject =
2795
+ contextMenu.item.source === 'asset'
2796
+ ? { assetPath: projectAssetPath(contextMenu.item.root, contextMenu.item.path) }
2797
+ : {};
2798
+ return (
2799
+ <MenuItem
2800
+ key={item.id}
2801
+ type="button"
2802
+ role="menuitem"
2803
+ data-testid={item.testId}
2804
+ disabled={!file || (item.disabled?.(subject) ?? false)}
2805
+ onClick={() => {
2806
+ void item.execute(subject);
2807
+ setContextMenu(null);
2808
+ }}
2809
+ >
2810
+ {typeof item.label === 'function' ? item.label() : item.label}
2811
+ </MenuItem>
2812
+ );
2813
+ })}
2814
+ <MenuItem
2815
+ type="button"
2816
+ role="menuitem"
2817
+ data-command-id={PROJECT_ASSET_COMMANDS.reveal.id}
2818
+ disabled={!selectedProjectPaths[0]}
2819
+ onClick={() => {
2820
+ if (selectedProjectPaths[0]) void revealInFinder(selectedProjectPaths[0]);
2821
+ setContextMenu(null);
2822
+ }}
2823
+ >
2824
+ Reveal in Finder
2825
+ </MenuItem>
2826
+ <MenuItem
2827
+ type="button"
2828
+ role="menuitem"
2829
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
2830
+ disabled={selectedAssetPaths.length === 0}
2831
+ onClick={() => {
2832
+ void deleteSelected();
2833
+ setContextMenu(null);
2834
+ }}
2835
+ >
2836
+ Delete <kbd>⌫</kbd>
2837
+ </MenuItem>
2838
+ </Menu>
2839
+ </ThemeRootPortal>
2840
+ )}
2841
+ </div>
2842
+ );
2843
+ }