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

@@ -1,2 +1,4 @@
# Generated runtime files
src-tauri/runtime/
src-tauri/engine/
src-tauri/bin/

View File

@@ -22,10 +22,11 @@ if (!fs.existsSync(bundleDir)) {
}
// Files to bundle
// 需要打包的文件
const filesToBundle = [
{
src: path.join(rootPath, 'packages/platform-web/dist/runtime.browser.js'),
dst: path.join(bundleDir, 'runtime.browser.js')
src: path.join(rootPath, 'packages/platform-web/dist/index.mjs'),
dst: path.join(bundleDir, 'platform-web.mjs')
},
{
src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'),
@@ -96,34 +97,101 @@ for (const { src, dst } of typeFilesToBundle) {
}
}
// Update tauri.conf.json to include runtime directory
if (success) {
const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json');
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
// Copy engine modules directory from dist/engine to src-tauri/engine
// 复制引擎模块目录从 dist/engine 到 src-tauri/engine
const engineSrcDir = path.join(editorPath, 'dist', 'engine');
const engineDstDir = path.join(editorPath, 'src-tauri', 'engine');
// Add runtime directory to resources
if (!config.bundle) {
config.bundle = {};
}
if (!config.bundle.resources) {
config.bundle.resources = {};
/**
* Recursively copy directory
* 递归复制目录
*/
function copyDirRecursive(src, dst) {
if (!fs.existsSync(src)) {
return false;
}
// Handle both array and object format for resources
if (Array.isArray(config.bundle.resources)) {
if (!config.bundle.resources.includes('runtime/**/*')) {
config.bundle.resources.push('runtime/**/*');
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
console.log('✓ Updated tauri.conf.json with runtime resources');
}
} else if (typeof config.bundle.resources === 'object') {
// Object format - add runtime files if not present
if (!config.bundle.resources['runtime/**/*']) {
config.bundle.resources['runtime/**/*'] = '.';
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
console.log('✓ Updated tauri.conf.json with runtime resources');
if (!fs.existsSync(dst)) {
fs.mkdirSync(dst, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, dstPath);
} else {
fs.copyFileSync(srcPath, dstPath);
}
}
return true;
}
if (fs.existsSync(engineSrcDir)) {
// Remove old engine directory if exists
if (fs.existsSync(engineDstDir)) {
fs.rmSync(engineDstDir, { recursive: true });
}
if (copyDirRecursive(engineSrcDir, engineDstDir)) {
// Count files
let fileCount = 0;
function countFiles(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
countFiles(path.join(dir, entry.name));
} else {
fileCount++;
}
}
}
countFiles(engineDstDir);
console.log(`✓ Copied engine modules directory (${fileCount} files)`);
}
} else {
console.warn(`Engine modules directory not found: ${engineSrcDir}`);
console.log(' Build editor-app first: pnpm --filter @esengine/editor-app build');
}
// Copy esbuild binary for user code compilation
// 复制 esbuild 二进制文件用于用户代码编译
const binDir = path.join(editorPath, 'src-tauri', 'bin');
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
console.log(`Created bin directory: ${binDir}`);
}
// Platform-specific esbuild binary paths
// 平台特定的 esbuild 二进制路径
const esbuildSources = {
win32: path.join(rootPath, 'node_modules/@esbuild/win32-x64/esbuild.exe'),
darwin: path.join(rootPath, 'node_modules/@esbuild/darwin-x64/bin/esbuild'),
linux: path.join(rootPath, 'node_modules/@esbuild/linux-x64/bin/esbuild'),
};
const platform = process.platform;
const esbuildSrc = esbuildSources[platform];
const esbuildDst = path.join(binDir, platform === 'win32' ? 'esbuild.exe' : 'esbuild');
if (esbuildSrc && fs.existsSync(esbuildSrc)) {
try {
fs.copyFileSync(esbuildSrc, esbuildDst);
// Ensure executable permission on Unix
if (platform !== 'win32') {
fs.chmodSync(esbuildDst, 0o755);
}
const stats = fs.statSync(esbuildDst);
console.log(`✓ Bundled esbuild binary (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
} catch (error) {
console.warn(`Failed to bundle esbuild: ${error.message}`);
console.log(' User code compilation will require global esbuild installation');
}
} else {
console.warn(`esbuild binary not found for platform ${platform}: ${esbuildSrc}`);
console.log(' User code compilation will require global esbuild installation');
}
if (!success) {

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,

View File

@@ -11,9 +11,11 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"runtime/**/*": "."
},
"resources": [
"runtime/**/*",
"engine/**/*",
"bin/*"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

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