feat(editor): 完善用户代码热更新和环境检测 (#275)

* fix: 更新 bundle-runtime 脚本使用正确的 platform-web 输出文件

原脚本引用的 runtime.browser.js 不存在,改为使用 index.mjs

* feat(editor): 完善用户代码热更新和环境检测

## 热更新改进
- 添加 hotReloadInstances() 方法,通过更新原型链实现真正的热更新
- 组件实例保留数据,仅更新方法
- ComponentRegistry 支持热更新时替换同名组件类

## 环境检测
- 启动时检测 esbuild 可用性
- 在启动页面底部显示环境状态指示器
- 添加 check_environment Rust 命令和前端 API

## esbuild 打包
- 将 esbuild 二进制文件打包到应用中
- 用户无需全局安装 esbuild
- 支持 Windows/macOS/Linux 平台

## 文件监视优化
- 添加 300ms 防抖,避免重复编译
- 修复路径分隔符混合问题

## 资源打包修复
- 修复 Tauri 资源配置,保留 engine 目录结构
- 添加 src-tauri/bin/ 和 src-tauri/engine/ 到 gitignore

* fix: 将热更新模式改为可选,修复测试失败

- ComponentRegistry 添加 hotReloadEnabled 标志,默认禁用
- 只有启用热更新模式时才会替换同名组件类
- 编辑器环境自动启用热更新模式
- reset() 方法中重置热更新标志

* test: 添加热更新模式的测试用例
This commit is contained in:
YHH
2025-12-05 14:24:09 +08:00
committed by GitHub
parent d7454e3ca4
commit 6702f0bfad
12 changed files with 755 additions and 57 deletions

View File

@@ -67,6 +67,34 @@ pub struct CompileResult {
pub output_path: Option<String>,
}
/// Environment check result.
/// 环境检测结果。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentCheckResult {
/// Whether all required tools are available | 所有必需工具是否可用
pub ready: bool,
/// esbuild availability status | esbuild 可用性状态
pub esbuild: ToolStatus,
}
/// Tool availability status.
/// 工具可用性状态。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolStatus {
/// Whether the tool is available | 工具是否可用
pub available: bool,
/// Tool version (if available) | 工具版本(如果可用)
pub version: Option<String>,
/// Tool path (if available) | 工具路径(如果可用)
pub path: Option<String>,
/// Source of the tool: "bundled", "local", "global" | 工具来源
pub source: Option<String>,
/// Error message (if not available) | 错误信息(如果不可用)
pub error: Option<String>,
}
/// File change event sent to frontend.
/// 发送到前端的文件变更事件。
#[derive(Debug, Clone, Serialize)]
@@ -78,6 +106,82 @@ pub struct FileChangeEvent {
pub paths: Vec<String>,
}
/// Check development environment.
/// 检测开发环境。
///
/// Checks if all required tools (esbuild, etc.) are available.
/// 检查所有必需的工具是否可用。
#[command]
pub async fn check_environment() -> Result<EnvironmentCheckResult, String> {
let esbuild_status = check_esbuild_status();
Ok(EnvironmentCheckResult {
ready: esbuild_status.available,
esbuild: esbuild_status,
})
}
/// Check esbuild availability and get its status.
/// 检查 esbuild 可用性并获取其状态。
fn check_esbuild_status() -> ToolStatus {
// Try bundled esbuild first | 首先尝试打包的 esbuild
if let Some(bundled_path) = find_bundled_esbuild() {
match get_esbuild_version(&bundled_path) {
Ok(version) => {
return ToolStatus {
available: true,
version: Some(version),
path: Some(bundled_path),
source: Some("bundled".to_string()),
error: None,
};
}
Err(e) => {
println!("[Environment] Bundled esbuild found but failed to get version: {}", e);
}
}
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
match get_esbuild_version(global_esbuild) {
Ok(version) => {
ToolStatus {
available: true,
version: Some(version),
path: Some(global_esbuild.to_string()),
source: Some("global".to_string()),
error: None,
}
}
Err(_) => {
ToolStatus {
available: false,
version: None,
path: None,
source: None,
error: Some("esbuild not found | 未找到 esbuild".to_string()),
}
}
}
}
/// Get esbuild version.
/// 获取 esbuild 版本。
fn get_esbuild_version(esbuild_path: &str) -> Result<String, String> {
let output = Command::new(esbuild_path)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run esbuild: {}", e))?;
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(version)
} else {
Err("esbuild --version failed".to_string())
}
}
/// Compile TypeScript using esbuild.
/// 使用 esbuild 编译 TypeScript。
///
@@ -254,6 +358,11 @@ pub async fn watch_scripts(
println!("[UserCode] Started watching: {}", watch_path_clone.display());
// Debounce state | 防抖状态
let mut pending_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut last_event_time = std::time::Instant::now();
let debounce_duration = Duration::from_millis(300);
// Event loop | 事件循环
loop {
// Check for shutdown | 检查关闭信号
@@ -277,19 +386,28 @@ pub async fn watch_scripts(
.collect();
if !ts_paths.is_empty() {
let change_type = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
// Only handle create/modify/remove events | 只处理创建/修改/删除事件
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
// Add to pending paths and update last event time | 添加到待处理路径并更新最后事件时间
for path in ts_paths {
pending_paths.insert(path);
}
last_event_time = std::time::Instant::now();
}
_ => continue,
};
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Check if we should emit pending events (debounce) | 检查是否应该发送待处理事件(防抖)
if !pending_paths.is_empty() && last_event_time.elapsed() >= debounce_duration {
let file_event = FileChangeEvent {
change_type: change_type.to_string(),
paths: ts_paths,
change_type: "modify".to_string(),
paths: pending_paths.drain().collect(),
};
println!("[UserCode] File change detected: {:?}", file_event);
println!("[UserCode] File change detected (debounced): {:?}", file_event);
// Emit event to frontend | 向前端发送事件
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
@@ -297,9 +415,6 @@ pub async fn watch_scripts(
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// No events, continue | 无事件,继续
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
println!("[UserCode] Watcher channel disconnected");
break;
@@ -352,10 +467,21 @@ pub async fn stop_watch_scripts(
/// Find esbuild executable path.
/// 查找 esbuild 可执行文件路径。
///
/// Search order | 搜索顺序:
/// 1. Bundled esbuild in app resources | 应用资源中打包的 esbuild
/// 2. Local node_modules | 本地 node_modules
/// 3. Global esbuild | 全局 esbuild
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try local node_modules first | 首先尝试本地 node_modules
// Try bundled esbuild first (in app resources) | 首先尝试打包的 esbuild在应用资源中
if let Some(bundled) = find_bundled_esbuild() {
println!("[Compiler] Using bundled esbuild: {}", bundled);
return Ok(bundled);
}
// Try local node_modules | 尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
@@ -363,6 +489,7 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
};
if local_esbuild.exists() {
println!("[Compiler] Using local esbuild: {}", local_esbuild.display());
return Ok(local_esbuild.to_string_lossy().to_string());
}
@@ -375,11 +502,51 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
.output();
match check {
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
Ok(output) if output.status.success() => {
println!("[Compiler] Using global esbuild");
Ok(global_esbuild.to_string())
},
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild请安装: npm install -g esbuild".to_string())
}
}
/// Find bundled esbuild in app resources.
/// 在应用资源中查找打包的 esbuild。
fn find_bundled_esbuild() -> Option<String> {
// Get the executable path | 获取可执行文件路径
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
// In development, resources are in src-tauri directory | 开发模式下,资源在 src-tauri 目录
// In production, resources are next to the executable | 生产模式下,资源在可执行文件旁边
let esbuild_name = if cfg!(windows) { "esbuild.exe" } else { "esbuild" };
// Try production path (resources next to exe) | 尝试生产路径(资源在 exe 旁边)
let prod_path = exe_dir.join("bin").join(esbuild_name);
if prod_path.exists() {
return Some(prod_path.to_string_lossy().to_string());
}
// Try development path (in src-tauri/bin) | 尝试开发路径(在 src-tauri/bin 中)
// This handles running via `cargo tauri dev`
let dev_path = exe_dir
.ancestors()
.find_map(|p| {
let candidate = p.join("src-tauri").join("bin").join(esbuild_name);
if candidate.exists() {
Some(candidate)
} else {
None
}
});
if let Some(path) = dev_path {
return Some(path.to_string_lossy().to_string());
}
None
}
/// Parse esbuild error output.
/// 解析 esbuild 错误输出。
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {

View File

@@ -93,6 +93,7 @@ fn main() {
commands::compile_typescript,
commands::watch_scripts,
commands::stop_watch_scripts,
commands::check_environment,
// Build commands | 构建命令
commands::prepare_build_directory,
commands::copy_directory,