const name = 'mx-res-manager'; import type { AssetPreviewData, AssetTreeNode, CatalogTreeResult, IndexProgress, SelectionRefResult, UnusedAssetsResult, } from '../shared/types.js'; import { fileNameFromUrl } from '../core/asset-tree.js'; import { nextFrame } from '../core/file-util.js'; import { referenceIndex } from '../core/reference-index.js'; import { isPreviewableImage, toPanelFileUrl } from '../shared/asset-style.js'; import { compactMoveItems, dedupeMoveItems, isInvalidMoveTarget, normalizeDbUrl, } from '../shared/organize-move.js'; import type { OrganizeMoveItem } from '../shared/organize-move.js'; import { membershipDedupeArray, membershipFromArray, membershipHas } from '../shared/MembershipSet.js'; import * as fs from 'fs/promises'; import * as path from 'path'; let lastOpenAssetUrl = ''; let suppressAssetEvents = false; const TEXT_PREVIEW_EXT = membershipFromArray([ '.ts', '.js', '.json', '.effect', '.txt', '.mtl', '.prefab', '.scene', '.anim', '.fnt', '.md', '.css', '.html', '.xml', '.yaml', '.yml', ]); function cloneTree(nodes: AssetTreeNode[]): AssetTreeNode[] { return nodes.map((node) => ({ ...node, children: node.children ? cloneTree(node.children) : undefined, })); } function cloneSelectionResult(result: SelectionRefResult): SelectionRefResult { return { asset: result.asset ? { ...result.asset } : null, referencedBy: result.referencedBy.map((item) => ({ ...item })), references: result.references.map((item) => ({ ...item })), referencedByTree: cloneTree(result.referencedByTree), referencesTree: cloneTree(result.referencesTree), indexReady: result.indexReady, indexProgress: result.indexProgress, indexing: result.indexing, catalogIndexing: result.catalogIndexing, }; } function cloneUnusedResult(result: UnusedAssetsResult): UnusedAssetsResult { return { folder: result.folder ? { ...result.folder } : null, scopeLabel: result.scopeLabel, assets: result.assets.map((item) => ({ ...item })), tree: cloneTree(result.tree), total: result.total, indexReady: result.indexReady, indexProgress: result.indexProgress, indexing: result.indexing, catalogIndexing: result.catalogIndexing, }; } function cloneCatalogResult(result: CatalogTreeResult): CatalogTreeResult { return { tree: cloneTree(result.tree), catalogReady: result.catalogReady, indexReady: result.indexReady, indexProgress: result.indexProgress, indexing: result.indexing, catalogIndexing: result.catalogIndexing, }; } function pushIndexProgress(progress: IndexProgress) { try { Editor.Message.send(name, 'index-progress-updated', { ...progress }); } catch { // 面板未打开 } } function pushCatalogToPanel(result: CatalogTreeResult) { try { Editor.Message.send(name, 'catalog-updated', cloneCatalogResult(result)); } catch { // 面板未打开 } } async function onIndexStateChanged(progress: IndexProgress) { pushIndexProgress(progress); if (!progress.catalogIndexing && progress.ready) { pushCatalogToPanel(referenceIndex.getCatalogTree()); } } export const methods = { open() { Editor.Panel.open(name); }, getVersion() { return Editor.App.version; }, async getIndexProgress(): Promise { return referenceIndex.getProgress(); }, async getCatalogTree(): Promise { return cloneCatalogResult(referenceIndex.getCatalogTree()); }, async querySelection(uuid: string): Promise { return cloneSelectionResult(await referenceIndex.querySelection(uuid)); }, async queryUnused(folderUuid: string | null): Promise { return cloneUnusedResult(await referenceIndex.queryUnused(folderUuid)); }, async getAssetPreview(uuid: string): Promise { const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid); if (!info?.uuid) { return null; } const filePath = String(info.file ?? ''); const mainUrl = String(info.url).split('@')[0]; const base = { uuid: String(info.uuid), url: mainUrl, name: fileNameFromUrl(mainUrl), assetType: String(info.type ?? ''), importer: String(info.importer ?? ''), filePath, }; if (isPreviewableImage(base.assetType, base.importer, filePath)) { return { ...base, kind: 'image', imageUrl: toPanelFileUrl(filePath), }; } const ext = path.extname(filePath).toLowerCase(); if (membershipHas(TEXT_PREVIEW_EXT, ext)) { try { const text = await fs.readFile(filePath, 'utf8'); return { ...base, kind: 'text', textContent: text.slice(0, 12000), }; } catch { return { ...base, kind: 'info' }; } } return { ...base, kind: 'info' }; }, async removeReference(recordId: string): Promise { const record = referenceIndex.getRecordById(recordId); if (!record) { return false; } return referenceIndex.removeReference(record); }, async rebuildIndex() { await referenceIndex.rebuild(); pushCatalogToPanel(referenceIndex.getCatalogTree()); }, onIndexProgress(progress: IndexProgress) { void onIndexStateChanged(progress); }, async moveOrganizeItems( items: OrganizeMoveItem[], targetFolderUrl: string, ): Promise<{ moved: number; skipped: number }> { if (!items.length || !targetFolderUrl) { return { moved: 0, skipped: 0 }; } suppressAssetEvents = true; let moved = 0; let skipped = 0; const target = normalizeDbUrl(targetFolderUrl); try { const compacted = compactMoveItems(dedupeMoveItems(items)); for (const item of compacted) { if (isInvalidMoveTarget(item, target)) { skipped++; continue; } const result = await Editor.Message.request('asset-db', 'move-asset', item.url, target); if (result) { moved++; } else { skipped++; } } if (moved > 0) { await referenceIndex.rebuild(); pushCatalogToPanel(referenceIndex.getCatalogTree()); } } finally { suppressAssetEvents = false; } return { moved, skipped }; }, async deleteAssets(uuids: string[]): Promise<{ deleted: number; failed: number }> { if (!uuids.length) { return { deleted: 0, failed: 0 }; } suppressAssetEvents = true; let deleted = 0; let failed = 0; const removedItems: Array<{ uuid: string; subAssetUuids: string[]; filePath?: string }> = []; try { Editor.Selection.clear('asset'); const uniqueUuids = membershipDedupeArray(uuids); for (const uuid of uniqueUuids) { const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid); if (!info?.url || info.isDirectory) { failed++; continue; } const mainUrl = String(info.url).split('@')[0]; const subAssetUuids: string[] = []; if (info.subAssets) { for (const key of Object.keys(info.subAssets)) { subAssetUuids.push(info.subAssets[key].uuid); } } try { const result = await Editor.Message.request('asset-db', 'delete-asset', mainUrl); if (!result) { console.error(`[mx-res-manager] delete-asset 失败: ${mainUrl}`); failed++; continue; } } catch (error) { console.error(`[mx-res-manager] delete-asset 异常: ${mainUrl}`, error); failed++; continue; } deleted++; removedItems.push({ uuid: String(info.uuid), subAssetUuids, filePath: info.file ? String(info.file) : undefined, }); await nextFrame(); } if (removedItems.length > 0) { referenceIndex.removeDeletedAssets(removedItems); pushCatalogToPanel(referenceIndex.getCatalogTree()); } } finally { suppressAssetEvents = false; } return { deleted, failed }; }, async onAssetChange(uuid: string) { if (suppressAssetEvents) { return; } await referenceIndex.reindexUuid(uuid); if (!referenceIndex.getProgress().catalogIndexing) { pushCatalogToPanel(referenceIndex.getCatalogTree()); } }, async onAssetDelete(_uuid: string) { if (suppressAssetEvents) { return; } await referenceIndex.rebuild(); pushCatalogToPanel(referenceIndex.getCatalogTree()); }, async locateSource(recordId: string) { const record = referenceIndex.getRecordById(recordId); if (!record) { return; } await locateAsset(record.sourceUrl, record.nodePath); }, async locateTarget(recordId: string) { const record = referenceIndex.getRecordById(recordId); if (!record) { return; } await locateAsset(record.targetUrl); }, async locateAssetByUuid(uuid: string) { const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid); if (info?.url) { await locateAsset(String(info.url).split('@')[0]); } }, }; interface SceneNodeTree { uuid: unknown; name: string; children?: SceneNodeTree[]; } function findNodeByPath(root: SceneNodeTree, nodePath: string, isScene: boolean): SceneNodeTree | null { const segments = nodePath.split('/').filter(Boolean); if (!isScene && segments.length > 1) { segments.shift(); } let cur: SceneNodeTree = root; for (const segment of segments) { let found: SceneNodeTree | undefined; if (cur.children?.length) { for (const child of cur.children) { if (child.name === segment) { found = child; break; } } } if (!found) { return cur; } cur = found; } return cur; } async function waitForAssetNodeTree(assetUuid: string, maxRetry = 30): Promise { for (let i = 0; i < maxRetry; i++) { const nodeUuids = (await Editor.Message.request( 'scene', 'query-nodes-by-asset-uuid', assetUuid, )) as string[] | undefined; const nodeUuid = nodeUuids?.[0]; if (nodeUuid) { const nodeTree = (await Editor.Message.request('scene', 'query-node-tree', nodeUuid)) as unknown as SceneNodeTree; if (nodeTree) { return nodeTree; } } await new Promise((resolve) => setTimeout(resolve, 100)); } return null; } async function locateAsset(url: string, nodePath?: string) { if (!url) { return; } const mainUrl = url.split('@')[0]; const needOpen = lastOpenAssetUrl !== mainUrl; if (needOpen) { await Editor.Message.request('asset-db', 'open-asset', mainUrl); lastOpenAssetUrl = mainUrl; } const assetUuid = (await Editor.Message.request('asset-db', 'query-uuid', mainUrl)) as string | undefined; if (assetUuid) { Editor.Selection.clear('asset'); Editor.Selection.select('asset', [assetUuid]); } if (!nodePath || !assetUuid) { return; } const isScene = mainUrl.endsWith('.scene'); const nodeTree = await waitForAssetNodeTree(assetUuid, needOpen ? 30 : 5); if (!nodeTree) { return; } const targetNode = findNodeByPath(nodeTree, nodePath, isScene); if (!targetNode?.uuid) { return; } const selectNode = () => { Editor.Selection.clear('node'); Editor.Selection.select('node', [String(targetNode.uuid)]); }; if (needOpen) { setTimeout(selectNode, 200); } else { selectNode(); } } async function waitForAssetDbReady(maxRetry = 60): Promise { for (let i = 0; i < maxRetry; i++) { try { const assetsPath = await Editor.Message.request('asset-db', 'query-path', 'db://assets'); if (assetsPath) { return true; } } catch { // asset-db 尚未就绪 } await new Promise((resolve) => setTimeout(resolve, 200)); } return false; } export async function load() { console.log(`[${name}] 等待 asset-db 就绪…`); const ready = await waitForAssetDbReady(); if (!ready) { console.error(`[${name}] asset-db 未就绪,跳过引用索引构建`); return; } console.log(`[${name}] 开始构建引用索引…`); void referenceIndex .rebuild() .then(() => { console.log(`[${name}] 引用索引构建完成`); }) .catch((error) => { console.error(`[${name}] 引用索引构建失败`, error); }); } export function unload() { lastOpenAssetUrl = ''; }