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:
YHH
2025-11-18 14:46:51 +08:00
committed by GitHub
parent eac660b1a0
commit bce3a6e253
251 changed files with 26144 additions and 8844 deletions

View 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()))
}

View 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))
}

View 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::*;

View 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(())
}

View 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())
}

View 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(())
}

View 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(())
}