feat(editor): 实现用户脚本编译加载和自动重编译 (#273)
This commit is contained in:
@@ -381,12 +381,12 @@ function App() {
|
||||
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
|
||||
await TauriAPI.setProjectBasePath(projectPath);
|
||||
|
||||
// 复制类型定义到项目,用于 IDE 智能感知
|
||||
// Copy type definitions to project for IDE intellisense
|
||||
// 更新项目 tsconfig,直接引用引擎类型定义
|
||||
// Update project tsconfig to reference engine type definitions directly
|
||||
try {
|
||||
await TauriAPI.copyTypeDefinitions(projectPath);
|
||||
await TauriAPI.updateProjectTsconfig(projectPath);
|
||||
} catch (e) {
|
||||
console.warn('[App] Failed to copy type definitions:', e);
|
||||
console.warn('[App] Failed to update project tsconfig:', e);
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
@@ -840,14 +840,17 @@ function App() {
|
||||
setStatus(t('header.status.ready'));
|
||||
}}
|
||||
onDeleteProject={async (projectPath) => {
|
||||
console.log('[App] onDeleteProject called with path:', projectPath);
|
||||
try {
|
||||
console.log('[App] Calling TauriAPI.deleteFolder...');
|
||||
await TauriAPI.deleteFolder(projectPath);
|
||||
console.log('[App] deleteFolder succeeded');
|
||||
// 删除成功后从列表中移除并触发重新渲染
|
||||
// Remove from list and trigger re-render after successful deletion
|
||||
settings.removeRecentProject(projectPath);
|
||||
setStatus(t('header.status.ready'));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
console.error('[App] Failed to delete project:', error);
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '删除项目失败' : 'Failed to Delete Project',
|
||||
message: locale === 'zh'
|
||||
|
||||
@@ -332,13 +332,17 @@ export class TauriAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制类型定义文件到项目
|
||||
* Copy type definition files to project for IDE intellisense
|
||||
* 更新项目的 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 copyTypeDefinitions(projectPath: string): Promise<void> {
|
||||
return await invoke<void>('copy_type_definitions', { projectPath });
|
||||
static async updateProjectTsconfig(projectPath: string): Promise<void> {
|
||||
return await invoke<void>('update_project_tsconfig', { projectPath });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
BuildService,
|
||||
WebBuildPipeline,
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry
|
||||
moduleRegistry,
|
||||
UserCodeService,
|
||||
UserCodeTarget
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
@@ -78,6 +80,7 @@ import {
|
||||
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;
|
||||
@@ -104,6 +107,7 @@ export interface EditorServices {
|
||||
propertyRendererRegistry: PropertyRendererRegistry;
|
||||
fieldEditorRegistry: FieldEditorRegistry;
|
||||
buildService: BuildService;
|
||||
userCodeService: UserCodeService;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
@@ -271,6 +275,74 @@ export class ServiceRegistry {
|
||||
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 {
|
||||
// Compile runtime scripts | 编译运行时脚本
|
||||
const compileResult = await userCodeService.compile({
|
||||
projectPath: projectPath,
|
||||
target: UserCodeTarget.Runtime
|
||||
});
|
||||
|
||||
if (compileResult.success && compileResult.outputPath) {
|
||||
// Load compiled module | 加载编译后的模块
|
||||
const module = await userCodeService.load(compileResult.outputPath, UserCodeTarget.Runtime);
|
||||
|
||||
// Register user components to editor | 注册用户组件到编辑器
|
||||
userCodeService.registerComponents(module, componentRegistry);
|
||||
|
||||
// Notify that user code has been reloaded | 通知用户代码已重新加载
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath,
|
||||
exports: Object.keys(module.exports)
|
||||
});
|
||||
} else if (compileResult.errors.length > 0) {
|
||||
console.warn('[UserCodeService] Compilation errors:', compileResult.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Subscribe to script file changes (create/delete/modify)
|
||||
// 订阅脚本文件变更(创建/删除/修改)
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
messageHub.subscribe('file:modified', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
@@ -299,7 +371,8 @@ export class ServiceRegistry {
|
||||
inspectorRegistry,
|
||||
propertyRendererRegistry,
|
||||
fieldEditorRegistry,
|
||||
buildService
|
||||
buildService,
|
||||
userCodeService
|
||||
};
|
||||
}
|
||||
|
||||
@@ -310,6 +383,37 @@ export class ServiceRegistry {
|
||||
}) 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
|
||||
|
||||
@@ -125,6 +125,9 @@ export function ContentBrowser({
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
|
||||
// Refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
@@ -329,6 +332,53 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
};
|
||||
}, [fileActionRegistry]);
|
||||
|
||||
// 键盘快捷键处理 | Keyboard shortcuts handling
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 如果正在输入或有对话框打开,不处理快捷键
|
||||
// Skip shortcuts if typing or dialog is open
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
renameDialog ||
|
||||
deleteConfirmDialog ||
|
||||
createFileDialog
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只在内容浏览器区域处理快捷键
|
||||
// Only handle shortcuts when content browser has focus
|
||||
if (!containerRef.current?.contains(document.activeElement) &&
|
||||
document.activeElement !== containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// F2 - 重命名 | Rename
|
||||
if (e.key === 'F2' && selectedPaths.size === 1) {
|
||||
e.preventDefault();
|
||||
const selectedPath = Array.from(selectedPaths)[0];
|
||||
const asset = assets.find(a => a.path === selectedPath);
|
||||
if (asset) {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete - 删除 | Delete
|
||||
if (e.key === 'Delete' && selectedPaths.size === 1) {
|
||||
e.preventDefault();
|
||||
const selectedPath = Array.from(selectedPaths)[0];
|
||||
const asset = assets.find(a => a.path === selectedPath);
|
||||
if (asset) {
|
||||
setDeleteConfirmDialog(asset);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]);
|
||||
|
||||
const getTemplateLabel = (label: string): string => {
|
||||
const mapping = templateLabels[label];
|
||||
if (mapping) {
|
||||
@@ -510,6 +560,9 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
|
||||
// Handle asset click
|
||||
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
|
||||
// 聚焦容器以启用键盘快捷键 | Focus container to enable keyboard shortcuts
|
||||
containerRef.current?.focus();
|
||||
|
||||
if (e.shiftKey && lastSelectedPath) {
|
||||
const lastIndex = assets.findIndex(a => a.path === lastSelectedPath);
|
||||
const currentIndex = assets.findIndex(a => a.path === asset.path);
|
||||
@@ -562,9 +615,12 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
const settings = SettingsService.getInstance();
|
||||
const editorCommand = settings.getScriptEditorCommand();
|
||||
|
||||
if (editorCommand && projectPath) {
|
||||
if (editorCommand) {
|
||||
// 使用项目路径,如果没有则使用文件所在目录
|
||||
// Use project path, or file's parent directory if not available
|
||||
const workingDir = projectPath || asset.path.substring(0, asset.path.lastIndexOf('\\')) || asset.path.substring(0, asset.path.lastIndexOf('/'));
|
||||
try {
|
||||
await TauriAPI.openWithEditor(projectPath, editorCommand, asset.path);
|
||||
await TauriAPI.openWithEditor(workingDir, editorCommand, asset.path);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to open with editor:', error);
|
||||
@@ -624,21 +680,38 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(async (asset: AssetItem) => {
|
||||
try {
|
||||
const deletedPath = asset.path;
|
||||
|
||||
if (asset.type === 'folder') {
|
||||
await TauriAPI.deleteFolder(asset.path);
|
||||
// Also delete folder meta file if exists | 同时删除文件夹的 meta 文件
|
||||
try {
|
||||
await TauriAPI.deleteFile(`${asset.path}.meta`);
|
||||
} catch {
|
||||
// Meta file may not exist, ignore | meta 文件可能不存在,忽略
|
||||
}
|
||||
} else {
|
||||
await TauriAPI.deleteFile(asset.path);
|
||||
// Also delete corresponding meta file if exists | 同时删除对应的 meta 文件
|
||||
try {
|
||||
await TauriAPI.deleteFile(`${asset.path}.meta`);
|
||||
} catch {
|
||||
// Meta file may not exist, ignore | meta 文件可能不存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// Notify that a file was deleted | 通知文件已删除
|
||||
messageHub?.publish('file:deleted', { path: deletedPath });
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
}
|
||||
}, [currentPath, loadAssets]);
|
||||
}, [currentPath, loadAssets, messageHub]);
|
||||
|
||||
// Get breadcrumbs
|
||||
const getBreadcrumbs = useCallback(() => {
|
||||
@@ -996,7 +1069,11 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Left Panel - Folder Tree */}
|
||||
<div className="content-browser-left">
|
||||
{/* Favorites Section */}
|
||||
@@ -1291,6 +1368,9 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// Notify that a file was created | 通知文件已创建
|
||||
messageHub?.publish('file:created', { path: filePath });
|
||||
} catch (error) {
|
||||
console.error('Failed to create file:', error);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
@@ -900,6 +901,27 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return;
|
||||
}
|
||||
|
||||
// 脚本文件使用配置的编辑器打开
|
||||
// Open script files with configured editor
|
||||
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
|
||||
const settings = SettingsService.getInstance();
|
||||
const editorCommand = settings.getScriptEditorCommand();
|
||||
|
||||
if (editorCommand) {
|
||||
// 使用项目路径,如果没有则使用文件所在目录
|
||||
// Use project path, or file's parent directory if not available
|
||||
const workingDir = rootPath || node.path.substring(0, node.path.lastIndexOf('\\')) || node.path.substring(0, node.path.lastIndexOf('/'));
|
||||
try {
|
||||
await TauriAPI.openWithEditor(workingDir, editorCommand, node.path);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to open with editor:', error);
|
||||
// 如果失败,回退到系统默认应用
|
||||
// Fall back to system default app if failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileActionRegistry) {
|
||||
const handled = await fileActionRegistry.handleDoubleClick(node.path);
|
||||
if (handled) {
|
||||
|
||||
@@ -310,7 +310,12 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
className="startup-dialog-btn danger"
|
||||
onClick={async () => {
|
||||
if (deleteConfirm && onDeleteProject) {
|
||||
await onDeleteProject(deleteConfirm);
|
||||
try {
|
||||
await onDeleteProject(deleteConfirm);
|
||||
} catch (error) {
|
||||
console.error('[StartupPage] Failed to delete project:', error);
|
||||
// Error will be handled by App.tsx error dialog
|
||||
}
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.content-browser.is-drawer {
|
||||
|
||||
Reference in New Issue
Block a user