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

@@ -357,6 +357,47 @@ export class TauriAPI {
static convertFileSrc(filePath: string, protocol?: string): string {
return convertFileSrc(filePath, protocol);
}
/**
* 检测开发环境
* Check development environment
*
* Checks if all required tools (esbuild, etc.) are available.
* 检查所有必需的工具是否可用。
*
* @returns 环境检测结果 | Environment check result
*/
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
return await invoke<EnvironmentCheckResult>('check_environment');
}
}
/**
* 工具可用性状态
* Tool availability status
*/
export interface ToolStatus {
/** 工具是否可用 | Whether the tool is available */
available: boolean;
/** 工具版本 | Tool version */
version?: string;
/** 工具路径 | Tool path */
path?: string;
/** 工具来源: "bundled", "local", "global" | Tool source */
source?: string;
/** 错误信息 | Error message */
error?: string;
}
/**
* 环境检测结果
* Environment check result
*/
export interface EnvironmentCheckResult {
/** 所有必需工具是否可用 | Whether all required tools are available */
ready: boolean;
/** esbuild 可用性状态 | esbuild availability status */
esbuild: ToolStatus;
}
export interface DirectoryEntry {

View File

@@ -39,7 +39,8 @@ import {
WeChatBuildPipeline,
moduleRegistry,
UserCodeService,
UserCodeTarget
UserCodeTarget,
type HotReloadEvent
} from '@esengine/editor-core';
import { ViewportService } from '../../services/ViewportService';
import { TransformComponent } from '@esengine/engine-core';
@@ -146,6 +147,10 @@ export class ServiceRegistry {
CoreComponentRegistry.register(comp.type as any);
}
// Enable hot reload for editor environment
// 在编辑器环境中启用热更新
CoreComponentRegistry.enableHotReload();
const projectService = new ProjectService(messageHub, fileAPI);
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const propertyMetadata = new PropertyMetadataService();
@@ -321,10 +326,46 @@ export class ServiceRegistry {
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
currentProjectPath = data.path;
await compileAndLoadUserScripts(data.path);
// Start watching for file changes (external editor support)
// 开始监视文件变更(支持外部编辑器)
userCodeService.watch(data.path, async (event) => {
console.log('[UserCodeService] Hot reload event:', event.changedFiles);
if (event.newModule) {
// 1. Register new/updated components to registries
// 1. 注册新的/更新的组件到注册表
userCodeService.registerComponents(event.newModule, componentRegistry);
// 2. Hot reload: update prototype chain of existing instances
// 2. 热更新:更新现有实例的原型链
const updatedCount = userCodeService.hotReloadInstances(event.newModule);
console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`);
// 3. Notify that user code has been reloaded
// 3. 通知用户代码已重新加载
messageHub.publish('usercode:reloaded', {
projectPath: data.path,
exports: Object.keys(event.newModule.exports),
updatedInstances: updatedCount
});
}
}).catch(err => {
console.warn('[UserCodeService] Failed to start file watcher:', err);
});
});
// Subscribe to script file changes (create/delete/modify)
// 订阅脚本文件变更(创建/删除/修改)
// Subscribe to project:closed to stop watching
// 订阅 project:closed 以停止监视
messageHub.subscribe('project:closed', async () => {
currentProjectPath = null;
await userCodeService.stopWatch();
});
// Subscribe to script file changes (create/delete) from editor operations
// 订阅编辑器操作的脚本文件变更(创建/删除)
// Note: file:modified is handled by the Rust file watcher for external editor support
// 注意file:modified 由 Rust 文件监视器处理以支持外部编辑器
messageHub.subscribe('file:created', async (data: { path: string }) => {
if (currentProjectPath && this.isScriptFile(data.path)) {
await compileAndLoadUserScripts(currentProjectPath);
@@ -337,12 +378,6 @@ export class ServiceRegistry {
}
});
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();

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { Globe, ChevronDown, Download, X, Loader2, Trash2 } from 'lucide-react';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo';
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
import '../styles/StartupPage.css';
type Locale = 'en' | 'zh';
@@ -33,6 +34,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
const [showEnvStatus, setShowEnvStatus] = useState(false);
const langMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -59,6 +62,24 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
});
}, []);
// 启动时检测开发环境
useEffect(() => {
TauriAPI.checkEnvironment().then((result) => {
setEnvCheck(result);
// 如果环境就绪,在控制台显示信息
if (result.ready) {
console.log('[Environment] Ready ✓');
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
} else {
// 环境有问题,显示提示
setShowEnvStatus(true);
console.warn('[Environment] Not ready:', result.esbuild.error);
}
}).catch((error) => {
console.error('[Environment] Check failed:', error);
});
}, []);
const translations = {
en: {
title: 'ESEngine Editor',
@@ -76,7 +97,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
deleteConfirmTitle: 'Delete Project',
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
cancel: 'Cancel',
delete: 'Delete'
delete: 'Delete',
envReady: 'Environment Ready',
envNotReady: 'Environment Issue',
esbuildReady: 'esbuild ready',
esbuildMissing: 'esbuild not found'
},
zh: {
title: 'ESEngine 编辑器',
@@ -94,7 +119,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
deleteConfirmTitle: '删除项目',
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
cancel: '取消',
delete: '删除'
delete: '删除',
envReady: '环境就绪',
envNotReady: '环境问题',
esbuildReady: 'esbuild 就绪',
esbuildMissing: '未找到 esbuild'
}
};
@@ -220,6 +249,43 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
<div className="startup-footer">
<span className="startup-version">{versionText}</span>
{/* 环境状态指示器 | Environment Status Indicator */}
{envCheck && (
<div
className={`startup-env-status ${envCheck.ready ? 'ready' : 'warning'}`}
onClick={() => setShowEnvStatus(!showEnvStatus)}
title={envCheck.ready ? t.envReady : t.envNotReady}
>
{envCheck.ready ? (
<CheckCircle size={14} />
) : (
<AlertCircle size={14} />
)}
{showEnvStatus && (
<div className="startup-env-tooltip">
<div className="env-tooltip-title">
{envCheck.ready ? t.envReady : t.envNotReady}
</div>
<div className={`env-tooltip-item ${envCheck.esbuild.available ? 'ok' : 'error'}`}>
{envCheck.esbuild.available ? (
<>
<CheckCircle size={12} />
<span>esbuild {envCheck.esbuild.version}</span>
<span className="env-source">({envCheck.esbuild.source})</span>
</>
) : (
<>
<AlertCircle size={12} />
<span>{t.esbuildMissing}</span>
</>
)}
</div>
</div>
)}
</div>
)}
{onLocaleChange && (
<div className="startup-locale-dropdown" ref={langMenuRef}>
<button

View File

@@ -526,3 +526,92 @@
background: #b91c1c;
border-color: #b91c1c;
}
/* 环境状态指示器样式 | Environment Status Indicator Styles */
.startup-env-status {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s;
}
.startup-env-status.ready {
color: #4ade80;
}
.startup-env-status.ready:hover {
background: rgba(74, 222, 128, 0.1);
}
.startup-env-status.warning {
color: #f59e0b;
animation: pulse 2s infinite;
}
.startup-env-status.warning:hover {
background: rgba(245, 158, 11, 0.1);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.startup-env-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 12px 16px;
min-width: 200px;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
.startup-env-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #3e3e42;
}
.env-tooltip-title {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #3e3e42;
font-size: 12px;
font-weight: 500;
color: #ffffff;
}
.env-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.env-tooltip-item.ok {
color: #4ade80;
}
.env-tooltip-item.error {
color: #f87171;
}
.env-source {
opacity: 0.6;
font-size: 11px;
}