feat(editor): 实现用户脚本编译加载和自动重编译 (#273)

This commit is contained in:
YHH
2025-12-04 19:32:51 +08:00
committed by GitHub
parent 3d16bbdc64
commit 0d9bab910e
17 changed files with 951 additions and 127 deletions

View File

@@ -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<String>,
/// Whether to generate source map | 是否生成 source map
pub source_map: bool,
/// Whether to minify | 是否压缩
pub minify: bool,
/// External dependencies | 外部依赖
pub external: Vec<String>,
/// Module aliases (e.g., "@esengine/ecs-framework" -> "/path/to/shim.js")
/// 模块别名
pub alias: Option<std::collections::HashMap<String, String>>,
/// Project root for resolving imports | 项目根目录用于解析导入
pub project_root: String,
}
@@ -106,11 +111,27 @@ pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult
args.push("--minify".to_string());
}
// Add global name for IIFE format | 添加 IIFE 格式的全局名称
if let Some(ref global_name) = options.global_name {
args.push(format!("--global-name={}", global_name));
}
// Add external dependencies | 添加外部依赖
for external in &options.external {
args.push(format!("--external:{}", external));
}
// Add module aliases | 添加模块别名
if let Some(ref aliases) = options.alias {
for (from, to) in aliases {
args.push(format!("--alias:{}={}", from, to));
}
}
// Build full command string for error reporting | 构建完整命令字符串用于错误报告
let cmd_str = format!("{} {}", esbuild_path, args.join(" "));
println!("[Compiler] Running: {}", cmd_str);
// Run esbuild | 运行 esbuild
let output = Command::new(&esbuild_path)
.args(&args)
@@ -119,6 +140,7 @@ pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
if output.status.success() {
println!("[Compiler] Compilation successful: {}", options.output_path);
Ok(CompileResult {
success: true,
errors: vec![],
@@ -126,7 +148,37 @@ pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let errors = parse_esbuild_errors(&stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
println!("[Compiler] Compilation failed");
println!("[Compiler] stdout: {}", stdout);
println!("[Compiler] stderr: {}", stderr);
// Try to parse errors from both stdout and stderr | 尝试从 stdout 和 stderr 解析错误
let mut errors = parse_esbuild_errors(&stderr);
if errors.is_empty() {
errors = parse_esbuild_errors(&stdout);
}
// If still no parsed errors, include the raw output and command | 如果仍然没有解析到错误,包含原始输出和命令
if errors.is_empty() {
let combined_output = if !stderr.is_empty() && !stdout.is_empty() {
format!("stdout: {}\nstderr: {}", stdout.trim(), stderr.trim())
} else if !stderr.is_empty() {
stderr.trim().to_string()
} else if !stdout.is_empty() {
stdout.trim().to_string()
} else {
format!("Command failed: {}", cmd_str)
};
errors.push(CompileError {
message: combined_output,
file: None,
line: None,
column: None,
});
}
Ok(CompileResult {
success: false,

View File

@@ -82,10 +82,35 @@ pub fn delete_file(path: String) -> 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

View File

@@ -142,6 +142,109 @@ pub fn get_temp_dir() -> Result<String, String> {
.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<String> {
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<String> {
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 <folder> <file>
// 这会打开文件夹并同时打开文件
// Correct command format: code <folder> <file>
// 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 <folder> <file>
// 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<String, String> {
.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<String, String> {
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::path::PathBuf> {
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(())
}

View File

@@ -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()
}

View File

@@ -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'

View File

@@ -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<void> {
return await invoke<void>('copy_type_definitions', { projectPath });
static async updateProjectTsconfig(projectPath: string): Promise<void> {
return await invoke<void>('update_project_tsconfig', { projectPath });
}
/**

View File

@@ -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

View File

@@ -125,6 +125,9 @@ export function ContentBrowser({
const messageHub = Core.services.resolve(MessageHub);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
// Refs
const containerRef = useRef<HTMLDivElement>(null);
// State
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(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 (
<div className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}>
<div
ref={containerRef}
className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}
tabIndex={-1}
>
{/* Left Panel - Folder Tree */}
<div className="content-browser-left">
{/* 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);
}

View File

@@ -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<FileTreeHandle, FileTreeProps>(({ 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) {

View File

@@ -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);
}}

View File

@@ -6,6 +6,7 @@
background: #1e1e1e;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.content-browser.is-drawer {