@vgai/editor 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  faArrowLeft,
3
3
  faBoxArchive,
4
+ faChevronRight,
4
5
  faCubes,
5
6
  faEllipsis,
6
7
  faFile,
@@ -12,6 +13,7 @@ import {
12
13
  faPaintbrush,
13
14
  faPuzzlePiece,
14
15
  faImage as faSceneIcon,
16
+ faTable,
15
17
  faTableCellsLarge,
16
18
  } from '@fortawesome/free-solid-svg-icons';
17
19
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -22,6 +24,7 @@ import {
22
24
  assetDocumentKind,
23
25
  } from '../asset-workflow/asset-capabilities';
24
26
  import { confirmAssetAction } from '../asset-workflow/asset-workflow-quality';
27
+ import { importRecipePath } from '../asset-workflow/import-contract';
25
28
  import {
26
29
  PROJECT_ASSET_COMMANDS,
27
30
  projectAssetBrowserStateKey,
@@ -56,6 +59,7 @@ import { getModelThumbnailRenderer, modelThumbnailFormat } from '../model-thumbn
56
59
  import { getCurrentProject } from '../project-manager';
57
60
  import { getStorageBackend } from '../storage';
58
61
  import { openAssetDocument } from './asset-documents';
62
+ import { reimportStoredProjectAssets } from './asset-editor-persistence';
59
63
  import './asset-workflow.css';
60
64
 
61
65
  // --- Helpers ---
@@ -81,16 +85,16 @@ function getAssetKind(entry: AssetEntry): AssetKind {
81
85
  }
82
86
 
83
87
  const assetIconColors: Record<BrowserKind, string> = {
84
- folder: '#e8a740',
85
- model: '#6bc5f7',
86
- scene: '#8b5cf6',
87
- prefab: '#3b82f6',
88
- material: '#e879f9',
89
- audio: '#34d399',
90
- image: '#f472b6',
91
- json: '#fbbf24',
92
- unknown: '#6b7280',
93
- source: '#fb923c',
88
+ folder: 'var(--asset-icon-folder)',
89
+ model: 'var(--asset-icon-model)',
90
+ scene: 'var(--asset-icon-scene)',
91
+ prefab: 'var(--asset-icon-prefab)',
92
+ material: 'var(--asset-icon-material)',
93
+ audio: 'var(--asset-icon-audio)',
94
+ image: 'var(--asset-icon-image)',
95
+ json: 'var(--asset-icon-json)',
96
+ unknown: 'var(--asset-icon-unknown)',
97
+ source: 'var(--asset-icon-source)',
94
98
  };
95
99
 
96
100
  const assetIconMap: Record<BrowserKind, typeof faFolder> = {
@@ -250,6 +254,7 @@ interface PersistedAssetBrowserState {
250
254
  sort: AssetSort;
251
255
  filter: AssetFilter;
252
256
  density: number;
257
+ expanded: string[];
253
258
  }
254
259
 
255
260
  const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
@@ -258,6 +263,7 @@ const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
258
263
  sort: 'name',
259
264
  filter: 'all',
260
265
  density: 96,
266
+ expanded: ['public'],
261
267
  };
262
268
 
263
269
  function readAssetBrowserState(): PersistedAssetBrowserState {
@@ -276,7 +282,7 @@ function readAssetBrowserState(): PersistedAssetBrowserState {
276
282
  // public asset root, so those old locations migrate home once.
277
283
  currentPath:
278
284
  legacyRoot === undefined || legacyRoot === 'public' ? (parsed.currentPath ?? '') : '',
279
- viewMode: legacyViewMode === 'columns' ? 'list' : legacyViewMode === 'list' ? 'list' : 'grid',
285
+ viewMode: legacyViewMode === 'columns' || legacyViewMode === 'list' ? legacyViewMode : 'grid',
280
286
  };
281
287
  } catch {
282
288
  return DEFAULT_BROWSER_STATE;
@@ -290,8 +296,8 @@ interface AssetBrowserProps {
290
296
  }
291
297
 
292
298
  type AssetRoot = 'public';
293
- type AssetViewMode = 'grid' | 'list';
294
- type AssetSort = 'name' | 'type' | 'size' | 'health';
299
+ type AssetViewMode = 'grid' | 'list' | 'columns';
300
+ type AssetSort = 'name' | 'type' | 'size' | 'modified' | 'health';
295
301
  type AssetFilter =
296
302
  | 'all'
297
303
  | 'runtime-ready'
@@ -367,6 +373,8 @@ function compareAssetEntries(
367
373
  if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
368
374
  if (sort === 'size' && left.entry.size !== right.entry.size)
369
375
  return right.entry.size - left.entry.size;
376
+ if (sort === 'modified' && left.entry.modifiedAt !== right.entry.modifiedAt)
377
+ return right.entry.modifiedAt - left.entry.modifiedAt;
370
378
  if (sort === 'type') {
371
379
  const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
372
380
  if (order !== 0) return order;
@@ -468,7 +476,7 @@ function TypedAssetThumbnail({ kind, name }: { kind: BrowserKind; name: string }
468
476
  height: 44,
469
477
  borderRadius: '50%',
470
478
  background: `radial-gradient(circle at 32% 28%, white 0, ${color} 18%, var(--vgai-bg-inset) 72%)`,
471
- boxShadow: 'inset -5px -7px 10px rgb(0 0 0 / 35%)',
479
+ boxShadow: 'var(--vgai-shadow-md)',
472
480
  }}
473
481
  />
474
482
  );
@@ -819,6 +827,16 @@ function AssetBrowserToolbar({
819
827
  <EditorIcon icon={faList} />
820
828
  </IconButton>
821
829
  </Tooltip>
830
+ <Tooltip text="Column view" position="top">
831
+ <IconButton
832
+ aria-label="Column view"
833
+ data-testid="asset-view-columns"
834
+ aria-pressed={viewMode === 'columns'}
835
+ onClick={() => onViewModeChange('columns')}
836
+ >
837
+ <EditorIcon icon={faTable} />
838
+ </IconButton>
839
+ </Tooltip>
822
840
  </ToolbarGroup>
823
841
  <Select
824
842
  className="vgai-select vgai-project-assets-toolbar__select"
@@ -829,6 +847,7 @@ function AssetBrowserToolbar({
829
847
  <option value="name">Name</option>
830
848
  <option value="type">Type</option>
831
849
  <option value="size">Size</option>
850
+ <option value="modified">Modified</option>
832
851
  <option value="health">Health</option>
833
852
  </Select>
834
853
  <Select
@@ -913,6 +932,98 @@ function AssetBrowserToolbar({
913
932
  );
914
933
  }
915
934
 
935
+ function ProjectAssetTree(props: {
936
+ currentPath: string;
937
+ expanded: ReadonlySet<string>;
938
+ revision: number;
939
+ onExpanded(path: string): void;
940
+ onNavigate(path: string): void;
941
+ }) {
942
+ const [folders, setFolders] = useState(new Map<string, string[]>());
943
+
944
+ useEffect(() => {
945
+ setFolders(new Map());
946
+ }, [props.revision]);
947
+
948
+ useEffect(() => {
949
+ for (const key of props.expanded) {
950
+ if (!key.startsWith('public') || folders.has(key)) continue;
951
+ const path = key === 'public' ? '' : key.slice('public/'.length);
952
+ void listAssets('public', path).then((entries) => {
953
+ setFolders((current) => {
954
+ const next = new Map(current);
955
+ next.set(
956
+ key,
957
+ entries.filter((entry) => entry.type === 'directory').map((entry) => entry.name),
958
+ );
959
+ return next;
960
+ });
961
+ });
962
+ }
963
+ }, [folders, props.expanded]);
964
+
965
+ const renderFolder = (path: string, depth: number): React.ReactNode => {
966
+ const key = path ? `public/${path}` : 'public';
967
+ const open = props.expanded.has(key);
968
+ const name = path.split('/').pop() || 'public';
969
+ return (
970
+ <div key={key}>
971
+ <div className="vgai-asset-tree-row" style={{ paddingLeft: 6 + depth * 14 }}>
972
+ <IconButton
973
+ className="vgai-asset-tree-toggle"
974
+ aria-label={`${open ? 'Collapse' : 'Expand'} ${name}`}
975
+ aria-expanded={open}
976
+ onClick={() => props.onExpanded(key)}
977
+ >
978
+ <EditorIcon
979
+ icon={faChevronRight}
980
+ style={{ transform: open ? 'rotate(90deg)' : undefined }}
981
+ />
982
+ </IconButton>
983
+ <Button
984
+ variant="ghost"
985
+ size="compact"
986
+ className="vgai-asset-tree-target"
987
+ aria-current={props.currentPath === path ? 'page' : undefined}
988
+ onClick={() => props.onNavigate(path)}
989
+ >
990
+ <EditorIcon icon={faFolder} />
991
+ {name}
992
+ </Button>
993
+ </div>
994
+ {open &&
995
+ folders
996
+ .get(key)
997
+ ?.map((child) => renderFolder(path ? `${path}/${child}` : child, depth + 1))}
998
+ </div>
999
+ );
1000
+ };
1001
+
1002
+ return (
1003
+ <aside className="vgai-asset-tree" aria-label="Project asset sources">
1004
+ <div className="vgai-asset-facet-title">Project</div>
1005
+ {renderFolder('', 0)}
1006
+ <div className="vgai-asset-facet-title">References</div>
1007
+ <Button
1008
+ variant="ghost"
1009
+ size="compact"
1010
+ className="vgai-asset-tree-target"
1011
+ onClick={() => props.onNavigate('asset-library/local')}
1012
+ >
1013
+ Imported
1014
+ </Button>
1015
+ <Button
1016
+ variant="ghost"
1017
+ size="compact"
1018
+ className="vgai-asset-tree-target"
1019
+ onClick={() => props.onNavigate('.generated')}
1020
+ >
1021
+ Generated
1022
+ </Button>
1023
+ </aside>
1024
+ );
1025
+ }
1026
+
916
1027
  export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
917
1028
  const store = useEditorStore();
918
1029
  const history = useHistoryService();
@@ -925,8 +1036,12 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
925
1036
  const [density, setDensity] = useState(initial.density);
926
1037
  const [searchOpen, setSearchOpen] = useState(false);
927
1038
  const [entries, setEntries] = useState<AssetEntry[]>([]);
1039
+ const [expanded, setExpanded] = useState(new Set(initial.expanded));
1040
+ const [treeRevision, setTreeRevision] = useState(0);
928
1041
  const [searchQuery, setSearchQuery] = useState('');
929
1042
  const [selectedFiles, setSelectedFiles] = useState(new Set<string>());
1043
+ const [reimportableSelectedPaths, setReimportableSelectedPaths] = useState<string[]>([]);
1044
+ const [operationError, setOperationError] = useState<string | null>(null);
930
1045
  const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
931
1046
  const [contextMenu, setContextMenu] = useState<{
932
1047
  x: number;
@@ -946,9 +1061,10 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
946
1061
  sort,
947
1062
  filter,
948
1063
  density,
1064
+ expanded: [...expanded],
949
1065
  } satisfies PersistedAssetBrowserState),
950
1066
  );
951
- }, [currentPath, density, filter, sort, viewMode]);
1067
+ }, [currentPath, density, expanded, filter, sort, viewMode]);
952
1068
 
953
1069
  const fetchEntries = useCallback(async (root: AssetRoot, dir: string) => {
954
1070
  const key = `${root}:${dir}`;
@@ -1110,6 +1226,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1110
1226
 
1111
1227
  // --- Refresh ---
1112
1228
  const onRefresh = useCallback(() => {
1229
+ setTreeRevision((revision) => revision + 1);
1113
1230
  cacheRef.current.clear();
1114
1231
  fetchEntries(currentRoot, currentPath);
1115
1232
  }, [currentRoot, currentPath, fetchEntries]);
@@ -1146,11 +1263,38 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1146
1263
  ),
1147
1264
  [currentPath, selectedItems],
1148
1265
  );
1266
+ const selectedProjectPaths = useMemo(
1267
+ () => selectedAssetPaths.map((path) => `public/${path}`),
1268
+ [selectedAssetPaths],
1269
+ );
1270
+
1271
+ useEffect(() => {
1272
+ let cancelled = false;
1273
+ void Promise.all(
1274
+ selectedAssetPaths.map(async (path) => ({
1275
+ path,
1276
+ reimportable: await getStorageBackend().exists(importRecipePath(path)),
1277
+ })),
1278
+ ).then((checks) => {
1279
+ if (!cancelled) {
1280
+ setReimportableSelectedPaths(
1281
+ checks.filter((check) => check.reimportable).map((check) => check.path),
1282
+ );
1283
+ }
1284
+ });
1285
+ return () => {
1286
+ cancelled = true;
1287
+ };
1288
+ }, [selectedAssetPaths]);
1149
1289
 
1150
1290
  useEffect(() => {
1151
1291
  const available = new Set(browserEntries.map((item) => item.key));
1152
1292
  setSelectedFiles((current) => {
1153
1293
  const next = new Set([...current].filter((key) => available.has(key)));
1294
+ if (current.size > 0 && next.size === 0) {
1295
+ setSelectionAnchor(null);
1296
+ clearSelectedAsset();
1297
+ }
1154
1298
  return next.size === current.size ? current : next;
1155
1299
  });
1156
1300
  }, [browserEntries]);
@@ -1174,8 +1318,42 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1174
1318
  )
1175
1319
  return;
1176
1320
  await finishOperation({ type: 'delete', paths: selectedAssetPaths });
1321
+ setSelectedFiles(new Set());
1322
+ setSelectionAnchor(null);
1323
+ clearSelectedAsset();
1177
1324
  }, [finishOperation, selectedAssetPaths]);
1178
1325
 
1326
+ const moveSelected = useCallback(async () => {
1327
+ if (selectedAssetPaths.length === 0) return;
1328
+ const destinationFolder = window.prompt('Move selected assets to folder', currentPath);
1329
+ if (destinationFolder === null) return;
1330
+ const folder = destinationFolder.replace(/^\/+|\/+$/g, '');
1331
+ setOperationError(null);
1332
+ try {
1333
+ await finishOperation({
1334
+ type: 'move-many',
1335
+ moves: selectedAssetPaths.map((source) => ({
1336
+ source,
1337
+ destination: folder ? `${folder}/${source.split('/').pop()}` : source.split('/').pop()!,
1338
+ })),
1339
+ });
1340
+ } catch (error) {
1341
+ setOperationError(error instanceof Error ? error.message : String(error));
1342
+ }
1343
+ }, [currentPath, finishOperation, selectedAssetPaths]);
1344
+
1345
+ const reimportSelected = useCallback(async () => {
1346
+ if (reimportableSelectedPaths.length === 0) return;
1347
+ setOperationError(null);
1348
+ try {
1349
+ await reimportStoredProjectAssets(reimportableSelectedPaths);
1350
+ cacheRef.current.clear();
1351
+ await fetchEntries(currentRoot, currentPath);
1352
+ } catch (error) {
1353
+ setOperationError(error instanceof Error ? error.message : String(error));
1354
+ }
1355
+ }, [currentPath, currentRoot, fetchEntries, reimportableSelectedPaths]);
1356
+
1179
1357
  const createFolder = useCallback(async () => {
1180
1358
  if (currentRoot !== 'public') return;
1181
1359
  const name = window.prompt('Folder name');
@@ -1225,6 +1403,15 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1225
1403
  [currentPath, finishOperation],
1226
1404
  );
1227
1405
 
1406
+ const toggleExpanded = useCallback((path: string) => {
1407
+ setExpanded((current) => {
1408
+ const next = new Set(current);
1409
+ if (next.has(path)) next.delete(path);
1410
+ else next.add(path);
1411
+ return next;
1412
+ });
1413
+ }, []);
1414
+
1228
1415
  useEffect(() => {
1229
1416
  const handler = (event: Event) => {
1230
1417
  const id = (event as CustomEvent<{ id?: string }>).detail?.id;
@@ -1232,13 +1419,15 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1232
1419
  if (id === PROJECT_ASSET_COMMANDS.refresh.id) onRefresh();
1233
1420
  else if (id === PROJECT_ASSET_COMMANDS.import.id) importInputRef.current?.click();
1234
1421
  else if (id === PROJECT_ASSET_COMMANDS.createFolder.id) void createFolder();
1422
+ else if (id === PROJECT_ASSET_COMMANDS.move.id) void moveSelected();
1423
+ else if (id === PROJECT_ASSET_COMMANDS.reimport.id) void reimportSelected();
1235
1424
  else if (id === PROJECT_ASSET_COMMANDS.delete.id) void deleteSelected();
1236
1425
  else if (id === PROJECT_ASSET_COMMANDS.rename.id && item) void renameItem(item);
1237
1426
  else if (id === PROJECT_ASSET_COMMANDS.duplicate.id && item) void duplicateItem(item);
1238
- else if (id === PROJECT_ASSET_COMMANDS.copyPath.id && selectedAssetPaths[0])
1239
- void navigator.clipboard.writeText(selectedAssetPaths[0]);
1240
- else if (id === PROJECT_ASSET_COMMANDS.reveal.id)
1241
- void revealInFinder(`public/${currentPath}`);
1427
+ else if (id === PROJECT_ASSET_COMMANDS.copyPath.id && selectedProjectPaths[0])
1428
+ void navigator.clipboard.writeText(selectedProjectPaths[0]);
1429
+ else if (id === PROJECT_ASSET_COMMANDS.reveal.id && selectedProjectPaths[0])
1430
+ void revealInFinder(selectedProjectPaths[0]);
1242
1431
  };
1243
1432
  window.addEventListener('editor:project-asset-command', handler);
1244
1433
  return () => window.removeEventListener('editor:project-asset-command', handler);
@@ -1247,14 +1436,17 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1247
1436
  currentPath,
1248
1437
  deleteSelected,
1249
1438
  duplicateItem,
1439
+ moveSelected,
1250
1440
  onRefresh,
1441
+ reimportSelected,
1251
1442
  renameItem,
1252
- selectedAssetPaths,
1253
1443
  selectedItems,
1444
+ selectedProjectPaths,
1254
1445
  ]);
1255
1446
 
1256
1447
  return (
1257
1448
  <div
1449
+ className="vgai-asset-browser"
1258
1450
  onPointerDown={() => setActiveScope('asset-browser')}
1259
1451
  onKeyDown={(event) => {
1260
1452
  if (isEditableTarget(event.target)) return;
@@ -1273,12 +1465,18 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1273
1465
  }}
1274
1466
  style={{
1275
1467
  display: 'flex',
1276
- flexDirection: 'column',
1277
1468
  flex: 1,
1278
1469
  minHeight: 0,
1279
1470
  position: 'relative',
1280
1471
  }}
1281
1472
  >
1473
+ <ProjectAssetTree
1474
+ currentPath={currentPath}
1475
+ expanded={expanded}
1476
+ revision={treeRevision}
1477
+ onExpanded={toggleExpanded}
1478
+ onNavigate={(path) => navigate('public', path)}
1479
+ />
1282
1480
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
1283
1481
  <TextInput
1284
1482
  ref={importInputRef}
@@ -1313,19 +1511,62 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1313
1511
  onRefresh={onRefresh}
1314
1512
  />
1315
1513
 
1514
+ {operationError && (
1515
+ <div role="alert" className="vgai-asset-operation-error">
1516
+ {operationError}
1517
+ </div>
1518
+ )}
1519
+ {selectedAssetPaths.length > 0 && (
1520
+ <EditorToolbar label="Selected asset actions" compact>
1521
+ <span>{selectedAssetPaths.length} selected</span>
1522
+ <Button variant="ghost" size="compact" onClick={() => void moveSelected()}>
1523
+ Move selected
1524
+ </Button>
1525
+ <Button
1526
+ variant="ghost"
1527
+ size="compact"
1528
+ disabled={reimportableSelectedPaths.length === 0}
1529
+ title={
1530
+ reimportableSelectedPaths.length
1531
+ ? 'Run the saved recipes as one batch'
1532
+ : 'No selected asset has a saved import recipe'
1533
+ }
1534
+ onClick={() => void reimportSelected()}
1535
+ >
1536
+ Reimport selected
1537
+ </Button>
1538
+ <Button
1539
+ variant="ghost"
1540
+ size="compact"
1541
+ aria-label="Delete selected assets"
1542
+ onClick={() => void deleteSelected()}
1543
+ >
1544
+ Delete
1545
+ </Button>
1546
+ </EditorToolbar>
1547
+ )}
1548
+
1316
1549
  {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
1317
1550
  <div
1318
1551
  className="vgai-project-assets-results"
1319
1552
  data-view={viewMode}
1553
+ data-testid="project-asset-results"
1554
+ role="listbox"
1555
+ aria-label="Project assets"
1556
+ aria-multiselectable="true"
1320
1557
  style={{
1321
1558
  flex: 1,
1322
1559
  overflowY: 'auto',
1323
1560
  padding: 8,
1324
- display: viewMode === 'grid' ? 'grid' : 'flex',
1561
+ display: viewMode === 'list' ? 'flex' : 'grid',
1325
1562
  gridTemplateColumns:
1326
- viewMode === 'grid' ? `repeat(auto-fill, minmax(${density}px, 1fr))` : undefined,
1563
+ viewMode === 'grid'
1564
+ ? `repeat(auto-fill, minmax(${density}px, 1fr))`
1565
+ : viewMode === 'columns'
1566
+ ? 'repeat(auto-fill, minmax(190px, 1fr))'
1567
+ : undefined,
1327
1568
  gridAutoRows: viewMode === 'grid' ? 'min-content' : undefined,
1328
- flexDirection: viewMode === 'grid' ? undefined : 'column',
1569
+ flexDirection: viewMode === 'list' ? 'column' : undefined,
1329
1570
  gap: viewMode === 'grid' ? 6 : 1,
1330
1571
  alignContent: 'start',
1331
1572
  }}
@@ -1409,12 +1650,45 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1409
1650
  >
1410
1651
  Duplicate <kbd>⌘D</kbd>
1411
1652
  </MenuItem>
1653
+ <MenuItem
1654
+ type="button"
1655
+ role="menuitem"
1656
+ data-command-id={PROJECT_ASSET_COMMANDS.move.id}
1657
+ disabled={selectedAssetPaths.length === 0}
1658
+ title={selectedAssetPaths.length ? 'Move selected assets' : 'Select a file to move'}
1659
+ onClick={() => {
1660
+ void moveSelected();
1661
+ setContextMenu(null);
1662
+ }}
1663
+ >
1664
+ Move selected…
1665
+ </MenuItem>
1666
+ <MenuItem
1667
+ type="button"
1668
+ role="menuitem"
1669
+ data-command-id={PROJECT_ASSET_COMMANDS.reimport.id}
1670
+ disabled={reimportableSelectedPaths.length === 0}
1671
+ title={
1672
+ reimportableSelectedPaths.length
1673
+ ? 'Run the saved recipes as one batch'
1674
+ : 'No selected asset has a saved import recipe'
1675
+ }
1676
+ onClick={() => {
1677
+ void reimportSelected();
1678
+ setContextMenu(null);
1679
+ }}
1680
+ >
1681
+ Reimport selected
1682
+ </MenuItem>
1412
1683
  <MenuItem
1413
1684
  type="button"
1414
1685
  role="menuitem"
1415
1686
  data-command-id={PROJECT_ASSET_COMMANDS.copyPath.id}
1687
+ disabled={!selectedProjectPaths[0]}
1416
1688
  onClick={() => {
1417
- void navigator.clipboard.writeText(contextMenu.item.name);
1689
+ if (selectedProjectPaths[0]) {
1690
+ void navigator.clipboard.writeText(selectedProjectPaths[0]);
1691
+ }
1418
1692
  setContextMenu(null);
1419
1693
  }}
1420
1694
  >
@@ -1424,8 +1698,9 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1424
1698
  type="button"
1425
1699
  role="menuitem"
1426
1700
  data-command-id={PROJECT_ASSET_COMMANDS.reveal.id}
1701
+ disabled={!selectedProjectPaths[0]}
1427
1702
  onClick={() => {
1428
- void revealInFinder(`public/${currentPath}`);
1703
+ if (selectedProjectPaths[0]) void revealInFinder(selectedProjectPaths[0]);
1429
1704
  setContextMenu(null);
1430
1705
  }}
1431
1706
  >