import { useState, useEffect, useRef } from 'react'; import { Entity } from '@esengine/ecs-framework'; import { EntityStoreService, MessageHub, InspectorRegistry, InspectorContext } from '@esengine/editor-core'; import { PropertyInspector } from './PropertyInspector'; import { FileSearch, ChevronDown, ChevronRight, X, Settings, File as FileIcon, Folder, Clock, HardDrive, Tag, Layers, ArrowUpDown, GitBranch, Activity, AlertTriangle, RefreshCw, Image as ImageIcon } from 'lucide-react'; import { TauriAPI } from '../api/tauri'; import { useToast } from './Toast'; import { SettingsService } from '../services/SettingsService'; import { convertFileSrc } from '@tauri-apps/api/core'; import '../styles/EntityInspector.css'; interface InspectorProps { entityStore: EntityStoreService; messageHub: MessageHub; inspectorRegistry: InspectorRegistry; projectPath?: string | null; } function getProfilerService(): any { return (window as any).__PROFILER_SERVICE__; } function formatNumber(value: number, decimalPlaces: number): string { if (decimalPlaces < 0) { return String(value); } if (Number.isInteger(value)) { return String(value); } return value.toFixed(decimalPlaces); } interface AssetFileInfo { name: string; path: string; extension?: string; size?: number; modified?: number; isDirectory: boolean; } type InspectorTarget = | { type: 'entity'; data: Entity } | { type: 'remote-entity'; data: any; details?: any } | { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean } | { type: 'extension'; data: unknown } | null; export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) { const [target, setTarget] = useState(null); const [expandedComponents, setExpandedComponents] = useState>(new Set()); const [componentVersion, setComponentVersion] = useState(0); const [autoRefresh, setAutoRefresh] = useState(true); const [decimalPlaces, setDecimalPlaces] = useState(() => { const settings = SettingsService.getInstance(); return settings.get('inspector.decimalPlaces', 4); }); const { showToast } = useToast(); const targetRef = useRef(null); useEffect(() => { targetRef.current = target; }, [target]); useEffect(() => { const handleSettingsChanged = (event: Event) => { const customEvent = event as CustomEvent; const changedSettings = customEvent.detail; if ('inspector.decimalPlaces' in changedSettings) { setDecimalPlaces(changedSettings['inspector.decimalPlaces']); } }; window.addEventListener('settings:changed', handleSettingsChanged); return () => { window.removeEventListener('settings:changed', handleSettingsChanged); }; }, []); useEffect(() => { const handleEntitySelection = (data: { entity: Entity | null }) => { if (data.entity) { setTarget({ type: 'entity', data: data.entity }); } else { setTarget(null); } setComponentVersion(0); }; const handleRemoteEntitySelection = (data: { entity: any }) => { setTarget({ type: 'remote-entity', data: data.entity }); const profilerService = getProfilerService(); if (profilerService && data.entity?.id !== undefined) { profilerService.requestEntityDetails(data.entity.id); } }; const handleEntityDetails = (event: Event) => { const customEvent = event as CustomEvent; const details = customEvent.detail; const currentTarget = targetRef.current; if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) { setTarget({ ...currentTarget, details }); } }; const handleExtensionSelection = (data: { data: unknown }) => { setTarget({ type: 'extension', data: data.data }); }; const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => { const fileInfo = data.fileInfo; if (fileInfo.isDirectory) { setTarget({ type: 'asset-file', data: fileInfo }); return; } const textExtensions = ['txt', 'json', 'md', 'ts', 'tsx', 'js', 'jsx', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'btree', 'ecs']; const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif']; const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase()); const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase()); if (isTextFile) { try { const content = await TauriAPI.readFileContent(fileInfo.path); setTarget({ type: 'asset-file', data: fileInfo, content }); } catch (error) { console.error('Failed to read file content:', error); setTarget({ type: 'asset-file', data: fileInfo }); } } else if (isImageFile) { setTarget({ type: 'asset-file', data: fileInfo, isImage: true }); } else { setTarget({ type: 'asset-file', data: fileInfo }); } }; const handleComponentChange = () => { setComponentVersion((prev) => prev + 1); }; const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection); const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection); const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection); const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection); const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange); const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange); window.addEventListener('profiler:entity-details', handleEntityDetails); return () => { unsubEntitySelect(); unsubRemoteSelect(); unsubNodeSelect(); unsubAssetFileSelect(); unsubComponentAdded(); unsubComponentRemoved(); window.removeEventListener('profiler:entity-details', handleEntityDetails); }; }, [messageHub]); useEffect(() => { if (!autoRefresh || target?.type !== 'remote-entity') { return; } const profilerService = getProfilerService(); if (!profilerService) { return; } const handleProfilerData = () => { const currentTarget = targetRef.current; if (currentTarget?.type === 'remote-entity' && currentTarget.data?.id !== undefined) { profilerService.requestEntityDetails(currentTarget.data.id); } }; const unsubscribe = profilerService.subscribe(handleProfilerData); return () => { unsubscribe(); }; }, [autoRefresh, target?.type]); const handleRemoveComponent = (index: number) => { if (target?.type !== 'entity') return; const entity = target.data; const component = entity.components[index]; if (component) { entity.removeComponent(component); messageHub.publish('component:removed', { entity, component }); } }; const toggleComponentExpanded = (index: number) => { setExpandedComponents((prev) => { const newSet = new Set(prev); if (newSet.has(index)) { newSet.delete(index); } else { newSet.add(index); } return newSet; }); }; const handlePropertyChange = (component: any, propertyName: string, value: any) => { if (target?.type !== 'entity') return; const entity = target.data; messageHub.publish('component:property:changed', { entity, component, propertyName, value }); }; const renderRemoteProperty = (key: string, value: any) => { if (value === null || value === undefined) { return (
null
); } if (Array.isArray(value)) { const getItemDisplay = (item: any): string => { if (typeof item !== 'object' || item === null) { return String(item); } if (item.typeName) return String(item.typeName); const constructorName = item.constructor?.name; if (constructorName && constructorName !== 'Object') { return constructorName; } if (item.type) return String(item.type); if (item.name) return String(item.name); if (item.className) return String(item.className); if (item._type) return String(item._type); const keys = Object.keys(item); if (keys.length <= 3) { return `{${keys.join(', ')}}`; } return `{${keys.slice(0, 3).join(', ')}...}`; }; // 检查是否是组件数组(有typeName和properties) const isComponentArray = value.length > 0 && value[0]?.typeName && value[0]?.properties; if (isComponentArray) { return (
{value.map((item, index) => ( ))}
); } // 检查是否是字符串数组(如componentTypes) const isStringArray = value.length > 0 && value.every((item: any) => typeof item === 'string'); if (isStringArray) { return (
{value.map((item: string, index: number) => ( {item} ))}
); } return (
{value.length === 0 ? ( Empty ) : ( value.map((item, index) => (
[{index}]: {getItemDisplay(item)}
)) )}
); } if (typeof value === 'object' && !Array.isArray(value)) { return (
{Object.entries(value).map(([subKey, subValue]) => (
{subKey}: {typeof subValue === 'object' && subValue !== null ? Array.isArray(subValue) ? `Array(${subValue.length})` : subValue.constructor?.name || JSON.stringify(subValue) : String(subValue)}
))}
); } return (
{String(value)}
); }; const formatFileSize = (bytes?: number): string => { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(2)} ${units[unitIndex]}`; }; const formatDate = (timestamp?: number): string => { if (!timestamp) return '未知'; const date = new Date(timestamp * 1000); return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }; const renderAssetFile = (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => { const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon; const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9'; return (
{fileInfo.name}
文件信息
{fileInfo.isDirectory ? '文件夹' : fileInfo.extension ? `.${fileInfo.extension}` : '文件'}
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
{formatFileSize(fileInfo.size)}
)} {fileInfo.modified !== undefined && (
{formatDate(fileInfo.modified)}
)}
{fileInfo.path}
{isImage && (
图片预览
)} {content && (
文件预览
{content}
)} {!content && !isImage && !fileInfo.isDirectory && (
此文件类型不支持预览
)}
); }; if (!target) { return (
未选择对象
选择实体或节点以查看详细信息
); } if (target.type === 'extension') { const context: InspectorContext = { target: target.data, projectPath, readonly: false }; const extensionContent = inspectorRegistry.render(target.data, context); if (extensionContent) { return extensionContent; } return (
未找到合适的检视器
此对象类型未注册检视器扩展
); } if (target.type === 'asset-file') { return renderAssetFile(target.data, target.content, target.isImage); } if (target.type === 'remote-entity') { const entity = target.data; const details = (target as any).details; const handleManualRefresh = () => { const profilerService = getProfilerService(); if (profilerService && entity?.id !== undefined) { profilerService.requestEntityDetails(entity.id); } }; return (
运行时实体 #{entity.id} {entity.destroyed && ( 已销毁 )}
基本信息
{entity.id}
{entity.name && (
{entity.name}
)}
{entity.enabled ? 'true' : 'false'}
{entity.tag !== undefined && entity.tag !== 0 && (
{entity.tag}
)}
{(entity.depth !== undefined || entity.updateOrder !== undefined || entity.parentId !== undefined || entity.childCount !== undefined) && (
层级信息
{entity.depth !== undefined && (
{entity.depth}
)} {entity.updateOrder !== undefined && (
{entity.updateOrder}
)} {entity.parentId !== undefined && (
{entity.parentId === null ? '无' : entity.parentId}
)} {entity.childCount !== undefined && (
{entity.childCount}
)} {entity.activeInHierarchy !== undefined && (
{entity.activeInHierarchy ? 'true' : 'false'}
)}
)} {entity.componentMask !== undefined && (
调试信息
{entity.componentMask}
)} {details && details.components && Array.isArray(details.components) && details.components.length > 0 && (
组件 ({details.components.length})
{details.components.map((comp: any, index: number) => ( ))}
)} {details && Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes').length > 0 && (
其他信息
{Object.entries(details) .filter(([key]) => key !== 'components' && key !== 'componentTypes') .map(([key, value]) => renderRemoteProperty(key, value))}
)}
); } if (target.type === 'entity') { const entity = target.data; return (
{entity.name || `Entity #${entity.id}`}
基本信息
{entity.id}
{entity.enabled ? 'true' : 'false'}
{entity.components.length > 0 && (
组件
{entity.components.map((component: any, index: number) => { const isExpanded = expandedComponents.has(index); const componentName = component.constructor?.name || 'Component'; return (
toggleComponentExpanded(index)} style={{ display: 'flex', alignItems: 'center', padding: '6px 8px', backgroundColor: '#3a3a3a', cursor: 'pointer', userSelect: 'none', borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' }} > {isExpanded ? : } {componentName}
{isExpanded && (
handlePropertyChange(component, propName, value)} />
)}
); })}
)}
); } return null; } interface ComponentItemProps { component: { typeName: string; properties: Record; }; decimalPlaces?: number; } function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemProps) { const [isExpanded, setIsExpanded] = useState(false); return (
setIsExpanded(!isExpanded)} style={{ display: 'flex', alignItems: 'center', padding: '6px 8px', backgroundColor: '#3a3a3a', cursor: 'pointer', userSelect: 'none', borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' }} > {isExpanded ? : } {component.typeName}
{isExpanded && (
{Object.entries(component.properties).map(([propName, propValue]) => ( ))}
)}
); } interface PropertyValueRendererProps { name: string; value: any; depth: number; decimalPlaces?: number; } function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: PropertyValueRendererProps) { const [isExpanded, setIsExpanded] = useState(false); const isExpandable = value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0; const isArray = Array.isArray(value); const renderSimpleValue = (val: any): string => { if (val === null || val === undefined) return 'null'; if (typeof val === 'boolean') return val ? 'true' : 'false'; if (typeof val === 'number') return formatNumber(val, decimalPlaces); if (typeof val === 'string') return val.length > 50 ? val.substring(0, 50) + '...' : val; if (Array.isArray(val)) return `Array(${val.length})`; if (typeof val === 'object') { const keys = Object.keys(val); if (keys.length === 0) return '{}'; if (keys.length <= 2) { const preview = keys.map(k => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', '); return `{${preview}}`; } return `{${keys.slice(0, 2).join(', ')}...}`; } return String(val); }; if (isExpandable) { return (
0 ? '12px' : 0 }}>
setIsExpanded(!isExpanded)} style={{ display: 'flex', alignItems: 'center', padding: '3px 0', fontSize: '11px', borderBottom: '1px solid #333', cursor: 'pointer', userSelect: 'none' }} > {isExpanded ? : } {name} {!isExpanded && ( {renderSimpleValue(value)} )}
{isExpanded && (
{Object.entries(value).map(([key, val]) => ( ))}
)}
); } if (isArray && value.length > 0) { return (
0 ? '12px' : 0 }}>
setIsExpanded(!isExpanded)} style={{ display: 'flex', alignItems: 'center', padding: '3px 0', fontSize: '11px', borderBottom: '1px solid #333', cursor: 'pointer', userSelect: 'none' }} > {isExpanded ? : } {name} Array({value.length})
{isExpanded && (
{value.map((item: any, index: number) => ( ))}
)}
); } return (
0 ? '12px' : 0 }} > {name} {renderSimpleValue(value)}
); } interface ImagePreviewProps { src: string; alt: string; } function ImagePreview({ src, alt }: ImagePreviewProps) { const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [imageError, setImageError] = useState(false); const containerRef = useRef(null); const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; setScale(prev => Math.min(Math.max(prev * delta, 0.1), 10)); }; const handleMouseDown = (e: React.MouseEvent) => { if (e.button === 0) { setIsDragging(true); setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); } }; const handleMouseMove = (e: React.MouseEvent) => { if (isDragging) { setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); } }; const handleMouseUp = () => { setIsDragging(false); }; const handleReset = () => { setScale(1); setPosition({ x: 0, y: 0 }); }; if (imageError) { return (
图片加载失败
); } return (
{alt} setImageError(true)} />
缩放: {(scale * 100).toFixed(0)}%
); }