refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
4
packages/editor/editor-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated runtime files
|
||||
src-tauri/runtime/
|
||||
src-tauri/engine/
|
||||
src-tauri/bin/
|
||||
18
packages/editor/editor-app/.swcrc
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"react": {
|
||||
"runtime": "automatic"
|
||||
}
|
||||
},
|
||||
"target": "es2020"
|
||||
}
|
||||
}
|
||||
15
packages/editor/editor-app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESEngine Editor</title>
|
||||
<!-- ES Module Shims: 为不支持 Import Maps 的浏览器提供 polyfill -->
|
||||
<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- Import Map 将由 PluginLoader 在运行时动态注入 -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
89
packages/editor/editor-app/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "@esengine/editor-app",
|
||||
"version": "1.0.14",
|
||||
"description": "ESEngine Editor Application - Cross-platform desktop editor",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:sdk": "cd ../editor-runtime && pnpm build",
|
||||
"build": "npm run build:sdk && tsc && vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"tauri": "tauri",
|
||||
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
|
||||
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
|
||||
"bundle:runtime": "node scripts/bundle-runtime.mjs",
|
||||
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",
|
||||
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/asset-system-editor": "workspace:*",
|
||||
"@esengine/audio": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/behavior-tree-editor": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/blueprint-editor": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/engine": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/mesh-3d": "workspace:*",
|
||||
"@esengine/mesh-3d-editor": "workspace:*",
|
||||
"@esengine/particle": "workspace:*",
|
||||
"@esengine/particle-editor": "workspace:*",
|
||||
"@esengine/physics-rapier2d": "workspace:*",
|
||||
"@esengine/platform-web": "workspace:*",
|
||||
"@esengine/physics-rapier2d-editor": "workspace:*",
|
||||
"@esengine/runtime-core": "workspace:*",
|
||||
"@esengine/sdk": "workspace:*",
|
||||
"@esengine/shader-editor": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/tilemap": "workspace:*",
|
||||
"@esengine/tilemap-editor": "workspace:*",
|
||||
"@esengine/fairygui": "workspace:*",
|
||||
"@esengine/fairygui-editor": "workspace:*",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"flexlayout-react": "^0.8.17",
|
||||
"i18next": "^25.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"tsyringe": "^4.10.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.13.5",
|
||||
"@tauri-apps/cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react-swc": "^4.2.0",
|
||||
"sharp": "^0.34.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-swc-transform": "^1.1.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0"
|
||||
}
|
||||
}
|
||||
3127
packages/editor/editor-app/pnpm-lock.yaml
generated
Normal file
15
packages/editor/editor-app/public/assets/react-dom-shim.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// React DOM shim - 从全局变量导出 ReactDOM
|
||||
const ReactDOM = window.ReactDOM;
|
||||
export default ReactDOM;
|
||||
export const {
|
||||
createPortal,
|
||||
flushSync,
|
||||
hydrate,
|
||||
render,
|
||||
unmountComponentAtNode,
|
||||
unstable_batchedUpdates,
|
||||
unstable_renderSubtreeIntoContainer,
|
||||
version,
|
||||
createRoot,
|
||||
hydrateRoot
|
||||
} = ReactDOM;
|
||||
4
packages/editor/editor-app/public/assets/react-jsx-runtime-shim.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// React JSX Runtime shim - 从全局变量导出
|
||||
const ReactJSXRuntime = window.ReactJSXRuntime;
|
||||
export const { jsx, jsxs, Fragment } = ReactJSXRuntime;
|
||||
export default ReactJSXRuntime;
|
||||
40
packages/editor/editor-app/public/assets/react-shim.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
// React shim - 从全局变量导出 React
|
||||
// 这个文件用于 Import Map,让插件的 import 'react' 能正确解析到主应用的 React
|
||||
const React = window.React;
|
||||
export default React;
|
||||
export const {
|
||||
Children,
|
||||
Component,
|
||||
Fragment,
|
||||
Profiler,
|
||||
PureComponent,
|
||||
StrictMode,
|
||||
Suspense,
|
||||
cloneElement,
|
||||
createContext,
|
||||
createElement,
|
||||
createFactory,
|
||||
createRef,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
lazy,
|
||||
memo,
|
||||
startTransition,
|
||||
unstable_act,
|
||||
useCallback,
|
||||
useContext,
|
||||
useDebugValue,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useInsertionEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
useTransition,
|
||||
version
|
||||
} = React;
|
||||
27
packages/editor/editor-app/public/runtime.config.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"runtime": {
|
||||
"version": "1.0.0",
|
||||
"modules": {
|
||||
"platform-web": {
|
||||
"type": "javascript",
|
||||
"main": "runtime.browser.js",
|
||||
"development": {
|
||||
"path": "../platform-web/dist"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "wasm",
|
||||
"files": ["es_engine_bg.wasm", "es_engine.js"],
|
||||
"development": {
|
||||
"path": "../engine/pkg"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/editor/editor-app/runtime.config.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"runtime": {
|
||||
"version": "1.0.0",
|
||||
"modules": {
|
||||
"platform-web": {
|
||||
"type": "javascript",
|
||||
"main": "runtime.browser.js",
|
||||
"development": {
|
||||
"path": "../platform-web/dist"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "wasm",
|
||||
"files": ["es_engine_bg.wasm", "es_engine.js"],
|
||||
"development": {
|
||||
"path": "../engine/pkg"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
214
packages/editor/editor-app/scripts/bundle-runtime.mjs
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Bundle runtime files for production build
|
||||
* 为生产构建打包运行时文件
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const editorPath = path.resolve(__dirname, '..');
|
||||
const rootPath = path.resolve(editorPath, '../..');
|
||||
const bundleDir = path.join(editorPath, 'src-tauri', 'runtime');
|
||||
|
||||
// Create bundle directory
|
||||
if (!fs.existsSync(bundleDir)) {
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
console.log(`Created bundle directory: ${bundleDir}`);
|
||||
}
|
||||
|
||||
// Files to bundle
|
||||
// 需要打包的文件
|
||||
const filesToBundle = [
|
||||
{
|
||||
src: path.join(rootPath, 'packages/platform-web/dist/index.mjs'),
|
||||
dst: path.join(bundleDir, 'platform-web.mjs')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'),
|
||||
dst: path.join(bundleDir, 'es_engine_bg.wasm')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine.js'),
|
||||
dst: path.join(bundleDir, 'es_engine.js')
|
||||
}
|
||||
];
|
||||
|
||||
// Type definition files for IDE intellisense
|
||||
// 用于 IDE 智能感知的类型定义文件
|
||||
const typesDir = path.join(bundleDir, 'types');
|
||||
if (!fs.existsSync(typesDir)) {
|
||||
fs.mkdirSync(typesDir, { recursive: true });
|
||||
console.log(`Created types directory: ${typesDir}`);
|
||||
}
|
||||
|
||||
const typeFilesToBundle = [
|
||||
{
|
||||
src: path.join(rootPath, 'packages/core/dist/index.d.ts'),
|
||||
dst: path.join(typesDir, 'ecs-framework.d.ts')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine-core/dist/index.d.ts'),
|
||||
dst: path.join(typesDir, 'engine-core.d.ts')
|
||||
}
|
||||
];
|
||||
|
||||
// Copy files
|
||||
let success = true;
|
||||
for (const { src, dst } of filesToBundle) {
|
||||
try {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error(`Source file not found: ${src}`);
|
||||
console.log('Please build the runtime modules first:');
|
||||
console.log(' npm run build --workspace=@esengine/platform-web');
|
||||
console.log(' cd packages/engine && wasm-pack build --target web');
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dst);
|
||||
const stats = fs.statSync(dst);
|
||||
console.log(`✓ Bundled ${path.basename(dst)} (${(stats.size / 1024).toFixed(2)} KB)`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to bundle ${path.basename(src)}: ${error.message}`);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy type definition files (optional - don't fail if not found)
|
||||
// 复制类型定义文件(可选 - 找不到不报错)
|
||||
for (const { src, dst } of typeFilesToBundle) {
|
||||
try {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`Type definition not found: ${src}`);
|
||||
console.log(' Build packages first: pnpm --filter @esengine/core build');
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dst);
|
||||
const stats = fs.statSync(dst);
|
||||
console.log(`✓ Bundled type definition ${path.basename(dst)} (${(stats.size / 1024).toFixed(2)} KB)`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to bundle type definition ${path.basename(src)}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy engine modules directory from dist/engine to src-tauri/engine
|
||||
// 复制引擎模块目录从 dist/engine 到 src-tauri/engine
|
||||
const engineSrcDir = path.join(editorPath, 'dist', 'engine');
|
||||
const engineDstDir = path.join(editorPath, 'src-tauri', 'engine');
|
||||
|
||||
/**
|
||||
* Recursively copy directory
|
||||
* 递归复制目录
|
||||
*/
|
||||
function copyDirRecursive(src, dst) {
|
||||
if (!fs.existsSync(src)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const dstPath = path.join(dst, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, dstPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fs.existsSync(engineSrcDir)) {
|
||||
// Remove old engine directory if exists
|
||||
if (fs.existsSync(engineDstDir)) {
|
||||
fs.rmSync(engineDstDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (copyDirRecursive(engineSrcDir, engineDstDir)) {
|
||||
// Count files
|
||||
let fileCount = 0;
|
||||
function countFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
countFiles(path.join(dir, entry.name));
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
countFiles(engineDstDir);
|
||||
console.log(`✓ Copied engine modules directory (${fileCount} files)`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Engine modules directory not found: ${engineSrcDir}`);
|
||||
console.log(' Build editor-app first: pnpm --filter @esengine/editor-app build');
|
||||
}
|
||||
|
||||
// Copy esbuild binary for user code compilation
|
||||
// 复制 esbuild 二进制文件用于用户代码编译
|
||||
const binDir = path.join(editorPath, 'src-tauri', 'bin');
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
console.log(`Created bin directory: ${binDir}`);
|
||||
}
|
||||
|
||||
// Platform-specific esbuild binary paths
|
||||
// 平台特定的 esbuild 二进制路径
|
||||
const esbuildSources = {
|
||||
win32: path.join(rootPath, 'node_modules/@esbuild/win32-x64/esbuild.exe'),
|
||||
darwin: path.join(rootPath, 'node_modules/@esbuild/darwin-x64/bin/esbuild'),
|
||||
linux: path.join(rootPath, 'node_modules/@esbuild/linux-x64/bin/esbuild'),
|
||||
};
|
||||
|
||||
const platform = process.platform;
|
||||
const esbuildSrc = esbuildSources[platform];
|
||||
const esbuildDst = path.join(binDir, platform === 'win32' ? 'esbuild.exe' : 'esbuild');
|
||||
|
||||
let esbuildBundled = false;
|
||||
if (esbuildSrc && fs.existsSync(esbuildSrc)) {
|
||||
try {
|
||||
fs.copyFileSync(esbuildSrc, esbuildDst);
|
||||
// Ensure executable permission on Unix
|
||||
if (platform !== 'win32') {
|
||||
fs.chmodSync(esbuildDst, 0o755);
|
||||
}
|
||||
const stats = fs.statSync(esbuildDst);
|
||||
console.log(`✓ Bundled esbuild binary (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
esbuildBundled = true;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to bundle esbuild: ${error.message}`);
|
||||
console.log(' User code compilation will require global esbuild installation');
|
||||
}
|
||||
} else {
|
||||
console.warn(`esbuild binary not found for platform ${platform}: ${esbuildSrc}`);
|
||||
console.log(' User code compilation will require global esbuild installation');
|
||||
}
|
||||
|
||||
// Create a placeholder file if esbuild was not bundled
|
||||
// Tauri requires resources patterns to match at least one file
|
||||
// 如果 esbuild 没有打包,创建占位文件
|
||||
// Tauri 要求资源模式至少匹配一个文件
|
||||
if (!esbuildBundled) {
|
||||
const placeholderPath = path.join(binDir, '.gitkeep');
|
||||
fs.writeFileSync(placeholderPath, '# Placeholder for Tauri resources\n# esbuild binary will be bundled during release build\n');
|
||||
console.log('✓ Created placeholder in bin directory');
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('Runtime bundling failed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Runtime files bundled successfully!');
|
||||
47
packages/editor/editor-app/scripts/kill-dev-server.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 清理开发服务器进程
|
||||
* 用于 Windows 平台自动清理残留的 Vite 进程
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const PORT = 5173;
|
||||
|
||||
try {
|
||||
console.log(`正在查找占用端口 ${PORT} 的进程...`);
|
||||
|
||||
// Windows 命令
|
||||
const result = execSync(`netstat -ano | findstr :${PORT}`, { encoding: 'utf8' });
|
||||
|
||||
// 解析 PID
|
||||
const lines = result.split('\n');
|
||||
const pids = new Set();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('LISTENING')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && pid !== '0') {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pids.size === 0) {
|
||||
console.log(`✓ 端口 ${PORT} 未被占用`);
|
||||
} else {
|
||||
console.log(`发现 ${pids.size} 个进程占用端口 ${PORT}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
// Windows 需要使用 /F /PID 而不是 //F //PID
|
||||
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf8', stdio: 'ignore' });
|
||||
console.log(`✓ 已终止进程 PID: ${pid}`);
|
||||
} catch (e) {
|
||||
console.log(`✗ 无法终止进程 PID: ${pid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果 netstat 没有找到结果,会抛出错误,这是正常的
|
||||
console.log(`✓ 端口 ${PORT} 未被占用`);
|
||||
}
|
||||
33
packages/editor/editor-app/scripts/sync-version.js
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 同步 package.json 和 tauri.conf.json 的版本号
|
||||
* 在 npm version 命令执行后自动运行
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 读取 package.json
|
||||
const packageJsonPath = join(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const newVersion = packageJson.version;
|
||||
|
||||
// 读取 tauri.conf.json
|
||||
const tauriConfigPath = join(__dirname, '../src-tauri/tauri.conf.json');
|
||||
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8'));
|
||||
|
||||
// 更新 tauri.conf.json 的版本号
|
||||
const oldVersion = tauriConfig.version;
|
||||
tauriConfig.version = newVersion;
|
||||
|
||||
// 写回文件(保持格式)
|
||||
writeFileSync(tauriConfigPath, JSON.stringify(tauriConfig, null, 2) + '\n', 'utf8');
|
||||
|
||||
console.log(`✓ Version synced: ${oldVersion} → ${newVersion}`);
|
||||
console.log(` - package.json: ${newVersion}`);
|
||||
console.log(` - tauri.conf.json: ${newVersion}`);
|
||||
6991
packages/editor/editor-app/src-tauri/Cargo.lock
generated
Normal file
49
packages/editor/editor-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "ecs-editor"
|
||||
version = "1.0.0"
|
||||
description = "ESEngine Editor - Cross-platform desktop editor"
|
||||
authors = ["yhh"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ecs_editor_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-fs = "2.0"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-http = "2.0"
|
||||
tauri-plugin-cli = "2.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
glob = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
chrono = "0.4"
|
||||
zip = "0.6"
|
||||
base64 = "0.22"
|
||||
tiny_http = "0.12"
|
||||
once_cell = "1.19"
|
||||
urlencoding = "2.1"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
notify = "7.0"
|
||||
notify-debouncer-mini = "0.5"
|
||||
regex = "1"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
3
packages/editor/editor-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
packages/editor/editor-app/src-tauri/capabilities/http.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"identifier": "http-capability",
|
||||
"description": "HTTP permissions for GitHub API access and plugin marketplace",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://github.com/**" },
|
||||
{ "url": "https://api.github.com/**" },
|
||||
{ "url": "https://raw.githubusercontent.com/**" },
|
||||
{ "url": "https://cdn.jsdelivr.net/**" },
|
||||
{ "url": "https://fastly.jsdelivr.net/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
packages/editor/editor-app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
packages/editor/editor-app/src-tauri/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 888 B |
BIN
packages/editor/editor-app/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/icon.icns
Normal file
BIN
packages/editor/editor-app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
packages/editor/editor-app/src-tauri/icons/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512"><rect width="512" height="512" fill="#1E1E1E"/><defs><radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0%" style="stop-color:#569cd6;stop-opacity:.15"/><stop offset="100%" style="stop-color:#569cd6;stop-opacity:0"/></radialGradient><linearGradient id="cubeGrad1" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#569cd6"/><stop offset="100%" style="stop-color:#4a8bc2"/></linearGradient><linearGradient id="cubeGrad2" x1="0%" x2="0%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#4ec9b0"/><stop offset="100%" style="stop-color:#3da592"/></linearGradient><filter id="shadow"><feGaussianBlur in="SourceAlpha" stdDeviation="4"/><feOffset dx="2" dy="2" result="offsetblur"/><feComponentTransfer><feFuncA slope=".3" type="linear"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><circle cx="256" cy="256" r="200" fill="url(#glow)"/><g filter="url(#shadow)"><path fill="none" stroke="url(#cubeGrad1)" stroke-width="6" d="M 180 140 L 332 140 L 332 292 L 180 292 Z" opacity=".4"/><path fill="rgba(86, 156, 214, 0.08)" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" d="M 140 180 L 292 180 L 292 332 L 140 332 Z"/><line x1="140" x2="180" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="140" x2="180" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><circle cx="216" cy="216" r="14" fill="#CE9178" opacity=".95"><animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="256" cy="256" r="14" fill="#4EC9B0" opacity=".95"><animate attributeName="opacity" begin="0.3s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="296" cy="296" r="14" fill="#569CD6" opacity=".95"><animate attributeName="opacity" begin="0.6s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><line x1="216" x2="256" y1="216" y2="256" stroke="#4EC9B0" stroke-linecap="round" stroke-width="2" opacity=".5"/><line x1="256" x2="296" y1="256" y2="296" stroke="#569CD6" stroke-linecap="round" stroke-width="2" opacity=".5"/></g><g stroke="#4EC9B0" stroke-linecap="round" stroke-width="3" opacity=".3"><line x1="130" x2="130" y1="170" y2="190"/><line x1="130" x2="150" y1="170" y2="170"/><line x1="302" x2="302" y1="342" y2="322"/><line x1="302" x2="282" y1="342" y2="342"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 847 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
535
packages/editor/editor-app/src-tauri/src/commands/build.rs
Normal file
@@ -0,0 +1,535 @@
|
||||
//! 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>>,
|
||||
/// Module alias mappings (e.g., @esengine/ecs-framework -> /path/to/module)
|
||||
/// 模块别名映射(例如 @esengine/ecs-framework -> /path/to/module)
|
||||
pub alias: Option<std::collections::HashMap<String, String>>,
|
||||
/// Global name for IIFE format (assigns exports to window.{globalName})
|
||||
/// IIFE 格式的全局变量名(将导出赋值给 window.{globalName})
|
||||
pub global_name: Option<String>,
|
||||
/// Files to inject at the start of bundle (esbuild --inject)
|
||||
/// 在打包开始时注入的文件(esbuild --inject)
|
||||
pub inject: Option<Vec<String>>,
|
||||
/// Banner code to prepend to bundle
|
||||
/// 添加到打包文件开头的代码
|
||||
pub banner: Option<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 | 构建输出文件路径
|
||||
// Note: Don't use .with_extension() as it replaces the last dot-segment
|
||||
// 注意:不要使用 .with_extension(),因为它会替换最后一个点分段
|
||||
// e.g., "esengine.core" would become "esengine.js" instead of "esengine.core.js"
|
||||
let output_file = Path::new(&options.output_dir)
|
||||
.join(format!("{}.js", &options.bundle_name));
|
||||
|
||||
// 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());
|
||||
// Show detailed warnings instead of just count
|
||||
// 显示详细警告而不仅仅是数量
|
||||
args.push("--log-level=warning".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));
|
||||
}
|
||||
}
|
||||
|
||||
// Add alias mappings | 添加别名映射
|
||||
if let Some(ref aliases) = options.alias {
|
||||
for (from, to) in aliases {
|
||||
args.push(format!("--alias:{}={}", from, to));
|
||||
}
|
||||
}
|
||||
|
||||
// Add global name for IIFE format | 为 IIFE 格式添加全局变量名
|
||||
if let Some(ref global_name) = options.global_name {
|
||||
args.push(format!("--global-name={}", global_name));
|
||||
}
|
||||
|
||||
// Add inject files | 添加注入文件
|
||||
if let Some(ref inject_files) = options.inject {
|
||||
for file in inject_files {
|
||||
args.push(format!("--inject:{}", file));
|
||||
}
|
||||
}
|
||||
|
||||
// Add banner | 添加 banner
|
||||
if let Some(ref banner) = options.banner {
|
||||
args.push(format!("--banner:js={}", banner));
|
||||
}
|
||||
|
||||
// Log esbuild command for debugging
|
||||
println!("[esbuild] bundle_name: {}", options.bundle_name);
|
||||
println!("[esbuild] format: {}", options.format);
|
||||
println!("[esbuild] output_file: {}", output_file.display());
|
||||
println!("[esbuild] entry_points: {:?}", options.entry_points);
|
||||
println!("[esbuild] args: {:?}", args);
|
||||
|
||||
// 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 解析警告
|
||||
// esbuild outputs warnings to stderr even on success
|
||||
// esbuild 即使成功也会将警告输出到 stderr
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
if !stderr.is_empty() {
|
||||
println!("[esbuild] stderr output:\n{}", stderr);
|
||||
|
||||
// Collect all non-empty lines as warnings
|
||||
// esbuild warning format varies, so collect everything
|
||||
// 收集所有非空行作为警告,因为 esbuild 警告格式多变
|
||||
for line in stderr.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
warnings.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
// Skip node_modules, hidden directories, and other large directories
|
||||
// 跳过 node_modules、隐藏目录和其他大型目录
|
||||
if file_name_str.starts_with('.')
|
||||
|| file_name_str == "node_modules"
|
||||
|| file_name_str == "target"
|
||||
|| file_name_str == ".git"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Read binary file and return as raw bytes.
|
||||
/// 读取二进制文件并返回原始字节。
|
||||
#[tauri::command]
|
||||
pub async fn read_binary_file(file_path: String) -> Result<Vec<u8>, String> {
|
||||
fs::read(&file_path)
|
||||
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))
|
||||
}
|
||||
1107
packages/editor/editor-app/src-tauri/src/commands/compiler.rs
Normal file
107
packages/editor/editor-app/src-tauri/src/commands/dialog.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 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>,
|
||||
default_path: 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");
|
||||
}
|
||||
|
||||
// Set default directory | 设置默认目录
|
||||
if let Some(path) = default_path {
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
if path_buf.exists() {
|
||||
dialog = dialog.set_directory(&path_buf);
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
291
packages/editor/editor-app/src-tauri/src/commands/file_system.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
//! 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))
|
||||
}
|
||||
|
||||
/// Append text to log file (auto-creates parent directories)
|
||||
/// 追加文本到日志文件(自动创建父目录)
|
||||
#[tauri::command]
|
||||
pub fn append_to_log(path: String, content: String) -> Result<(), String> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
|
||||
// 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))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Failed to open log file {}: {}", path, e))?;
|
||||
|
||||
writeln!(file, "{}", content)
|
||||
.map_err(|e| format!("Failed to write to log 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> {
|
||||
println!("[delete_folder] Attempting to delete: {}", path);
|
||||
|
||||
// Check if path exists
|
||||
// 检查路径是否存在
|
||||
let dir_path = std::path::Path::new(&path);
|
||||
if !dir_path.exists() {
|
||||
println!("[delete_folder] Path does not exist: {}", path);
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
println!("[delete_folder] Path is not a directory: {}", path);
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
match fs::remove_dir_all(&path) {
|
||||
Ok(_) => {
|
||||
println!("[delete_folder] Successfully deleted: {}", path);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to delete folder {}: {}", path, e);
|
||||
eprintln!("[delete_folder] Error: {}", error_msg);
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Get file modification time (milliseconds since UNIX epoch)
|
||||
/// 获取文件修改时间(Unix 纪元以来的毫秒数)
|
||||
#[tauri::command]
|
||||
pub fn get_file_mtime(path: String) -> Result<u64, String> {
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to get metadata for {}: {}", path, e))?;
|
||||
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|e| format!("Failed to get modified time for {}: {}", path, e))?;
|
||||
|
||||
let millis = modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| format!("Time error: {}", e))?
|
||||
.as_millis() as u64;
|
||||
|
||||
Ok(millis)
|
||||
}
|
||||
|
||||
/// Copy file from source to destination
|
||||
#[tauri::command]
|
||||
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&dst).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(&src, &dst)
|
||||
.map_err(|e| format!("Failed to copy file {} to {}: {}", src, dst, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
26
packages/editor/editor-app/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! 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 | 重新导出所有命令以方便使用
|
||||
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::*;
|
||||
pub use system::*;
|
||||
193
packages/editor/editor-app/src-tauri/src/commands/modules.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! 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.
|
||||
/// 获取引擎模块目录路径。
|
||||
///
|
||||
/// In dev mode: First tries dist/engine, then falls back to packages/ source directory.
|
||||
/// 在开发模式下:首先尝试 dist/engine,然后回退到 packages/ 源目录。
|
||||
///
|
||||
/// In production: Uses the bundled resource directory.
|
||||
/// 在生产模式下:使用打包的资源目录。
|
||||
#[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 manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// Try dist/engine first (if modules have been copied with actual content)
|
||||
// 首先尝试 dist/engine(如果模块已复制且包含实际内容)
|
||||
let dist_engine_path = manifest_dir
|
||||
.parent()
|
||||
.map(|p| p.join("dist/engine"))
|
||||
.unwrap_or_else(|| PathBuf::from("dist/engine"));
|
||||
|
||||
// Check if dist/engine has actual module content (not just empty directories)
|
||||
// 检查 dist/engine 是否有实际模块内容(而不仅是空目录)
|
||||
let dist_core_output = dist_engine_path.join("core/dist/index.mjs");
|
||||
if dist_core_output.exists() {
|
||||
println!("[modules] Using dist/engine path: {:?}", dist_engine_path);
|
||||
return Ok(dist_engine_path);
|
||||
}
|
||||
|
||||
// Fallback: use packages/ source directory directly (dev mode without copy)
|
||||
// 回退:直接使用 packages/ 源目录(开发模式无需复制)
|
||||
// This allows building without running copy-modules first
|
||||
// 这样可以在不运行 copy-modules 的情况下进行构建
|
||||
let packages_path = manifest_dir
|
||||
.parent() // editor-app
|
||||
.and_then(|p| p.parent()) // packages
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("packages"));
|
||||
|
||||
// Verify packages directory has module.json files
|
||||
// 验证 packages 目录包含 module.json 文件
|
||||
let core_module = packages_path.join("core/module.json");
|
||||
if core_module.exists() {
|
||||
println!("[modules] Using packages source path: {:?}", packages_path);
|
||||
return Ok(packages_path);
|
||||
}
|
||||
|
||||
return Err(format!(
|
||||
"Engine modules directory not found in dev mode. Tried: {:?}, {:?}. \
|
||||
Either run 'pnpm copy-modules' or ensure packages/ directory exists.",
|
||||
dist_engine_path, packages_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())
|
||||
}
|
||||
270
packages/editor/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 pnpm_command = if cfg!(target_os = "windows") {
|
||||
"pnpm.cmd"
|
||||
} else {
|
||||
"pnpm"
|
||||
};
|
||||
|
||||
// Step 1: Install dependencies
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "install".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let install_output = Command::new(&pnpm_command)
|
||||
.args(["install"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run pnpm install: {}", e))?;
|
||||
|
||||
if !install_output.status.success() {
|
||||
return Err(format!(
|
||||
"pnpm 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(&pnpm_command)
|
||||
.args(["run", "build"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run pnpm run build: {}", e))?;
|
||||
|
||||
if !build_output.status.success() {
|
||||
return Err(format!(
|
||||
"pnpm 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(())
|
||||
}
|
||||
@@ -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/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(())
|
||||
}
|
||||
749
packages/editor/editor-app/src-tauri/src/commands/system.rs
Normal file
@@ -0,0 +1,749 @@
|
||||
//! System operations
|
||||
//!
|
||||
//! OS-level operations like opening files, showing in folder, devtools, etc.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::net::UdpSocket;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tiny_http::{Server, Response};
|
||||
use qrcode::QrCode;
|
||||
use image::Luma;
|
||||
|
||||
// Global server state
|
||||
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
static SERVER_STOP_FLAG: once_cell::sync::Lazy<Arc<AtomicBool>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(AtomicBool::new(false)));
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Open folder in system file explorer
|
||||
/// 在系统文件管理器中打开文件夹
|
||||
#[tauri::command]
|
||||
pub fn open_folder(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let normalized_path = path.replace('/', "\\");
|
||||
Command::new("explorer")
|
||||
.arg(&normalized_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show file in system file explorer
|
||||
#[tauri::command]
|
||||
pub fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
println!("[show_in_folder] Received path: {}", file_path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::path::Path;
|
||||
|
||||
// Normalize path separators for Windows
|
||||
// 规范化路径分隔符
|
||||
let normalized_path = file_path.replace('/', "\\");
|
||||
println!("[show_in_folder] Normalized path: {}", normalized_path);
|
||||
|
||||
// Verify the path exists before trying to show it
|
||||
// 验证路径存在
|
||||
let path = Path::new(&normalized_path);
|
||||
let exists = path.exists();
|
||||
println!("[show_in_folder] Path exists: {}", exists);
|
||||
|
||||
if !exists {
|
||||
return Err(format!("Path does not exist: {}", normalized_path));
|
||||
}
|
||||
|
||||
// Windows explorer requires /select, to be concatenated with the path
|
||||
// without spaces. Use a single argument to avoid shell parsing issues.
|
||||
// Windows 资源管理器要求 /select, 与路径连接在一起,中间没有空格
|
||||
let select_arg = format!("/select,{}", normalized_path);
|
||||
println!("[show_in_folder] Explorer arg: {}", select_arg);
|
||||
|
||||
Command::new("explorer")
|
||||
.arg(&select_arg)
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// Get system temp directory
|
||||
#[tauri::command]
|
||||
pub fn get_temp_dir() -> Result<String, String> {
|
||||
std::env::temp_dir()
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Failed to get temp directory".to_string())
|
||||
}
|
||||
|
||||
/// 使用 where 命令查找可执行文件路径
|
||||
/// Use 'where' command to find executable path
|
||||
#[cfg(target_os = "windows")]
|
||||
fn find_command_path(cmd: &str) -> Option<String> {
|
||||
use std::process::Command as StdCommand;
|
||||
use std::path::Path;
|
||||
|
||||
// 使用 where 命令查找
|
||||
let output = StdCommand::new("where")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// 取第一行结果(可能有多个匹配)
|
||||
if let Some(first_line) = stdout.lines().next() {
|
||||
let path = first_line.trim();
|
||||
if !path.is_empty() {
|
||||
let path_obj = Path::new(path);
|
||||
|
||||
// 检查是否是 bin 目录下的脚本(VSCode/Cursor 特征)
|
||||
// Check if it's a script in bin directory (VSCode/Cursor pattern)
|
||||
let is_bin_script = path_obj.parent()
|
||||
.map(|p| p.ends_with("bin"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// 如果找到的是 .cmd 或 .bat,或者是 bin 目录下的脚本(where 可能不返回扩展名)
|
||||
// If found .cmd or .bat, or a script in bin directory (where may not return extension)
|
||||
let has_script_ext = path.ends_with(".cmd") || path.ends_with(".bat");
|
||||
|
||||
if has_script_ext || is_bin_script {
|
||||
// 尝试找 Code.exe (VSCode) 或 Cursor.exe 等
|
||||
// Try to find Code.exe (VSCode) or Cursor.exe etc.
|
||||
if let Some(bin_dir) = path_obj.parent() {
|
||||
if let Some(parent_dir) = bin_dir.parent() {
|
||||
// VSCode: bin/code.cmd -> Code.exe
|
||||
let exe_path = parent_dir.join("Code.exe");
|
||||
if exe_path.exists() {
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
println!("[find_command_path] Found {} exe at: {}", cmd, exe_str);
|
||||
return Some(exe_str);
|
||||
}
|
||||
|
||||
// Cursor: bin/cursor.cmd -> Cursor.exe
|
||||
let cursor_exe = parent_dir.join("Cursor.exe");
|
||||
if cursor_exe.exists() {
|
||||
let exe_str = cursor_exe.to_string_lossy().to_string();
|
||||
println!("[find_command_path] Found {} exe at: {}", cmd, exe_str);
|
||||
return Some(exe_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[find_command_path] Found {} at: {}", cmd, path);
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn find_command_path(cmd: &str) -> Option<String> {
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
let output = StdCommand::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let path = stdout.trim();
|
||||
if !path.is_empty() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析编辑器命令,返回实际可执行路径
|
||||
/// Resolve editor command to actual executable path
|
||||
fn resolve_editor_command(editor_command: &str) -> String {
|
||||
use std::path::Path;
|
||||
|
||||
// 如果命令已经是完整路径且存在,直接返回
|
||||
// If command is already a full path and exists, return it
|
||||
if Path::new(editor_command).exists() {
|
||||
return editor_command.to_string();
|
||||
}
|
||||
|
||||
// 使用系统命令查找可执行文件路径
|
||||
// Use system command to find executable path
|
||||
if let Some(path) = find_command_path(editor_command) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 回退到原始命令 | Fall back to original command
|
||||
editor_command.to_string()
|
||||
}
|
||||
|
||||
/// Open project folder with specified editor
|
||||
/// 使用指定编辑器打开项目文件夹
|
||||
///
|
||||
/// @param project_path - Project folder path | 项目文件夹路径
|
||||
/// @param editor_command - Editor command (e.g., "code", "cursor") | 编辑器命令
|
||||
/// @param file_path - Optional file to open (will be opened in the editor) | 可选的要打开的文件
|
||||
#[tauri::command]
|
||||
pub fn open_with_editor(
|
||||
project_path: String,
|
||||
editor_command: String,
|
||||
file_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use std::path::Path;
|
||||
|
||||
// Normalize paths
|
||||
let normalized_project = project_path.replace('/', "\\");
|
||||
let normalized_file = file_path.map(|f| f.replace('/', "\\"));
|
||||
|
||||
// Verify project path exists
|
||||
let project = Path::new(&normalized_project);
|
||||
if !project.exists() {
|
||||
return Err(format!("Project path does not exist: {}", normalized_project));
|
||||
}
|
||||
|
||||
// 解析编辑器命令到实际路径
|
||||
// Resolve editor command to actual path
|
||||
let resolved_command = resolve_editor_command(&editor_command);
|
||||
|
||||
println!(
|
||||
"[open_with_editor] editor: {} -> {}, project: {}, file: {:?}",
|
||||
editor_command, resolved_command, normalized_project, normalized_file
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&resolved_command);
|
||||
|
||||
// VSCode/Cursor CLI 正确用法:
|
||||
// 1. 使用 --folder-uri 或直接传文件夹路径会打开新窗口
|
||||
// 2. 使用 --add 可以将文件夹添加到当前工作区
|
||||
// 3. 使用 --goto file:line:column 可以打开文件并定位
|
||||
//
|
||||
// VSCode/Cursor CLI correct usage:
|
||||
// 1. Use --folder-uri or pass folder path directly to open new window
|
||||
// 2. Use --add to add folder to current workspace
|
||||
// 3. Use --goto file:line:column to open file and navigate
|
||||
//
|
||||
// 正确命令格式: code <folder> <file>
|
||||
// 这会打开文件夹并同时打开文件
|
||||
// Correct command format: code <folder> <file>
|
||||
// This opens the folder and also opens the file
|
||||
|
||||
// Add project folder first
|
||||
// 先添加项目文件夹
|
||||
cmd.arg(&normalized_project);
|
||||
|
||||
// If a specific file is provided, add it directly (not with -g)
|
||||
// VSCode will open the folder AND the file
|
||||
// 如果提供了文件,直接添加(不使用 -g)
|
||||
// VSCode 会同时打开文件夹和文件
|
||||
if let Some(ref file) = normalized_file {
|
||||
let file_path_obj = Path::new(file);
|
||||
if file_path_obj.exists() {
|
||||
cmd.arg(file);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to open with editor '{}': {}", resolved_command, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get application resource directory
|
||||
#[tauri::command]
|
||||
pub fn get_app_resource_dir(app: AppHandle) -> Result<String, String> {
|
||||
app.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Failed to get resource directory: {}", e))
|
||||
.and_then(|p| {
|
||||
p.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid path encoding".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current working directory
|
||||
#[tauri::command]
|
||||
pub fn get_current_dir() -> Result<String, String> {
|
||||
std::env::current_dir()
|
||||
.and_then(|p| Ok(p.to_string_lossy().to_string()))
|
||||
.map_err(|e| format!("Failed to get current directory: {}", e))
|
||||
}
|
||||
|
||||
/// Update project tsconfig.json with engine type paths
|
||||
/// 更新项目的 tsconfig.json,添加引擎类型路径
|
||||
///
|
||||
/// Scans dist/engine/ directory and adds paths for all modules with .d.ts files.
|
||||
/// 扫描 dist/engine/ 目录,为所有有 .d.ts 文件的模块添加路径。
|
||||
#[tauri::command]
|
||||
pub fn update_project_tsconfig(app: AppHandle, project_path: String) -> Result<(), String> {
|
||||
use std::path::Path;
|
||||
|
||||
let project = Path::new(&project_path);
|
||||
if !project.exists() {
|
||||
return Err(format!("Project path does not exist: {}", project_path));
|
||||
}
|
||||
|
||||
// Get engine modules path (dist/engine/)
|
||||
// 获取引擎模块路径
|
||||
let engine_path = get_engine_modules_base_path_internal(&app)?;
|
||||
|
||||
// Read existing tsconfig.json
|
||||
// 读取现有的 tsconfig.json
|
||||
let tsconfig_path = project.join("tsconfig.json");
|
||||
let tsconfig_editor_path = project.join("tsconfig.editor.json");
|
||||
|
||||
// Update runtime tsconfig
|
||||
// 更新运行时 tsconfig
|
||||
if tsconfig_path.exists() {
|
||||
update_tsconfig_file(&tsconfig_path, &engine_path, false)?;
|
||||
println!("[update_project_tsconfig] Updated {}", tsconfig_path.display());
|
||||
}
|
||||
|
||||
// Update editor tsconfig
|
||||
// 更新编辑器 tsconfig
|
||||
if tsconfig_editor_path.exists() {
|
||||
update_tsconfig_file(&tsconfig_editor_path, &engine_path, true)?;
|
||||
println!("[update_project_tsconfig] Updated {}", tsconfig_editor_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Internal function to get engine modules base path
|
||||
/// 内部函数:获取引擎模块基础路径
|
||||
fn get_engine_modules_base_path_internal(app: &AppHandle) -> Result<String, String> {
|
||||
let resource_dir = app.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Failed to get resource directory: {}", e))?;
|
||||
|
||||
// Production mode: resource_dir/engine/
|
||||
// 生产模式
|
||||
let prod_path = resource_dir.join("engine");
|
||||
if prod_path.exists() {
|
||||
return prod_path.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid path encoding".to_string());
|
||||
}
|
||||
|
||||
// Development mode: workspace/packages/editor-app/dist/engine/
|
||||
// 开发模式
|
||||
if let Some(ws_root) = find_workspace_root() {
|
||||
let dev_path = ws_root.join("packages").join("editor-app").join("dist").join("engine");
|
||||
if dev_path.exists() {
|
||||
return dev_path.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid path encoding".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Engine modules directory not found".to_string())
|
||||
}
|
||||
|
||||
/// Find workspace root directory
|
||||
/// 查找工作区根目录
|
||||
fn find_workspace_root() -> Option<std::path::PathBuf> {
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| {
|
||||
let mut dir = cwd.as_path();
|
||||
loop {
|
||||
if dir.join("pnpm-workspace.yaml").exists() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
match dir.parent() {
|
||||
Some(parent) => dir = parent,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Update a tsconfig file with engine paths
|
||||
/// 使用引擎路径更新 tsconfig 文件
|
||||
///
|
||||
/// Scans all subdirectories in engine_path for index.d.ts files.
|
||||
/// 扫描 engine_path 下所有子目录的 index.d.ts 文件。
|
||||
fn update_tsconfig_file(
|
||||
tsconfig_path: &std::path::Path,
|
||||
engine_path: &str,
|
||||
include_editor: bool,
|
||||
) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
let content = fs::read_to_string(tsconfig_path)
|
||||
.map_err(|e| format!("Failed to read tsconfig: {}", e))?;
|
||||
|
||||
let mut config: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse tsconfig: {}", e))?;
|
||||
|
||||
// Normalize path for cross-platform compatibility
|
||||
// 规范化路径以实现跨平台兼容
|
||||
let engine_path_normalized = engine_path.replace('\\', "/");
|
||||
|
||||
// Build paths mapping by scanning engine modules directory
|
||||
// 通过扫描引擎模块目录构建路径映射
|
||||
let mut paths = serde_json::Map::new();
|
||||
let mut module_count = 0;
|
||||
|
||||
let engine_dir = std::path::Path::new(engine_path);
|
||||
if let Ok(entries) = fs::read_dir(engine_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let module_path = entry.path();
|
||||
if !module_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let module_id = module_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip editor modules for runtime tsconfig
|
||||
// 运行时 tsconfig 跳过编辑器模块
|
||||
if !include_editor && module_id.ends_with("-editor") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for index.d.ts
|
||||
// 检查是否存在 index.d.ts
|
||||
let dts_path = module_path.join("index.d.ts");
|
||||
if !dts_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read module.json to get the actual package name
|
||||
// 读取 module.json 获取实际的包名
|
||||
let module_json_path = module_path.join("module.json");
|
||||
let module_name = if module_json_path.exists() {
|
||||
fs::read_to_string(&module_json_path)
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
|
||||
.and_then(|json| json.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| format!("@esengine/{}", module_id))
|
||||
} else {
|
||||
format!("@esengine/{}", module_id)
|
||||
};
|
||||
|
||||
let dts_path_str = format!("{}/{}/index.d.ts", engine_path_normalized, module_id);
|
||||
paths.insert(module_name, serde_json::json!([dts_path_str]));
|
||||
module_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("[update_tsconfig_file] Found {} modules with type definitions", module_count);
|
||||
|
||||
// Update compilerOptions.paths
|
||||
// 更新 compilerOptions.paths
|
||||
if let Some(compiler_options) = config.get_mut("compilerOptions") {
|
||||
if let Some(obj) = compiler_options.as_object_mut() {
|
||||
obj.insert("paths".to_string(), serde_json::Value::Object(paths));
|
||||
// Remove typeRoots since we're using paths
|
||||
// 移除 typeRoots,因为我们使用 paths
|
||||
obj.remove("typeRoots");
|
||||
}
|
||||
}
|
||||
|
||||
// Write back
|
||||
// 写回文件
|
||||
let output = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize tsconfig: {}", e))?;
|
||||
|
||||
fs::write(tsconfig_path, output)
|
||||
.map_err(|e| format!("Failed to write tsconfig: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a local HTTP server for runtime preview
|
||||
#[tauri::command]
|
||||
pub fn start_local_server(root_path: String, port: u16) -> Result<String, String> {
|
||||
// If server already running, just return the URL (server persists)
|
||||
if SERVER_RUNNING.load(Ordering::SeqCst) {
|
||||
return Ok(format!("http://127.0.0.1:{}", port));
|
||||
}
|
||||
|
||||
SERVER_STOP_FLAG.store(false, Ordering::SeqCst);
|
||||
SERVER_RUNNING.store(true, Ordering::SeqCst);
|
||||
|
||||
// Bind to 0.0.0.0 to allow LAN access
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let server = Server::http(&addr)
|
||||
.map_err(|e| format!("Failed to start server: {}", e))?;
|
||||
|
||||
let root = root_path.clone();
|
||||
let stop_flag = Arc::clone(&SERVER_STOP_FLAG);
|
||||
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Use recv_timeout to allow checking stop flag periodically
|
||||
match server.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(Some(request)) => {
|
||||
let url = request.url().to_string();
|
||||
|
||||
// Split URL and query string
|
||||
let url_without_query = url.split('?').next().unwrap_or(&url);
|
||||
|
||||
// Handle different request types
|
||||
let file_path = if url.starts_with("/asset?path=") {
|
||||
// Asset proxy - extract and decode path parameter
|
||||
// 资产代理 - 提取并解码路径参数
|
||||
let query = &url[7..]; // Skip "/asset?"
|
||||
if let Some(path_value) = query.strip_prefix("path=") {
|
||||
let decoded = urlencoding::decode(path_value)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
// Normalize path: remove ./ prefix and join with root
|
||||
// 规范化路径:移除 ./ 前缀并与根目录连接
|
||||
let normalized = decoded.trim_start_matches("./");
|
||||
PathBuf::from(&root).join(normalized)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else if url_without_query == "/" || url_without_query.is_empty() {
|
||||
// Root - serve index.html
|
||||
PathBuf::from(&root).join("index.html")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
// Static files - remove leading slash and append to root
|
||||
let path = url_without_query.trim_start_matches('/');
|
||||
PathBuf::from(&root).join(path)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
println!("[DevServer] Request: {} -> {}", url, file_path);
|
||||
|
||||
let response = match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let content_type = if file_path.ends_with(".html") {
|
||||
"text/html; charset=utf-8"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
Response::from_data(content)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
|
||||
.unwrap(),
|
||||
)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => Response::from_string("Not Found")
|
||||
.with_status_code(404),
|
||||
};
|
||||
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout, continue loop
|
||||
}
|
||||
Err(_) => {
|
||||
// Error, exit loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SERVER_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(format!("http://127.0.0.1:{}", port))
|
||||
}
|
||||
|
||||
/// Stop the local HTTP server
|
||||
#[tauri::command]
|
||||
pub fn stop_local_server() -> Result<(), String> {
|
||||
SERVER_STOP_FLAG.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get local IP address for LAN access
|
||||
#[tauri::command]
|
||||
pub fn get_local_ip() -> Result<String, String> {
|
||||
// Use ipconfig on Windows to get the real LAN IP
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "ipconfig"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run ipconfig: {}", e))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse ipconfig output to find IPv4 addresses
|
||||
let mut found_ips: Vec<String> = Vec::new();
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.contains("IPv4") || line.contains("IP Address") {
|
||||
// Extract IP from line like " IPv4 Address. . . . . . . . . . . : 192.168.1.100"
|
||||
if let Some(ip_part) = line.split(':').nth(1) {
|
||||
let ip = ip_part.trim().to_string();
|
||||
// Prefer 192.168.x.x or 10.x.x.x addresses
|
||||
if ip.starts_with("192.168.") || ip.starts_with("10.") {
|
||||
return Ok(ip);
|
||||
}
|
||||
// Collect other IPs as fallback, skip virtual ones
|
||||
if !ip.starts_with("172.") && !ip.starts_with("127.") && !ip.starts_with("169.254.") {
|
||||
found_ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return first non-virtual IP found
|
||||
if let Some(ip) = found_ips.first() {
|
||||
return Ok(ip.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for non-Windows or if ipconfig fails
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
socket.connect("8.8.8.8:80")
|
||||
.map_err(|e| format!("Failed to connect: {}", e))?;
|
||||
|
||||
let local_addr = socket.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?;
|
||||
|
||||
Ok(local_addr.ip().to_string())
|
||||
}
|
||||
|
||||
/// Generate QR code as base64 PNG
|
||||
#[tauri::command]
|
||||
pub fn generate_qrcode(text: String) -> Result<String, String> {
|
||||
let code = QrCode::new(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to create QR code: {}", e))?;
|
||||
|
||||
let image = code.render::<Luma<u8>>()
|
||||
.min_dimensions(200, 200)
|
||||
.build();
|
||||
|
||||
let mut png_data = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut png_data);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
|
||||
|
||||
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png_data))
|
||||
}
|
||||
10
packages/editor/editor-app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! ECS Editor Library
|
||||
//!
|
||||
//! Exports all public modules for the Tauri application.
|
||||
|
||||
pub mod commands;
|
||||
pub mod profiler_ws;
|
||||
pub mod state;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use state::{ProfilerState, ProjectPaths};
|
||||
212
packages/editor/editor-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! ESEngine Editor - Tauri Backend
|
||||
//!
|
||||
//! Clean entry point that handles application setup and command registration.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod commands;
|
||||
mod profiler_ws;
|
||||
mod state;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
|
||||
|
||||
fn main() {
|
||||
// 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()
|
||||
// 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())
|
||||
.plugin(tauri_plugin_cli::init())
|
||||
// Register custom URI scheme for project files
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
handle_project_protocol(request, &project_paths_for_protocol)
|
||||
})
|
||||
// Setup application state | 设置应用状态
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
app.manage(script_watcher_state);
|
||||
Ok(())
|
||||
})
|
||||
// Register all commands
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 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::append_to_log,
|
||||
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,
|
||||
commands::copy_file,
|
||||
commands::get_file_mtime,
|
||||
// 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::open_folder,
|
||||
commands::show_in_folder,
|
||||
commands::get_temp_dir,
|
||||
commands::open_with_editor,
|
||||
commands::update_project_tsconfig,
|
||||
commands::get_app_resource_dir,
|
||||
commands::get_current_dir,
|
||||
commands::start_local_server,
|
||||
commands::stop_local_server,
|
||||
commands::get_local_ip,
|
||||
commands::generate_qrcode,
|
||||
// User code compilation | 用户代码编译
|
||||
commands::compile_typescript,
|
||||
commands::check_types,
|
||||
commands::watch_scripts,
|
||||
commands::watch_assets,
|
||||
commands::stop_watch_scripts,
|
||||
commands::check_environment,
|
||||
commands::install_esbuild,
|
||||
// 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,
|
||||
commands::read_binary_file,
|
||||
// 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");
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// Debug logging
|
||||
println!("[project://] Full URI: {}", uri);
|
||||
println!("[project://] Path: {}", 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)
|
||||
// CORS headers for dynamic ES module imports | 动态 ES 模块导入所需的 CORS 头
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type")
|
||||
.header("Access-Control-Expose-Headers", "Content-Length")
|
||||
// Allow cross-origin script loading | 允许跨域脚本加载
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MIME type based on file extension
|
||||
/// 根据文件扩展名获取 MIME 类型
|
||||
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") || file_path.ends_with(".mjs") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else if file_path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if file_path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
193
packages/editor/editor-app/src-tauri/src/profiler_ws.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
pub struct ProfilerServer {
|
||||
tx: broadcast::Sender<String>,
|
||||
port: u16,
|
||||
shutdown_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
|
||||
task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl ProfilerServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
let (tx, _) = broadcast::channel(100);
|
||||
Self {
|
||||
tx,
|
||||
port,
|
||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
||||
task_handle: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let addr = format!("127.0.0.1:{}", self.port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
println!("[ProfilerServer] Listening on: {}", addr);
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
// 存储 shutdown sender
|
||||
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
||||
|
||||
// 启动服务器任务
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 监听新连接
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(handle_connection(stream, peer_addr, tx));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ProfilerServer] Failed to accept connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 监听关闭信号
|
||||
_ = &mut shutdown_rx => {
|
||||
println!("[ProfilerServer] Received shutdown signal");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[ProfilerServer] Server task ending");
|
||||
});
|
||||
|
||||
// 存储任务句柄
|
||||
*self.task_handle.lock().await = Some(task);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
println!("[ProfilerServer] Stopping server...");
|
||||
|
||||
// 发送关闭信号
|
||||
if let Some(shutdown_tx) = self.shutdown_tx.lock().await.take() {
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
|
||||
// 等待任务完成
|
||||
if let Some(handle) = self.task_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
println!("[ProfilerServer] Server stopped");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn broadcast(&self, message: String) {
|
||||
let _ = self.tx.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
peer_addr: SocketAddr,
|
||||
tx: broadcast::Sender<String>,
|
||||
) {
|
||||
let ws_stream = match accept_async(stream).await {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
// 忽略非 WebSocket 连接的错误(如普通 HTTP 请求、健康检查等)
|
||||
// 这些是正常现象,不需要输出错误日志
|
||||
let error_str = e.to_string();
|
||||
if !error_str.contains("Connection: upgrade") && !error_str.contains("protocol error") {
|
||||
eprintln!("[ProfilerServer] WebSocket error: {}", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
|
||||
let mut rx = tx.subscribe();
|
||||
|
||||
println!("[ProfilerServer] Client {} connected", peer_addr);
|
||||
|
||||
// Send initial connection confirmation
|
||||
let _ = ws_sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "connected",
|
||||
"message": "Connected to ECS Editor Profiler"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Spawn task to forward broadcast messages to this client
|
||||
let forward_task = tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if ws_sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
// Parse incoming messages
|
||||
if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let msg_type = json_value.get("type").and_then(|t| t.as_str());
|
||||
|
||||
if msg_type == Some("debug_data") {
|
||||
// Broadcast debug data from game client to all clients (including frontend)
|
||||
tx.send(text).ok();
|
||||
} else if msg_type == Some("ping") {
|
||||
// Respond to ping
|
||||
let _ = tx.send(
|
||||
serde_json::json!({
|
||||
"type": "pong",
|
||||
"timestamp": chrono::Utc::now().timestamp_millis()
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
} else if msg_type == Some("log") {
|
||||
// Inject clientId into log messages
|
||||
if let Some(data) = json_value.get_mut("data").and_then(|d| d.as_object_mut()) {
|
||||
data.insert("clientId".to_string(), serde_json::Value::String(peer_addr.to_string()));
|
||||
}
|
||||
tx.send(json_value.to_string()).ok();
|
||||
} else {
|
||||
// Forward all other messages (like get_raw_entity_list, get_entity_details, etc.)
|
||||
// to all connected clients (this enables frontend -> game client communication)
|
||||
tx.send(text).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
println!("[ProfilerServer] Client {} disconnected", peer_addr);
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
// Respond to WebSocket ping
|
||||
tx.send(
|
||||
serde_json::json!({
|
||||
"type": "pong",
|
||||
"data": String::from_utf8_lossy(&data)
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ProfilerServer] Error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
forward_task.abort();
|
||||
println!("[ProfilerServer] Connection handler ended for {}", peer_addr);
|
||||
}
|
||||
69
packages/editor/editor-app/src-tauri/src/state.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! 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.
|
||||
/// 项目路径状态。
|
||||
///
|
||||
/// 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.
|
||||
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()
|
||||
}
|
||||
}
|
||||
162
packages/editor/editor-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"productName": "ESEngine Editor",
|
||||
"version": "1.0.14",
|
||||
"identifier": "com.esengine.editor",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run build:watch",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"runtime/**/*",
|
||||
"engine/**/*",
|
||||
"bin/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"ecs"
|
||||
],
|
||||
"name": "ECS Scene File",
|
||||
"description": "ESEngine Scene File",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.13",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"providerShortName": null,
|
||||
"entitlements": null
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ESEngine Editor",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"skipTaskbar": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: blob:; style-src 'self' 'unsafe-inline' tauri: https: http:; img-src 'self' tauri: project: asset: https: http: data: blob:; connect-src 'self' tauri: project: asset: https: http: ws: wss:",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "main",
|
||||
"windows": [
|
||||
"main",
|
||||
"frame-debugger"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"fs:allow-create",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-copy-file"
|
||||
],
|
||||
"scope": {
|
||||
"allow": [
|
||||
"$HOME/**",
|
||||
"$APPDATA/**",
|
||||
"$APPLOCALDATA/**",
|
||||
"$APPCACHE/**",
|
||||
"$APPLOG/**",
|
||||
"$DESKTOP/**",
|
||||
"$DOCUMENT/**",
|
||||
"$DOWNLOAD/**",
|
||||
"$TEMP/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"http-capability"
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"cli": {
|
||||
"args": [
|
||||
{
|
||||
"name": "file",
|
||||
"index": 1,
|
||||
"takesValue": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/esengine/esengine/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFDQjNFNDIxREFBODNDNkMKUldSc1BLamFJZVN6SEJIRXRWWEovVXRta08yNWFkZmtKNnZoSHFmbi9ZdGxubUMzSHJaN3J0VEcK"
|
||||
}
|
||||
}
|
||||
}
|
||||
1550
packages/editor/editor-app/src/App.tsx
Normal file
45
packages/editor/editor-app/src/adapters/TauriFileAPI.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { IFileAPI } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
/**
|
||||
* Tauri 文件 API 适配器
|
||||
*
|
||||
* 实现 IFileAPI 接口,连接 editor-core 和 Tauri 后端
|
||||
*/
|
||||
export class TauriFileAPI implements IFileAPI {
|
||||
public async openSceneDialog(): Promise<string | null> {
|
||||
return await TauriAPI.openSceneDialog();
|
||||
}
|
||||
|
||||
public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await TauriAPI.saveSceneDialog(defaultName, scenesDir);
|
||||
}
|
||||
|
||||
public async readFileContent(path: string): Promise<string> {
|
||||
return await TauriAPI.readFileContent(path);
|
||||
}
|
||||
|
||||
public async saveProject(path: string, data: string): Promise<void> {
|
||||
return await TauriAPI.saveProject(path, data);
|
||||
}
|
||||
|
||||
public async exportBinary(data: Uint8Array, path: string): Promise<void> {
|
||||
return await TauriAPI.exportBinary(data, path);
|
||||
}
|
||||
|
||||
public async createDirectory(path: string): Promise<void> {
|
||||
return await TauriAPI.createDirectory(path);
|
||||
}
|
||||
|
||||
public async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await TauriAPI.writeFileContent(path, content);
|
||||
}
|
||||
|
||||
public async pathExists(path: string): Promise<boolean> {
|
||||
return await TauriAPI.pathExists(path);
|
||||
}
|
||||
|
||||
public async getFileMtime(path: string): Promise<number> {
|
||||
return await TauriAPI.getFileMtime(path);
|
||||
}
|
||||
}
|
||||
475
packages/editor/editor-app/src/api/tauri.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 文件过滤器定义
|
||||
*/
|
||||
interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri IPC 通信层
|
||||
*/
|
||||
export class TauriAPI {
|
||||
static async openFolderDialog(title?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('open_folder_dialog', { title });
|
||||
}
|
||||
|
||||
static async openFileDialog(
|
||||
title?: string,
|
||||
filters?: FileFilter[],
|
||||
multiple?: boolean
|
||||
): Promise<string[] | null> {
|
||||
return await invoke<string[] | null>('open_file_dialog', {
|
||||
title,
|
||||
filters,
|
||||
multiple
|
||||
});
|
||||
}
|
||||
|
||||
static async saveFileDialog(
|
||||
title?: string,
|
||||
defaultName?: string,
|
||||
filters?: FileFilter[],
|
||||
defaultPath?: string
|
||||
): Promise<string | null> {
|
||||
return await invoke<string | null>('save_file_dialog', {
|
||||
title,
|
||||
defaultName,
|
||||
defaultPath,
|
||||
filters
|
||||
});
|
||||
}
|
||||
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await this.openFolderDialog('Select Project Directory');
|
||||
}
|
||||
|
||||
static async openProject(path: string): Promise<string> {
|
||||
return await invoke<string>('open_project', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
static async saveProject(path: string, data: string): Promise<void> {
|
||||
return await invoke<void>('save_project', { path, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出二进制数据
|
||||
*/
|
||||
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
|
||||
return await invoke<void>('export_binary', {
|
||||
data: Array.from(data),
|
||||
outputPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录查找匹配模式的文件
|
||||
*/
|
||||
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_directory', { path, pattern });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(文本)
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(二进制)
|
||||
* Read file content as binary ArrayBuffer
|
||||
*/
|
||||
static async readFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
// Use Tauri read_file_as_base64 command which returns base64 encoded data
|
||||
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
|
||||
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
// Decode base64 to ArrayBuffer
|
||||
// 将 base64 解码为 ArrayBuffer
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
|
||||
return await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置项目基础路径,用于 Custom Protocol
|
||||
*/
|
||||
static async setProjectBasePath(path: string): Promise<void> {
|
||||
return await invoke<void>('set_project_base_path', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换开发者工具(仅在debug模式下可用)
|
||||
*/
|
||||
static async toggleDevtools(): Promise<void> {
|
||||
return await invoke<void>('toggle_devtools');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* Open save scene dialog
|
||||
*
|
||||
* @param defaultName 默认文件名(可选)| Default file name (optional)
|
||||
* @param scenesDir 场景目录路径(可选)| Scenes directory path (optional)
|
||||
* @returns 用户选择的文件路径,取消则返回 null | Selected file path or null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await this.saveFileDialog(
|
||||
'Save ECS Scene',
|
||||
defaultName,
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
scenesDir
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开场景文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
const result = await this.openFileDialog(
|
||||
'Open ECS Scene',
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
* @param path 目录路径
|
||||
*/
|
||||
static async createDirectory(path: string): Promise<void> {
|
||||
return await invoke<void>('create_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
* @param path 文件路径
|
||||
* @param content 文件内容
|
||||
*/
|
||||
static async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否存在
|
||||
* @param path 文件或目录路径
|
||||
* @returns 路径是否存在
|
||||
*/
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async openFileWithSystemApp(path: string): Promise<void> {
|
||||
await invoke('open_file_with_default_app', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文件管理器中显示文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
await invoke('show_in_folder', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定编辑器打开项目
|
||||
* Open project with specified editor
|
||||
*
|
||||
* @param projectPath 项目文件夹路径 | Project folder path
|
||||
* @param editorCommand 编辑器命令(如 "code", "cursor")| Editor command
|
||||
* @param filePath 可选的要打开的文件路径 | Optional file path to open
|
||||
*/
|
||||
static async openWithEditor(
|
||||
projectPath: string,
|
||||
editorCommand: string,
|
||||
filePath?: string
|
||||
): Promise<void> {
|
||||
await invoke('open_with_editor', {
|
||||
projectPath,
|
||||
editorCommand,
|
||||
filePath: filePath || null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开行为树文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
const result = await this.openFileDialog(
|
||||
'Select Behavior Tree',
|
||||
[{ name: 'Behavior Tree Files', extensions: ['btree'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描项目中的所有行为树文件
|
||||
* @param projectPath 项目路径
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路 径,不含扩展名)
|
||||
*/
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或文件夹
|
||||
* @param oldPath 原路径
|
||||
* @param newPath 新路径
|
||||
*/
|
||||
static async renameFileOrFolder(oldPath: string, newPath: string): Promise<void> {
|
||||
return await invoke<void>('rename_file_or_folder', { oldPath, newPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async deleteFile(path: string): Promise<void> {
|
||||
return await invoke<void>('delete_file', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹
|
||||
* @param path 文件夹路径
|
||||
*/
|
||||
static async deleteFolder(path: string): Promise<void> {
|
||||
return await invoke<void>('delete_folder', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async createFile(path: string): Promise<void> {
|
||||
return await invoke<void>('create_file', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件并转换为base64
|
||||
* @param path 文件路径
|
||||
* @returns base64编码的文件内容
|
||||
*/
|
||||
static async readFileAsBase64(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param src 源文件路径
|
||||
* @param dst 目标文件路径
|
||||
*/
|
||||
static async copyFile(src: string, dst: string): Promise<void> {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
static async getFileMtime(path: string): Promise<number> {
|
||||
return await invoke<number>('get_file_mtime', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @param filePath 文件路径
|
||||
* @param content 二进制数据
|
||||
*/
|
||||
static async writeBinaryFile(filePath: string, content: Uint8Array): Promise<void> {
|
||||
return await invoke<void>('write_binary_file', {
|
||||
filePath,
|
||||
content: Array.from(content)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录路径
|
||||
* @returns 临时目录路径
|
||||
*/
|
||||
static async getTempDir(): Promise<string> {
|
||||
return await invoke<string>('get_temp_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用资源目录
|
||||
* @returns 资源目录路径
|
||||
*/
|
||||
static async getAppResourceDir(): Promise<string> {
|
||||
return await invoke<string>('get_app_resource_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前工作目录
|
||||
* @returns 当前工作目录路径
|
||||
*/
|
||||
static async getCurrentDir(): Promise<string> {
|
||||
return await invoke<string>('get_current_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本地HTTP服务器
|
||||
* @param rootPath 服务器根目录
|
||||
* @param port 端口号
|
||||
* @returns 服务器URL
|
||||
*/
|
||||
static async startLocalServer(rootPath: string, port: number): Promise<string> {
|
||||
return await invoke<string>('start_local_server', { rootPath, port });
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本地HTTP服务器
|
||||
*/
|
||||
static async stopLocalServer(): Promise<void> {
|
||||
return await invoke<void>('stop_local_server');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机局域网IP地址
|
||||
* @returns 局域网IP地址
|
||||
*/
|
||||
static async getLocalIp(): Promise<string> {
|
||||
return await invoke<string>('get_local_ip');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
* @param text 要编码的文本
|
||||
* @returns base64编码的PNG图片
|
||||
*/
|
||||
static async generateQRCode(text: string): Promise<string> {
|
||||
return await invoke<string>('generate_qrcode', { text });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目的 tsconfig.json,添加引擎类型路径
|
||||
* Update project tsconfig.json with engine type paths
|
||||
*
|
||||
* This updates the tsconfig to point directly to engine's .d.ts files
|
||||
* instead of copying them to the project.
|
||||
* 这会更新 tsconfig 直接指向引擎的 .d.ts 文件,而不是复制到项目。
|
||||
*
|
||||
* @param projectPath 项目路径 | Project path
|
||||
*/
|
||||
static async updateProjectTsconfig(projectPath: string): Promise<void> {
|
||||
return await invoke<void>('update_project_tsconfig', { projectPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地文件路径转换为 Tauri 可访问的 asset URL
|
||||
* @param filePath 本地文件路径
|
||||
* @param protocol 协议类型 (默认: 'asset')
|
||||
* @returns 转换后的 URL,可用于 img src、audio src 等
|
||||
* @example
|
||||
* const url = TauriAPI.convertFileSrc('C:\\Users\\...\\image.png');
|
||||
* // 返回: 'https://asset.localhost/C:/Users/.../image.png'
|
||||
*/
|
||||
static convertFileSrc(filePath: string, protocol?: string): string {
|
||||
return convertFileSrc(filePath, protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测开发环境
|
||||
* Check development environment
|
||||
*
|
||||
* Checks if all required tools (esbuild, etc.) are available.
|
||||
* 检查所有必需的工具是否可用。
|
||||
*
|
||||
* @returns 环境检测结果 | Environment check result
|
||||
*/
|
||||
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
|
||||
return await invoke<EnvironmentCheckResult>('check_environment');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 esbuild(全局)
|
||||
* Install esbuild globally via npm
|
||||
*
|
||||
* This command installs esbuild globally using `npm install -g esbuild`.
|
||||
* 使用 `npm install -g esbuild` 全局安装 esbuild。
|
||||
*
|
||||
* @returns Promise that resolves when installation completes
|
||||
*/
|
||||
static async installEsbuild(): Promise<void> {
|
||||
return await invoke<void>('install_esbuild');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具可用性状态
|
||||
* Tool availability status
|
||||
*/
|
||||
export interface ToolStatus {
|
||||
/** 工具是否可用 | Whether the tool is available */
|
||||
available: boolean;
|
||||
/** 工具版本 | Tool version */
|
||||
version?: string;
|
||||
/** 工具路径 | Tool path */
|
||||
path?: string;
|
||||
/** 工具来源: "bundled", "local", "global" | Tool source */
|
||||
source?: string;
|
||||
/** 错误信息 | Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境检测结果
|
||||
* Environment check result
|
||||
*/
|
||||
export interface EnvironmentCheckResult {
|
||||
/** 所有必需工具是否可用 | Whether all required tools are available */
|
||||
ready: boolean;
|
||||
/** esbuild 可用性状态 | esbuild availability status */
|
||||
esbuild: ToolStatus;
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目信息
|
||||
*/
|
||||
export interface ProjectInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
theme: string;
|
||||
autoSave: boolean;
|
||||
recentProjects: string[];
|
||||
}
|
||||
84
packages/editor/editor-app/src/app/managers/DialogManager.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ConfirmDialogData } from '../../services/TauriDialogService';
|
||||
|
||||
interface ErrorDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框数据
|
||||
* External modification dialog data
|
||||
*/
|
||||
export interface ExternalModificationDialogData {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showProfiler: boolean;
|
||||
showAdvancedProfiler: boolean;
|
||||
showPortManager: boolean;
|
||||
showSettings: boolean;
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
showRenderDebug: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
externalModificationDialog: ExternalModificationDialogData | null;
|
||||
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowAdvancedProfiler: (show: boolean) => void;
|
||||
setShowPortManager: (show: boolean) => void;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setShowRenderDebug: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
}
|
||||
|
||||
export const useDialogStore = create<DialogState>((set) => ({
|
||||
showProfiler: false,
|
||||
showAdvancedProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null,
|
||||
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
||||
setShowPortManager: (show) => set({ showPortManager: show }),
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showProfiler: false,
|
||||
showAdvancedProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null
|
||||
})
|
||||
}));
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 插件安装器
|
||||
* Plugin Installer
|
||||
*
|
||||
* 现在所有插件都使用统一的 IPlugin 接口,无需适配器。
|
||||
* Now all plugins use the unified IPlugin interface, no adapter needed.
|
||||
*/
|
||||
|
||||
import type { PluginManager } from '@esengine/editor-core';
|
||||
|
||||
// 内置插件
|
||||
import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
|
||||
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
import { AssetMetaPlugin } from '../../plugins/builtin/AssetMetaPlugin';
|
||||
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||
|
||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
import { ParticlePlugin } from '@esengine/particle-editor';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { FGUIPlugin } from '@esengine/fairygui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
import { Mesh3DPlugin } from '@esengine/mesh-3d-editor';
|
||||
|
||||
// 纯运行时插件 | Runtime-only plugins
|
||||
import { CameraPlugin } from '@esengine/camera';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
* 安装所有内置插件
|
||||
*/
|
||||
async installBuiltinPlugins(pluginManager: PluginManager): Promise<void> {
|
||||
// 内置编辑器插件
|
||||
const builtinPlugins = [
|
||||
{ name: 'GizmoPlugin', plugin: GizmoPlugin },
|
||||
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
||||
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
||||
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||
{ name: 'AssetMetaPlugin', plugin: AssetMetaPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of builtinPlugins) {
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'CameraPlugin', plugin: CameraPlugin },
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||
{ name: 'Mesh3DPlugin', plugin: Mesh3DPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
// 详细日志,检查 editorModule 是否存在
|
||||
console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
535
packages/editor/editor-app/src/app/managers/ServiceRegistry.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
IMessageHub,
|
||||
SerializerRegistry,
|
||||
EntityStoreService,
|
||||
EditorComponentRegistry,
|
||||
ProjectService,
|
||||
ComponentDiscoveryService,
|
||||
PropertyMetadataService,
|
||||
LogService,
|
||||
SettingsRegistry,
|
||||
SceneManagerService,
|
||||
SceneTemplateRegistry,
|
||||
FileActionRegistry,
|
||||
IFileActionRegistry,
|
||||
EntityCreationRegistry,
|
||||
PluginManager,
|
||||
IPluginManager,
|
||||
InspectorRegistry,
|
||||
IInspectorRegistry,
|
||||
PropertyRendererRegistry,
|
||||
FieldEditorRegistry,
|
||||
ComponentActionRegistry,
|
||||
ComponentInspectorRegistry,
|
||||
IDialogService,
|
||||
IFileSystemService,
|
||||
CompilerRegistry,
|
||||
ICompilerRegistry,
|
||||
IViewportService_ID,
|
||||
IPreviewSceneService,
|
||||
IEditorViewportServiceIdentifier,
|
||||
PreviewSceneService,
|
||||
EditorViewportService,
|
||||
BuildService,
|
||||
WebBuildPipeline,
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry,
|
||||
UserCodeService,
|
||||
UserCodeTarget,
|
||||
type HotReloadEvent
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { AudioSourceComponent } from '@esengine/audio';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
import { CommandRegistry } from '../../core/commands/CommandRegistry';
|
||||
import { PanelRegistry } from '../../core/commands/PanelRegistry';
|
||||
import type { EditorEventMap } from '../../core/events/EditorEventMap';
|
||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||
import { TauriDialogService } from '../../services/TauriDialogService';
|
||||
import { NotificationService } from '../../services/NotificationService';
|
||||
import {
|
||||
StringRenderer,
|
||||
NumberRenderer,
|
||||
BooleanRenderer,
|
||||
NullRenderer,
|
||||
Vector2Renderer,
|
||||
Vector3Renderer,
|
||||
ColorRenderer,
|
||||
ComponentRenderer,
|
||||
ArrayRenderer,
|
||||
FallbackRenderer
|
||||
} from '../../infrastructure/property-renderers';
|
||||
import {
|
||||
AssetFieldEditor,
|
||||
Vector2FieldEditor,
|
||||
Vector3FieldEditor,
|
||||
Vector4FieldEditor,
|
||||
ColorFieldEditor,
|
||||
AnimationClipsFieldEditor,
|
||||
EntityRefFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
|
||||
import { PluginSDKRegistry } from '../../services/PluginSDKRegistry';
|
||||
|
||||
export interface EditorServices {
|
||||
uiRegistry: UIRegistry;
|
||||
messageHub: MessageHub;
|
||||
serializerRegistry: SerializerRegistry;
|
||||
entityStore: EntityStoreService;
|
||||
componentRegistry: EditorComponentRegistry;
|
||||
projectService: ProjectService;
|
||||
componentDiscovery: ComponentDiscoveryService;
|
||||
propertyMetadata: PropertyMetadataService;
|
||||
logService: LogService;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
sceneManager: SceneManagerService;
|
||||
fileActionRegistry: FileActionRegistry;
|
||||
pluginManager: PluginManager;
|
||||
diContainer: DIContainer;
|
||||
eventBus: TypedEventBus<EditorEventMap>;
|
||||
commandRegistry: CommandRegistry;
|
||||
panelRegistry: PanelRegistry;
|
||||
fileSystem: TauriFileSystemService;
|
||||
dialog: TauriDialogService;
|
||||
notification: NotificationService;
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
propertyRendererRegistry: PropertyRendererRegistry;
|
||||
fieldEditorRegistry: FieldEditorRegistry;
|
||||
buildService: BuildService;
|
||||
userCodeService: UserCodeService;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
registerAllServices(coreInstance: Core): EditorServices {
|
||||
const fileAPI = new TauriFileAPI();
|
||||
|
||||
const uiRegistry = new UIRegistry();
|
||||
const messageHub = new MessageHub();
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new EditorComponentRegistry();
|
||||
|
||||
// 注册标准组件到编辑器和核心注册表
|
||||
// Register to both editor registry (for UI) and core registry (for serialization)
|
||||
const standardComponents = [
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
|
||||
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
|
||||
];
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
|
||||
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description,
|
||||
icon: comp.icon
|
||||
});
|
||||
}
|
||||
|
||||
// Enable hot reload for editor environment
|
||||
// 在编辑器环境中启用热更新
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
const logService = new LogService();
|
||||
const settingsRegistry = new SettingsRegistry();
|
||||
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore);
|
||||
const fileActionRegistry = new FileActionRegistry();
|
||||
const entityCreationRegistry = new EntityCreationRegistry();
|
||||
const componentActionRegistry = new ComponentActionRegistry();
|
||||
const componentInspectorRegistry = new ComponentInspectorRegistry();
|
||||
|
||||
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||
Core.services.registerInstance(MessageHub, messageHub);
|
||||
Core.services.registerInstance(IMessageHub, messageHub); // Symbol 注册用于跨包插件访问
|
||||
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||
Core.services.registerInstance(EntityStoreService, entityStore);
|
||||
Core.services.registerInstance(EditorComponentRegistry, componentRegistry);
|
||||
Core.services.registerInstance(ProjectService, projectService);
|
||||
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
|
||||
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
|
||||
Core.services.registerInstance(LogService, logService);
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManager);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
|
||||
|
||||
// 注册预制体文件处理器 | Register prefab file handler
|
||||
fileActionRegistry.registerActionHandler({
|
||||
extensions: ['prefab'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 发布事件,由编辑器面板处理预制体选择/预览
|
||||
// Publish event for editor panels to handle prefab selection/preview
|
||||
messageHub.publish('prefab:selected', { path: filePath });
|
||||
}
|
||||
});
|
||||
|
||||
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
|
||||
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
|
||||
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
|
||||
|
||||
const pluginManager = new PluginManager();
|
||||
Core.services.registerInstance(IPluginManager, pluginManager);
|
||||
|
||||
const diContainer = new DIContainer();
|
||||
const eventBus = new TypedEventBus<EditorEventMap>();
|
||||
const commandRegistry = new CommandRegistry();
|
||||
const panelRegistry = new PanelRegistry();
|
||||
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
const dialog = new TauriDialogService();
|
||||
const notification = new NotificationService();
|
||||
Core.services.registerInstance(NotificationService, notification);
|
||||
Core.services.registerInstance(IDialogService, dialog);
|
||||
Core.services.registerInstance(IFileSystemService, fileSystem);
|
||||
|
||||
// Register viewport service for editor panels
|
||||
// 注册视口服务供编辑器面板使用
|
||||
const viewportService = ViewportService.getInstance();
|
||||
Core.services.registerInstance(IViewportService_ID, viewportService);
|
||||
|
||||
// Register preview scene service for isolated preview scenes
|
||||
// 注册预览场景服务,用于隔离的预览场景
|
||||
const previewSceneService = PreviewSceneService.getInstance();
|
||||
Core.services.registerInstance(IPreviewSceneService, previewSceneService);
|
||||
|
||||
// Register editor viewport service for coordinating viewports with overlays
|
||||
// 注册编辑器视口服务,协调带有覆盖层的视口
|
||||
const editorViewportService = EditorViewportService.getInstance();
|
||||
editorViewportService.setViewportService(viewportService);
|
||||
Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
||||
|
||||
const propertyRendererRegistry = new PropertyRendererRegistry();
|
||||
Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry);
|
||||
|
||||
propertyRendererRegistry.register(new StringRenderer());
|
||||
propertyRendererRegistry.register(new NumberRenderer());
|
||||
propertyRendererRegistry.register(new BooleanRenderer());
|
||||
propertyRendererRegistry.register(new NullRenderer());
|
||||
propertyRendererRegistry.register(new Vector2Renderer());
|
||||
propertyRendererRegistry.register(new Vector3Renderer());
|
||||
propertyRendererRegistry.register(new ColorRenderer());
|
||||
propertyRendererRegistry.register(new ComponentRenderer());
|
||||
propertyRendererRegistry.register(new ArrayRenderer());
|
||||
propertyRendererRegistry.register(new FallbackRenderer());
|
||||
|
||||
const fieldEditorRegistry = new FieldEditorRegistry();
|
||||
Core.services.registerInstance(FieldEditorRegistry, fieldEditorRegistry);
|
||||
|
||||
fieldEditorRegistry.register(new AssetFieldEditor());
|
||||
fieldEditorRegistry.register(new Vector2FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector3FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
fieldEditorRegistry.register(new EntityRefFieldEditor());
|
||||
|
||||
// 注册组件检查器
|
||||
// Register component inspectors
|
||||
componentInspectorRegistry.register(new TransformComponentInspector());
|
||||
|
||||
// 注册构建服务
|
||||
// Register build service
|
||||
const buildService = new BuildService();
|
||||
|
||||
// Register Web build pipeline with file system service
|
||||
// 注册 Web 构建管线并注入文件系统服务
|
||||
const webPipeline = new WebBuildPipeline();
|
||||
webPipeline.setFileSystem(buildFileSystem);
|
||||
|
||||
// Get engine modules path from Tauri backend
|
||||
// 从 Tauri 后端获取引擎模块路径
|
||||
invoke<string>('get_engine_modules_base_path').then(enginePath => {
|
||||
console.log('[ServiceRegistry] Engine modules path:', enginePath);
|
||||
webPipeline.setEngineModulesPath(enginePath);
|
||||
}).catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to get engine modules path:', err);
|
||||
});
|
||||
|
||||
buildService.register(webPipeline);
|
||||
|
||||
// Register WeChat build pipeline
|
||||
// 注册微信构建管线
|
||||
const wechatPipeline = new WeChatBuildPipeline();
|
||||
wechatPipeline.setFileSystem(buildFileSystem);
|
||||
buildService.register(wechatPipeline);
|
||||
|
||||
Core.services.registerInstance(BuildService, buildService);
|
||||
|
||||
// Initialize ModuleRegistry with Tauri file system
|
||||
// 使用 Tauri 文件系统初始化 ModuleRegistry
|
||||
// Engine modules are read via Tauri commands from local file system
|
||||
// 引擎模块通过 Tauri 命令从本地文件系统读取
|
||||
const tauriModuleFs = new TauriModuleFileSystem();
|
||||
moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err);
|
||||
});
|
||||
|
||||
// Initialize UserCodeService for user script compilation and loading
|
||||
// 初始化 UserCodeService 用于用户脚本编译和加载
|
||||
const userCodeService = new UserCodeService(fileSystem);
|
||||
Core.services.registerInstance(UserCodeService, userCodeService);
|
||||
|
||||
// Helper function to compile and load user scripts
|
||||
// 辅助函数:编译和加载用户脚本
|
||||
let currentProjectPath: string | null = null;
|
||||
|
||||
const compileAndLoadUserScripts = async (projectPath: string) => {
|
||||
// Ensure PluginSDKRegistry is initialized before loading user code
|
||||
// 确保在加载用户代码之前 PluginSDKRegistry 已初始化
|
||||
PluginSDKRegistry.initialize();
|
||||
|
||||
try {
|
||||
// 1. 编译运行时脚本 | Compile runtime scripts
|
||||
const runtimeResult = await userCodeService.compile({
|
||||
projectPath: projectPath,
|
||||
target: UserCodeTarget.Runtime
|
||||
});
|
||||
|
||||
if (runtimeResult.success && runtimeResult.outputPath) {
|
||||
const module = await userCodeService.load(runtimeResult.outputPath, UserCodeTarget.Runtime);
|
||||
userCodeService.registerComponents(module, componentRegistry);
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath,
|
||||
exports: Object.keys(module.exports)
|
||||
});
|
||||
} else if (runtimeResult.errors.length > 0) {
|
||||
console.warn('[UserCodeService] Runtime compilation errors:', runtimeResult.errors);
|
||||
}
|
||||
|
||||
// 2. 编译编辑器脚本 | Compile editor scripts
|
||||
const editorResult = await userCodeService.compile({
|
||||
projectPath: projectPath,
|
||||
target: UserCodeTarget.Editor
|
||||
});
|
||||
|
||||
if (editorResult.success && editorResult.outputPath) {
|
||||
const editorModule = await userCodeService.load(editorResult.outputPath, UserCodeTarget.Editor);
|
||||
userCodeService.registerEditorExtensions(editorModule, componentInspectorRegistry);
|
||||
messageHub.publish('usercode:editor-reloaded', {
|
||||
projectPath,
|
||||
exports: Object.keys(editorModule.exports)
|
||||
});
|
||||
} else if (editorResult.errors.length > 0) {
|
||||
// 编辑器脚本编译错误只记录,不影响运行时
|
||||
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
||||
}
|
||||
|
||||
// 编译完成,发出就绪信号 | Compilation done, signal ready
|
||||
userCodeService.signalReady();
|
||||
} catch (error) {
|
||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
|
||||
// Signal ready even on failure to avoid blocking scene loading
|
||||
userCodeService.signalReady();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to project:opened to compile and load user scripts
|
||||
// 订阅 project:opened 以编译和加载用户脚本
|
||||
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
|
||||
currentProjectPath = data.path;
|
||||
await compileAndLoadUserScripts(data.path);
|
||||
|
||||
// Start watching for file changes (external editor support)
|
||||
// 开始监视文件变更(支持外部编辑器)
|
||||
userCodeService.watch(data.path, async (event) => {
|
||||
console.log('[UserCodeService] Hot reload event:', event.changedFiles);
|
||||
|
||||
if (event.newModule) {
|
||||
// 1. 注册新的/更新的组件到注册表
|
||||
userCodeService.registerComponents(event.newModule, componentRegistry);
|
||||
|
||||
// 2. 热更新:更新现有实例的原型链
|
||||
const updatedCount = userCodeService.hotReloadInstances(event.newModule);
|
||||
console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`);
|
||||
|
||||
// 3. 如果正在预览,热更新用户系统
|
||||
const scene = Core.scene;
|
||||
if (scene && !scene.isEditorMode) {
|
||||
userCodeService.hotReloadSystems(event.newModule, scene);
|
||||
}
|
||||
|
||||
// 4. 通知用户代码已重新加载
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath: data.path,
|
||||
exports: Object.keys(event.newModule.exports),
|
||||
updatedInstances: updatedCount
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[UserCodeService] Failed to start file watcher:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to project:closed to stop watching and cleanup
|
||||
// 订阅 project:closed 以停止监视和清理
|
||||
messageHub.subscribe('project:closed', async () => {
|
||||
currentProjectPath = null;
|
||||
await userCodeService.stopWatch();
|
||||
userCodeService.unregisterEditorExtensions(componentInspectorRegistry);
|
||||
});
|
||||
|
||||
// Subscribe to script file changes (create/delete) from editor operations
|
||||
// 订阅编辑器操作的脚本文件变更(创建/删除)
|
||||
// Note: file:modified is handled by the Rust file watcher for external editor support
|
||||
// 注意:file:modified 由 Rust 文件监视器处理以支持外部编辑器
|
||||
messageHub.subscribe('file:created', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
messageHub.subscribe('file:deleted', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 预览开始时注册用户系统
|
||||
// Register user systems when preview starts
|
||||
messageHub.subscribe('preview:start', () => {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 预览停止时移除用户系统
|
||||
// Unregister user systems when preview stops
|
||||
messageHub.subscribe('preview:stop', () => {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.unregisterSystems(scene);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
serializerRegistry,
|
||||
entityStore,
|
||||
componentRegistry,
|
||||
projectService,
|
||||
componentDiscovery,
|
||||
propertyMetadata,
|
||||
logService,
|
||||
settingsRegistry,
|
||||
sceneManager,
|
||||
fileActionRegistry,
|
||||
pluginManager,
|
||||
diContainer,
|
||||
eventBus,
|
||||
commandRegistry,
|
||||
panelRegistry,
|
||||
fileSystem,
|
||||
dialog,
|
||||
notification,
|
||||
inspectorRegistry,
|
||||
propertyRendererRegistry,
|
||||
fieldEditorRegistry,
|
||||
buildService,
|
||||
userCodeService
|
||||
};
|
||||
}
|
||||
|
||||
setupRemoteLogListener(logService: LogService): void {
|
||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||
const { level, message, timestamp, clientId } = event.detail;
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a TypeScript script file (not in editor folder)
|
||||
* 检查文件路径是否为 TypeScript 脚本文件(不在 editor 文件夹中)
|
||||
*/
|
||||
private isScriptFile(filePath: string): boolean {
|
||||
// Must be .ts file | 必须是 .ts 文件
|
||||
if (!filePath.endsWith('.ts')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize path separators | 规范化路径分隔符
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Must be in scripts folder | 必须在 scripts 文件夹中
|
||||
if (!normalizedPath.includes('/scripts/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude editor scripts | 排除编辑器脚本
|
||||
if (normalizedPath.includes('/scripts/editor/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude .esengine folder | 排除 .esengine 文件夹
|
||||
if (normalizedPath.includes('/.esengine/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认场景模板
|
||||
* Register default scene template with default entities
|
||||
*/
|
||||
private registerDefaultSceneTemplate(): void {
|
||||
// 注册默认相机创建器
|
||||
// Register default camera creator
|
||||
SceneTemplateRegistry.registerDefaultEntity((scene) => {
|
||||
// 检查是否已存在相机
|
||||
// Check if camera already exists
|
||||
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
|
||||
if (existingCameras.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建默认相机实体
|
||||
// Create default camera entity
|
||||
const cameraEntity = scene.createEntity('Main Camera');
|
||||
cameraEntity.addComponent(new TransformComponent());
|
||||
const camera = new CameraComponent();
|
||||
camera.orthographicSize = 1;
|
||||
cameraEntity.addComponent(camera);
|
||||
|
||||
return cameraEntity;
|
||||
});
|
||||
}
|
||||
}
|
||||
3
packages/editor/editor-app/src/app/managers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ServiceRegistry';
|
||||
export * from './DialogManager';
|
||||
export * from './PluginInstaller';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ICommand } from './ICommand';
|
||||
|
||||
/**
|
||||
* 命令基类
|
||||
* 提供默认实现,具体命令继承此类
|
||||
*/
|
||||
export abstract class BaseCommand implements ICommand {
|
||||
abstract execute(): void;
|
||||
abstract undo(): void;
|
||||
abstract getDescription(): string;
|
||||
|
||||
/**
|
||||
* 默认不支持合并
|
||||
*/
|
||||
canMergeWith(_other: ICommand): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认抛出错误
|
||||
*/
|
||||
mergeWith(_other: ICommand): ICommand {
|
||||
throw new Error(`${this.constructor.name} 不支持合并操作`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { ICommand } from './ICommand';
|
||||
|
||||
/**
|
||||
* 命令历史记录配置
|
||||
*/
|
||||
export interface CommandManagerConfig {
|
||||
/**
|
||||
* 最大历史记录数量
|
||||
*/
|
||||
maxHistorySize?: number;
|
||||
|
||||
/**
|
||||
* 是否自动合并相似命令
|
||||
*/
|
||||
autoMerge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令管理器
|
||||
* 管理命令的执行、撤销、重做以及历史记录
|
||||
*/
|
||||
export class CommandManager {
|
||||
private undoStack: ICommand[] = [];
|
||||
private redoStack: ICommand[] = [];
|
||||
private readonly config: Required<CommandManagerConfig>;
|
||||
private isExecuting = false;
|
||||
|
||||
constructor(config: CommandManagerConfig = {}) {
|
||||
this.config = {
|
||||
maxHistorySize: config.maxHistorySize ?? 100,
|
||||
autoMerge: config.autoMerge ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(command: ICommand): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中执行新命令');
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销上一个命令
|
||||
*/
|
||||
undo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中撤销');
|
||||
}
|
||||
|
||||
const command = this.undoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
} catch (error) {
|
||||
this.undoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做上一个被撤销的命令
|
||||
*/
|
||||
redo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中重做');
|
||||
}
|
||||
|
||||
const command = this.redoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
} catch (error) {
|
||||
this.redoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取撤销栈的描述列表
|
||||
*/
|
||||
getUndoHistory(): string[] {
|
||||
return this.undoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重做栈的描述列表
|
||||
*/
|
||||
getRedoHistory(): string[] {
|
||||
return this.redoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有历史记录
|
||||
*/
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行命令(作为单一操作,可以一次撤销)
|
||||
*/
|
||||
executeBatch(commands: ICommand[]): void {
|
||||
if (commands.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量命令
|
||||
* 将多个命令组合为一个命令
|
||||
*/
|
||||
class BatchCommand implements ICommand {
|
||||
constructor(private readonly commands: ICommand[]) {}
|
||||
|
||||
execute(): void {
|
||||
for (const command of this.commands) {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
const command = this.commands[i];
|
||||
if (command) {
|
||||
command.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `批量操作 (${this.commands.length} 个命令)`;
|
||||
}
|
||||
|
||||
canMergeWith(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
mergeWith(): ICommand {
|
||||
throw new Error('批量命令不支持合并');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 命令接口
|
||||
* 实现命令模式,支持撤销/重做功能
|
||||
*/
|
||||
export interface ICommand {
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(): void;
|
||||
|
||||
/**
|
||||
* 撤销命令
|
||||
*/
|
||||
undo(): void;
|
||||
|
||||
/**
|
||||
* 获取命令描述(用于显示历史记录)
|
||||
*/
|
||||
getDescription(): string;
|
||||
|
||||
/**
|
||||
* 检查命令是否可以合并
|
||||
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean;
|
||||
|
||||
/**
|
||||
* 与另一个命令合并
|
||||
*/
|
||||
mergeWith(other: ICommand): ICommand;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EditorComponentRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 添加组件命令
|
||||
*
|
||||
* 自动添加缺失的依赖组件(通过 @ECSComponent requires 选项声明)
|
||||
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
|
||||
*/
|
||||
export class AddComponentCommand extends BaseCommand {
|
||||
private component: Component | null = null;
|
||||
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
|
||||
private autoAddedDependencies: Component[] = [];
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private ComponentClass: new () => Component,
|
||||
private initialData?: Record<string, unknown>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 先添加缺失的依赖组件 | Add missing dependencies first
|
||||
this.addMissingDependencies();
|
||||
|
||||
this.component = new this.ComponentClass();
|
||||
|
||||
// 应用初始数据 | Apply initial data
|
||||
if (this.initialData) {
|
||||
for (const [key, value] of Object.entries(this.initialData)) {
|
||||
(this.component as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.entity.addComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: this.component
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缺失的依赖组件
|
||||
* Add missing dependency components
|
||||
*/
|
||||
private addMissingDependencies(): void {
|
||||
const dependencies = getComponentDependencies(this.ComponentClass);
|
||||
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRegistry = Core.services.tryResolve(EditorComponentRegistry) as EditorComponentRegistry | null;
|
||||
if (!componentRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const depName of dependencies) {
|
||||
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
|
||||
const depInfo = componentRegistry.getComponent(depName);
|
||||
|
||||
if (!depInfo?.type) {
|
||||
console.warn(`Dependency component not found in registry: ${depName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const DepClass = depInfo.type;
|
||||
|
||||
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
|
||||
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
|
||||
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
|
||||
|
||||
if (foundByName) {
|
||||
// 组件已存在(通过名称匹配),跳过添加
|
||||
// Component already exists (matched by name), skip adding
|
||||
continue;
|
||||
}
|
||||
|
||||
// 自动添加依赖组件 | Auto-add dependency component
|
||||
const depComponent = new DepClass();
|
||||
this.entity.addComponent(depComponent);
|
||||
this.autoAddedDependencies.push(depComponent);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: depComponent,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.component) return;
|
||||
|
||||
// 先移除主组件 | Remove main component first
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: getComponentTypeName(this.ComponentClass)
|
||||
});
|
||||
|
||||
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
|
||||
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
|
||||
const dep = this.autoAddedDependencies[i];
|
||||
if (dep) {
|
||||
this.entity.removeComponent(dep);
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: dep.constructor.name,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
this.autoAddedDependencies = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const mainName = getComponentTypeName(this.ComponentClass);
|
||||
if (this.autoAddedDependencies.length > 0) {
|
||||
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
|
||||
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
|
||||
}
|
||||
return `添加组件: ${mainName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 移除组件命令
|
||||
*/
|
||||
export class RemoveComponentCommand extends BaseCommand {
|
||||
private componentData: Record<string, unknown> = {};
|
||||
private ComponentClass: new () => Component;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private component: Component
|
||||
) {
|
||||
super();
|
||||
this.ComponentClass = component.constructor as new () => Component;
|
||||
|
||||
// 保存组件数据用于撤销
|
||||
for (const key of Object.keys(component)) {
|
||||
if (key !== 'entity' && key !== 'id') {
|
||||
this.componentData[key] = (component as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: this.ComponentClass.name
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const newComponent = new this.ComponentClass();
|
||||
|
||||
// 恢复数据
|
||||
for (const [key, value] of Object.entries(this.componentData)) {
|
||||
(newComponent as any)[key] = value;
|
||||
}
|
||||
|
||||
this.entity.addComponent(newComponent);
|
||||
this.component = newComponent;
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: newComponent
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移除组件: ${this.ComponentClass.name}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* 更新组件属性命令
|
||||
*/
|
||||
export class UpdateComponentCommand extends BaseCommand {
|
||||
private oldValue: unknown;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private component: Component,
|
||||
private propertyName: string,
|
||||
private newValue: unknown
|
||||
) {
|
||||
super();
|
||||
this.oldValue = (component as any)[propertyName];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
(this.component as any)[this.propertyName] = this.newValue;
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
value: this.newValue
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
(this.component as any)[this.propertyName] = this.oldValue;
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
value: this.oldValue
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `更新 ${this.component.constructor.name}.${this.propertyName}`;
|
||||
}
|
||||
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof UpdateComponentCommand)) return false;
|
||||
|
||||
return (
|
||||
this.entity === other.entity &&
|
||||
this.component === other.component &&
|
||||
this.propertyName === other.propertyName
|
||||
);
|
||||
}
|
||||
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof UpdateComponentCommand)) {
|
||||
throw new Error('无法合并不同类型的命令');
|
||||
}
|
||||
|
||||
// 保留原始值,使用新命令的新值
|
||||
const merged = new UpdateComponentCommand(
|
||||
this.messageHub,
|
||||
this.entity,
|
||||
this.component,
|
||||
this.propertyName,
|
||||
other.newValue
|
||||
);
|
||||
merged.oldValue = this.oldValue;
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AddComponentCommand } from './AddComponentCommand';
|
||||
export { RemoveComponentCommand } from './RemoveComponentCommand';
|
||||
export { UpdateComponentCommand } from './UpdateComponentCommand';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带动画组件的Sprite实体命令
|
||||
*/
|
||||
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加 Transform、Sprite、Animator 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new SpriteAnimatorComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建动画Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||