feat(editor-app): 重构浏览器预览使用import maps

This commit is contained in:
yhh
2025-12-03 16:19:50 +08:00
parent 55f644a091
commit c2f8cb5272
50 changed files with 5995 additions and 1499 deletions

View File

@@ -1100,6 +1100,8 @@ dependencies = [
"futures-util",
"glob",
"image",
"notify",
"notify-debouncer-mini",
"once_cell",
"qrcode",
"serde",
@@ -1379,6 +1381,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futf"
version = "0.1.5"
@@ -2205,6 +2216,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -2214,6 +2245,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -2379,6 +2419,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2603,6 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -2708,6 +2769,46 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.9.4",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-debouncer-mini"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]]
name = "num-bigint"
version = "0.4.6"

View File

@@ -34,6 +34,8 @@ once_cell = "1.19"
urlencoding = "2.1"
qrcode = "0.14"
image = "0.25"
notify = "7.0"
notify-debouncer-mini = "0.5"
[profile.dev]
incremental = true

View File

@@ -0,0 +1,455 @@
//! Build related commands.
//! 构建相关命令。
//!
//! Provides file operations and compilation for build pipelines.
//! 为构建管线提供文件操作和编译功能。
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
/// Build progress event.
/// 构建进度事件。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildProgressEvent {
/// Progress percentage (0-100) | 进度百分比
pub progress: u32,
/// Current step message | 当前步骤消息
pub message: String,
/// Current step index | 当前步骤索引
pub current_step: u32,
/// Total steps | 总步骤数
pub total_steps: u32,
}
/// Clean and recreate output directory.
/// 清理并重建输出目录。
#[tauri::command]
pub async fn prepare_build_directory(output_path: String) -> Result<(), String> {
let path = Path::new(&output_path);
// Remove existing directory if exists | 如果存在则删除现有目录
if path.exists() {
fs::remove_dir_all(path)
.map_err(|e| format!("Failed to clean output directory | 清理输出目录失败: {}", e))?;
}
// Create fresh directory | 创建新目录
fs::create_dir_all(path)
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
Ok(())
}
/// Copy directory recursively.
/// 递归复制目录。
#[tauri::command]
pub async fn copy_directory(
src: String,
dst: String,
patterns: Option<Vec<String>>,
) -> Result<u32, String> {
let src_path = Path::new(&src);
let dst_path = Path::new(&dst);
if !src_path.exists() {
return Err(format!("Source directory does not exist | 源目录不存在: {}", src));
}
// Create destination directory | 创建目标目录
fs::create_dir_all(dst_path)
.map_err(|e| format!("Failed to create destination directory | 创建目标目录失败: {}", e))?;
let mut copied_count = 0u32;
// Recursively copy | 递归复制
copy_dir_recursive(src_path, dst_path, &patterns, &mut copied_count)?;
Ok(copied_count)
}
/// Helper function to copy directory recursively.
/// 递归复制目录的辅助函数。
fn copy_dir_recursive(
src: &Path,
dst: &Path,
patterns: &Option<Vec<String>>,
count: &mut u32,
) -> Result<(), String> {
for entry in fs::read_dir(src)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let src_path = entry.path();
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
if src_path.is_dir() {
// Skip hidden directories | 跳过隐藏目录
if file_name.to_string_lossy().starts_with('.') {
continue;
}
fs::create_dir_all(&dst_path)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
copy_dir_recursive(&src_path, &dst_path, patterns, count)?;
} else {
// Check if file matches patterns | 检查文件是否匹配模式
if let Some(ref pats) = patterns {
let file_name_str = file_name.to_string_lossy();
let matches = pats.iter().any(|p| {
if p.starts_with("*.") {
let ext = &p[2..];
file_name_str.ends_with(&format!(".{}", ext))
} else {
file_name_str.contains(p)
}
});
if !matches {
continue;
}
}
fs::copy(&src_path, &dst_path)
.map_err(|e| format!("Failed to copy file | 复制文件失败: {} -> {}: {}",
src_path.display(), dst_path.display(), e))?;
*count += 1;
}
}
Ok(())
}
/// Bundle options for esbuild.
/// esbuild 打包选项。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleOptions {
/// Entry files | 入口文件
pub entry_points: Vec<String>,
/// Output directory | 输出目录
pub output_dir: String,
/// Output format (esm or iife) | 输出格式
pub format: String,
/// Bundle name | 打包名称
pub bundle_name: String,
/// Whether to minify | 是否压缩
pub minify: bool,
/// Whether to generate source map | 是否生成 source map
pub source_map: bool,
/// External dependencies | 外部依赖
pub external: Vec<String>,
/// Project root for resolving imports | 项目根目录
pub project_root: String,
/// Define replacements | 宏定义替换
pub define: Option<std::collections::HashMap<String, String>>,
}
/// Bundle result.
/// 打包结果。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleResult {
/// Whether bundling succeeded | 是否打包成功
pub success: bool,
/// Output file path | 输出文件路径
pub output_file: Option<String>,
/// Output file size in bytes | 输出文件大小(字节)
pub output_size: Option<u64>,
/// Error message if failed | 失败时的错误信息
pub error: Option<String>,
/// Warnings | 警告
pub warnings: Vec<String>,
}
/// Bundle JavaScript/TypeScript files using esbuild.
/// 使用 esbuild 打包 JavaScript/TypeScript 文件。
#[tauri::command]
pub async fn bundle_scripts(options: BundleOptions) -> Result<BundleResult, String> {
let esbuild_path = find_esbuild(&options.project_root)?;
// Build output file path | 构建输出文件路径
let output_file = Path::new(&options.output_dir)
.join(&options.bundle_name)
.with_extension("js");
// Ensure output directory exists | 确保输出目录存在
if let Some(parent) = output_file.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
}
// Build esbuild arguments | 构建 esbuild 参数
let mut args: Vec<String> = options.entry_points.clone();
args.push("--bundle".to_string());
args.push(format!("--outfile={}", output_file.display()));
args.push(format!("--format={}", options.format));
args.push("--platform=browser".to_string());
args.push("--target=es2020".to_string());
if options.source_map {
args.push("--sourcemap".to_string());
}
if options.minify {
args.push("--minify".to_string());
}
for external in &options.external {
args.push(format!("--external:{}", external));
}
// Add define replacements | 添加宏定义替换
if let Some(ref defines) = options.define {
for (key, value) in defines {
args.push(format!("--define:{}={}", key, value));
}
}
// Run esbuild | 运行 esbuild
let output = Command::new(&esbuild_path)
.args(&args)
.current_dir(&options.project_root)
.output()
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
if output.status.success() {
// Get output file size | 获取输出文件大小
let output_size = fs::metadata(&output_file)
.map(|m| m.len())
.ok();
// Parse warnings from stderr | 从 stderr 解析警告
let stderr = String::from_utf8_lossy(&output.stderr);
let warnings: Vec<String> = stderr
.lines()
.filter(|l| l.contains("warning"))
.map(|l| l.to_string())
.collect();
Ok(BundleResult {
success: true,
output_file: Some(output_file.to_string_lossy().to_string()),
output_size,
error: None,
warnings,
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(BundleResult {
success: false,
output_file: None,
output_size: None,
error: Some(stderr.to_string()),
warnings: vec![],
})
}
}
/// Generate HTML file from template.
/// 从模板生成 HTML 文件。
#[tauri::command]
pub async fn generate_html(
output_path: String,
title: String,
scripts: Vec<String>,
body_content: Option<String>,
) -> Result<(), String> {
let scripts_html: String = scripts
.iter()
.map(|s| format!(r#" <script src="{}"></script>"#, s))
.collect::<Vec<_>>()
.join("\n");
let body = body_content.unwrap_or_else(|| {
r#" <canvas id="game-canvas" style="width: 100%; height: 100%;"></canvas>"#.to_string()
});
let html = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{ width: 100%; height: 100%; overflow: hidden; background: #000; }}
</style>
</head>
<body>
{}
{}
</body>
</html>"#,
title, body, scripts_html
);
// Ensure parent directory exists | 确保父目录存在
let path = Path::new(&output_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
}
fs::write(&output_path, html)
.map_err(|e| format!("Failed to write HTML file | 写入 HTML 文件失败: {}", e))?;
Ok(())
}
/// Get file size.
/// 获取文件大小。
#[tauri::command]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
fs::metadata(&file_path)
.map(|m| m.len())
.map_err(|e| format!("Failed to get file size | 获取文件大小失败: {}", e))
}
/// Get directory size recursively.
/// 递归获取目录大小。
#[tauri::command]
pub async fn get_directory_size(dir_path: String) -> Result<u64, String> {
let path = Path::new(&dir_path);
if !path.exists() {
return Err(format!("Directory does not exist | 目录不存在: {}", dir_path));
}
calculate_dir_size(path)
}
/// Helper to calculate directory size.
/// 计算目录大小的辅助函数。
fn calculate_dir_size(path: &Path) -> Result<u64, String> {
let mut total_size = 0u64;
for entry in fs::read_dir(path)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let entry_path = entry.path();
if entry_path.is_dir() {
total_size += calculate_dir_size(&entry_path)?;
} else {
total_size += fs::metadata(&entry_path)
.map(|m| m.len())
.unwrap_or(0);
}
}
Ok(total_size)
}
/// Find esbuild executable.
/// 查找 esbuild 可执行文件。
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try local node_modules first | 首先尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
project_path.join("node_modules/.bin/esbuild")
};
if local_esbuild.exists() {
return Ok(local_esbuild.to_string_lossy().to_string());
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
let check = Command::new(global_esbuild)
.arg("--version")
.output();
match check {
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
_ => Err("esbuild not found | 未找到 esbuild".to_string())
}
}
/// Write JSON file.
/// 写入 JSON 文件。
#[tauri::command]
pub async fn write_json_file(file_path: String, content: String) -> Result<(), String> {
let path = Path::new(&file_path);
// Ensure parent directory exists | 确保父目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
}
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write JSON file | 写入 JSON 文件失败: {}", e))?;
Ok(())
}
/// List files in directory with extension filter.
/// 列出目录中指定扩展名的文件。
#[tauri::command]
pub async fn list_files_by_extension(
dir_path: String,
extensions: Vec<String>,
recursive: bool,
) -> Result<Vec<String>, String> {
let path = Path::new(&dir_path);
if !path.exists() {
return Ok(vec![]);
}
let mut files = Vec::new();
list_files_recursive(path, &extensions, recursive, &mut files)?;
Ok(files)
}
/// Helper to list files recursively.
/// 递归列出文件的辅助函数。
fn list_files_recursive(
path: &Path,
extensions: &[String],
recursive: bool,
files: &mut Vec<String>,
) -> Result<(), String> {
for entry in fs::read_dir(path)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let entry_path = entry.path();
if entry_path.is_dir() {
if recursive {
list_files_recursive(&entry_path, extensions, recursive, files)?;
}
} else if let Some(ext) = entry_path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.iter().any(|e| e.to_lowercase() == ext_str) {
files.push(entry_path.to_string_lossy().to_string());
}
}
}
Ok(())
}
/// Read binary file and return as base64.
/// 读取二进制文件并返回 base64 编码。
#[tauri::command]
pub async fn read_binary_file_as_base64(path: String) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let bytes = fs::read(&path)
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))?;
Ok(STANDARD.encode(&bytes))
}

View File

@@ -0,0 +1,389 @@
//! User code compilation commands.
//! 用户代码编译命令。
//!
//! Provides TypeScript compilation using esbuild for user scripts.
//! 使用 esbuild 为用户脚本提供 TypeScript 编译。
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::sync::mpsc::channel;
use std::time::Duration;
use tauri::{command, AppHandle, Emitter, State};
use crate::state::ScriptWatcherState;
/// Compilation options.
/// 编译选项。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompileOptions {
/// Entry file path | 入口文件路径
pub entry_path: String,
/// Output file path | 输出文件路径
pub output_path: String,
/// Output format (esm or iife) | 输出格式
pub format: String,
/// Whether to generate source map | 是否生成 source map
pub source_map: bool,
/// Whether to minify | 是否压缩
pub minify: bool,
/// External dependencies | 外部依赖
pub external: Vec<String>,
/// Project root for resolving imports | 项目根目录用于解析导入
pub project_root: String,
}
/// Compilation error.
/// 编译错误。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompileError {
/// Error message | 错误信息
pub message: String,
/// File path | 文件路径
pub file: Option<String>,
/// Line number | 行号
pub line: Option<u32>,
/// Column number | 列号
pub column: Option<u32>,
}
/// Compilation result.
/// 编译结果。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompileResult {
/// Whether compilation succeeded | 是否编译成功
pub success: bool,
/// Compilation errors | 编译错误
pub errors: Vec<CompileError>,
/// Output file path (if successful) | 输出文件路径(如果成功)
pub output_path: Option<String>,
}
/// File change event sent to frontend.
/// 发送到前端的文件变更事件。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileChangeEvent {
/// Type of change: "create", "modify", "remove" | 变更类型
pub change_type: String,
/// File paths that changed | 发生变更的文件路径
pub paths: Vec<String>,
}
/// Compile TypeScript using esbuild.
/// 使用 esbuild 编译 TypeScript。
///
/// # Arguments | 参数
/// * `options` - Compilation options | 编译选项
///
/// # Returns | 返回
/// Compilation result | 编译结果
#[command]
pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult, String> {
// Check if esbuild is available | 检查 esbuild 是否可用
let esbuild_path = find_esbuild(&options.project_root)?;
// Build esbuild arguments | 构建 esbuild 参数
let mut args = vec![
options.entry_path.clone(),
"--bundle".to_string(),
format!("--outfile={}", options.output_path),
format!("--format={}", options.format),
"--platform=browser".to_string(),
"--target=es2020".to_string(),
];
// Add source map option | 添加 source map 选项
if options.source_map {
args.push("--sourcemap".to_string());
}
// Add minify option | 添加压缩选项
if options.minify {
args.push("--minify".to_string());
}
// Add external dependencies | 添加外部依赖
for external in &options.external {
args.push(format!("--external:{}", external));
}
// Run esbuild | 运行 esbuild
let output = Command::new(&esbuild_path)
.args(&args)
.current_dir(&options.project_root)
.output()
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
if output.status.success() {
Ok(CompileResult {
success: true,
errors: vec![],
output_path: Some(options.output_path),
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let errors = parse_esbuild_errors(&stderr);
Ok(CompileResult {
success: false,
errors,
output_path: None,
})
}
}
/// Watch for file changes in scripts directory.
/// 监视脚本目录中的文件变更。
///
/// Emits "user-code:file-changed" events when files change.
/// 当文件发生变更时触发 "user-code:file-changed" 事件。
#[command]
pub async fn watch_scripts(
app: AppHandle,
watcher_state: State<'_, ScriptWatcherState>,
project_path: String,
scripts_dir: String,
) -> Result<(), String> {
let watch_path = Path::new(&project_path).join(&scripts_dir);
if !watch_path.exists() {
return Err(format!(
"Scripts directory does not exist | 脚本目录不存在: {}",
watch_path.display()
));
}
// Check if already watching this project | 检查是否已在监视此项目
{
let watchers = watcher_state.watchers.lock().await;
if watchers.contains_key(&project_path) {
println!("[UserCode] Already watching: {}", project_path);
return Ok(());
}
}
// Create a channel for shutdown signal | 创建关闭信号通道
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
// Clone values for the spawned task | 克隆值以供任务使用
let project_path_clone = project_path.clone();
let watch_path_clone = watch_path.clone();
let app_clone = app.clone();
// Spawn file watcher task | 启动文件监视任务
tokio::spawn(async move {
// Create notify watcher | 创建 notify 监视器
let (tx, rx) = channel();
let mut watcher = match RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
},
Config::default().with_poll_interval(Duration::from_millis(500)),
) {
Ok(w) => w,
Err(e) => {
eprintln!("[UserCode] Failed to create watcher: {}", e);
return;
}
};
// Start watching | 开始监视
if let Err(e) = watcher.watch(&watch_path_clone, RecursiveMode::Recursive) {
eprintln!("[UserCode] Failed to watch path: {}", e);
return;
}
println!("[UserCode] Started watching: {}", watch_path_clone.display());
// Event loop | 事件循环
loop {
// Check for shutdown | 检查关闭信号
if shutdown_rx.try_recv().is_ok() {
println!("[UserCode] Stopping watcher for: {}", project_path_clone);
break;
}
// Check for file events with timeout | 带超时检查文件事件
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
// Filter for TypeScript/JavaScript files | 过滤 TypeScript/JavaScript 文件
let ts_paths: Vec<String> = event
.paths
.iter()
.filter(|p| {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "ts" | "tsx" | "js" | "jsx")
})
.map(|p| p.to_string_lossy().to_string())
.collect();
if !ts_paths.is_empty() {
let change_type = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => continue,
};
let file_event = FileChangeEvent {
change_type: change_type.to_string(),
paths: ts_paths,
};
println!("[UserCode] File change detected: {:?}", file_event);
// Emit event to frontend | 向前端发送事件
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
eprintln!("[UserCode] Failed to emit event: {}", e);
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// No events, continue | 无事件,继续
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
println!("[UserCode] Watcher channel disconnected");
break;
}
}
}
});
// Store watcher handle | 存储监视器句柄
{
let mut watchers = watcher_state.watchers.lock().await;
watchers.insert(
project_path.clone(),
crate::state::WatcherHandle { shutdown_tx },
);
}
println!("[UserCode] Watch scripts started for: {}/{}", project_path, scripts_dir);
Ok(())
}
/// Stop watching for file changes.
/// 停止监视文件变更。
#[command]
pub async fn stop_watch_scripts(
watcher_state: State<'_, ScriptWatcherState>,
project_path: Option<String>,
) -> Result<(), String> {
let mut watchers = watcher_state.watchers.lock().await;
match project_path {
Some(path) => {
// Stop specific watcher | 停止特定监视器
if let Some(handle) = watchers.remove(&path) {
let _ = handle.shutdown_tx.send(());
println!("[UserCode] Stopped watching: {}", path);
}
}
None => {
// Stop all watchers | 停止所有监视器
for (path, handle) in watchers.drain() {
let _ = handle.shutdown_tx.send(());
println!("[UserCode] Stopped watching: {}", path);
}
}
}
Ok(())
}
/// Find esbuild executable path.
/// 查找 esbuild 可执行文件路径。
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try local node_modules first | 首先尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
project_path.join("node_modules/.bin/esbuild")
};
if local_esbuild.exists() {
return Ok(local_esbuild.to_string_lossy().to_string());
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
// Check if global esbuild exists | 检查全局 esbuild 是否存在
let check = Command::new(global_esbuild)
.arg("--version")
.output();
match check {
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild请安装: npm install -g esbuild".to_string())
}
}
/// Parse esbuild error output.
/// 解析 esbuild 错误输出。
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
let mut errors = Vec::new();
// Simple error parsing - esbuild outputs errors in a specific format
// 简单的错误解析 - esbuild 以特定格式输出错误
for line in stderr.lines() {
if line.contains("error:") || line.contains("Error:") {
// Try to parse file:line:column format | 尝试解析 file:line:column 格式
let parts: Vec<&str> = line.splitn(2, ": ").collect();
if parts.len() == 2 {
let location = parts[0];
let message = parts[1].to_string();
// Parse location (file:line:column) | 解析位置
let loc_parts: Vec<&str> = location.split(':').collect();
let (file, line_num, column) = if loc_parts.len() >= 3 {
(
Some(loc_parts[0].to_string()),
loc_parts[1].parse().ok(),
loc_parts[2].parse().ok(),
)
} else {
(None, None, None)
};
errors.push(CompileError {
message,
file,
line: line_num,
column,
});
} else {
errors.push(CompileError {
message: line.to_string(),
file: None,
line: None,
column: None,
});
}
}
}
// If no specific errors found, add the whole stderr as one error
// 如果没有找到特定错误,将整个 stderr 作为一个错误
if errors.is_empty() && !stderr.trim().is_empty() {
errors.push(CompileError {
message: stderr.to_string(),
file: None,
line: None,
column: None,
});
}
errors
}

View File

@@ -1,17 +1,25 @@
//! Command modules
//! Command modules.
//! 命令模块。
//!
//! All Tauri commands organized by domain.
//! 所有按领域组织的 Tauri 命令。
pub mod build;
pub mod compiler;
pub mod dialog;
pub mod file_system;
pub mod modules;
pub mod plugin;
pub mod profiler;
pub mod project;
pub mod system;
// Re-export all commands for convenience
// Re-export all commands for convenience | 重新导出所有命令以方便使用
pub use build::*;
pub use compiler::*;
pub use dialog::*;
pub use file_system::*;
pub use modules::*;
pub use plugin::*;
pub use profiler::*;
pub use project::*;

View File

@@ -0,0 +1,175 @@
//! Engine Module Commands
//! 引擎模块命令
//!
//! Commands for reading engine module configurations.
//! 用于读取引擎模块配置的命令。
use std::path::PathBuf;
use tauri::{command, AppHandle};
#[cfg(not(debug_assertions))]
use tauri::Manager;
/// Module index structure.
/// 模块索引结构。
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ModuleIndex {
pub version: String,
#[serde(rename = "generatedAt")]
pub generated_at: String,
pub modules: Vec<ModuleIndexEntry>,
}
/// Module index entry.
/// 模块索引条目。
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ModuleIndexEntry {
pub id: String,
pub name: String,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "hasRuntime")]
pub has_runtime: bool,
#[serde(rename = "editorPackage")]
pub editor_package: Option<String>,
#[serde(rename = "isCore")]
pub is_core: bool,
pub category: String,
/// JS bundle size in bytes | JS 包大小(字节)
#[serde(rename = "jsSize")]
pub js_size: Option<u64>,
/// Whether this module requires WASM | 是否需要 WASM
#[serde(rename = "requiresWasm")]
pub requires_wasm: Option<bool>,
/// WASM file size in bytes | WASM 文件大小(字节)
#[serde(rename = "wasmSize")]
pub wasm_size: Option<u64>,
}
/// Get the engine modules directory path.
/// 获取引擎模块目录路径。
///
/// Uses compile-time CARGO_MANIFEST_DIR in dev mode to locate dist/engine.
/// 在开发模式下使用编译时的 CARGO_MANIFEST_DIR 来定位 dist/engine。
#[allow(unused_variables)]
fn get_engine_modules_path(app: &AppHandle) -> Result<PathBuf, String> {
// In development mode, use compile-time path
// 在开发模式下,使用编译时路径
#[cfg(debug_assertions)]
{
// CARGO_MANIFEST_DIR is set at compile time, pointing to src-tauri
// CARGO_MANIFEST_DIR 在编译时设置,指向 src-tauri
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(|p| p.join("dist/engine"))
.unwrap_or_else(|| PathBuf::from("dist/engine"));
if dev_path.exists() {
println!("[modules] Using dev path: {:?}", dev_path);
return Ok(dev_path);
}
// Fallback: try current working directory
// 回退:尝试当前工作目录
let cwd_path = std::env::current_dir()
.map(|p| p.join("dist/engine"))
.unwrap_or_else(|_| PathBuf::from("dist/engine"));
if cwd_path.exists() {
println!("[modules] Using cwd path: {:?}", cwd_path);
return Ok(cwd_path);
}
return Err(format!(
"Engine modules directory not found in dev mode. Tried: {:?}, {:?}. Run 'pnpm copy-modules' first.",
dev_path, cwd_path
));
}
// Production: use resource directory
// 生产环境:使用资源目录
#[cfg(not(debug_assertions))]
{
let resource_path = app
.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource dir: {}", e))?;
let prod_path = resource_path.join("engine");
if prod_path.exists() {
return Ok(prod_path);
}
// Fallback: try exe directory
// 回退:尝试可执行文件目录
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get exe path: {}", e))?;
let exe_dir = exe_path.parent()
.ok_or("Failed to get exe directory")?;
let exe_engine_path = exe_dir.join("engine");
if exe_engine_path.exists() {
return Ok(exe_engine_path);
}
Err(format!(
"Engine modules directory not found. Tried: {:?}, {:?}",
prod_path, exe_engine_path
))
}
}
/// Read the engine modules index.
/// 读取引擎模块索引。
#[command]
pub async fn read_engine_modules_index(app: AppHandle) -> Result<ModuleIndex, String> {
println!("[modules] read_engine_modules_index called");
let engine_path = get_engine_modules_path(&app)?;
println!("[modules] engine_path: {:?}", engine_path);
let index_path = engine_path.join("index.json");
if !index_path.exists() {
return Err(format!(
"Module index not found at {:?}. Run 'pnpm copy-modules' first.",
index_path
));
}
let content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("Failed to read index.json: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse index.json: {}", e))
}
/// Read a specific module's manifest.
/// 读取特定模块的清单。
#[command]
pub async fn read_module_manifest(app: AppHandle, module_id: String) -> Result<serde_json::Value, String> {
let engine_path = get_engine_modules_path(&app)?;
let manifest_path = engine_path.join(&module_id).join("module.json");
if !manifest_path.exists() {
return Err(format!(
"Module manifest not found for '{}' at {:?}",
module_id, manifest_path
));
}
let content = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read module.json for {}: {}", module_id, e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse module.json for {}: {}", module_id, e))
}
/// Get the base path to engine modules directory.
/// 获取引擎模块目录的基础路径。
#[command]
pub async fn get_engine_modules_base_path(app: AppHandle) -> Result<String, String> {
let path = get_engine_modules_path(&app)?;
path.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Failed to convert path to string".to_string())
}

View File

@@ -12,14 +12,15 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::Manager;
use state::{ProfilerState, ProjectPaths};
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
fn main() {
// Initialize shared state
// Initialize shared state | 初始化共享状态
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
let project_paths_for_protocol = Arc::clone(&project_paths);
let profiler_state = ProfilerState::new();
let script_watcher_state = ScriptWatcherState::new();
// Build and run the Tauri application
tauri::Builder::default()
@@ -34,10 +35,11 @@ fn main() {
.register_uri_scheme_protocol("project", move |_app, request| {
handle_project_protocol(request, &project_paths_for_protocol)
})
// Setup application state
// Setup application state | 设置应用状态
.setup(move |app| {
app.manage(project_paths);
app.manage(profiler_state);
app.manage(script_watcher_state);
Ok(())
})
// Register all commands
@@ -85,6 +87,24 @@ fn main() {
commands::stop_local_server,
commands::get_local_ip,
commands::generate_qrcode,
// User code compilation | 用户代码编译
commands::compile_typescript,
commands::watch_scripts,
commands::stop_watch_scripts,
// Build commands | 构建命令
commands::prepare_build_directory,
commands::copy_directory,
commands::bundle_scripts,
commands::generate_html,
commands::get_file_size,
commands::get_directory_size,
commands::write_json_file,
commands::list_files_by_extension,
commands::read_binary_file_as_base64,
// Engine modules | 引擎模块
commands::read_engine_modules_index,
commands::read_module_manifest,
commands::get_engine_modules_base_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,17 +1,52 @@
//! Application state definitions
//! Application state definitions.
//! 应用状态定义。
//!
//! Centralized state management for the Tauri application.
//! Tauri 应用的集中状态管理。
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use crate::profiler_ws::ProfilerServer;
/// Project paths state
/// Project paths state.
/// 项目路径状态。
///
/// Stores the current project path and other path-related information.
/// 存储当前项目路径和其他路径相关信息。
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
/// Script watcher state.
/// 脚本监视器状态。
///
/// Manages file watchers for hot reload functionality.
/// 管理用于热重载功能的文件监视器。
pub struct ScriptWatcherState {
/// Active watchers keyed by project path | 按项目路径索引的活动监视器
pub watchers: Arc<TokioMutex<HashMap<String, WatcherHandle>>>,
}
/// Handle to a running file watcher.
/// 正在运行的文件监视器句柄。
pub struct WatcherHandle {
/// Shutdown signal sender | 关闭信号发送器
pub shutdown_tx: tokio::sync::oneshot::Sender<()>,
}
impl ScriptWatcherState {
pub fn new() -> Self {
Self {
watchers: Arc::new(TokioMutex::new(HashMap::new())),
}
}
}
impl Default for ScriptWatcherState {
fn default() -> Self {
Self::new()
}
}
/// Profiler server state
///
/// Manages the lifecycle of the WebSocket profiler server.