diff --git a/packages/core/src/Platform/PlatformDetector.ts b/packages/core/src/Platform/PlatformDetector.ts index 61f9f134..d83e5470 100644 --- a/packages/core/src/Platform/PlatformDetector.ts +++ b/packages/core/src/Platform/PlatformDetector.ts @@ -188,6 +188,46 @@ export class PlatformDetector { window.location !== undefined; } + /** + * 检测是否在 Tauri 桌面环境中运行 + * Check if running in Tauri desktop environment + * + * 同时支持 Tauri v1 (__TAURI__) 和 v2 (__TAURI_INTERNALS__) + * Supports both Tauri v1 (__TAURI__) and v2 (__TAURI_INTERNALS__) + */ + public static isTauriEnvironment(): boolean { + if (typeof window === 'undefined') { + return false; + } + // Tauri v1 uses __TAURI__, Tauri v2 uses __TAURI_INTERNALS__ + return '__TAURI__' in window || '__TAURI_INTERNALS__' in window; + } + + /** + * 检测是否在编辑器环境中运行 + * Check if running in editor environment + * + * 包括 Tauri 桌面应用或带 __ESENGINE_EDITOR__ 标记的环境 + * Includes Tauri desktop app or environments marked with __ESENGINE_EDITOR__ + */ + public static isEditorEnvironment(): boolean { + if (typeof window === 'undefined') { + return false; + } + + // Tauri desktop app | Tauri 桌面应用 + if (this.isTauriEnvironment()) { + return true; + } + + // Editor marker | 编辑器标记 + if ('__ESENGINE_EDITOR__' in window) { + return true; + } + + return false; + } + /** * 获取详细的环境信息(用于调试) */ diff --git a/packages/editor-app/src-tauri/src/commands/compiler.rs b/packages/editor-app/src-tauri/src/commands/compiler.rs index bdaa2a22..fdacca9e 100644 --- a/packages/editor-app/src-tauri/src/commands/compiler.rs +++ b/packages/editor-app/src-tauri/src/commands/compiler.rs @@ -24,12 +24,17 @@ pub struct CompileOptions { pub output_path: String, /// Output format (esm or iife) | 输出格式 pub format: String, + /// Global name for IIFE format | IIFE 格式的全局名称 + pub global_name: Option, /// Whether to generate source map | 是否生成 source map pub source_map: bool, /// Whether to minify | 是否压缩 pub minify: bool, /// External dependencies | 外部依赖 pub external: Vec, + /// Module aliases (e.g., "@esengine/ecs-framework" -> "/path/to/shim.js") + /// 模块别名 + pub alias: Option>, /// Project root for resolving imports | 项目根目录用于解析导入 pub project_root: String, } @@ -106,11 +111,27 @@ pub async fn compile_typescript(options: CompileOptions) -> Result Result Result Result<(), String> { } /// Delete directory (recursive) +/// 递归删除目录 #[tauri::command] pub fn delete_folder(path: String) -> Result<(), String> { - fs::remove_dir_all(&path) - .map_err(|e| format!("Failed to delete folder {}: {}", path, e)) + println!("[delete_folder] Attempting to delete: {}", path); + + // Check if path exists + // 检查路径是否存在 + let dir_path = std::path::Path::new(&path); + if !dir_path.exists() { + println!("[delete_folder] Path does not exist: {}", path); + return Err(format!("Directory does not exist: {}", path)); + } + + if !dir_path.is_dir() { + println!("[delete_folder] Path is not a directory: {}", path); + return Err(format!("Path is not a directory: {}", path)); + } + + match fs::remove_dir_all(&path) { + Ok(_) => { + println!("[delete_folder] Successfully deleted: {}", path); + Ok(()) + } + Err(e) => { + let error_msg = format!("Failed to delete folder {}: {}", path, e); + eprintln!("[delete_folder] Error: {}", error_msg); + Err(error_msg) + } + } } /// Rename or move file/folder diff --git a/packages/editor-app/src-tauri/src/commands/system.rs b/packages/editor-app/src-tauri/src/commands/system.rs index 318f762b..8395c893 100644 --- a/packages/editor-app/src-tauri/src/commands/system.rs +++ b/packages/editor-app/src-tauri/src/commands/system.rs @@ -142,6 +142,109 @@ pub fn get_temp_dir() -> Result { .ok_or_else(|| "Failed to get temp directory".to_string()) } +/// 使用 where 命令查找可执行文件路径 +/// Use 'where' command to find executable path +#[cfg(target_os = "windows")] +fn find_command_path(cmd: &str) -> Option { + use std::process::Command as StdCommand; + use std::path::Path; + + // 使用 where 命令查找 + let output = StdCommand::new("where") + .arg(cmd) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // 取第一行结果(可能有多个匹配) + if let Some(first_line) = stdout.lines().next() { + let path = first_line.trim(); + if !path.is_empty() { + let path_obj = Path::new(path); + + // 检查是否是 bin 目录下的脚本(VSCode/Cursor 特征) + // Check if it's a script in bin directory (VSCode/Cursor pattern) + let is_bin_script = path_obj.parent() + .map(|p| p.ends_with("bin")) + .unwrap_or(false); + + // 如果找到的是 .cmd 或 .bat,或者是 bin 目录下的脚本(where 可能不返回扩展名) + // If found .cmd or .bat, or a script in bin directory (where may not return extension) + let has_script_ext = path.ends_with(".cmd") || path.ends_with(".bat"); + + if has_script_ext || is_bin_script { + // 尝试找 Code.exe (VSCode) 或 Cursor.exe 等 + // Try to find Code.exe (VSCode) or Cursor.exe etc. + if let Some(bin_dir) = path_obj.parent() { + if let Some(parent_dir) = bin_dir.parent() { + // VSCode: bin/code.cmd -> Code.exe + let exe_path = parent_dir.join("Code.exe"); + if exe_path.exists() { + let exe_str = exe_path.to_string_lossy().to_string(); + println!("[find_command_path] Found {} exe at: {}", cmd, exe_str); + return Some(exe_str); + } + + // Cursor: bin/cursor.cmd -> Cursor.exe + let cursor_exe = parent_dir.join("Cursor.exe"); + if cursor_exe.exists() { + let exe_str = cursor_exe.to_string_lossy().to_string(); + println!("[find_command_path] Found {} exe at: {}", cmd, exe_str); + return Some(exe_str); + } + } + } + } + + println!("[find_command_path] Found {} at: {}", cmd, path); + return Some(path.to_string()); + } + } + } + None +} + +#[cfg(not(target_os = "windows"))] +fn find_command_path(cmd: &str) -> Option { + use std::process::Command as StdCommand; + + let output = StdCommand::new("which") + .arg(cmd) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let path = stdout.trim(); + if !path.is_empty() { + return Some(path.to_string()); + } + } + None +} + +/// 解析编辑器命令,返回实际可执行路径 +/// Resolve editor command to actual executable path +fn resolve_editor_command(editor_command: &str) -> String { + use std::path::Path; + + // 如果命令已经是完整路径且存在,直接返回 + // If command is already a full path and exists, return it + if Path::new(editor_command).exists() { + return editor_command.to_string(); + } + + // 使用系统命令查找可执行文件路径 + // Use system command to find executable path + if let Some(path) = find_command_path(editor_command) { + return path; + } + + // 回退到原始命令 | Fall back to original command + editor_command.to_string() +} + /// Open project folder with specified editor /// 使用指定编辑器打开项目文件夹 /// @@ -166,27 +269,49 @@ pub fn open_with_editor( return Err(format!("Project path does not exist: {}", normalized_project)); } + // 解析编辑器命令到实际路径 + // Resolve editor command to actual path + let resolved_command = resolve_editor_command(&editor_command); + println!( - "[open_with_editor] editor: {}, project: {}, file: {:?}", - editor_command, normalized_project, normalized_file + "[open_with_editor] editor: {} -> {}, project: {}, file: {:?}", + editor_command, resolved_command, normalized_project, normalized_file ); - let mut cmd = Command::new(&editor_command); + let mut cmd = Command::new(&resolved_command); - // Add project folder as first argument + // VSCode/Cursor CLI 正确用法: + // 1. 使用 --folder-uri 或直接传文件夹路径会打开新窗口 + // 2. 使用 --add 可以将文件夹添加到当前工作区 + // 3. 使用 --goto file:line:column 可以打开文件并定位 + // + // VSCode/Cursor CLI correct usage: + // 1. Use --folder-uri or pass folder path directly to open new window + // 2. Use --add to add folder to current workspace + // 3. Use --goto file:line:column to open file and navigate + // + // 正确命令格式: code + // 这会打开文件夹并同时打开文件 + // Correct command format: code + // This opens the folder and also opens the file + + // Add project folder first + // 先添加项目文件夹 cmd.arg(&normalized_project); - // If a specific file is provided, add it as a second argument - // Most editors support: editor + // If a specific file is provided, add it directly (not with -g) + // VSCode will open the folder AND the file + // 如果提供了文件,直接添加(不使用 -g) + // VSCode 会同时打开文件夹和文件 if let Some(ref file) = normalized_file { - let file_path = Path::new(file); - if file_path.exists() { + let file_path_obj = Path::new(file); + if file_path_obj.exists() { cmd.arg(file); } } cmd.spawn() - .map_err(|e| format!("Failed to open with editor '{}': {}", editor_command, e))?; + .map_err(|e| format!("Failed to open with editor '{}': {}", resolved_command, e))?; Ok(()) } @@ -212,10 +337,13 @@ pub fn get_current_dir() -> Result { .map_err(|e| format!("Failed to get current directory: {}", e)) } -/// Copy type definitions to project for IDE intellisense -/// 复制类型定义文件到项目以支持 IDE 智能感知 +/// Update project tsconfig.json with engine type paths +/// 更新项目的 tsconfig.json,添加引擎类型路径 +/// +/// Scans dist/engine/ directory and adds paths for all modules with .d.ts files. +/// 扫描 dist/engine/ 目录,为所有有 .d.ts 文件的模块添加路径。 #[tauri::command] -pub fn copy_type_definitions(app: AppHandle, project_path: String) -> Result<(), String> { +pub fn update_project_tsconfig(app: AppHandle, project_path: String) -> Result<(), String> { use std::fs; use std::path::Path; @@ -224,38 +352,68 @@ pub fn copy_type_definitions(app: AppHandle, project_path: String) -> Result<(), return Err(format!("Project path does not exist: {}", project_path)); } - // Create types directory in project - // 在项目中创建 types 目录 - let types_dir = project.join("types"); - if !types_dir.exists() { - fs::create_dir_all(&types_dir) - .map_err(|e| format!("Failed to create types directory: {}", e))?; + // Get engine modules path (dist/engine/) + // 获取引擎模块路径 + let engine_path = get_engine_modules_base_path_internal(&app)?; + + // Read existing tsconfig.json + // 读取现有的 tsconfig.json + let tsconfig_path = project.join("tsconfig.json"); + let tsconfig_editor_path = project.join("tsconfig.editor.json"); + + // Update runtime tsconfig + // 更新运行时 tsconfig + if tsconfig_path.exists() { + update_tsconfig_file(&tsconfig_path, &engine_path, false)?; + println!("[update_project_tsconfig] Updated {}", tsconfig_path.display()); } - // Get resource directory (where bundled files are) - // 获取资源目录(打包文件所在位置) + // Update editor tsconfig + // 更新编辑器 tsconfig + if tsconfig_editor_path.exists() { + update_tsconfig_file(&tsconfig_editor_path, &engine_path, true)?; + println!("[update_project_tsconfig] Updated {}", tsconfig_editor_path.display()); + } + + Ok(()) +} + +/// Internal function to get engine modules base path +/// 内部函数:获取引擎模块基础路径 +fn get_engine_modules_base_path_internal(app: &AppHandle) -> Result { let resource_dir = app.path() .resource_dir() .map_err(|e| format!("Failed to get resource directory: {}", e))?; - // Type definition files to copy - // 要复制的类型定义文件 - // Format: (resource_path, workspace_path, dest_name) - // 格式:(资源路径,工作区路径,目标文件名) - // Note: resource_path is relative to Tauri resource dir (runtime/ is mapped to .) - // 注意:resource_path 相对于 Tauri 资源目录(runtime/ 映射到 .) - let type_files = [ - ("types/ecs-framework.d.ts", "packages/core/dist/index.d.ts", "ecs-framework.d.ts"), - ("types/engine-core.d.ts", "packages/engine-core/dist/index.d.ts", "engine-core.d.ts"), - ]; + // Production mode: resource_dir/engine/ + // 生产模式 + let prod_path = resource_dir.join("engine"); + if prod_path.exists() { + return prod_path.to_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Invalid path encoding".to_string()); + } - // Try to find workspace root (for development mode) - // 尝试查找工作区根目录(用于开发模式) - let workspace_root = std::env::current_dir() + // Development mode: workspace/packages/editor-app/dist/engine/ + // 开发模式 + if let Some(ws_root) = find_workspace_root() { + let dev_path = ws_root.join("packages").join("editor-app").join("dist").join("engine"); + if dev_path.exists() { + return dev_path.to_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Invalid path encoding".to_string()); + } + } + + Err("Engine modules directory not found".to_string()) +} + +/// Find workspace root directory +/// 查找工作区根目录 +fn find_workspace_root() -> Option { + std::env::current_dir() .ok() .and_then(|cwd| { - // Look for pnpm-workspace.yaml or package.json in parent directories - // 在父目录中查找 pnpm-workspace.yaml 或 package.json let mut dir = cwd.as_path(); loop { if dir.join("pnpm-workspace.yaml").exists() { @@ -266,40 +424,87 @@ pub fn copy_type_definitions(app: AppHandle, project_path: String) -> Result<(), None => return None, } } - }); + }) +} - let mut copied_count = 0; - for (resource_relative, workspace_relative, dest_name) in type_files { - let dest_path = types_dir.join(dest_name); +/// Update a tsconfig file with engine paths +/// 使用引擎路径更新 tsconfig 文件 +/// +/// Scans all subdirectories in engine_path for index.d.ts files. +/// 扫描 engine_path 下所有子目录的 index.d.ts 文件。 +fn update_tsconfig_file( + tsconfig_path: &std::path::Path, + engine_path: &str, + include_editor: bool, +) -> Result<(), String> { + use std::fs; - // Try resource directory first (production mode) - // 首先尝试资源目录(生产模式) - let src_path = resource_dir.join(resource_relative); - if src_path.exists() { - fs::copy(&src_path, &dest_path) - .map_err(|e| format!("Failed to copy {}: {}", resource_relative, e))?; - println!("[copy_type_definitions] Copied {} to {}", src_path.display(), dest_path.display()); - copied_count += 1; - continue; - } + let content = fs::read_to_string(tsconfig_path) + .map_err(|e| format!("Failed to read tsconfig: {}", e))?; - // Try workspace directory (development mode) - // 尝试工作区目录(开发模式) - if let Some(ref ws_root) = workspace_root { - let ws_src_path = ws_root.join(workspace_relative); - if ws_src_path.exists() { - fs::copy(&ws_src_path, &dest_path) - .map_err(|e| format!("Failed to copy {}: {}", workspace_relative, e))?; - println!("[copy_type_definitions] Copied {} to {} (dev mode)", ws_src_path.display(), dest_path.display()); - copied_count += 1; + let mut config: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse tsconfig: {}", e))?; + + // Normalize path for cross-platform compatibility + // 规范化路径以实现跨平台兼容 + let engine_path_normalized = engine_path.replace('\\', "/"); + + // Build paths mapping by scanning engine modules directory + // 通过扫描引擎模块目录构建路径映射 + let mut paths = serde_json::Map::new(); + let mut module_count = 0; + + let engine_dir = std::path::Path::new(engine_path); + if let Ok(entries) = fs::read_dir(engine_dir) { + for entry in entries.flatten() { + let module_path = entry.path(); + if !module_path.is_dir() { continue; } - } - println!("[copy_type_definitions] {} not found, skipping", dest_name); + let module_id = module_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Skip editor modules for runtime tsconfig + // 运行时 tsconfig 跳过编辑器模块 + if !include_editor && module_id.ends_with("-editor") { + continue; + } + + // Check for index.d.ts + // 检查是否存在 index.d.ts + let dts_path = module_path.join("index.d.ts"); + if dts_path.exists() { + let module_name = format!("@esengine/{}", module_id); + let dts_path_str = format!("{}/{}/index.d.ts", engine_path_normalized, module_id); + paths.insert(module_name, serde_json::json!([dts_path_str])); + module_count += 1; + } + } } - println!("[copy_type_definitions] Copied {} type definition files to {}", copied_count, types_dir.display()); + println!("[update_tsconfig_file] Found {} modules with type definitions", module_count); + + // Update compilerOptions.paths + // 更新 compilerOptions.paths + if let Some(compiler_options) = config.get_mut("compilerOptions") { + if let Some(obj) = compiler_options.as_object_mut() { + obj.insert("paths".to_string(), serde_json::Value::Object(paths)); + // Remove typeRoots since we're using paths + // 移除 typeRoots,因为我们使用 paths + obj.remove("typeRoots"); + } + } + + // Write back + // 写回文件 + let output = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize tsconfig: {}", e))?; + + fs::write(tsconfig_path, output) + .map_err(|e| format!("Failed to write tsconfig: {}", e))?; + Ok(()) } diff --git a/packages/editor-app/src-tauri/src/main.rs b/packages/editor-app/src-tauri/src/main.rs index cac4365e..62423781 100644 --- a/packages/editor-app/src-tauri/src/main.rs +++ b/packages/editor-app/src-tauri/src/main.rs @@ -82,7 +82,7 @@ fn main() { commands::show_in_folder, commands::get_temp_dir, commands::open_with_editor, - commands::copy_type_definitions, + commands::update_project_tsconfig, commands::get_app_resource_dir, commands::get_current_dir, commands::start_local_server, @@ -156,7 +156,13 @@ fn handle_project_protocol( tauri::http::Response::builder() .status(200) .header("Content-Type", mime_type) + // CORS headers for dynamic ES module imports | 动态 ES 模块导入所需的 CORS 头 .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "GET, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type") + .header("Access-Control-Expose-Headers", "Content-Length") + // Allow cross-origin script loading | 允许跨域脚本加载 + .header("Cross-Origin-Resource-Policy", "cross-origin") .body(content) .unwrap() } @@ -164,6 +170,7 @@ fn handle_project_protocol( eprintln!("Failed to read file {}: {}", file_path, e); tauri::http::Response::builder() .status(404) + .header("Access-Control-Allow-Origin", "*") .body(Vec::new()) .unwrap() } diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 6537ece9..4655a6cc 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -381,12 +381,12 @@ function App() { // 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件) await TauriAPI.setProjectBasePath(projectPath); - // 复制类型定义到项目,用于 IDE 智能感知 - // Copy type definitions to project for IDE intellisense + // 更新项目 tsconfig,直接引用引擎类型定义 + // Update project tsconfig to reference engine type definitions directly try { - await TauriAPI.copyTypeDefinitions(projectPath); + await TauriAPI.updateProjectTsconfig(projectPath); } catch (e) { - console.warn('[App] Failed to copy type definitions:', e); + console.warn('[App] Failed to update project tsconfig:', e); } const settings = SettingsService.getInstance(); @@ -840,14 +840,17 @@ function App() { setStatus(t('header.status.ready')); }} onDeleteProject={async (projectPath) => { + console.log('[App] onDeleteProject called with path:', projectPath); try { + console.log('[App] Calling TauriAPI.deleteFolder...'); await TauriAPI.deleteFolder(projectPath); + console.log('[App] deleteFolder succeeded'); // 删除成功后从列表中移除并触发重新渲染 // Remove from list and trigger re-render after successful deletion settings.removeRecentProject(projectPath); setStatus(t('header.status.ready')); } catch (error) { - console.error('Failed to delete project:', error); + console.error('[App] Failed to delete project:', error); setErrorDialog({ title: locale === 'zh' ? '删除项目失败' : 'Failed to Delete Project', message: locale === 'zh' diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index 58dd03ff..ad5304f0 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -332,13 +332,17 @@ export class TauriAPI { } /** - * 复制类型定义文件到项目 - * Copy type definition files to project for IDE intellisense + * 更新项目的 tsconfig.json,添加引擎类型路径 + * Update project tsconfig.json with engine type paths + * + * This updates the tsconfig to point directly to engine's .d.ts files + * instead of copying them to the project. + * 这会更新 tsconfig 直接指向引擎的 .d.ts 文件,而不是复制到项目。 * * @param projectPath 项目路径 | Project path */ - static async copyTypeDefinitions(projectPath: string): Promise { - return await invoke('copy_type_definitions', { projectPath }); + static async updateProjectTsconfig(projectPath: string): Promise { + return await invoke('update_project_tsconfig', { projectPath }); } /** diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index dfd3b709..32ad2f85 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -37,7 +37,9 @@ import { BuildService, WebBuildPipeline, WeChatBuildPipeline, - moduleRegistry + moduleRegistry, + UserCodeService, + UserCodeTarget } from '@esengine/editor-core'; import { ViewportService } from '../../services/ViewportService'; import { TransformComponent } from '@esengine/engine-core'; @@ -78,6 +80,7 @@ import { import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector'; import { buildFileSystem } from '../../services/BuildFileSystemService'; import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem'; +import { PluginSDKRegistry } from '../../services/PluginSDKRegistry'; export interface EditorServices { uiRegistry: UIRegistry; @@ -104,6 +107,7 @@ export interface EditorServices { propertyRendererRegistry: PropertyRendererRegistry; fieldEditorRegistry: FieldEditorRegistry; buildService: BuildService; + userCodeService: UserCodeService; } export class ServiceRegistry { @@ -271,6 +275,74 @@ export class ServiceRegistry { console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err); }); + // Initialize UserCodeService for user script compilation and loading + // 初始化 UserCodeService 用于用户脚本编译和加载 + const userCodeService = new UserCodeService(fileSystem); + Core.services.registerInstance(UserCodeService, userCodeService); + + // Helper function to compile and load user scripts + // 辅助函数:编译和加载用户脚本 + let currentProjectPath: string | null = null; + + const compileAndLoadUserScripts = async (projectPath: string) => { + // Ensure PluginSDKRegistry is initialized before loading user code + // 确保在加载用户代码之前 PluginSDKRegistry 已初始化 + PluginSDKRegistry.initialize(); + + try { + // Compile runtime scripts | 编译运行时脚本 + const compileResult = await userCodeService.compile({ + projectPath: projectPath, + target: UserCodeTarget.Runtime + }); + + if (compileResult.success && compileResult.outputPath) { + // Load compiled module | 加载编译后的模块 + const module = await userCodeService.load(compileResult.outputPath, UserCodeTarget.Runtime); + + // Register user components to editor | 注册用户组件到编辑器 + userCodeService.registerComponents(module, componentRegistry); + + // Notify that user code has been reloaded | 通知用户代码已重新加载 + messageHub.publish('usercode:reloaded', { + projectPath, + exports: Object.keys(module.exports) + }); + } else if (compileResult.errors.length > 0) { + console.warn('[UserCodeService] Compilation errors:', compileResult.errors); + } + } catch (error) { + console.error('[UserCodeService] Failed to compile/load:', error); + } + }; + + // Subscribe to project:opened to compile and load user scripts + // 订阅 project:opened 以编译和加载用户脚本 + messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => { + currentProjectPath = data.path; + await compileAndLoadUserScripts(data.path); + }); + + // Subscribe to script file changes (create/delete/modify) + // 订阅脚本文件变更(创建/删除/修改) + messageHub.subscribe('file:created', async (data: { path: string }) => { + if (currentProjectPath && this.isScriptFile(data.path)) { + await compileAndLoadUserScripts(currentProjectPath); + } + }); + + messageHub.subscribe('file:deleted', async (data: { path: string }) => { + if (currentProjectPath && this.isScriptFile(data.path)) { + await compileAndLoadUserScripts(currentProjectPath); + } + }); + + messageHub.subscribe('file:modified', async (data: { path: string }) => { + if (currentProjectPath && this.isScriptFile(data.path)) { + await compileAndLoadUserScripts(currentProjectPath); + } + }); + // 注册默认场景模板 - 创建默认相机 // Register default scene template - creates default camera this.registerDefaultSceneTemplate(); @@ -299,7 +371,8 @@ export class ServiceRegistry { inspectorRegistry, propertyRendererRegistry, fieldEditorRegistry, - buildService + buildService, + userCodeService }; } @@ -310,6 +383,37 @@ export class ServiceRegistry { }) as EventListener); } + /** + * Check if a file path is a TypeScript script file (not in editor folder) + * 检查文件路径是否为 TypeScript 脚本文件(不在 editor 文件夹中) + */ + private isScriptFile(filePath: string): boolean { + // Must be .ts file | 必须是 .ts 文件 + if (!filePath.endsWith('.ts')) { + return false; + } + + // Normalize path separators | 规范化路径分隔符 + const normalizedPath = filePath.replace(/\\/g, '/'); + + // Must be in scripts folder | 必须在 scripts 文件夹中 + if (!normalizedPath.includes('/scripts/')) { + return false; + } + + // Exclude editor scripts | 排除编辑器脚本 + if (normalizedPath.includes('/scripts/editor/')) { + return false; + } + + // Exclude .esengine folder | 排除 .esengine 文件夹 + if (normalizedPath.includes('/.esengine/')) { + return false; + } + + return true; + } + /** * 注册默认场景模板 * Register default scene template with default entities diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index c788ba01..8e499381 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -125,6 +125,9 @@ export function ContentBrowser({ const messageHub = Core.services.resolve(MessageHub); const fileActionRegistry = Core.services.resolve(FileActionRegistry); + // Refs + const containerRef = useRef(null); + // State const [currentPath, setCurrentPath] = useState(null); const [selectedPaths, setSelectedPaths] = useState>(new Set()); @@ -329,6 +332,53 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { }; }, [fileActionRegistry]); + // 键盘快捷键处理 | Keyboard shortcuts handling + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // 如果正在输入或有对话框打开,不处理快捷键 + // Skip shortcuts if typing or dialog is open + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + renameDialog || + deleteConfirmDialog || + createFileDialog + ) { + return; + } + + // 只在内容浏览器区域处理快捷键 + // Only handle shortcuts when content browser has focus + if (!containerRef.current?.contains(document.activeElement) && + document.activeElement !== containerRef.current) { + return; + } + + // F2 - 重命名 | Rename + if (e.key === 'F2' && selectedPaths.size === 1) { + e.preventDefault(); + const selectedPath = Array.from(selectedPaths)[0]; + const asset = assets.find(a => a.path === selectedPath); + if (asset) { + setRenameDialog({ asset, newName: asset.name }); + } + } + + // Delete - 删除 | Delete + if (e.key === 'Delete' && selectedPaths.size === 1) { + e.preventDefault(); + const selectedPath = Array.from(selectedPaths)[0]; + const asset = assets.find(a => a.path === selectedPath); + if (asset) { + setDeleteConfirmDialog(asset); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]); + const getTemplateLabel = (label: string): string => { const mapping = templateLabels[label]; if (mapping) { @@ -510,6 +560,9 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { // Handle asset click const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => { + // 聚焦容器以启用键盘快捷键 | Focus container to enable keyboard shortcuts + containerRef.current?.focus(); + if (e.shiftKey && lastSelectedPath) { const lastIndex = assets.findIndex(a => a.path === lastSelectedPath); const currentIndex = assets.findIndex(a => a.path === asset.path); @@ -562,9 +615,12 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { const settings = SettingsService.getInstance(); const editorCommand = settings.getScriptEditorCommand(); - if (editorCommand && projectPath) { + if (editorCommand) { + // 使用项目路径,如果没有则使用文件所在目录 + // Use project path, or file's parent directory if not available + const workingDir = projectPath || asset.path.substring(0, asset.path.lastIndexOf('\\')) || asset.path.substring(0, asset.path.lastIndexOf('/')); try { - await TauriAPI.openWithEditor(projectPath, editorCommand, asset.path); + await TauriAPI.openWithEditor(workingDir, editorCommand, asset.path); return; } catch (error) { console.error('Failed to open with editor:', error); @@ -624,21 +680,38 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { // Handle delete const handleDelete = useCallback(async (asset: AssetItem) => { try { + const deletedPath = asset.path; + if (asset.type === 'folder') { await TauriAPI.deleteFolder(asset.path); + // Also delete folder meta file if exists | 同时删除文件夹的 meta 文件 + try { + await TauriAPI.deleteFile(`${asset.path}.meta`); + } catch { + // Meta file may not exist, ignore | meta 文件可能不存在,忽略 + } } else { await TauriAPI.deleteFile(asset.path); + // Also delete corresponding meta file if exists | 同时删除对应的 meta 文件 + try { + await TauriAPI.deleteFile(`${asset.path}.meta`); + } catch { + // Meta file may not exist, ignore | meta 文件可能不存在,忽略 + } } if (currentPath) { await loadAssets(currentPath); } + // Notify that a file was deleted | 通知文件已删除 + messageHub?.publish('file:deleted', { path: deletedPath }); + setDeleteConfirmDialog(null); } catch (error) { console.error('Failed to delete:', error); } - }, [currentPath, loadAssets]); + }, [currentPath, loadAssets, messageHub]); // Get breadcrumbs const getBreadcrumbs = useCallback(() => { @@ -996,7 +1069,11 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { } return ( -
+
{/* Left Panel - Folder Tree */}
{/* Favorites Section */} @@ -1291,6 +1368,9 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { if (currentPath) { await loadAssets(currentPath); } + + // Notify that a file was created | 通知文件已创建 + messageHub?.publish('file:created', { path: filePath }); } catch (error) { console.error('Failed to create file:', error); } diff --git a/packages/editor-app/src/components/FileTree.tsx b/packages/editor-app/src/components/FileTree.tsx index be3a2c44..7a6b7b17 100644 --- a/packages/editor-app/src/components/FileTree.tsx +++ b/packages/editor-app/src/components/FileTree.tsx @@ -6,6 +6,7 @@ import { } from 'lucide-react'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { MessageHub, FileActionRegistry } from '@esengine/editor-core'; +import { SettingsService } from '../services/SettingsService'; import { Core } from '@esengine/ecs-framework'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { ConfirmDialog } from './ConfirmDialog'; @@ -900,6 +901,27 @@ export const FileTree = forwardRef(({ rootPath, o return; } + // 脚本文件使用配置的编辑器打开 + // Open script files with configured editor + if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') { + const settings = SettingsService.getInstance(); + const editorCommand = settings.getScriptEditorCommand(); + + if (editorCommand) { + // 使用项目路径,如果没有则使用文件所在目录 + // Use project path, or file's parent directory if not available + const workingDir = rootPath || node.path.substring(0, node.path.lastIndexOf('\\')) || node.path.substring(0, node.path.lastIndexOf('/')); + try { + await TauriAPI.openWithEditor(workingDir, editorCommand, node.path); + return; + } catch (error) { + console.error('Failed to open with editor:', error); + // 如果失败,回退到系统默认应用 + // Fall back to system default app if failed + } + } + } + if (fileActionRegistry) { const handled = await fileActionRegistry.handleDoubleClick(node.path); if (handled) { diff --git a/packages/editor-app/src/components/StartupPage.tsx b/packages/editor-app/src/components/StartupPage.tsx index 95700d96..39c3a524 100644 --- a/packages/editor-app/src/components/StartupPage.tsx +++ b/packages/editor-app/src/components/StartupPage.tsx @@ -310,7 +310,12 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec className="startup-dialog-btn danger" onClick={async () => { if (deleteConfirm && onDeleteProject) { - await onDeleteProject(deleteConfirm); + try { + await onDeleteProject(deleteConfirm); + } catch (error) { + console.error('[StartupPage] Failed to delete project:', error); + // Error will be handled by App.tsx error dialog + } } setDeleteConfirm(null); }} diff --git a/packages/editor-app/src/styles/ContentBrowser.css b/packages/editor-app/src/styles/ContentBrowser.css index cd5ec30e..7555af69 100644 --- a/packages/editor-app/src/styles/ContentBrowser.css +++ b/packages/editor-app/src/styles/ContentBrowser.css @@ -6,6 +6,7 @@ background: #1e1e1e; color: #e0e0e0; font-size: 12px; + outline: none; } .content-browser.is-drawer { diff --git a/packages/editor-core/src/Services/AssetRegistryService.ts b/packages/editor-core/src/Services/AssetRegistryService.ts index 7e36804b..adc33507 100644 --- a/packages/editor-core/src/Services/AssetRegistryService.ts +++ b/packages/editor-core/src/Services/AssetRegistryService.ts @@ -12,7 +12,7 @@ * Uses .meta files to persistently store each asset's GUID. */ -import { Core, createLogger } from '@esengine/ecs-framework'; +import { Core, createLogger, PlatformDetector } from '@esengine/ecs-framework'; import { MessageHub } from './MessageHub'; import { AssetMetaManager, @@ -215,6 +215,9 @@ export class AssetRegistryService { /** Asset meta manager for .meta file management */ private _metaManager: AssetMetaManager; + /** Tauri event unlisten function | Tauri 事件取消监听函数 */ + private _eventUnlisten: (() => void) | undefined; + /** Manifest file name */ static readonly MANIFEST_FILE = 'asset-manifest.json'; /** Current manifest version */ @@ -311,6 +314,10 @@ export class AssetRegistryService { // Save updated manifest await this._saveManifest(); + // Subscribe to file change events (Tauri only) + // 订阅文件变化事件(仅 Tauri 环境) + await this._subscribeToFileChanges(); + logger.info(`Project assets loaded: ${this._database.getStatistics().totalAssets} assets`); // Publish event @@ -320,10 +327,77 @@ export class AssetRegistryService { }); } + /** + * Subscribe to file change events from Tauri backend + * 订阅来自 Tauri 后端的文件变化事件 + */ + private async _subscribeToFileChanges(): Promise { + // Only in Tauri environment + // 仅在 Tauri 环境中 + if (!PlatformDetector.isTauriEnvironment()) { + return; + } + + try { + const { listen } = await import('@tauri-apps/api/event'); + + // Listen to user-code:file-changed event + // 监听 user-code:file-changed 事件 + this._eventUnlisten = await listen<{ + changeType: string; + paths: string[]; + }>('user-code:file-changed', async (event) => { + const { changeType, paths } = event.payload; + + logger.debug('File change event received | 收到文件变化事件', { changeType, paths }); + + // Handle file creation - register new assets and generate .meta + // 处理文件创建 - 注册新资产并生成 .meta + if (changeType === 'create' || changeType === 'modify') { + for (const absolutePath of paths) { + // Skip .meta files + if (absolutePath.endsWith('.meta')) continue; + + // Register or refresh the asset + await this.registerAsset(absolutePath); + } + } else if (changeType === 'remove') { + for (const absolutePath of paths) { + // Skip .meta files + if (absolutePath.endsWith('.meta')) continue; + + // Unregister the asset + await this.unregisterAsset(absolutePath); + } + } + }); + + logger.info('Subscribed to file change events | 已订阅文件变化事件'); + } catch (error) { + logger.warn('Failed to subscribe to file change events | 订阅文件变化事件失败:', error); + } + } + + /** + * Unsubscribe from file change events + * 取消订阅文件变化事件 + */ + private _unsubscribeFromFileChanges(): void { + if (this._eventUnlisten) { + this._eventUnlisten(); + this._eventUnlisten = undefined; + logger.debug('Unsubscribed from file change events | 已取消订阅文件变化事件'); + } + } + /** * Unload current project */ unloadProject(): void { + // Unsubscribe from file change events + // 取消订阅文件变化事件 + this._unsubscribeFromFileChanges(); + this._projectPath = null; this._manifest = null; this._database.clear(); diff --git a/packages/editor-core/src/Services/ProjectService.ts b/packages/editor-core/src/Services/ProjectService.ts index d251c7e6..a72f99c5 100644 --- a/packages/editor-core/src/Services/ProjectService.ts +++ b/packages/editor-core/src/Services/ProjectService.ts @@ -130,13 +130,10 @@ export class ProjectService implements IService { const assetsPath = `${projectPath}${sep}assets`; await this.fileAPI.createDirectory(assetsPath); - // Create types folder for type definitions - // 创建类型定义文件夹 - const typesPath = `${projectPath}${sep}types`; - await this.fileAPI.createDirectory(typesPath); - - // Create tsconfig.json for TypeScript support - // 创建 tsconfig.json 用于 TypeScript 支持 + // Create tsconfig.json for runtime scripts (components, systems) + // 创建运行时脚本的 tsconfig.json(组件、系统等) + // Note: paths will be populated by update_project_tsconfig when project is opened + // 注意:paths 会在项目打开时由 update_project_tsconfig 填充 const tsConfig = { compilerOptions: { target: 'ES2020', @@ -149,21 +146,40 @@ export class ProjectService implements IService { forceConsistentCasingInFileNames: true, experimentalDecorators: true, emitDecoratorMetadata: true, - noEmit: true, - // Reference local type definitions - // 引用本地类型定义文件 - typeRoots: ['./types'], - paths: { - '@esengine/ecs-framework': ['./types/ecs-framework.d.ts'], - '@esengine/engine-core': ['./types/engine-core.d.ts'] - } + noEmit: true + // paths will be added by editor when project is opened + // paths 会在编辑器打开项目时添加 }, include: ['scripts/**/*.ts'], - exclude: ['.esengine'] + exclude: ['scripts/editor/**/*.ts', '.esengine'] }; const tsConfigPath = `${projectPath}${sep}tsconfig.json`; await this.fileAPI.writeFileContent(tsConfigPath, JSON.stringify(tsConfig, null, 2)); + // Create tsconfig.editor.json for editor extension scripts + // 创建编辑器扩展脚本的 tsconfig.editor.json + const tsConfigEditor = { + compilerOptions: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + lib: ['ES2020', 'DOM'], + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + noEmit: true + // paths will be added by editor when project is opened + // paths 会在编辑器打开项目时添加 + }, + include: ['scripts/editor/**/*.ts'], + exclude: ['.esengine'] + }; + const tsConfigEditorPath = `${projectPath}${sep}tsconfig.editor.json`; + await this.fileAPI.writeFileContent(tsConfigEditorPath, JSON.stringify(tsConfigEditor, null, 2)); + await this.messageHub.publish('project:created', { path: projectPath }); diff --git a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts index 0fe90d86..dee914b3 100644 --- a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts @@ -215,8 +215,9 @@ export interface IUserCodeService { * - Classes extending System * * @param module - User code module | 用户代码模块 + * @param componentRegistry - Optional ComponentRegistry to register components | 可选的 ComponentRegistry 用于注册组件 */ - registerComponents(module: UserCodeModule): void; + registerComponents(module: UserCodeModule, componentRegistry?: any): void; /** * Register editor extensions from user module. diff --git a/packages/editor-core/src/Services/UserCode/UserCodeService.ts b/packages/editor-core/src/Services/UserCode/UserCodeService.ts index 7e7d4dff..5242a540 100644 --- a/packages/editor-core/src/Services/UserCode/UserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/UserCodeService.ts @@ -7,7 +7,7 @@ */ import type { IService } from '@esengine/ecs-framework'; -import { Injectable, createLogger } from '@esengine/ecs-framework'; +import { Injectable, createLogger, PlatformDetector } from '@esengine/ecs-framework'; import type { IUserCodeService, UserScriptInfo, @@ -110,6 +110,9 @@ export class UserCodeService implements IService, IUserCodeService { const errors: CompileError[] = []; const warnings: CompileError[] = []; + // Store project path for later use in load() | 存储项目路径供 load() 使用 + this._currentProjectPath = options.projectPath; + const sep = options.projectPath.includes('\\') ? '\\' : '/'; const scriptsDir = `${options.projectPath}${sep}${SCRIPTS_DIR}`; const outputDir = options.outputDir || `${options.projectPath}${sep}${USER_CODE_OUTPUT_DIR}`; @@ -146,14 +149,35 @@ export class UserCodeService implements IService, IUserCodeService { const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`; await this._fileSystem.writeFile(entryPath, entryContent); + // Create shim files for framework dependencies | 创建框架依赖的 shim 文件 + await this._createDependencyShims(outputDir, options.target); + + // Determine global name for IIFE output | 确定 IIFE 输出的全局名称 + const globalName = options.target === UserCodeTarget.Runtime + ? '__USER_RUNTIME_EXPORTS__' + : '__USER_EDITOR_EXPORTS__'; + + // Build alias map for framework dependencies | 构建框架依赖的别名映射 + const shimPath = `${outputDir}${sep}_shim_ecs_framework.js`.replace(/\\/g, '/'); + const alias: Record = { + '@esengine/ecs-framework': shimPath, + '@esengine/core': shimPath, + '@esengine/engine-core': shimPath, + '@esengine/math': shimPath + }; + // Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译 + // Use IIFE format to avoid ES module import issues in Tauri + // 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题 const compileResult = await this._runEsbuild({ entryPath, outputPath, - format: options.format || 'esm', + format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri + globalName, sourceMap: options.sourceMap ?? true, minify: options.minify ?? false, - external: this._getExternalDependencies(options.target), + external: [], // Don't use external, use alias instead | 不使用 external,使用 alias + alias, projectRoot: options.projectPath }); @@ -207,12 +231,30 @@ export class UserCodeService implements IService, IUserCodeService { */ async load(modulePath: string, target: UserCodeTarget): Promise { try { - // Add cache-busting query parameter for hot reload | 添加缓存破坏参数用于热更新 - const cacheBuster = `?t=${Date.now()}`; - const moduleUrl = `file://${modulePath}${cacheBuster}`; + let moduleExports: Record; - // Dynamic import the module | 动态导入模块 - const moduleExports = await import(/* @vite-ignore */ moduleUrl); + if (PlatformDetector.isTauriEnvironment()) { + // In Tauri, read file content and execute via script tag + // 在 Tauri 中,读取文件内容并通过 script 标签执行 + // This avoids CORS and module resolution issues + // 这避免了 CORS 和模块解析问题 + const { invoke } = await import('@tauri-apps/api/core'); + + const content = await invoke('read_file_content', { + path: modulePath + }); + + logger.debug(`Loading module via script injection`, { originalPath: modulePath }); + + // Execute module code and capture exports | 执行模块代码并捕获导出 + moduleExports = await this._executeModuleCode(content, target); + } else { + // Fallback to file:// for non-Tauri environments + // 非 Tauri 环境使用 file:// + const cacheBuster = `?t=${Date.now()}`; + const moduleUrl = `file://${modulePath}${cacheBuster}`; + moduleExports = await import(/* @vite-ignore */ moduleUrl); + } const module: UserCodeModule = { id: `user-${target}-${Date.now()}`, @@ -273,7 +315,7 @@ export class UserCodeService implements IService, IUserCodeService { * * @param module - User code module | 用户代码模块 */ - registerComponents(module: UserCodeModule): void { + registerComponents(module: UserCodeModule, componentRegistry?: any): void { if (module.target !== UserCodeTarget.Runtime) { logger.warn('Cannot register components from editor module | 无法从编辑器模块注册组件'); return; @@ -289,10 +331,24 @@ export class UserCodeService implements IService, IUserCodeService { // Check if it's a Component subclass | 检查是否是 Component 子类 if (this._isComponentClass(exported)) { - // Register with ComponentRegistry | 注册到 ComponentRegistry - // Note: Actual registration depends on runtime context - // 注意:实际注册取决于运行时上下文 logger.debug(`Found component: ${name} | 发现组件: ${name}`); + + // Register with ComponentRegistry if provided | 如果提供了 ComponentRegistry 则注册 + // ComponentRegistry expects ComponentTypeInfo object, not the class directly + // ComponentRegistry 期望 ComponentTypeInfo 对象,而不是直接传入类 + if (componentRegistry && typeof componentRegistry.register === 'function') { + try { + componentRegistry.register({ + name: name, + type: exported, + category: 'User', // User-defined components | 用户自定义组件 + description: `User component: ${name}` + }); + } catch (err) { + logger.warn(`Failed to register component ${name} | 注册组件 ${name} 失败:`, err); + } + } + componentCount++; } @@ -373,7 +429,7 @@ export class UserCodeService implements IService, IUserCodeService { try { // Check if we're in Tauri environment | 检查是否在 Tauri 环境 - if (typeof window !== 'undefined' && '__TAURI__' in window) { + if (PlatformDetector.isTauriEnvironment()) { const { invoke } = await import('@tauri-apps/api/core'); const { listen } = await import('@tauri-apps/api/event'); @@ -461,7 +517,7 @@ export class UserCodeService implements IService, IUserCodeService { } // Stop backend file watcher | 停止后端文件监视器 - if (typeof window !== 'undefined' && '__TAURI__' in window) { + if (PlatformDetector.isTauriEnvironment()) { const { invoke } = await import('@tauri-apps/api/core'); await invoke('stop_watch_scripts', { projectPath: this._currentProjectPath @@ -577,6 +633,17 @@ export class UserCodeService implements IService, IUserCodeService { /** * Build entry point content that re-exports all user scripts. * 构建重新导出所有用户脚本的入口点内容。 + * + * Entry file is in: {projectPath}/.esengine/compiled/_entry_runtime.ts + * Scripts are in: {projectPath}/scripts/ + * So the relative path from entry to scripts is: ../../scripts/ + * + * For IIFE format, we inject shims that map global variables to module imports. + * This allows user code to use `import { Component } from '@esengine/ecs-framework'` + * while actually accessing `window.__ESENGINE_FRAMEWORK__`. + * 对于 IIFE 格式,我们注入 shim 将全局变量映射到模块导入。 + * 这使用户代码可以使用 `import { Component } from '@esengine/ecs-framework'`, + * 实际上访问的是 `window.__ESENGINE_FRAMEWORK__`。 */ private _buildEntryPoint( scripts: UserScriptInfo[], @@ -589,22 +656,60 @@ export class UserCodeService implements IService, IUserCodeService { '' ]; + // Entry file is in .esengine/compiled/, need to go up 2 levels to reach project root + // 入口文件在 .esengine/compiled/ 目录,需要上升 2 级到达项目根目录 + const relativePrefix = `../../${SCRIPTS_DIR}`; + for (const script of scripts) { // Convert absolute path to relative import | 将绝对路径转换为相对导入 const relativePath = script.relativePath.replace(/\\/g, '/').replace(/\.tsx?$/, ''); if (script.exports.length > 0) { - lines.push(`export { ${script.exports.join(', ')} } from './${SCRIPTS_DIR}/${relativePath}';`); + lines.push(`export { ${script.exports.join(', ')} } from '${relativePrefix}/${relativePath}';`); } else { // Re-export everything if we couldn't detect specific exports // 如果无法检测到具体导出,则重新导出所有内容 - lines.push(`export * from './${SCRIPTS_DIR}/${relativePath}';`); + lines.push(`export * from '${relativePrefix}/${relativePath}';`); } } return lines.join('\n'); } + /** + * Create shim files that map global variables to module imports. + * 创建将全局变量映射到模块导入的 shim 文件。 + * + * This is used for IIFE format to resolve external dependencies. + * The shim exports the global __ESENGINE__.ecsFramework which is set by PluginSDKRegistry. + * 这用于 IIFE 格式解析外部依赖。 + * shim 导出全局的 __ESENGINE__.ecsFramework,由 PluginSDKRegistry 设置。 + * + * @param outputDir - Output directory | 输出目录 + * @param target - Target environment | 目标环境 + * @returns Array of shim file paths | shim 文件路径数组 + */ + private async _createDependencyShims( + outputDir: string, + target: UserCodeTarget + ): Promise { + const sep = outputDir.includes('\\') ? '\\' : '/'; + const shimPaths: string[] = []; + + // Create shim for @esengine/ecs-framework | 为 @esengine/ecs-framework 创建 shim + // This uses window.__ESENGINE__.ecsFramework set by PluginSDKRegistry + // 这使用 PluginSDKRegistry 设置的 window.__ESENGINE__.ecsFramework + const ecsShimPath = `${outputDir}${sep}_shim_ecs_framework.js`; + const ecsShimContent = `// Shim for @esengine/ecs-framework +// Maps to window.__ESENGINE__.ecsFramework set by PluginSDKRegistry +module.exports = (typeof window !== 'undefined' && window.__ESENGINE__ && window.__ESENGINE__.ecsFramework) || {}; +`; + await this._fileSystem.writeFile(ecsShimPath, ecsShimContent); + shimPaths.push(ecsShimPath); + + return shimPaths; + } + /** * Get external dependencies that should not be bundled. * 获取不应打包的外部依赖。 @@ -640,14 +745,16 @@ export class UserCodeService implements IService, IUserCodeService { entryPath: string; outputPath: string; format: 'esm' | 'iife'; + globalName?: string; sourceMap: boolean; minify: boolean; external: string[]; + alias?: Record; projectRoot: string; }): Promise<{ success: boolean; errors: CompileError[] }> { try { // Check if we're in Tauri environment | 检查是否在 Tauri 环境 - if (typeof window !== 'undefined' && '__TAURI__' in window) { + if (PlatformDetector.isTauriEnvironment()) { // Use Tauri command | 使用 Tauri 命令 const { invoke } = await import('@tauri-apps/api/core'); @@ -665,9 +772,11 @@ export class UserCodeService implements IService, IUserCodeService { entryPath: options.entryPath, outputPath: options.outputPath, format: options.format, + globalName: options.globalName, sourceMap: options.sourceMap, minify: options.minify, external: options.external, + alias: options.alias, projectRoot: options.projectRoot } }); @@ -701,6 +810,56 @@ export class UserCodeService implements IService, IUserCodeService { } } + /** + * Execute compiled module code and return exports. + * 执行编译后的模块代码并返回导出。 + * + * The code should be in IIFE format that sets a global variable. + * 代码应该是设置全局变量的 IIFE 格式。 + * + * @param code - Compiled JavaScript code | 编译后的 JavaScript 代码 + * @param target - Target environment | 目标环境 + * @returns Module exports | 模块导出 + */ + private async _executeModuleCode( + code: string, + target: UserCodeTarget + ): Promise> { + // Determine global name based on target | 根据目标确定全局名称 + const globalName = target === UserCodeTarget.Runtime + ? '__USER_RUNTIME_EXPORTS__' + : '__USER_EDITOR_EXPORTS__'; + + // Clear any previous exports | 清除之前的导出 + (window as any)[globalName] = undefined; + + try { + // esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); + // When executed via new Function(), var declarations stay in function scope + // We need to replace "var globalName" with "window.globalName" to expose it + // esbuild 生成: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); + // 通过 new Function() 执行时,var 声明在函数作用域内 + // 需要替换 "var globalName" 为 "window.globalName" 以暴露到全局 + const modifiedCode = code.replace( + new RegExp(`^"use strict";\\s*var ${globalName}`, 'm'), + `"use strict";\nwindow.${globalName}` + ); + + // Execute the IIFE code | 执行 IIFE 代码 + // eslint-disable-next-line no-new-func + const executeScript = new Function(modifiedCode); + executeScript(); + + // Get exports from global | 从全局获取导出 + const exports = (window as any)[globalName] || {}; + + return exports; + } catch (error) { + logger.error('Failed to execute user code | 执行用户代码失败:', error); + throw error; + } + } + /** * Ensure directory exists, create if not. * 确保目录存在,如果不存在则创建。 @@ -719,17 +878,29 @@ export class UserCodeService implements IService, IUserCodeService { /** * Check if a class extends Component. * 检查类是否继承自 Component。 + * + * Uses the actual Component class from the global framework to check inheritance. + * 使用全局框架中的实际 Component 类来检查继承关系。 */ private _isComponentClass(cls: any): boolean { - // Check prototype chain for Component | 检查原型链中是否有 Component - let proto = cls.prototype; - while (proto) { - if (proto.constructor?.name === 'Component') { - return true; - } - proto = Object.getPrototypeOf(proto); + // Get Component class from global framework | 从全局框架获取 Component 类 + const framework = (window as any).__ESENGINE__?.ecsFramework; + + if (!framework?.Component) { + return false; + } + + // Use instanceof or prototype chain check | 使用 instanceof 或原型链检查 + try { + const ComponentClass = framework.Component; + + // Check if cls.prototype is an instance of Component + // 检查 cls.prototype 是否是 Component 的实例 + return cls.prototype instanceof ComponentClass || + ComponentClass.prototype.isPrototypeOf(cls.prototype); + } catch { + return false; } - return false; } /** diff --git a/scripts/copy-engine-modules.mjs b/scripts/copy-engine-modules.mjs index b8a9defa..ff3eca72 100644 --- a/scripts/copy-engine-modules.mjs +++ b/scripts/copy-engine-modules.mjs @@ -110,7 +110,9 @@ function copyModule(module) { fs.copyFileSync(module.moduleJsonPath, destModuleJson); // Copy dist/index.js if exists + // 如果存在则拷贝 dist/index.js let hasRuntime = false; + let hasTypes = false; let sizeKB = 'N/A'; if (fs.existsSync(module.distPath)) { @@ -118,6 +120,7 @@ function copyModule(module) { fs.copyFileSync(module.distPath, destIndexJs); // Also copy source map if exists + // 如果存在则拷贝 source map const sourceMapPath = module.distPath + '.map'; if (fs.existsSync(sourceMapPath)) { fs.copyFileSync(sourceMapPath, destIndexJs + '.map'); @@ -128,7 +131,16 @@ function copyModule(module) { hasRuntime = true; } - return { hasRuntime, sizeKB }; + // Copy type definitions (.d.ts) if exists + // 如果存在则拷贝类型定义文件 (.d.ts) + const typesPath = module.distPath.replace(/\.js$/, '.d.ts'); + if (fs.existsSync(typesPath)) { + const destDts = path.join(moduleOutputDir, 'index.d.ts'); + fs.copyFileSync(typesPath, destDts); + hasTypes = true; + } + + return { hasRuntime, hasTypes, sizeKB }; } /** @@ -203,10 +215,11 @@ function main() { const moduleInfos = []; for (const module of modules) { - const { hasRuntime, sizeKB } = copyModule(module); + const { hasRuntime, hasTypes, sizeKB } = copyModule(module); if (hasRuntime) { - console.log(` ✓ ${module.id} (${sizeKB} KB)`); + const typesIndicator = hasTypes ? ' +types' : ''; + console.log(` ✓ ${module.id} (${sizeKB} KB${typesIndicator})`); } else { console.log(` ○ ${module.id} (config only)`); } @@ -216,6 +229,7 @@ function main() { name: module.name, displayName: module.displayName, hasRuntime, + hasTypes, editorPackage: module.editorPackage, isCore: module.isCore, category: module.category