refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
@@ -1,128 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
pub theme: String,
|
||||
pub auto_save: bool,
|
||||
pub recent_projects: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "dark".to_string(),
|
||||
auto_save: true,
|
||||
recent_projects: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
|
||||
fs::read_to_string(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接写入文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_global_blackboard(project_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
|
||||
}
|
||||
|
||||
fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let ecs_dir = Path::new(&project_path).join(".ecs");
|
||||
let config_path = ecs_dir.join("global-blackboard.json");
|
||||
|
||||
// 创建 .ecs 目录(如果不存在)
|
||||
if !ecs_dir.exists() {
|
||||
fs::create_dir_all(&ecs_dir)
|
||||
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write global blackboard: {}", e))
|
||||
}
|
||||
|
||||
97
packages/editor-app/src-tauri/src/commands/dialog.rs
Normal file
97
packages/editor-app/src-tauri/src/commands/dialog.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Dialog operations
|
||||
//!
|
||||
//! Generic system dialog commands for file/folder selection.
|
||||
//! No business-specific logic - all filtering is done via parameters.
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
/// File filter definition
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FileFilter {
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Open folder selection dialog
|
||||
#[tauri::command]
|
||||
pub async fn open_folder_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Select Folder");
|
||||
}
|
||||
|
||||
let folder = dialog.blocking_pick_folder();
|
||||
|
||||
Ok(folder.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
/// Open file selection dialog (generic)
|
||||
#[tauri::command]
|
||||
pub async fn open_file_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
multiple: Option<bool>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Select File");
|
||||
}
|
||||
|
||||
if let Some(filter_list) = filters {
|
||||
for filter in filter_list {
|
||||
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(&filter.name, &extensions);
|
||||
}
|
||||
}
|
||||
|
||||
if multiple.unwrap_or(false) {
|
||||
let files = dialog.blocking_pick_files();
|
||||
Ok(files.map(|paths| paths.iter().map(|p| p.to_string()).collect()))
|
||||
} else {
|
||||
let file = dialog.blocking_pick_file();
|
||||
Ok(file.map(|path| vec![path.to_string()]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Save file dialog (generic)
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
default_name: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Save File");
|
||||
}
|
||||
|
||||
if let Some(name) = default_name {
|
||||
dialog = dialog.set_file_name(&name);
|
||||
}
|
||||
|
||||
if let Some(filter_list) = filters {
|
||||
for filter in filter_list {
|
||||
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(&filter.name, &extensions);
|
||||
}
|
||||
}
|
||||
|
||||
let file = dialog.blocking_save_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
205
packages/editor-app/src-tauri/src/commands/file_system.rs
Normal file
205
packages/editor-app/src-tauri/src/commands/file_system.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Generic file system operations
|
||||
//!
|
||||
//! Provides low-level file system commands that can be composed by the frontend
|
||||
//! for business logic. No business-specific logic should be in this module.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Directory entry information
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<u64>,
|
||||
}
|
||||
|
||||
/// Read text file content
|
||||
#[tauri::command]
|
||||
pub fn read_file_content(path: String) -> Result<String, String> {
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Write text content to file (auto-creates parent directories)
|
||||
#[tauri::command]
|
||||
pub fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&path).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Write binary content to file (auto-creates parent directories)
|
||||
#[tauri::command]
|
||||
pub async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&file_path).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
/// Check if path exists
|
||||
#[tauri::command]
|
||||
pub fn path_exists(path: String) -> Result<bool, String> {
|
||||
Ok(Path::new(&path).exists())
|
||||
}
|
||||
|
||||
/// Create directory (recursive)
|
||||
#[tauri::command]
|
||||
pub fn create_directory(path: String) -> Result<(), String> {
|
||||
fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Create empty file
|
||||
#[tauri::command]
|
||||
pub fn create_file(path: String) -> Result<(), String> {
|
||||
fs::File::create(&path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete file
|
||||
#[tauri::command]
|
||||
pub fn delete_file(path: String) -> Result<(), String> {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Rename or move file/folder
|
||||
#[tauri::command]
|
||||
pub fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
|
||||
fs::rename(&old_path, &new_path)
|
||||
.map_err(|e| format!("Failed to rename {} to {}: {}", old_path, new_path, e))
|
||||
}
|
||||
|
||||
/// List directory contents with metadata
|
||||
#[tauri::command]
|
||||
pub fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||
let dir_path = Path::new(&path);
|
||||
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let read_dir = fs::read_dir(dir_path)
|
||||
.map_err(|e| format!("Failed to read directory {}: {}", path, e))?;
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if let Some(name) = entry_path.file_name() {
|
||||
let is_dir = entry_path.is_dir();
|
||||
|
||||
let (size, modified) = fs::metadata(&entry_path)
|
||||
.map(|metadata| {
|
||||
let size = if is_dir { None } else { Some(metadata.len()) };
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| {
|
||||
time.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs())
|
||||
});
|
||||
(size, modified)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_string_lossy().to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Scan directory for files matching a glob pattern
|
||||
#[tauri::command]
|
||||
pub fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
let separator = if path.contains('\\') { '\\' } else { '/' };
|
||||
let glob_pattern = format!(
|
||||
"{}{}{}",
|
||||
path.trim_end_matches(&['/', '\\'][..]),
|
||||
separator,
|
||||
pattern
|
||||
);
|
||||
|
||||
let normalized_pattern = if cfg!(windows) {
|
||||
glob_pattern.replace('/', "\\")
|
||||
} else {
|
||||
glob_pattern.replace('\\', "/")
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
match glob(&normalized_pattern) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() {
|
||||
files.push(entry.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Read file as base64 encoded string
|
||||
#[tauri::command]
|
||||
pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
let file_content = fs::read(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))?;
|
||||
|
||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
||||
}
|
||||
18
packages/editor-app/src-tauri/src/commands/mod.rs
Normal file
18
packages/editor-app/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Command modules
|
||||
//!
|
||||
//! All Tauri commands organized by domain.
|
||||
|
||||
pub mod dialog;
|
||||
pub mod file_system;
|
||||
pub mod plugin;
|
||||
pub mod profiler;
|
||||
pub mod project;
|
||||
pub mod system;
|
||||
|
||||
// Re-export all commands for convenience
|
||||
pub use dialog::*;
|
||||
pub use file_system::*;
|
||||
pub use plugin::*;
|
||||
pub use profiler::*;
|
||||
pub use project::*;
|
||||
pub use system::*;
|
||||
270
packages/editor-app/src-tauri/src/commands/plugin.rs
Normal file
270
packages/editor-app/src-tauri/src/commands/plugin.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Plugin management commands
|
||||
//!
|
||||
//! Building, installing, and uninstalling editor plugins.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use zip::write::FileOptions;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Build progress event payload
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct BuildProgress {
|
||||
pub step: String,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a plugin from source
|
||||
#[tauri::command]
|
||||
pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<String, String> {
|
||||
let plugin_path = Path::new(&plugin_folder);
|
||||
if !plugin_path.exists() {
|
||||
return Err(format!("Plugin folder does not exist: {}", plugin_folder));
|
||||
}
|
||||
|
||||
let package_json_path = plugin_path.join("package.json");
|
||||
if !package_json_path.exists() {
|
||||
return Err("package.json not found in plugin folder".to_string());
|
||||
}
|
||||
|
||||
let build_cache_dir = plugin_path.join(".build-cache");
|
||||
if !build_cache_dir.exists() {
|
||||
fs::create_dir_all(&build_cache_dir)
|
||||
.map_err(|e| format!("Failed to create .build-cache directory: {}", e))?;
|
||||
}
|
||||
|
||||
let npm_command = if cfg!(target_os = "windows") {
|
||||
"npm.cmd"
|
||||
} else {
|
||||
"npm"
|
||||
};
|
||||
|
||||
// Step 1: Install dependencies
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "install".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let install_output = Command::new(npm_command)
|
||||
.args(["install"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run npm install: {}", e))?;
|
||||
|
||||
if !install_output.status.success() {
|
||||
return Err(format!(
|
||||
"npm install failed: {}",
|
||||
String::from_utf8_lossy(&install_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// Step 2: Build
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "build".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let build_output = Command::new(npm_command)
|
||||
.args(["run", "build"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run npm run build: {}", e))?;
|
||||
|
||||
if !build_output.status.success() {
|
||||
return Err(format!(
|
||||
"npm run build failed: {}",
|
||||
String::from_utf8_lossy(&build_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let dist_path = plugin_path.join("dist");
|
||||
if !dist_path.exists() {
|
||||
return Err("dist directory not found after build".to_string());
|
||||
}
|
||||
|
||||
// Step 3: Package
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "package".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let zip_path = build_cache_dir.join("index.zip");
|
||||
let zip_file =
|
||||
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
|
||||
|
||||
let mut zip = zip::ZipWriter::new(zip_file);
|
||||
let options = FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o755);
|
||||
|
||||
// Add package.json
|
||||
let package_json_content = fs::read(&package_json_path)
|
||||
.map_err(|e| format!("Failed to read package.json: {}", e))?;
|
||||
zip.start_file("package.json", options)
|
||||
.map_err(|e| format!("Failed to add package.json to zip: {}", e))?;
|
||||
zip.write_all(&package_json_content)
|
||||
.map_err(|e| format!("Failed to write package.json to zip: {}", e))?;
|
||||
|
||||
// Add dist directory
|
||||
add_directory_to_zip(&mut zip, plugin_path, &dist_path, options)
|
||||
.map_err(|e| format!("Failed to add dist directory to zip: {}", e))?;
|
||||
|
||||
zip.finish()
|
||||
.map_err(|e| format!("Failed to finalize zip: {}", e))?;
|
||||
|
||||
// Step 4: Complete
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "complete".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
Ok(zip_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn add_directory_to_zip<W: std::io::Write + std::io::Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
base_path: &Path,
|
||||
current_path: &Path,
|
||||
options: FileOptions,
|
||||
) -> Result<(), String> {
|
||||
let entries = fs::read_dir(current_path)
|
||||
.map_err(|e| format!("Failed to read directory {}: {}", current_path.display(), e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
add_directory_to_zip(zip, base_path, &path, options)?;
|
||||
} else {
|
||||
let relative_path = path
|
||||
.strip_prefix(base_path)
|
||||
.map_err(|e| format!("Failed to get relative path: {}", e))?;
|
||||
|
||||
let zip_path = relative_path.to_string_lossy().replace('\\', "/");
|
||||
|
||||
let file_content = fs::read(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
|
||||
|
||||
zip.start_file(&zip_path, options)
|
||||
.map_err(|e| format!("Failed to add file {} to zip: {}", zip_path, e))?;
|
||||
|
||||
zip.write_all(&file_content)
|
||||
.map_err(|e| format!("Failed to write file {} to zip: {}", zip_path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a plugin from marketplace
|
||||
#[tauri::command]
|
||||
pub async fn install_marketplace_plugin(
|
||||
project_path: String,
|
||||
plugin_id: String,
|
||||
zip_data_base64: String,
|
||||
) -> Result<String, String> {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
let project_path = Path::new(&project_path);
|
||||
if !project_path.exists() {
|
||||
return Err(format!(
|
||||
"Project path does not exist: {}",
|
||||
project_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let plugins_dir = project_path.join("plugins");
|
||||
if !plugins_dir.exists() {
|
||||
fs::create_dir_all(&plugins_dir)
|
||||
.map_err(|e| format!("Failed to create plugins directory: {}", e))?;
|
||||
}
|
||||
|
||||
let plugin_dir = plugins_dir.join(&plugin_id);
|
||||
if plugin_dir.exists() {
|
||||
fs::remove_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to remove old plugin directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to create plugin directory: {}", e))?;
|
||||
|
||||
let zip_bytes = general_purpose::STANDARD
|
||||
.decode(&zip_data_base64)
|
||||
.map_err(|e| format!("Failed to decode base64 ZIP data: {}", e))?;
|
||||
|
||||
let cursor = Cursor::new(zip_bytes);
|
||||
let mut archive =
|
||||
ZipArchive::new(cursor).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read ZIP entry {}: {}", i, e))?;
|
||||
|
||||
let file_path = match file.enclosed_name() {
|
||||
Some(path) => path,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let out_path = plugin_dir.join(file_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
|
||||
std::io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", out_path.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugin_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_marketplace_plugin(
|
||||
project_path: String,
|
||||
plugin_id: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = Path::new(&project_path);
|
||||
let plugin_dir = project_path.join("plugins").join(&plugin_id);
|
||||
|
||||
if !plugin_dir.exists() {
|
||||
return Err(format!(
|
||||
"Plugin directory does not exist: {}",
|
||||
plugin_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to remove plugin directory: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
58
packages/editor-app/src-tauri/src/commands/profiler.rs
Normal file
58
packages/editor-app/src-tauri/src/commands/profiler.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Profiler server commands
|
||||
//!
|
||||
//! WebSocket profiler server management.
|
||||
|
||||
use std::sync::Arc;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
use crate::state::ProfilerState;
|
||||
|
||||
/// Start the profiler WebSocket server
|
||||
#[tauri::command]
|
||||
pub async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the profiler WebSocket server
|
||||
#[tauri::command]
|
||||
pub async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
if let Some(server) = server_lock.as_ref() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
/// Get profiler server status
|
||||
#[tauri::command]
|
||||
pub async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
77
packages/editor-app/src-tauri/src/commands/project.rs
Normal file
77
packages/editor-app/src-tauri/src/commands/project.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Project management commands
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::state::ProjectPaths;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_project(path: String) -> Result<String, String> {
|
||||
Ok(format!("Project opened: {}", path))
|
||||
}
|
||||
|
||||
/// Save project data
|
||||
#[tauri::command]
|
||||
pub fn save_project(path: String, data: String) -> Result<(), String> {
|
||||
fs::write(&path, data).map_err(|e| format!("Failed to save project: {}", e))
|
||||
}
|
||||
|
||||
/// Export binary data
|
||||
#[tauri::command]
|
||||
pub fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
|
||||
fs::write(&output_path, data).map_err(|e| format!("Failed to export binary: {}", e))
|
||||
}
|
||||
|
||||
/// Set current project base path
|
||||
#[tauri::command]
|
||||
pub fn set_project_base_path(path: String, state: tauri::State<ProjectPaths>) -> Result<(), String> {
|
||||
let mut paths = state
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock state: {}", e))?;
|
||||
paths.insert("current".to_string(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan for behavior tree files in project
|
||||
#[tauri::command]
|
||||
pub fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
|
||||
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
|
||||
|
||||
if !behaviors_path.exists() {
|
||||
fs::create_dir_all(&behaviors_path)
|
||||
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut btree_files = Vec::new();
|
||||
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
|
||||
|
||||
Ok(btree_files)
|
||||
}
|
||||
|
||||
fn scan_directory_recursive(
|
||||
base_path: &Path,
|
||||
current_path: &Path,
|
||||
results: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let entries =
|
||||
fs::read_dir(current_path).map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_directory_recursive(base_path, &path, results)?;
|
||||
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
|
||||
if let Ok(relative) = path.strip_prefix(base_path) {
|
||||
let relative_str = relative
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.trim_end_matches(".btree")
|
||||
.to_string();
|
||||
results.push(relative_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
96
packages/editor-app/src-tauri/src/commands/system.rs
Normal file
96
packages/editor-app/src-tauri/src/commands/system.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! System operations
|
||||
//!
|
||||
//! OS-level operations like opening files, showing in folder, devtools, etc.
|
||||
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Toggle developer tools (debug mode only)
|
||||
#[tauri::command]
|
||||
pub fn toggle_devtools(app: AppHandle) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("DevTools are only available in debug mode".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open file with system default application
|
||||
#[tauri::command]
|
||||
pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show file in system file explorer
|
||||
#[tauri::command]
|
||||
pub fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::path::Path;
|
||||
let path = Path::new(&file_path);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "Failed to get parent directory".to_string())?;
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// ECS Editor Library
|
||||
//! ECS Editor Library
|
||||
//!
|
||||
//! Exports all public modules for the Tauri application.
|
||||
|
||||
pub mod commands;
|
||||
pub mod project;
|
||||
pub mod profiler_ws;
|
||||
pub mod state;
|
||||
|
||||
pub use commands::*;
|
||||
pub use project::*;
|
||||
pub use profiler_ws::*;
|
||||
// Re-export commonly used types
|
||||
pub use state::{ProfilerState, ProjectPaths};
|
||||
|
||||
@@ -1,631 +1,153 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
//! ECS Framework Editor - Tauri Backend
|
||||
//!
|
||||
//! Clean entry point that handles application setup and command registration.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::AppHandle;
|
||||
use std::sync::{Arc, Mutex};
|
||||
mod commands;
|
||||
mod profiler_ws;
|
||||
mod state;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use ecs_editor_lib::profiler_ws::ProfilerServer;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
// IPC Commands
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to ECS Framework Editor.", name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_project(path: String) -> Result<String, String> {
|
||||
// 项目打开逻辑
|
||||
Ok(format!("Project opened: {}", path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_project(path: String, data: String) -> Result<(), String> {
|
||||
// 项目保存逻辑
|
||||
std::fs::write(&path, data)
|
||||
.map_err(|e| format!("Failed to save project: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
|
||||
std::fs::write(&output_path, data)
|
||||
.map_err(|e| format!("Failed to export binary: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn path_exists(path: String) -> Result<bool, String> {
|
||||
use std::path::Path;
|
||||
Ok(Path::new(&path).exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
|
||||
std::fs::rename(&old_path, &new_path)
|
||||
.map_err(|e| format!("Failed to rename: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_file(path: String) -> Result<(), String> {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_folder(path: String) -> Result<(), String> {
|
||||
std::fs::remove_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to delete folder: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_file(path: String) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
File::create(&path)
|
||||
.map_err(|e| format!("Failed to create file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let folder = app.dialog()
|
||||
.file()
|
||||
.set_title("Select Project Directory")
|
||||
.blocking_pick_folder();
|
||||
|
||||
Ok(folder.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_scene_dialog(app: AppHandle, default_name: Option<String>) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let mut dialog = app.dialog()
|
||||
.file()
|
||||
.set_title("Save ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"]);
|
||||
|
||||
if let Some(name) = default_name {
|
||||
dialog = dialog.set_file_name(&name);
|
||||
}
|
||||
|
||||
let file = dialog.blocking_save_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_scene_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.set_title("Open ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_behavior_tree_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.set_title("Select Behavior Tree")
|
||||
.add_filter("Behavior Tree Files", &["btree"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
|
||||
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
|
||||
|
||||
if !behaviors_path.exists() {
|
||||
fs::create_dir_all(&behaviors_path)
|
||||
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut btree_files = Vec::new();
|
||||
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
|
||||
|
||||
Ok(btree_files)
|
||||
}
|
||||
|
||||
fn scan_directory_recursive(
|
||||
base_path: &std::path::Path,
|
||||
current_path: &std::path::Path,
|
||||
results: &mut Vec<String>
|
||||
) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
let entries = fs::read_dir(current_path)
|
||||
.map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_directory_recursive(base_path, &path, results)?;
|
||||
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
|
||||
if let Ok(relative) = path.strip_prefix(base_path) {
|
||||
let relative_str = relative.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.trim_end_matches(".btree")
|
||||
.to_string();
|
||||
results.push(relative_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
use std::path::Path;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
let separator = if path.contains('\\') { '\\' } else { '/' };
|
||||
let glob_pattern = format!("{}{}{}", path.trim_end_matches(&['/', '\\'][..]), separator, pattern);
|
||||
let normalized_pattern = if cfg!(windows) {
|
||||
glob_pattern.replace('/', "\\")
|
||||
} else {
|
||||
glob_pattern.replace('\\', "/")
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
match glob(&normalized_pattern) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(path) => {
|
||||
if path.is_file() {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_file_content(path: String) -> Result<String, String> {
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DirectoryEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
size: Option<u64>,
|
||||
modified: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let dir_path = Path::new(&path);
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
match fs::read_dir(dir_path) {
|
||||
Ok(read_dir) => {
|
||||
for entry in read_dir {
|
||||
match entry {
|
||||
Ok(entry) => {
|
||||
let entry_path = entry.path();
|
||||
if let Some(name) = entry_path.file_name() {
|
||||
let is_dir = entry_path.is_dir();
|
||||
|
||||
// 获取文件元数据
|
||||
let (size, modified) = match fs::metadata(&entry_path) {
|
||||
Ok(metadata) => {
|
||||
let size = if is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(metadata.len())
|
||||
};
|
||||
|
||||
let modified = metadata.modified()
|
||||
.ok()
|
||||
.and_then(|time| {
|
||||
time.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs())
|
||||
});
|
||||
|
||||
(size, modified)
|
||||
}
|
||||
Err(_) => (None, None),
|
||||
};
|
||||
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_string_lossy().to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading directory entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to read directory: {}", e)),
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_project_base_path(
|
||||
path: String,
|
||||
state: tauri::State<Arc<Mutex<HashMap<String, String>>>>
|
||||
) -> Result<(), String> {
|
||||
let mut paths = state.lock().map_err(|e| format!("Failed to lock state: {}", e))?;
|
||||
paths.insert("current".to_string(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_devtools(app: AppHandle) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
Err("DevTools are only available in debug mode".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Profiler State
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<tokio::sync::Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
// 调用 stop 方法正确关闭服务器
|
||||
if let Some(server) = server_lock.as_ref() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
|
||||
fs::read_to_string(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接写入文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 写入二进制文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_global_blackboard(project_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
|
||||
}
|
||||
|
||||
fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let ecs_dir = Path::new(&project_path).join(".ecs");
|
||||
let config_path = ecs_dir.join("global-blackboard.json");
|
||||
|
||||
// 创建 .ecs 目录(如果不存在)
|
||||
if !ecs_dir.exists() {
|
||||
fs::create_dir_all(&ecs_dir)
|
||||
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_file_with_default_app(file_path: String) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::path::Path;
|
||||
let path = Path::new(&file_path);
|
||||
let parent = path.parent()
|
||||
.ok_or_else(|| "Failed to get parent directory".to_string())?;
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use state::{ProfilerState, ProjectPaths};
|
||||
|
||||
fn main() {
|
||||
let project_paths: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_clone = Arc::clone(&project_paths);
|
||||
// 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 {
|
||||
server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
};
|
||||
let profiler_state = ProfilerState::new();
|
||||
|
||||
// Build and run the Tauri application
|
||||
tauri::Builder::default()
|
||||
// Register plugins
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
// Register custom URI scheme for project files
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
let project_paths = Arc::clone(&project_paths_clone);
|
||||
|
||||
let uri = request.uri();
|
||||
let path = uri.path();
|
||||
|
||||
let file_path = {
|
||||
let paths = project_paths.lock().unwrap();
|
||||
if let Some(base_path) = paths.get("current") {
|
||||
format!("{}{}", base_path, path)
|
||||
} else {
|
||||
return tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let mime_type = if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"text/plain"
|
||||
};
|
||||
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
handle_project_protocol(request, &project_paths_for_protocol)
|
||||
})
|
||||
// Setup application state
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
Ok(())
|
||||
})
|
||||
// Register all commands
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
open_project,
|
||||
save_project,
|
||||
export_binary,
|
||||
create_directory,
|
||||
write_file_content,
|
||||
path_exists,
|
||||
rename_file_or_folder,
|
||||
delete_file,
|
||||
delete_folder,
|
||||
create_file,
|
||||
open_project_dialog,
|
||||
save_scene_dialog,
|
||||
open_scene_dialog,
|
||||
open_behavior_tree_dialog,
|
||||
scan_directory,
|
||||
scan_behavior_trees,
|
||||
read_file_content,
|
||||
list_directory,
|
||||
set_project_base_path,
|
||||
toggle_devtools,
|
||||
start_profiler_server,
|
||||
stop_profiler_server,
|
||||
get_profiler_status,
|
||||
read_behavior_tree_file,
|
||||
write_behavior_tree_file,
|
||||
write_binary_file,
|
||||
read_global_blackboard,
|
||||
write_global_blackboard,
|
||||
open_file_with_default_app,
|
||||
show_in_folder
|
||||
// Project management
|
||||
commands::open_project,
|
||||
commands::save_project,
|
||||
commands::export_binary,
|
||||
commands::set_project_base_path,
|
||||
commands::scan_behavior_trees,
|
||||
// File system operations
|
||||
commands::read_file_content,
|
||||
commands::write_file_content,
|
||||
commands::write_binary_file,
|
||||
commands::path_exists,
|
||||
commands::create_directory,
|
||||
commands::create_file,
|
||||
commands::delete_file,
|
||||
commands::delete_folder,
|
||||
commands::rename_file_or_folder,
|
||||
commands::list_directory,
|
||||
commands::scan_directory,
|
||||
commands::read_file_as_base64,
|
||||
// Dialog operations
|
||||
commands::open_folder_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::save_file_dialog,
|
||||
// Profiler server
|
||||
commands::start_profiler_server,
|
||||
commands::stop_profiler_server,
|
||||
commands::get_profiler_status,
|
||||
// Plugin management
|
||||
commands::build_plugin,
|
||||
commands::install_marketplace_plugin,
|
||||
commands::uninstall_marketplace_plugin,
|
||||
// System operations
|
||||
commands::toggle_devtools,
|
||||
commands::open_file_with_default_app,
|
||||
commands::show_in_folder,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
/// Handle the custom 'project://' URI scheme protocol
|
||||
///
|
||||
/// This allows the frontend to load project files through a custom protocol,
|
||||
/// enabling features like hot-reloading plugins from the project directory.
|
||||
fn handle_project_protocol(
|
||||
request: tauri::http::Request<Vec<u8>>,
|
||||
project_paths: &ProjectPaths,
|
||||
) -> tauri::http::Response<Vec<u8>> {
|
||||
let uri = request.uri();
|
||||
let path = uri.path();
|
||||
|
||||
let file_path = {
|
||||
let paths = match project_paths.lock() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return tauri::http::Response::builder()
|
||||
.status(500)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
match paths.get("current") {
|
||||
Some(base_path) => format!("{}{}", base_path, path),
|
||||
None => {
|
||||
return tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let mime_type = get_mime_type(&file_path);
|
||||
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MIME type based on file extension
|
||||
fn get_mime_type(file_path: &str) -> &'static str {
|
||||
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else {
|
||||
"text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ impl ProfilerServer {
|
||||
println!("[ProfilerServer] Server stopped");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn broadcast(&self, message: String) {
|
||||
let _ = self.tx.send(message);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub scenes: Vec<String>,
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, path: PathBuf) -> Self {
|
||||
Self {
|
||||
name,
|
||||
path,
|
||||
scenes: Vec::new(),
|
||||
assets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &PathBuf) -> Result<Self, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read project file: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse project file: {}", e))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let mut project_file = self.path.clone();
|
||||
project_file.push("project.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
|
||||
std::fs::write(&project_file, content)
|
||||
.map_err(|e| format!("Failed to write project file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
34
packages/editor-app/src-tauri/src/state.rs
Normal file
34
packages/editor-app/src-tauri/src/state.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Application state definitions
|
||||
//!
|
||||
//! Centralized state management for the Tauri application.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
/// Project paths state
|
||||
///
|
||||
/// Stores the current project path and other path-related information.
|
||||
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Profiler server state
|
||||
///
|
||||
/// Manages the lifecycle of the WebSocket profiler server.
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<TokioMutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
impl ProfilerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
server: Arc::new(TokioMutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProfilerState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user