@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.
- package/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- 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
|
+
}
|