Feature/runtime cdn and plugin loader (#240)

* feat(ui): 完善 UI 布局系统和编辑器可视化工具

* refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统

* fix: 修复 CodeQL 警告并提升测试覆盖率

* refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题

* fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤

* docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明

* fix(ci): 修复 type-check 失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖

* fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖

* fix(ci): platform-web 添加缺失的 behavior-tree 依赖

* fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
YHH
2025-11-27 20:42:46 +08:00
committed by GitHub
parent 71869b1a58
commit 107439d70c
367 changed files with 10661 additions and 12473 deletions

View File

@@ -3,6 +3,7 @@ import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-reac
import { checkForUpdates, installUpdate } from '../utils/updater';
import { getVersion } from '@tauri-apps/api/app';
import { open } from '@tauri-apps/plugin-shell';
import { MiniParticleLogo } from './MiniParticleLogo';
import '../styles/AboutDialog.css';
interface AboutDialogProps {
@@ -33,9 +34,9 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
en: {
title: 'About ECS Framework Editor',
title: 'About ESEngine Editor',
version: 'Version',
description: 'High-performance ECS framework editor for game development',
description: 'High-performance game editor for ECS-based game development',
checkUpdate: 'Check for Updates',
checking: 'Checking...',
updateAvailable: 'New version available',
@@ -49,9 +50,9 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
github: 'GitHub'
},
zh: {
title: '关于 ECS Framework Editor',
title: '关于 ESEngine Editor',
version: '版本',
description: '高性能 ECS 框架编辑器,用于游戏开发',
description: '高性能游戏编辑器,基于 ECS 架构',
checkUpdate: '检查更新',
checking: '检查中...',
updateAvailable: '发现新版本',
@@ -169,11 +170,11 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
<div className="about-content">
<div className="about-logo">
<div className="logo-placeholder">ECS</div>
<MiniParticleLogo text="ESEngine" width={360} height={60} fontSize={42} />
</div>
<div className="about-info">
<h3>ECS Framework Editor</h3>
<h3>ESEngine Editor</h3>
<p className="about-version">
{t('version')}: Editor {version}
</p>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw, Plus } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
@@ -8,6 +9,19 @@ import { ResizablePanel } from './ResizablePanel';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import '../styles/AssetBrowser.css';
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
if (!iconName) return <Plus size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
return <Plus size={size} />;
}
interface AssetItem {
name: string;
path: string;
@@ -304,21 +318,15 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
} else if (asset.type === 'file') {
const ext = asset.extension?.toLowerCase();
if (ext === 'ecs' && onOpenScene) {
console.log('[AssetBrowser] Opening scene:', asset.path);
onOpenScene(asset.path);
return;
}
if (fileActionRegistry) {
console.log('[AssetBrowser] Handling double click for:', asset.path);
console.log('[AssetBrowser] Extension:', asset.extension);
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
console.log('[AssetBrowser] Handled by plugin:', handled);
if (handled) {
return;
}
} else {
console.log('[AssetBrowser] FileActionRegistry not available');
}
try {
@@ -436,12 +444,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
for (const template of templates) {
items.push({
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
icon: template.icon,
icon: getIconComponent(template.icon, 16),
onClick: async () => {
const fileName = `${template.defaultFileName}.${template.extension}`;
const fileName = `new_${template.id}.${template.extension}`;
const filePath = `${asset.path}/${fileName}`;
const content = await template.createContent(fileName);
await TauriAPI.writeFileContent(filePath, content);
await template.create(filePath);
if (currentPath) {
await loadAssets(currentPath);
}

View File

@@ -39,10 +39,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
if (isOpen && compilerId) {
try {
const registry = Core.services.resolve(CompilerRegistry);
console.log('[CompilerConfigDialog] Registry resolved:', registry);
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
const comp = registry.get(compilerId);
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
setCompiler(comp || null);
} catch (error) {
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);

View File

@@ -91,9 +91,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
};
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
console.log('[EntityInspector] handlePropertyChange called:', propertyName, value);
if (!selectedEntity) {
console.log('[EntityInspector] No selectedEntity, returning');
return;
}
@@ -109,7 +107,6 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
});
// Also publish scene:modified so other panels can react
console.log('[EntityInspector] Publishing scene:modified');
messageHub.publish('scene:modified', {});
};

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
@@ -8,6 +9,19 @@ import { ConfirmDialog } from './ConfirmDialog';
import { PromptDialog } from './PromptDialog';
import '../styles/FileTree.css';
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
if (!iconName) return <Plus size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
return <Plus size={size} />;
}
interface TreeNode {
name: string;
path: string;
@@ -557,7 +571,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
for (const template of templates) {
baseItems.push({
label: template.label,
icon: template.icon,
icon: getIconComponent(template.icon, 16),
onClick: () => handleCreateTemplateFileClick(rootPath, template)
});
}
@@ -639,7 +653,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
for (const template of templates) {
items.push({
label: template.label,
icon: template.icon,
icon: getIconComponent(template.icon, 16),
onClick: () => handleCreateTemplateFileClick(node.path, template)
});
}
@@ -724,7 +738,6 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
// Handle .ecs scene files
const ext = node.name.split('.').pop()?.toLowerCase();
if (ext === 'ecs' && onOpenScene) {
console.log('[FileTree] Opening scene:', node.path);
onOpenScene(node.path);
return;
}

View File

@@ -1,4 +1,9 @@
import { useCallback, useRef, useEffect, useState } from 'react';
/**
* FlexLayoutDockContainer - Dockable panel container based on FlexLayout
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
*/
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
@@ -6,10 +11,86 @@ import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
export type { FlexDockPanel };
/**
* Panel IDs that should persist in DOM when switching tabs.
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
* 切换 tab 时需要保持 DOM 存在的面板 ID。
* 这些面板包含 WebGL canvas 或其他不能卸载的有状态内容。
*/
const PERSISTENT_PANEL_IDS = ['viewport'];
/** Tab header height in pixels | Tab 标签栏高度(像素) */
const TAB_HEADER_HEIGHT = 28;
interface PanelRect {
domRect: DOMRect;
isSelected: boolean;
isVisible?: boolean;
}
/**
* Get panel rectangle from FlexLayout model.
* 从 FlexLayout 模型获取面板矩形。
*/
function getPanelRectFromModel(model: Model, panelId: string): PanelRect | null {
const node = model.getNodeById(panelId);
if (!node || node.getType() !== 'tab') return null;
const parent = node.getParent();
if (!parent || parent.getType() !== 'tabset') return null;
const tabset = parent as any;
const selectedNode = tabset.getSelectedNode();
const isSelected = selectedNode?.getId() === panelId;
const tabsetRect = tabset.getRect();
if (!tabsetRect) return null;
return {
domRect: new DOMRect(
tabsetRect.x,
tabsetRect.y + TAB_HEADER_HEIGHT,
tabsetRect.width,
tabsetRect.height - TAB_HEADER_HEIGHT
),
isSelected
};
}
/**
* Get panel rectangle from DOM placeholder element.
* 从 DOM 占位符元素获取面板矩形。
*/
function getPanelRectFromDOM(panelId: string): PanelRect | null {
const placeholder = document.querySelector(`[data-panel-id="${panelId}"]`);
if (!placeholder) return null;
const placeholderRect = placeholder.getBoundingClientRect();
if (placeholderRect.width <= 0 || placeholderRect.height <= 0) return null;
const container = document.querySelector('.flexlayout-dock-container');
if (!container) return null;
const containerRect = container.getBoundingClientRect();
const parentTab = placeholder.closest('.flexlayout__tabset_content');
const isVisible = parentTab ? (parentTab as HTMLElement).offsetParent !== null : false;
return {
domRect: new DOMRect(
placeholderRect.x - containerRect.x,
placeholderRect.y - containerRect.y,
placeholderRect.width,
placeholderRect.height
),
isSelected: false,
isVisible
};
}
interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
activePanelId?: string;
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
activePanelId?: string;
}
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: FlexLayoutDockContainerProps) {
@@ -18,6 +99,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
const previousPanelIdsRef = useRef<string>('');
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
// Persistent panel state | 持久化面板状态
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
() => new Set(PERSISTENT_PANEL_IDS)
);
const persistentPanels = useMemo(
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
[panels]
);
const createDefaultLayout = useCallback((): IJsonModel => {
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
}, [panels, activePanelId]);
@@ -155,10 +247,80 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
}
}, [createDefaultLayout, panels]);
/**
* Track persistent panel positions and visibility.
* Uses FlexLayout model to determine if panel tab is selected,
* falls back to DOM measurement if model data unavailable.
* 追踪持久化面板的位置和可见性。
* 使用 FlexLayout 模型判断面板 tab 是否被选中,
* 如果模型数据不可用则回退到 DOM 测量。
*/
useEffect(() => {
if (!model) return;
const updatePersistentPanelPositions = () => {
const newRects = new Map<string, DOMRect>();
const newVisible = new Set<string>();
for (const panelId of PERSISTENT_PANEL_IDS) {
// Try to get position from FlexLayout model
const rect = getPanelRectFromModel(model, panelId);
if (rect) {
newRects.set(panelId, rect.domRect);
if (rect.isSelected) {
newVisible.add(panelId);
}
continue;
}
// Fallback: measure placeholder element in DOM
const placeholderRect = getPanelRectFromDOM(panelId);
if (placeholderRect) {
newRects.set(panelId, placeholderRect.domRect);
if (placeholderRect.isVisible) {
newVisible.add(panelId);
}
}
}
setPersistentPanelRects(newRects);
setVisiblePersistentPanels(newVisible);
};
// Initial update after DOM render
requestAnimationFrame(updatePersistentPanelPositions);
// Observe layout changes
const container = document.querySelector('.flexlayout-dock-container');
if (!container) return;
const mutationObserver = new MutationObserver(() => {
requestAnimationFrame(updatePersistentPanelPositions);
});
mutationObserver.observe(container, { childList: true, subtree: true, attributes: true });
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(updatePersistentPanelPositions);
});
resizeObserver.observe(container);
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [model]);
const factory = useCallback((node: TabNode) => {
const component = node.getComponent();
const panel = panels.find((p) => p.id === component);
return panel?.content || <div>Panel not found</div>;
const componentId = node.getComponent() || '';
// Persistent panels render as placeholder, actual content is in overlay
// 持久化面板渲染为占位符,实际内容在覆盖层中
if (PERSISTENT_PANEL_IDS.includes(componentId)) {
return <div className="persistent-panel-placeholder" data-panel-id={componentId} />;
}
const panel = panels.find((p) => p.id === componentId);
return panel?.content ?? <div>Panel not found</div>;
}, [panels]);
const onAction = useCallback((action: Action) => {
@@ -186,6 +348,52 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
onAction={onAction}
onModelChange={onModelChange}
/>
{/* Persistent panel overlay - always mounted, visibility controlled by CSS */}
{/* 持久化面板覆盖层 - 始终挂载,通过 CSS 控制可见性 */}
{persistentPanels.map((panel) => (
<PersistentPanelContainer
key={panel.id}
panel={panel}
rect={persistentPanelRects.get(panel.id)}
isVisible={visiblePersistentPanels.has(panel.id)}
/>
))}
</div>
);
}
/**
* Container for persistent panel content.
* 持久化面板内容容器。
*/
function PersistentPanelContainer({
panel,
rect,
isVisible
}: {
panel: FlexDockPanel;
rect?: DOMRect;
isVisible: boolean;
}) {
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
return (
<div
className="persistent-panel-container"
style={{
position: 'absolute',
left: hasValidRect ? rect.x : 0,
top: hasValidRect ? rect.y : 0,
width: hasValidRect ? rect.width : '100%',
height: hasValidRect ? rect.height : '100%',
visibility: isVisible ? 'visible' : 'hidden',
pointerEvents: isVisible ? 'auto' : 'none',
zIndex: isVisible ? 1 : -1,
overflow: 'hidden'
}}
>
{panel.content}
</div>
);
}

View File

@@ -71,28 +71,21 @@ export function GitHubAuth({ githubService, onSuccess, locale }: GitHubAuthProps
setError('');
try {
console.log('[GitHubAuth] Starting OAuth login...');
const deviceCodeResp = await githubService.requestDeviceCode();
console.log('[GitHubAuth] Device code received:', deviceCodeResp.user_code);
setUserCode(deviceCodeResp.user_code);
setVerificationUri(deviceCodeResp.verification_uri);
console.log('[GitHubAuth] Opening browser...');
await open(deviceCodeResp.verification_uri);
console.log('[GitHubAuth] Starting authentication polling...');
await githubService.authenticateWithDeviceFlow(
deviceCodeResp.device_code,
deviceCodeResp.interval,
(status) => {
console.log('[GitHubAuth] Auth status changed:', status);
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
}
);
console.log('[GitHubAuth] Authorization successful!');
setAuthStatus('authorized');
setTimeout(() => {
onSuccess();

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { UIRegistry, MessageHub, EditorPluginManager } from '@esengine/editor-core';
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import '../styles/MenuBar.css';
@@ -18,7 +18,7 @@ interface MenuBarProps {
locale?: string;
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: EditorPluginManager;
pluginManager?: PluginManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
@@ -62,29 +62,7 @@ export function MenuBar({
const menuRef = useRef<HTMLDivElement>(null);
const updateMenuItems = () => {
if (uiRegistry && pluginManager) {
const items = uiRegistry.getChildMenus('window');
// 过滤掉被禁用插件的菜单项
const enabledPlugins = pluginManager.getAllPluginMetadata()
.filter((p) => p.enabled)
.map((p) => p.name);
// 只显示启用插件的菜单项
const filteredItems = items.filter((item) => {
// 检查菜单项是否属于某个插件
return enabledPlugins.some((pluginName) => {
const plugin = pluginManager.getEditorPlugin(pluginName);
if (plugin && plugin.registerMenuItems) {
const pluginMenus = plugin.registerMenuItems();
return pluginMenus.some((m) => m.id === item.id);
}
return false;
});
});
setPluginMenuItems(filteredItems);
} else if (uiRegistry) {
// 如果没有 pluginManager显示所有菜单项
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
setPluginMenuItems(items);
}

View File

@@ -0,0 +1,178 @@
import { useEffect, useRef, useCallback } from 'react';
interface Particle {
x: number;
y: number;
targetX: number;
targetY: number;
size: number;
alpha: number;
color: string;
}
interface MiniParticleLogoProps {
/** Logo text to display / 要显示的Logo文字 */
text?: string;
/** Canvas width / 画布宽度 */
width?: number;
/** Canvas height / 画布高度 */
height?: number;
/** Font size / 字体大小 */
fontSize?: number;
}
/**
* Mini Particle Logo Component
* 小型粒子Logo组件 - 用于About对话框等小空间显示
*/
export function MiniParticleLogo({
text = 'ECS',
width = 80,
height = 80,
fontSize = 28
}: MiniParticleLogoProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const particlesRef = useRef<Particle[]>([]);
const createParticles = useCallback((
canvasWidth: number,
canvasHeight: number,
displayText: string,
textSize: number
): Particle[] => {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return [];
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
const textMetrics = tempCtx.measureText(displayText);
const textWidth = textMetrics.width;
const textHeight = textSize;
tempCanvas.width = textWidth + 10;
tempCanvas.height = textHeight + 10;
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = '#ffffff';
tempCtx.fillText(displayText, tempCanvas.width / 2, tempCanvas.height / 2);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const pixels = imageData.data;
const particles: Particle[] = [];
const gap = 2; // 小间隔以增加粒子密度 / Small gap for higher particle density
const offsetX = (canvasWidth - tempCanvas.width) / 2;
const offsetY = (canvasHeight - tempCanvas.height) / 2;
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
for (let y = 0; y < tempCanvas.height; y += gap) {
for (let x = 0; x < tempCanvas.width; x += gap) {
const index = (y * tempCanvas.width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha > 128) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(canvasWidth, canvasHeight) * 0.8;
particles.push({
x: canvasWidth / 2 + Math.cos(angle) * distance,
y: canvasHeight / 2 + Math.sin(angle) * distance,
targetX: offsetX + x,
targetY: offsetY + y,
size: Math.random() * 1 + 0.8,
alpha: Math.random() * 0.5 + 0.5,
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
});
}
}
}
return particles;
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
particlesRef.current = createParticles(width, height, text, fontSize);
const startTime = performance.now();
const duration = 1500; // 动画持续时间 / Animation duration
let isCancelled = false;
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
const animate = (currentTime: number) => {
if (isCancelled) return;
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeOutQuart(progress);
// 透明背景 / Transparent background
ctx.clearRect(0, 0, width, height);
for (const particle of particlesRef.current) {
const moveProgress = Math.min(easedProgress * 1.2, 1);
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
ctx.beginPath();
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.alpha;
ctx.fill();
}
ctx.globalAlpha = 1;
// 动画完成后添加微光效果 / Add subtle glow after animation completes
if (progress >= 1) {
const glowAlpha = 0.3 + Math.sin(currentTime / 500) * 0.1;
ctx.save();
ctx.shadowColor = '#4EC9B0';
ctx.shadowBlur = 8;
ctx.fillStyle = `rgba(255, 255, 255, ${glowAlpha})`;
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
ctx.restore();
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
isCancelled = true;
if (animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
}
};
}, [width, height, text, fontSize, createParticles]);
return (
<canvas
ref={canvasRef}
style={{
display: 'block',
borderRadius: '16px'
}}
/>
);
}

View File

@@ -0,0 +1,198 @@
/**
* Plugin List Setting Component
* 插件列表设置组件
*
* 简洁的插件列表,只显示:
* - 勾选框表示启用状态
* - 插件名称、版本
* - 插件描述
* - [Runtime] [Editor] 标签
*/
import { useState, useEffect } from 'react';
import { Core } from '@esengine/ecs-framework';
import { PluginManager, type RegisteredPlugin, type PluginCategory } from '@esengine/editor-core';
import { Check, Lock, RefreshCw, Package } from 'lucide-react';
import { NotificationService } from '../services/NotificationService';
import '../styles/PluginListSetting.css';
interface PluginListSettingProps {
pluginManager: PluginManager;
}
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
core: { zh: '核心', en: 'Core' },
rendering: { zh: '渲染', en: 'Rendering' },
ui: { zh: 'UI', en: 'UI' },
ai: { zh: 'AI', en: 'AI' },
physics: { zh: '物理', en: 'Physics' },
audio: { zh: '音频', en: 'Audio' },
networking: { zh: '网络', en: 'Networking' },
tools: { zh: '工具', en: 'Tools' },
content: { zh: '内容', en: 'Content' }
};
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'physics', 'audio', 'networking', 'tools'];
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
const [pendingChanges, setPendingChanges] = useState<Map<string, boolean>>(new Map());
useEffect(() => {
loadPlugins();
}, [pluginManager]);
const loadPlugins = () => {
const allPlugins = pluginManager.getAllPlugins();
setPlugins(allPlugins);
};
const showWarning = (message: string) => {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.show(message, 'warning', 3000);
}
};
const handleToggle = (pluginId: string) => {
const plugin = plugins.find(p => p.loader.descriptor.id === pluginId);
if (!plugin) return;
const descriptor = plugin.loader.descriptor;
// 核心插件不可禁用
if (descriptor.isCore) {
showWarning('核心插件不可禁用');
return;
}
const newEnabled = !plugin.enabled;
// 检查依赖
if (newEnabled) {
const deps = descriptor.dependencies || [];
const missingDeps = deps.filter(dep => {
const depPlugin = plugins.find(p => p.loader.descriptor.id === dep.id);
return depPlugin && !depPlugin.enabled;
});
if (missingDeps.length > 0) {
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
return;
}
} else {
// 检查是否有其他插件依赖此插件
const dependents = plugins.filter(p => {
if (!p.enabled || p.loader.descriptor.id === pluginId) return false;
const deps = p.loader.descriptor.dependencies || [];
return deps.some(d => d.id === pluginId);
});
if (dependents.length > 0) {
showWarning(`以下插件依赖此插件: ${dependents.map(p => p.loader.descriptor.name).join(', ')}`);
return;
}
}
// 记录待处理的更改
const newPendingChanges = new Map(pendingChanges);
newPendingChanges.set(pluginId, newEnabled);
setPendingChanges(newPendingChanges);
// 更新本地状态
setPlugins(plugins.map(p => {
if (p.loader.descriptor.id === pluginId) {
return { ...p, enabled: newEnabled };
}
return p;
}));
// 调用 PluginManager 的启用/禁用方法
if (newEnabled) {
pluginManager.enable(pluginId);
} else {
pluginManager.disable(pluginId);
}
};
// 按类别分组并排序
const groupedPlugins = plugins.reduce((acc, plugin) => {
const category = plugin.loader.descriptor.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(plugin);
return acc;
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
// 按照 categoryOrder 排序
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
return (
<div className="plugin-list-setting">
{pendingChanges.size > 0 && (
<div className="plugin-list-notice">
<RefreshCw size={14} />
<span></span>
</div>
)}
{sortedCategories.map(category => (
<div key={category} className="plugin-category">
<div className="plugin-category-header">
{categoryLabels[category]?.zh || category}
</div>
<div className="plugin-list">
{groupedPlugins[category].map(plugin => {
const descriptor = plugin.loader.descriptor;
const hasRuntime = !!plugin.loader.runtimeModule;
const hasEditor = !!plugin.loader.editorModule;
return (
<div
key={descriptor.id}
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
onClick={() => handleToggle(descriptor.id)}
>
<div className="plugin-checkbox">
{descriptor.isCore ? (
<Lock size={10} />
) : (
plugin.enabled && <Check size={10} />
)}
</div>
<div className="plugin-info">
<div className="plugin-header">
<span className="plugin-name">{descriptor.name}</span>
<span className="plugin-version">v{descriptor.version}</span>
</div>
{descriptor.description && (
<div className="plugin-description">
{descriptor.description}
</div>
)}
<div className="plugin-modules">
{hasRuntime && (
<span className="plugin-module-badge runtime">Runtime</span>
)}
{hasEditor && (
<span className="plugin-module-badge editor">Editor</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
{plugins.length === 0 && (
<div className="plugin-list-empty">
<Package size={32} />
<p></p>
</div>
)}
</div>
);
}

View File

@@ -1,438 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import {
Package,
CheckCircle,
XCircle,
Search,
Grid,
List,
ChevronDown,
ChevronRight,
X,
RefreshCw,
ShoppingCart
} from 'lucide-react';
import { PluginMarketPanel } from './PluginMarketPanel';
import { PluginMarketService } from '../services/PluginMarketService';
import { GitHubService } from '../services/GitHubService';
import '../styles/PluginManagerWindow.css';
interface PluginManagerWindowProps {
pluginManager: EditorPluginManager;
githubService: GitHubService;
onClose: () => void;
onRefresh?: () => Promise<void>;
onOpen?: () => void;
locale: string;
projectPath: string | null;
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
export function PluginManagerWindow({ pluginManager, githubService, onClose, onRefresh, onOpen, locale, projectPath }: PluginManagerWindowProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '插件管理器',
searchPlaceholder: '搜索插件...',
enabled: '已启用',
disabled: '已禁用',
enable: '启用',
disable: '禁用',
enablePlugin: '启用插件',
disablePlugin: '禁用插件',
refresh: '刷新',
refreshPluginList: '刷新插件列表',
close: '关闭',
listView: '列表视图',
gridView: '网格视图',
noPlugins: '未安装插件',
installed: '安装于',
categoryTools: '工具',
categoryWindows: '窗口',
categoryInspectors: '检查器',
categorySystem: '系统',
categoryImportExport: '导入/导出',
tabInstalled: '已安装',
tabMarketplace: '插件市场'
},
en: {
title: 'Plugin Manager',
searchPlaceholder: 'Search plugins...',
enabled: 'Enabled',
disabled: 'Disabled',
enable: 'Enable',
disable: 'Disable',
enablePlugin: 'Enable plugin',
disablePlugin: 'Disable plugin',
refresh: 'Refresh',
refreshPluginList: 'Refresh plugin list',
close: 'Close',
listView: 'List view',
gridView: 'Grid view',
noPlugins: 'No plugins installed',
installed: 'Installed',
categoryTools: 'Tools',
categoryWindows: 'Windows',
categoryInspectors: 'Inspectors',
categorySystem: 'System',
categoryImportExport: 'Import/Export',
tabInstalled: 'Installed',
tabMarketplace: 'Marketplace'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const getCategoryName = (category: EditorPluginCategory): string => {
const categoryKeys: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'categoryTools',
[EditorPluginCategory.Window]: 'categoryWindows',
[EditorPluginCategory.Inspector]: 'categoryInspectors',
[EditorPluginCategory.System]: 'categorySystem',
[EditorPluginCategory.ImportExport]: 'categoryImportExport'
};
return t(categoryKeys[category]);
};
const [activeTab, setActiveTab] = useState<'installed' | 'marketplace'>('installed');
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
const [filter, setFilter] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [expandedCategories, setExpandedCategories] = useState<Set<EditorPluginCategory>>(
new Set(Object.values(EditorPluginCategory))
);
const [isRefreshing, setIsRefreshing] = useState(false);
const marketService = useMemo(() => new PluginMarketService(pluginManager), [pluginManager]);
// 设置项目路径到 marketService
useEffect(() => {
marketService.setProjectPath(projectPath);
}, [projectPath, marketService]);
const updatePluginList = () => {
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
};
useEffect(() => {
if (onOpen) {
onOpen();
}
updatePluginList();
}, [pluginManager]);
// 监听 locale 变化,重新获取插件列表(以刷新插件的 displayName 和 description
useEffect(() => {
updatePluginList();
}, [locale]);
const handleRefresh = async () => {
if (!onRefresh || isRefreshing) return;
setIsRefreshing(true);
try {
await onRefresh();
updatePluginList();
} catch (error) {
console.error('Failed to refresh plugins:', error);
} finally {
setIsRefreshing(false);
}
};
const togglePlugin = async (name: string, enabled: boolean) => {
try {
if (enabled) {
await pluginManager.disablePlugin(name);
} else {
await pluginManager.enablePlugin(name);
}
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
} catch (error) {
console.error(`Failed to toggle plugin ${name}:`, error);
}
};
const toggleCategory = (category: EditorPluginCategory) => {
const newExpanded = new Set(expandedCategories);
if (newExpanded.has(category)) {
newExpanded.delete(category);
} else {
newExpanded.add(category);
}
setExpandedCategories(newExpanded);
};
const filteredPlugins = plugins.filter((plugin) => {
if (!filter) return true;
const searchLower = filter.toLowerCase();
return (
plugin.name.toLowerCase().includes(searchLower) ||
plugin.displayName.toLowerCase().includes(searchLower) ||
plugin.description?.toLowerCase().includes(searchLower)
);
});
const pluginsByCategory = filteredPlugins.reduce(
(acc, plugin) => {
if (!acc[plugin.category]) {
acc[plugin.category] = [];
}
acc[plugin.category].push(plugin);
return acc;
},
{} as Record<EditorPluginCategory, IEditorPluginMetadata[]>
);
const enabledCount = plugins.filter((p) => p.enabled).length;
const disabledCount = plugins.filter((p) => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
{plugin.description && <div className="plugin-card-description">{plugin.description}</div>}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{getCategoryName(plugin.category)}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
{t('installed')}: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && <div className="plugin-list-description">{plugin.description}</div>}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">{t('enabled')}</span>
) : (
<span className="status-badge disabled">{t('disabled')}</span>
)}
</div>
<button
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
>
{plugin.enabled ? t('disable') : t('enable')}
</button>
</div>
);
};
return (
<div className="plugin-manager-overlay" onClick={onClose}>
<div className="plugin-manager-window" onClick={(e) => e.stopPropagation()}>
<div className="plugin-manager-header">
<div className="plugin-manager-title">
<Package size={20} />
<h2>{t('title')}</h2>
</div>
<button className="plugin-manager-close" onClick={onClose} title={t('close')}>
<X size={20} />
</button>
</div>
<div className="plugin-manager-tabs">
<button
className={`plugin-manager-tab ${activeTab === 'installed' ? 'active' : ''}`}
onClick={() => setActiveTab('installed')}
>
<Package size={16} />
{t('tabInstalled')}
</button>
<button
className={`plugin-manager-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
onClick={() => setActiveTab('marketplace')}
>
<ShoppingCart size={16} />
{t('tabMarketplace')}
</button>
</div>
{activeTab === 'installed' && (
<>
<div className="plugin-toolbar">
<div className="plugin-toolbar-left">
<div className="plugin-search">
<Search size={14} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="plugin-toolbar-right">
<div className="plugin-stats">
<span className="stat-item enabled">
<CheckCircle size={14} />
{enabledCount} {t('enabled')}
</span>
<span className="stat-item disabled">
<XCircle size={14} />
{disabledCount} {t('disabled')}
</span>
</div>
{onRefresh && (
<button
className="plugin-refresh-btn"
onClick={handleRefresh}
disabled={isRefreshing}
title={t('refreshPluginList')}
style={{
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
fontSize: '12px',
opacity: isRefreshing ? 0.6 : 1
}}
>
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
{t('refresh')}
</button>
)}
<div className="plugin-view-mode">
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title={t('listView')}
>
<List size={14} />
</button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title={t('gridView')}
>
<Grid size={14} />
</button>
</div>
</div>
</div>
<div
className="plugin-content"
style={{ display: activeTab === 'installed' ? 'block' : 'none' }}
>
{plugins.length === 0 ? (
<div className="plugin-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
</div>
) : (
<div className="plugin-categories">
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
const cat = category as EditorPluginCategory;
const isExpanded = expandedCategories.has(cat);
return (
<div key={category} className="plugin-category">
<div
className="plugin-category-header"
onClick={() => toggleCategory(cat)}
>
<button className="plugin-category-toggle">
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[
categoryIcons[cat]
];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{getCategoryName(cat)}</span>
<span className="plugin-category-count">
{categoryPlugins.length}
</span>
</div>
{isExpanded && (
<div className={`plugin-category-content ${viewMode}`}>
{viewMode === 'grid'
? categoryPlugins.map(renderPluginCard)
: categoryPlugins.map(renderPluginList)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</>
)}
{activeTab === 'marketplace' && (
<PluginMarketPanel
marketService={marketService}
locale={locale}
projectPath={projectPath}
onReloadPlugins={onRefresh}
/>
)}
</div>
</div>
);
}

View File

@@ -1,440 +0,0 @@
import { useState, useEffect } from 'react';
import * as LucideIcons from 'lucide-react';
import {
Package,
Search,
Download,
CheckCircle,
ExternalLink,
Github,
Star,
AlertCircle,
RefreshCw,
Filter
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import type { PluginMarketService, PluginMarketMetadata } from '../services/PluginMarketService';
import '../styles/PluginMarketPanel.css';
interface PluginMarketPanelProps {
marketService: PluginMarketService;
locale: string;
projectPath: string | null;
onReloadPlugins?: () => Promise<void>;
}
export function PluginMarketPanel({ marketService, locale, projectPath, onReloadPlugins }: PluginMarketPanelProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '插件市场',
searchPlaceholder: '搜索插件...',
loading: '加载中...',
loadError: '无法连接到插件市场',
loadErrorDesc: '可能是网络连接问题,请检查您的网络设置后重试',
retry: '重试',
noPlugins: '没有找到插件',
install: '安装',
installed: '已安装',
update: '更新',
uninstall: '卸载',
viewSource: '查看源码',
official: '官方',
verified: '认证',
community: '社区',
filterAll: '全部',
filterOfficial: '官方插件',
filterCommunity: '社区插件',
categoryAll: '全部分类',
installing: '安装中...',
uninstalling: '卸载中...',
useDirectSource: '使用直连源',
useDirectSourceTip: '启用后直接从GitHub获取数据绕过CDN缓存适合测试',
latest: '最新',
releaseNotes: '更新日志',
selectVersion: '选择版本',
noProjectOpen: '请先打开一个项目'
},
en: {
title: 'Plugin Marketplace',
searchPlaceholder: 'Search plugins...',
loading: 'Loading...',
loadError: 'Unable to connect to plugin marketplace',
loadErrorDesc: 'This might be a network connection issue. Please check your network settings and try again',
retry: 'Retry',
noPlugins: 'No plugins found',
install: 'Install',
installed: 'Installed',
update: 'Update',
uninstall: 'Uninstall',
viewSource: 'View Source',
official: 'Official',
verified: 'Verified',
community: 'Community',
filterAll: 'All',
filterOfficial: 'Official',
filterCommunity: 'Community',
categoryAll: 'All Categories',
installing: 'Installing...',
uninstalling: 'Uninstalling...',
useDirectSource: 'Direct Source',
useDirectSourceTip: 'Fetch data directly from GitHub, bypassing CDN cache (for testing)',
latest: 'Latest',
releaseNotes: 'Release Notes',
selectVersion: 'Select Version',
noProjectOpen: 'Please open a project first'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const [plugins, setPlugins] = useState<PluginMarketMetadata[]>([]);
const [filteredPlugins, setFilteredPlugins] = useState<PluginMarketMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'official' | 'community'>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
const [useDirectSource, setUseDirectSource] = useState(marketService.isUsingDirectSource());
useEffect(() => {
loadPlugins();
}, []);
useEffect(() => {
filterPlugins();
}, [plugins, searchQuery, typeFilter, categoryFilter]);
const loadPlugins = async (bypassCache: boolean = false) => {
setLoading(true);
setError(null);
try {
const pluginList = await marketService.fetchPluginList(bypassCache);
setPlugins(pluginList);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const filterPlugins = () => {
let filtered = plugins;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags?.some((tag) => tag.toLowerCase().includes(query))
);
}
if (typeFilter !== 'all') {
filtered = filtered.filter((p) => p.category_type === typeFilter);
}
if (categoryFilter !== 'all') {
filtered = filtered.filter((p) => p.category === categoryFilter);
}
setFilteredPlugins(filtered);
};
const handleToggleDirectSource = () => {
const newValue = !useDirectSource;
setUseDirectSource(newValue);
marketService.setUseDirectSource(newValue);
loadPlugins(true);
};
const handleInstall = async (plugin: PluginMarketMetadata, version?: string) => {
if (!projectPath) {
alert(t('noProjectOpen') || 'Please open a project first');
return;
}
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
try {
await marketService.installPlugin(plugin, version, onReloadPlugins);
setPlugins([...plugins]);
} catch (error) {
console.error('Failed to install plugin:', error);
alert(`Failed to install ${plugin.name}: ${error}`);
} finally {
setInstallingPlugins((prev) => {
const next = new Set(prev);
next.delete(plugin.id);
return next;
});
}
};
const handleUninstall = async (plugin: PluginMarketMetadata) => {
if (!confirm(`Are you sure you want to uninstall ${plugin.name}?`)) {
return;
}
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
try {
await marketService.uninstallPlugin(plugin.id, onReloadPlugins);
setPlugins([...plugins]);
} catch (error) {
console.error('Failed to uninstall plugin:', error);
alert(`Failed to uninstall ${plugin.name}: ${error}`);
} finally {
setInstallingPlugins((prev) => {
const next = new Set(prev);
next.delete(plugin.id);
return next;
});
}
};
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category)))];
if (loading) {
return (
<div className="plugin-market-loading">
<RefreshCw size={32} className="spinning" />
<p>{t('loading')}</p>
</div>
);
}
if (error) {
return (
<div className="plugin-market-error">
<AlertCircle size={64} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('loadErrorDesc')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={() => loadPlugins(true)}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
return (
<div className="plugin-market-panel">
<div className="plugin-market-toolbar">
<div className="plugin-market-search">
<Search size={16} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="plugin-market-filters">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as any)}
className="plugin-market-filter-select"
>
<option value="all">{t('filterAll')}</option>
<option value="official">{t('filterOfficial')}</option>
<option value="community">{t('filterCommunity')}</option>
</select>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="plugin-market-filter-select"
>
<option value="all">{t('categoryAll')}</option>
{categories
.filter((c) => c !== 'all')
.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<label className="plugin-market-direct-source-toggle" title={t('useDirectSourceTip')}>
<input
type="checkbox"
checked={useDirectSource}
onChange={handleToggleDirectSource}
/>
<span className="toggle-label">{t('useDirectSource')}</span>
</label>
<button className="plugin-market-refresh" onClick={() => loadPlugins(true)} title={t('retry')}>
<RefreshCw size={16} />
</button>
</div>
</div>
<div className="plugin-market-content">
{filteredPlugins.length === 0 ? (
<div className="plugin-market-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
</div>
) : (
<div className="plugin-market-grid">
{filteredPlugins.map((plugin) => (
<PluginMarketCard
key={plugin.id}
plugin={plugin}
isInstalled={marketService.isInstalled(plugin.id)}
hasUpdate={marketService.hasUpdate(plugin)}
isInstalling={installingPlugins.has(plugin.id)}
onInstall={(version) => handleInstall(plugin, version)}
onUninstall={() => handleUninstall(plugin)}
t={t}
/>
))}
</div>
)}
</div>
</div>
);
}
interface PluginMarketCardProps {
plugin: PluginMarketMetadata;
isInstalled: boolean;
hasUpdate: boolean;
isInstalling: boolean;
onInstall: (version?: string) => void;
onUninstall: () => void;
t: (key: string) => string;
}
function PluginMarketCard({
plugin,
isInstalled,
hasUpdate,
isInstalling,
onInstall,
onUninstall,
t
}: PluginMarketCardProps) {
const [selectedVersion, setSelectedVersion] = useState(plugin.latestVersion);
const [showVersions, setShowVersions] = useState(false);
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : Package;
const selectedVersionData = plugin.versions.find((v) => v.version === selectedVersion);
const multipleVersions = plugin.versions.length > 1;
return (
<div className="plugin-market-card">
<div className="plugin-market-card-header">
<div className="plugin-market-card-icon">
<IconComponent size={32} />
</div>
<div className="plugin-market-card-info">
<div className="plugin-market-card-title">
<span>{plugin.name}</span>
{plugin.verified && (
<span className="plugin-market-badge official" title={t('official')}>
<CheckCircle size={14} />
</span>
)}
</div>
<div className="plugin-market-card-meta">
<span className="plugin-market-card-author">
<Github size={12} />
{plugin.author.name}
</span>
{multipleVersions ? (
<select
className="plugin-market-version-select"
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
onClick={(e) => e.stopPropagation()}
title={t('selectVersion')}
>
{plugin.versions.map((v) => (
<option key={v.version} value={v.version}>
v{v.version} {v.version === plugin.latestVersion ? `(${t('latest')})` : ''}
</option>
))}
</select>
) : (
<span className="plugin-market-card-version">v{plugin.latestVersion}</span>
)}
</div>
</div>
</div>
<div className="plugin-market-card-description">{plugin.description}</div>
{selectedVersionData && selectedVersionData.changes && (
<details className="plugin-market-version-changes">
<summary>{t('releaseNotes')}</summary>
<p>{selectedVersionData.changes}</p>
</details>
)}
{plugin.tags && plugin.tags.length > 0 && (
<div className="plugin-market-card-tags">
{plugin.tags.slice(0, 3).map((tag) => (
<span key={tag} className="plugin-market-tag">
{tag}
</span>
))}
</div>
)}
<div className="plugin-market-card-footer">
<button
className="plugin-market-card-link"
onClick={async (e) => {
e.stopPropagation();
try {
await open(plugin.repository.url);
} catch (error) {
console.error('Failed to open URL:', error);
}
}}
>
<ExternalLink size={14} />
{t('viewSource')}
</button>
<div className="plugin-market-card-actions">
{isInstalling ? (
<button className="plugin-market-btn installing" disabled>
<RefreshCw size={14} className="spinning" />
{isInstalled ? t('uninstalling') : t('installing')}
</button>
) : isInstalled ? (
<>
{hasUpdate && (
<button className="plugin-market-btn update" onClick={() => onInstall(plugin.latestVersion)}>
<Download size={14} />
{t('update')}
</button>
)}
<button className="plugin-market-btn installed" onClick={onUninstall}>
<CheckCircle size={14} />
{t('uninstall')}
</button>
</>
) : (
<button className="plugin-market-btn install" onClick={() => onInstall(selectedVersion)}>
<Download size={14} />
{t('install')}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,256 +0,0 @@
import { useState, useEffect } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight } from 'lucide-react';
import '../styles/PluginPanel.css';
interface PluginPanelProps {
pluginManager: EditorPluginManager;
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
const categoryNames: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'Tools',
[EditorPluginCategory.Window]: 'Windows',
[EditorPluginCategory.Inspector]: 'Inspectors',
[EditorPluginCategory.System]: 'System',
[EditorPluginCategory.ImportExport]: 'Import/Export'
};
export function PluginPanel({ pluginManager }: PluginPanelProps) {
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
const [filter, setFilter] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [expandedCategories, setExpandedCategories] = useState<Set<EditorPluginCategory>>(
new Set(Object.values(EditorPluginCategory))
);
useEffect(() => {
const updatePlugins = () => {
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
};
updatePlugins();
}, [pluginManager]);
const togglePlugin = async (name: string, enabled: boolean) => {
try {
if (enabled) {
await pluginManager.disablePlugin(name);
} else {
await pluginManager.enablePlugin(name);
}
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
} catch (error) {
console.error(`Failed to toggle plugin ${name}:`, error);
}
};
const toggleCategory = (category: EditorPluginCategory) => {
const newExpanded = new Set(expandedCategories);
if (newExpanded.has(category)) {
newExpanded.delete(category);
} else {
newExpanded.add(category);
}
setExpandedCategories(newExpanded);
};
const filteredPlugins = plugins.filter((plugin) => {
if (!filter) return true;
const searchLower = filter.toLowerCase();
return (
plugin.name.toLowerCase().includes(searchLower) ||
plugin.displayName.toLowerCase().includes(searchLower) ||
plugin.description?.toLowerCase().includes(searchLower)
);
});
const pluginsByCategory = filteredPlugins.reduce((acc, plugin) => {
if (!acc[plugin.category]) {
acc[plugin.category] = [];
}
acc[plugin.category].push(plugin);
return acc;
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
const enabledCount = plugins.filter((p) => p.enabled).length;
const disabledCount = plugins.filter((p) => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">Enabled</span>
) : (
<span className="status-badge disabled">Disabled</span>
)}
</div>
<button
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
);
};
return (
<div className="plugin-panel">
<div className="plugin-toolbar">
<div className="plugin-toolbar-left">
<div className="plugin-search">
<Search size={14} />
<input
type="text"
placeholder="Search plugins..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="plugin-toolbar-right">
<div className="plugin-stats">
<span className="stat-item enabled">
<CheckCircle size={14} />
{enabledCount} enabled
</span>
<span className="stat-item disabled">
<XCircle size={14} />
{disabledCount} disabled
</span>
</div>
<div className="plugin-view-mode">
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title="List view"
>
<List size={14} />
</button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title="Grid view"
>
<Grid size={14} />
</button>
</div>
</div>
</div>
<div className="plugin-content">
{plugins.length === 0 ? (
<div className="plugin-empty">
<Package size={48} />
<p>No plugins installed</p>
</div>
) : (
<div className="plugin-categories">
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
const cat = category as EditorPluginCategory;
const isExpanded = expandedCategories.has(cat);
return (
<div key={category} className="plugin-category">
<div
className="plugin-category-header"
onClick={() => toggleCategory(cat)}
>
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{categoryNames[cat]}</span>
<span className="plugin-category-count">
{categoryPlugins.length}
</span>
</div>
{isExpanded && (
<div className={`plugin-category-content ${viewMode}`}>
{viewMode === 'grid'
? categoryPlugins.map(renderPluginCard)
: categoryPlugins.map(renderPluginList)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,948 +0,0 @@
import { useState } from 'react';
import { X, AlertCircle, CheckCircle, Loader, ExternalLink, FolderOpen, FileArchive } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { GitHubService } from '../services/GitHubService';
import { GitHubAuth } from './GitHubAuth';
import { PluginPublishService, type PluginPublishInfo, type PublishProgress } from '../services/PluginPublishService';
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
import { PluginSourceParser, type ParsedPluginInfo } from '../services/PluginSourceParser';
import { open } from '@tauri-apps/plugin-shell';
import { EditorPluginCategory, type IEditorPluginMetadata } from '@esengine/editor-core';
import '../styles/PluginPublishWizard.css';
interface PluginPublishWizardProps {
githubService: GitHubService;
onClose: () => void;
locale: string;
inline?: boolean; // 是否内联显示(在 tab 中)而不是弹窗
}
type Step = 'auth' | 'selectSource' | 'info' | 'building' | 'confirm' | 'publishing' | 'success' | 'error';
type SourceType = 'folder' | 'zip';
function calculateNextVersion(currentVersion: string): string {
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
const [major, minor, patch] = parts;
return `${major}.${minor}.${(patch ?? 0) + 1}`;
}
export function PluginPublishWizard({ githubService, onClose, locale, inline = false }: PluginPublishWizardProps) {
const [publishService] = useState(() => new PluginPublishService(githubService));
const [buildService] = useState(() => new PluginBuildService());
const [sourceParser] = useState(() => new PluginSourceParser());
const [step, setStep] = useState<Step>(githubService.isAuthenticated() ? 'selectSource' : 'auth');
const [sourceType, setSourceType] = useState<SourceType | null>(null);
const [parsedPluginInfo, setParsedPluginInfo] = useState<ParsedPluginInfo | null>(null);
const [publishInfo, setPublishInfo] = useState<Partial<PluginPublishInfo>>({
category: 'community',
tags: []
});
const [prUrl, setPrUrl] = useState('');
const [error, setError] = useState('');
const [buildLog, setBuildLog] = useState<string[]>([]);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
const [builtZipPath, setBuiltZipPath] = useState<string>('');
const [existingPR, setExistingPR] = useState<{ number: number; url: string } | null>(null);
const [existingVersions, setExistingVersions] = useState<string[]>([]);
const [suggestedVersion, setSuggestedVersion] = useState<string>('');
const [existingManifest, setExistingManifest] = useState<any>(null);
const [isUpdate, setIsUpdate] = useState(false);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '发布插件到市场',
updateTitle: '更新插件版本',
stepAuth: '步骤 1: GitHub 登录',
stepSelectSource: '步骤 2: 选择插件源',
stepInfo: '步骤 3: 插件信息',
stepInfoUpdate: '步骤 3: 版本更新',
stepBuilding: '步骤 4: 构建打包',
stepConfirm: '步骤 5: 确认发布',
stepConfirmNoBuilding: '步骤 4: 确认发布',
githubLogin: 'GitHub 登录',
oauthLogin: 'OAuth 登录(推荐)',
tokenLogin: 'Token 登录',
oauthInstructions: '点击下方按钮开始授权:',
oauthStep1: '1. 点击"开始授权"按钮',
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
oauthStep3: '3. 输入下方显示的代码并授权',
oauthStep4: '4. 授权完成后会自动跳转',
startAuth: '开始授权',
authorizing: '等待授权中...',
authorized: '授权成功!',
authFailed: '授权失败',
userCode: '授权码',
copyCode: '复制代码',
openBrowser: '打开浏览器',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: '粘贴你的 GitHub Token',
tokenHint: '需要 repo 和 workflow 权限',
createToken: '创建 Token',
login: '登录',
switchToToken: '使用 Token 登录',
switchToOAuth: '使用 OAuth 登录',
selectSource: '选择插件源',
selectSourceDesc: '选择插件的来源类型',
selectFolder: '选择源代码文件夹',
selectFolderDesc: '选择包含你的插件源代码的文件夹(需要有 package.json系统将自动构建',
selectZip: '选择 ZIP 文件',
selectZipDesc: '选择已构建好的插件 ZIP 包(必须包含 package.json 和 dist 目录)',
zipRequirements: 'ZIP 文件要求',
zipStructure: 'ZIP 结构',
zipStructureDetails: 'ZIP 文件必须包含以下内容:',
zipFile1: 'package.json - 插件元数据',
zipFile2: 'dist/ - 构建后的代码目录(包含 index.esm.js',
zipExample: '示例结构',
zipBuildScript: '打包脚本',
zipBuildScriptDesc: '可以使用以下命令打包:',
recommendFolder: '💡 建议使用"源代码文件夹"方式,系统会自动构建',
browseFolder: '浏览文件夹',
browseZip: '浏览 ZIP 文件',
selectedFolder: '已选择文件夹',
selectedZip: '已选择 ZIP',
sourceTypeFolder: '源代码文件夹',
sourceTypeZip: 'ZIP 文件',
pluginInfo: '插件信息',
version: '版本号',
currentVersion: '当前版本',
suggestedVersion: '建议版本',
versionHistory: '版本历史',
updatePlugin: '更新插件',
newPlugin: '新插件',
category: '分类',
official: '官方',
community: '社区',
repositoryUrl: '仓库地址',
repositoryPlaceholder: 'https://github.com/username/repo',
releaseNotes: '更新说明',
releaseNotesPlaceholder: '描述这个版本的变更...',
tags: '标签(逗号分隔)',
tagsPlaceholder: 'ui, tool, editor',
homepage: '主页 URL可选',
next: '下一步',
back: '上一步',
build: '构建并打包',
building: '构建中...',
confirm: '确认发布',
publishing: '发布中...',
publishSuccess: '发布成功!',
publishError: '发布失败',
buildError: '构建失败',
prCreated: 'Pull Request 已创建',
viewPR: '查看 PR',
close: '关闭',
buildingStep1: '正在安装依赖...',
buildingStep2: '正在构建项目...',
buildingStep3: '正在打包 ZIP...',
publishingStep1: '正在 Fork 仓库...',
publishingStep2: '正在创建分支...',
publishingStep3: '正在上传文件...',
publishingStep4: '正在创建 Pull Request...',
confirmMessage: '确认要发布以下插件?',
reviewMessage: '你的插件提交已创建 PR维护者将进行审核。审核通过后插件将自动发布到市场。',
existingPRDetected: '检测到现有 PR',
existingPRMessage: '该插件已有待审核的 PR #{{number}}。点击"确认"将更新现有 PR不会创建新的 PR。',
viewExistingPR: '查看现有 PR'
},
en: {
title: 'Publish Plugin to Marketplace',
updateTitle: 'Update Plugin Version',
stepAuth: 'Step 1: GitHub Authentication',
stepSelectSource: 'Step 2: Select Plugin Source',
stepInfo: 'Step 3: Plugin Information',
stepInfoUpdate: 'Step 3: Version Update',
stepBuilding: 'Step 4: Build & Package',
stepConfirm: 'Step 5: Confirm Publication',
stepConfirmNoBuilding: 'Step 4: Confirm Publication',
githubLogin: 'GitHub Login',
oauthLogin: 'OAuth Login (Recommended)',
tokenLogin: 'Token Login',
oauthInstructions: 'Click the button below to start authorization:',
oauthStep1: '1. Click "Start Authorization"',
oauthStep2: '2. Open GitHub authorization page in browser',
oauthStep3: '3. Enter the code shown below and authorize',
oauthStep4: '4. Authorization will complete automatically',
startAuth: 'Start Authorization',
authorizing: 'Waiting for authorization...',
authorized: 'Authorized!',
authFailed: 'Authorization failed',
userCode: 'Authorization Code',
copyCode: 'Copy Code',
openBrowser: 'Open Browser',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: 'Paste your GitHub Token',
tokenHint: 'Requires repo and workflow permissions',
createToken: 'Create Token',
login: 'Login',
switchToToken: 'Use Token Login',
switchToOAuth: 'Use OAuth Login',
selectSource: 'Select Plugin Source',
selectSourceDesc: 'Choose the plugin source type',
selectFolder: 'Select Source Folder',
selectFolderDesc: 'Select the folder containing your plugin source code (must have package.json, will be built automatically)',
selectZip: 'Select ZIP File',
selectZipDesc: 'Select a pre-built plugin ZIP package (must contain package.json and dist directory)',
zipRequirements: 'ZIP File Requirements',
zipStructure: 'ZIP Structure',
zipStructureDetails: 'The ZIP file must contain:',
zipFile1: 'package.json - Plugin metadata',
zipFile2: 'dist/ - Built code directory (with index.esm.js)',
zipExample: 'Example Structure',
zipBuildScript: 'Build Script',
zipBuildScriptDesc: 'You can use the following commands to package:',
recommendFolder: '💡 Recommended: Use "Source Folder" mode for automatic build',
browseFolder: 'Browse Folder',
browseZip: 'Browse ZIP File',
selectedFolder: 'Selected Folder',
selectedZip: 'Selected ZIP',
sourceTypeFolder: 'Source Folder',
sourceTypeZip: 'ZIP File',
pluginInfo: 'Plugin Information',
version: 'Version',
currentVersion: 'Current Version',
suggestedVersion: 'Suggested Version',
versionHistory: 'Version History',
updatePlugin: 'Update Plugin',
newPlugin: 'New Plugin',
category: 'Category',
official: 'Official',
community: 'Community',
repositoryUrl: 'Repository URL',
repositoryPlaceholder: 'https://github.com/username/repo',
releaseNotes: 'Release Notes',
releaseNotesPlaceholder: 'Describe the changes in this version...',
tags: 'Tags (comma separated)',
tagsPlaceholder: 'ui, tool, editor',
homepage: 'Homepage URL (optional)',
next: 'Next',
back: 'Back',
build: 'Build & Package',
building: 'Building...',
confirm: 'Confirm & Publish',
publishing: 'Publishing...',
publishSuccess: 'Published Successfully!',
publishError: 'Publication Failed',
buildError: 'Build Failed',
prCreated: 'Pull Request Created',
viewPR: 'View PR',
close: 'Close',
buildingStep1: 'Installing dependencies...',
buildingStep2: 'Building project...',
buildingStep3: 'Packaging ZIP...',
publishingStep1: 'Forking repository...',
publishingStep2: 'Creating branch...',
publishingStep3: 'Uploading files...',
publishingStep4: 'Creating Pull Request...',
confirmMessage: 'Confirm publishing this plugin?',
reviewMessage:
'Your plugin submission has been created as a PR. Maintainers will review it. Once approved, the plugin will be published to the marketplace.',
existingPRDetected: 'Existing PR Detected',
existingPRMessage: 'This plugin already has a pending PR #{{number}}. Clicking "Confirm" will update the existing PR (no new PR will be created).',
viewExistingPR: 'View Existing PR'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleAuthSuccess = () => {
setStep('selectSource');
};
/**
* 选择并解析插件源(文件夹或 ZIP
* 统一处理逻辑,避免代码重复
*/
const handleSelectSource = async (type: SourceType) => {
setError('');
setSourceType(type);
try {
let parsedInfo: ParsedPluginInfo;
if (type === 'folder') {
// 选择文件夹
const selected = await openDialog({
directory: true,
multiple: false,
title: t('selectFolder')
});
if (!selected) return;
// 使用 PluginSourceParser 解析文件夹
parsedInfo = await sourceParser.parseFromFolder(selected as string);
} else {
// 选择 ZIP 文件
const selected = await openDialog({
directory: false,
multiple: false,
title: t('selectZip'),
filters: [
{
name: 'ZIP Files',
extensions: ['zip']
}
]
});
if (!selected) return;
// 使用 PluginSourceParser 解析 ZIP
parsedInfo = await sourceParser.parseFromZip(selected as string);
}
// 验证 package.json
sourceParser.validatePackageJson(parsedInfo.packageJson);
setParsedPluginInfo(parsedInfo);
// 检测已发布的版本
await checkExistingVersions(parsedInfo.packageJson);
// 检测是否已有待审核的 PR
await checkExistingPR(parsedInfo.packageJson);
// 进入下一步
setStep('info');
} catch (err) {
console.error('[PluginPublishWizard] Failed to parse plugin source:', err);
setError(err instanceof Error ? err.message : 'Failed to parse plugin source');
}
};
/**
* 检测插件是否已发布,获取版本信息
*/
const checkExistingVersions = async (packageJson: { name: string; version: string }) => {
try {
const pluginId = sourceParser.generatePluginId(packageJson.name);
const manifestContent = await githubService.getFileContent(
'esengine',
'ecs-editor-plugins',
`plugins/community/${pluginId}/manifest.json`,
'main'
);
const manifest = JSON.parse(manifestContent);
if (Array.isArray(manifest.versions)) {
const versions = manifest.versions.map((v: any) => v.version);
setExistingVersions(versions);
setExistingManifest(manifest);
setIsUpdate(true);
// 计算建议版本号
const latestVersion = manifest.latestVersion || versions[0];
const suggested = calculateNextVersion(latestVersion);
setSuggestedVersion(suggested);
// 更新模式:自动填充现有信息
setPublishInfo((prev) => ({
...prev,
version: suggested,
repositoryUrl: manifest.repository?.url || '',
category: manifest.category_type || 'community',
tags: manifest.tags || [],
homepage: manifest.homepage
}));
} else {
// 首次发布
resetToNewPlugin(packageJson.version);
}
} catch (err) {
console.log('[PluginPublishWizard] No existing versions found, this is a new plugin');
resetToNewPlugin(packageJson.version);
}
};
/**
* 重置为新插件状态
*/
const resetToNewPlugin = (version: string) => {
setExistingVersions([]);
setExistingManifest(null);
setIsUpdate(false);
setPublishInfo((prev) => ({
...prev,
version
}));
};
/**
* 检测是否已有待审核的 PR
*/
const checkExistingPR = async (packageJson: { name: string; version: string }) => {
try {
const user = githubService.getUser();
if (user) {
const branchName = `add-plugin-${packageJson.name}-v${packageJson.version}`;
const headBranch = `${user.login}:${branchName}`;
const pr = await githubService.findPullRequestByBranch('esengine', 'ecs-editor-plugins', headBranch);
if (pr) {
setExistingPR({ number: pr.number, url: pr.html_url });
} else {
setExistingPR(null);
}
}
} catch (err) {
console.log('[PluginPublishWizard] Failed to check existing PR:', err);
setExistingPR(null);
}
};
/**
* 从信息填写步骤进入下一步
* - 如果是 ZIP直接跳到确认发布
* - 如果是文件夹,需要先构建
*/
const handleNext = () => {
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
setError('Please fill in all required fields');
return;
}
if (!parsedPluginInfo) {
setError('Plugin source not selected');
return;
}
// ZIP 文件已经构建好,直接跳到确认步骤
if (parsedPluginInfo.sourceType === 'zip' && parsedPluginInfo.zipPath) {
setBuiltZipPath(parsedPluginInfo.zipPath);
setStep('confirm');
} else {
// 文件夹需要构建
setStep('building');
handleBuild();
}
};
/**
* 构建插件(仅对文件夹源有效)
*/
const handleBuild = async () => {
if (!parsedPluginInfo || parsedPluginInfo.sourceType !== 'folder') {
setError('Cannot build: plugin source is not a folder');
setStep('error');
return;
}
setBuildLog([]);
setBuildProgress(null);
setError('');
buildService.setProgressCallback((progress) => {
console.log('[PluginPublishWizard] Build progress:', progress);
setBuildProgress(progress);
if (progress.step === 'install') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep1')) {
return [...prev, t('buildingStep1')];
}
return prev;
});
} else if (progress.step === 'build') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep2')) {
return [...prev, t('buildingStep2')];
}
return prev;
});
} else if (progress.step === 'package') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep3')) {
return [...prev, t('buildingStep3')];
}
return prev;
});
} else if (progress.step === 'complete') {
setBuildLog((prev) => [...prev, t('buildComplete')]);
}
if (progress.output) {
console.log('[Build output]', progress.output);
}
});
try {
const zipPath = await buildService.buildPlugin(parsedPluginInfo.sourcePath);
console.log('[PluginPublishWizard] Build completed, ZIP at:', zipPath);
setBuiltZipPath(zipPath);
setStep('confirm');
} catch (err) {
console.error('[PluginPublishWizard] Build failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setStep('error');
}
};
/**
* 发布插件到市场
*/
const handlePublish = async () => {
setStep('publishing');
setError('');
setPublishProgress(null);
// 设置进度回调
publishService.setProgressCallback((progress) => {
setPublishProgress(progress);
});
try {
// 验证必填字段
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
throw new Error('Missing required fields');
}
// 验证插件源
if (!parsedPluginInfo) {
throw new Error('Plugin source not selected');
}
// 验证 ZIP 路径
if (!builtZipPath) {
throw new Error('Plugin ZIP file not available');
}
const { packageJson } = parsedPluginInfo;
const pluginMetadata: IEditorPluginMetadata = {
name: packageJson.name,
displayName: packageJson.description || packageJson.name,
description: packageJson.description || '',
version: packageJson.version,
category: EditorPluginCategory.Tool,
icon: 'Package',
enabled: true,
installedAt: Date.now()
};
const fullPublishInfo: PluginPublishInfo = {
pluginMetadata,
version: publishInfo.version || packageJson.version,
releaseNotes: publishInfo.releaseNotes || '',
repositoryUrl: publishInfo.repositoryUrl || '',
category: publishInfo.category || 'community',
tags: publishInfo.tags,
homepage: publishInfo.homepage,
screenshots: publishInfo.screenshots
};
console.log('[PluginPublishWizard] Publishing with info:', fullPublishInfo);
console.log('[PluginPublishWizard] Built ZIP path:', builtZipPath);
const prUrl = await publishService.publishPlugin(fullPublishInfo, builtZipPath);
setPrUrl(prUrl);
setStep('success');
} catch (err) {
console.error('[PluginPublishWizard] Publish failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setStep('error');
}
};
const openPR = async () => {
if (prUrl) {
await open(prUrl);
}
};
const wizardContent = (
<div className={inline ? 'plugin-publish-wizard inline' : 'plugin-publish-wizard'} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className="plugin-publish-header">
<h2>{t('title')}</h2>
{!inline && (
<button className="plugin-publish-close" onClick={onClose}>
<X size={20} />
</button>
)}
</div>
<div className="plugin-publish-content">
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
npm run build
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
# ZIP 结构:
# plugin.zip
# ├── package.json
# └── dist/
# └── index.esm.js`}
</pre>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
</div>
</details>
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
</div>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
</button>
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
{t('suggestedVersion')}: {suggestedVersion}
</button>
)}
</div>
)}
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="confirm-details">
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
)}
</div>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
</div>
)}
</div>
</div>
);
return inline ? wizardContent : (
<div className="plugin-publish-overlay" onClick={onClose}>
{wizardContent}
</div>
);
}

View File

@@ -1,354 +0,0 @@
import { useState } from 'react';
import { X, FolderOpen, Loader, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import type { GitHubService, PublishedPlugin } from '../services/GitHubService';
import { PluginPublishService, type PublishProgress } from '../services/PluginPublishService';
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
import { open } from '@tauri-apps/plugin-shell';
import { EditorPluginCategory } from '@esengine/editor-core';
import type { IEditorPluginMetadata } from '@esengine/editor-core';
import '../styles/PluginUpdateDialog.css';
interface PluginUpdateDialogProps {
plugin: PublishedPlugin;
githubService: GitHubService;
onClose: () => void;
onSuccess: () => void;
locale: string;
}
type Step = 'selectFolder' | 'info' | 'building' | 'publishing' | 'success' | 'error';
function calculateNextVersion(currentVersion: string): string {
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
const [major, minor, patch] = parts;
return `${major}.${minor}.${(patch ?? 0) + 1}`;
}
export function PluginUpdateDialog({ plugin, githubService, onClose, onSuccess, locale }: PluginUpdateDialogProps) {
const [publishService] = useState(() => new PluginPublishService(githubService));
const [buildService] = useState(() => new PluginBuildService());
const [step, setStep] = useState<Step>('selectFolder');
const [pluginFolder, setPluginFolder] = useState('');
const [version, setVersion] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [suggestedVersion] = useState(() => calculateNextVersion(plugin.latestVersion));
const [error, setError] = useState('');
const [buildLog, setBuildLog] = useState<string[]>([]);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
const [prUrl, setPrUrl] = useState('');
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '更新插件',
currentVersion: '当前版本',
newVersion: '新版本号',
useSuggested: '使用建议版本',
releaseNotes: '更新说明',
releaseNotesPlaceholder: '描述这个版本的变更...',
selectFolder: '选择插件文件夹',
selectFolderDesc: '选择包含更新后插件源代码的文件夹',
browseFolder: '浏览文件夹',
selectedFolder: '已选择文件夹',
next: '下一步',
back: '上一步',
buildAndPublish: '构建并发布',
building: '构建中...',
publishing: '发布中...',
success: '更新成功!',
error: '更新失败',
viewPR: '查看 PR',
close: '关闭',
buildError: '构建失败',
publishError: '发布失败',
buildingStep1: '正在安装依赖...',
buildingStep2: '正在构建项目...',
buildingStep3: '正在打包 ZIP...',
publishingStep1: '正在 Fork 仓库...',
publishingStep2: '正在创建分支...',
publishingStep3: '正在上传文件...',
publishingStep4: '正在创建 Pull Request...',
reviewMessage: '你的插件更新已创建 PR维护者将进行审核。审核通过后新版本将自动发布到市场。'
},
en: {
title: 'Update Plugin',
currentVersion: 'Current Version',
newVersion: 'New Version',
useSuggested: 'Use Suggested',
releaseNotes: 'Release Notes',
releaseNotesPlaceholder: 'Describe the changes in this version...',
selectFolder: 'Select Plugin Folder',
selectFolderDesc: 'Select the folder containing your updated plugin source code',
browseFolder: 'Browse Folder',
selectedFolder: 'Selected Folder',
next: 'Next',
back: 'Back',
buildAndPublish: 'Build & Publish',
building: 'Building...',
publishing: 'Publishing...',
success: 'Update Successful!',
error: 'Update Failed',
viewPR: 'View PR',
close: 'Close',
buildError: 'Build Failed',
publishError: 'Publish Failed',
buildingStep1: 'Installing dependencies...',
buildingStep2: 'Building project...',
buildingStep3: 'Packaging ZIP...',
publishingStep1: 'Forking repository...',
publishingStep2: 'Creating branch...',
publishingStep3: 'Uploading files...',
publishingStep4: 'Creating Pull Request...',
reviewMessage: 'Your plugin update has been created as a PR. Maintainers will review it. Once approved, the new version will be published to the marketplace.'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleSelectFolder = async () => {
try {
const selected = await openDialog({
directory: true,
multiple: false,
title: t('selectFolder')
});
if (!selected) return;
setPluginFolder(selected as string);
setStep('info');
} catch (err) {
console.error('[PluginUpdateDialog] Failed to select folder:', err);
setError(err instanceof Error ? err.message : 'Failed to select folder');
}
};
const handleBuildAndPublish = async () => {
if (!version || !releaseNotes) {
alert('Please fill in all required fields');
return;
}
setStep('building');
setBuildLog([]);
setError('');
try {
buildService.setProgressCallback((progress) => {
setBuildProgress(progress);
if (progress.output) {
setBuildLog((prev) => [...prev, progress.output!]);
}
});
const zipPath = await buildService.buildPlugin(pluginFolder);
console.log('[PluginUpdateDialog] Build completed:', zipPath);
setStep('publishing');
publishService.setProgressCallback((progress) => {
setPublishProgress(progress);
});
const { readTextFile } = await import('@tauri-apps/plugin-fs');
const packageJsonPath = `${pluginFolder}/package.json`;
const packageJsonContent = await readTextFile(packageJsonPath);
const pkgJson = JSON.parse(packageJsonContent);
const pluginMetadata: IEditorPluginMetadata = {
name: pkgJson.name,
displayName: pkgJson.description || pkgJson.name,
description: pkgJson.description || '',
version: pkgJson.version,
category: EditorPluginCategory.Tool,
icon: 'Package',
enabled: true,
installedAt: Date.now()
};
const publishInfo = {
pluginMetadata,
version,
releaseNotes,
category: plugin.category_type as 'official' | 'community',
repositoryUrl: plugin.repositoryUrl || '',
tags: []
};
const prUrl = await publishService.publishPlugin(publishInfo, zipPath);
console.log('[PluginUpdateDialog] Update published:', prUrl);
setPrUrl(prUrl);
setStep('success');
onSuccess();
} catch (err) {
console.error('[PluginUpdateDialog] Failed to update plugin:', err);
setError(err instanceof Error ? err.message : 'Update failed');
setStep('error');
}
};
const renderStepContent = () => {
switch (step) {
case 'selectFolder':
return (
<div className="update-dialog-step">
<h3>{t('selectFolder')}</h3>
<p className="step-description">{t('selectFolderDesc')}</p>
<button className="btn-browse" onClick={handleSelectFolder}>
<FolderOpen size={16} />
{t('browseFolder')}
</button>
</div>
);
case 'info':
return (
<div className="update-dialog-step">
<div className="current-plugin-info">
<h4>{plugin.name}</h4>
<p>
{t('currentVersion')}: <strong>v{plugin.latestVersion}</strong>
</p>
</div>
{pluginFolder && (
<div className="selected-folder-info">
<FolderOpen size={16} />
<span>{pluginFolder}</span>
</div>
)}
<div className="form-group">
<label>{t('newVersion')} *</label>
<div className="version-input-group">
<input
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder={suggestedVersion}
/>
<button
type="button"
className="btn-suggest"
onClick={() => setVersion(suggestedVersion)}
>
{t('useSuggested')} ({suggestedVersion})
</button>
</div>
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={6}
value={releaseNotes}
onChange={(e) => setReleaseNotes(e.target.value)}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
<div className="update-dialog-actions">
<button className="btn-back" onClick={() => setStep('selectFolder')}>
{t('back')}
</button>
<button
className="btn-primary"
onClick={handleBuildAndPublish}
disabled={!version || !releaseNotes}
>
{t('buildAndPublish')}
</button>
</div>
</div>
);
case 'building':
return (
<div className="update-dialog-step">
<h3>{t('building')}</h3>
{buildProgress && (
<div className="progress-container">
<p className="progress-message">{buildProgress.message}</p>
</div>
)}
{buildLog.length > 0 && (
<div className="build-log">
{buildLog.map((log, index) => (
<div key={index} className="log-line">
{log}
</div>
))}
</div>
)}
</div>
);
case 'publishing':
return (
<div className="update-dialog-step">
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="progress-container">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${publishProgress.progress}%` }} />
</div>
<p className="progress-message">{publishProgress.message}</p>
</div>
)}
</div>
);
case 'success':
return (
<div className="update-dialog-step success-step">
<CheckCircle size={64} className="success-icon" />
<h3>{t('success')}</h3>
<p className="success-message">{t('reviewMessage')}</p>
{prUrl && (
<button className="btn-view-pr" onClick={() => open(prUrl)}>
{t('viewPR')}
</button>
)}
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
case 'error':
return (
<div className="update-dialog-step error-step">
<AlertCircle size={64} className="error-icon" />
<h3>{t('error')}</h3>
<p className="error-message">{error}</p>
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
default:
return null;
}
};
return (
<div className="plugin-update-dialog-overlay">
<div className="plugin-update-dialog">
<div className="update-dialog-header">
<h2>{t('title')}: {plugin.name}</h2>
<button className="update-dialog-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="update-dialog-content">{renderStepContent()}</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, IFileSystemService } from '@esengine/editor-core';
import type { IFileSystem } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetSaveDialog } from './dialogs/AssetSaveDialog';
import { AssetField } from './inspectors/fields/AssetField';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
@@ -198,18 +197,55 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
case 'asset': {
const controlledBy = getControlledBy(propertyName);
const assetMeta = metadata as { assetType?: string; extensions?: string[] };
const fileExtension = assetMeta.extensions?.[0] || '';
const handleNavigate = (path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
};
const handleCreate = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
if (fileExtension === '.tilemap.json') {
messageHub.publish('tilemap:create-asset', {
entityId: entity?.id,
onChange: (newValue: string) => handleChange(propertyName, newValue)
});
} else if (fileExtension === '.btree') {
messageHub.publish('behavior-tree:create-asset', {
entityId: entity?.id,
onChange: (newValue: string) => handleChange(propertyName, newValue)
});
}
}
};
const creatableExtensions = ['.tilemap.json', '.btree'];
const canCreate = assetMeta.extensions?.some(ext => creatableExtensions.includes(ext));
return (
<AssetDropField
key={propertyName}
label={label}
value={value ?? ''}
assetType={assetMeta.assetType}
extensions={assetMeta.extensions}
readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy}
entityId={entity?.id?.toString()}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
<div key={propertyName} className="property-field">
<label className="property-label">
{label}
{controlledBy && (
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
<Lock size={10} />
</span>
)}
</label>
<AssetField
value={value ?? null}
onChange={(newValue) => handleChange(propertyName, newValue || '')}
fileExtension={fileExtension}
placeholder="拖拽或选择资源"
readonly={metadata.readOnly || !!controlledBy}
onNavigate={handleNavigate}
onCreate={canCreate ? handleCreate : undefined}
/>
</div>
);
}
@@ -885,223 +921,3 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
);
}
interface AssetDropFieldProps {
label: string;
value: string;
assetType?: string;
extensions?: string[];
readOnly?: boolean;
controlledBy?: string;
entityId?: string;
onChange: (value: string) => void;
}
function AssetDropField({ label, value, assetType, extensions, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Determine if this asset type can be created
const creatableExtensions = ['.tilemap.json', '.btree'];
const canCreate = extensions?.some(ext => creatableExtensions.includes(ext));
const fileExtension = extensions?.[0];
const handleCreate = () => {
setShowSaveDialog(true);
};
const handleSaveAsset = async (relativePath: string) => {
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
const messageHub = Core.services.tryResolve(MessageHub);
if (!fileSystem) {
console.error('[AssetDropField] FileSystem service not available');
return;
}
try {
// Get absolute path from project
const projectService = Core.services.tryResolve(
(await import('@esengine/editor-core')).ProjectService
);
const currentProject = projectService?.getCurrentProject();
if (!currentProject) {
console.error('[AssetDropField] No project loaded');
return;
}
const absolutePath = `${currentProject.path}/${relativePath}`.replace(/\\/g, '/');
// Create default content based on file type
let defaultContent = '';
if (fileExtension === '.tilemap.json') {
defaultContent = JSON.stringify({
name: 'New Tilemap',
version: 2,
width: 20,
height: 15,
tileWidth: 16,
tileHeight: 16,
layers: [
{
id: 'default',
name: 'Layer 0',
visible: true,
opacity: 1,
data: new Array(20 * 15).fill(0)
}
],
tilesets: []
}, null, 2);
} else if (fileExtension === '.btree') {
defaultContent = JSON.stringify({
name: 'New Behavior Tree',
version: 1,
nodes: [],
connections: []
}, null, 2);
}
// Write file
await fileSystem.writeFile(absolutePath, defaultContent);
// Update component with relative path
onChange(relativePath);
// Open editor panel if tilemap
if (messageHub && fileExtension === '.tilemap.json' && entityId) {
const { useTilemapEditorStore } = await import('@esengine/tilemap-editor');
useTilemapEditorStore.getState().setEntityId(entityId);
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
}
console.log('[AssetDropField] Created asset:', relativePath);
} catch (error) {
console.error('[AssetDropField] Failed to create asset:', error);
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readOnly) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readOnly) return;
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath) {
if (fileExtension) {
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
const lowerPath = assetPath.toLowerCase();
// Check if the path ends with any of the specified extensions
// This handles both simple extensions (.json) and compound extensions (.tilemap.json)
const isValidExtension = extensions.some((ext) => {
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
return lowerPath.endsWith(normalizedExt);
});
if (isValidExtension) {
onChange(assetPath);
}
} else {
onChange(assetPath);
}
}
};
const getFileName = (path: string) => {
if (!path) return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1];
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
if (!readOnly) onChange('');
};
const handleNavigate = (e: React.MouseEvent) => {
e.stopPropagation();
if (value) {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path: value });
}
}
};
return (
<div className="property-field">
<label className="property-label">
{label}
{controlledBy && (
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
<Lock size={10} />
</span>
)}
</label>
<div
className={`property-asset-drop ${isDragging ? 'dragging' : ''} ${value ? 'has-value' : ''} ${controlledBy ? 'controlled' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={controlledBy ? `Controlled by ${controlledBy}` : (value || 'Drop asset here')}
>
<span className="property-asset-text">
{value ? getFileName(value) : 'None'}
</span>
<div className="property-asset-actions">
{canCreate && !readOnly && !value && (
<button
className="property-asset-btn property-asset-btn-create"
onClick={(e) => {
e.stopPropagation();
handleCreate();
}}
title="创建新资产"
>
+
</button>
)}
{value && (
<button
className="property-asset-btn"
onClick={handleNavigate}
title="在资产浏览器中显示"
>
<ArrowRight size={12} />
</button>
)}
{value && !readOnly && (
<button className="property-asset-clear" onClick={handleClear}>×</button>
)}
</div>
</div>
{/* Save Dialog */}
<AssetSaveDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
onSave={handleSaveAsset}
title={fileExtension === '.tilemap.json' ? '创建 Tilemap 资产' : '创建资产'}
defaultFileName={fileExtension === '.tilemap.json' ? 'new-tilemap' : 'new-asset'}
fileExtension={fileExtension}
/>
</div>
);
}

View File

@@ -2,12 +2,42 @@ import { useState, useEffect, useRef } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import '../styles/SceneHierarchy.css';
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconComponent(iconName: string | undefined, size: number = 12): React.ReactNode {
if (!iconName) return <Plus size={size} />;
// 获取图标组件
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
// 回退到 Plus 图标
return <Plus size={size} />;
}
/**
* 类别图标映射
*/
const categoryIconMap: Record<string, string> = {
'rendering': 'Image',
'ui': 'LayoutGrid',
'physics': 'Box',
'audio': 'Volume2',
'basic': 'Plus',
'other': 'MoreHorizontal',
};
type ViewMode = 'local' | 'remote';
interface SceneHierarchyProps {
@@ -261,43 +291,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
commandManager.execute(command);
};
const handleCreateSpriteEntity = () => {
// Count only Sprite entities for naming
const spriteCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('Sprite ')).length;
const entityName = `Sprite ${spriteCount + 1}`;
const command = new CreateSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateAnimatedSpriteEntity = () => {
const animCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('AnimatedSprite ')).length;
const entityName = `AnimatedSprite ${animCount + 1}`;
const command = new CreateAnimatedSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateCameraEntity = () => {
const entityCount = entityStore.getAllEntities().length;
const entityName = `Camera ${entityCount + 1}`;
const command = new CreateCameraEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleDeleteEntity = async () => {
if (!selectedId) return;
@@ -539,9 +532,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
entityId={contextMenu.entityId}
pluginTemplates={pluginTemplates}
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
onCreateSprite={() => { handleCreateSpriteEntity(); closeContextMenu(); }}
onCreateAnimatedSprite={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}
onCreateCamera={() => { handleCreateCameraEntity(); closeContextMenu(); }}
onCreateFromTemplate={async (template) => {
await template.create(contextMenu.entityId ?? undefined);
closeContextMenu();
@@ -561,9 +551,6 @@ interface ContextMenuWithSubmenuProps {
entityId: number | null;
pluginTemplates: EntityCreationTemplate[];
onCreateEmpty: () => void;
onCreateSprite: () => void;
onCreateAnimatedSprite: () => void;
onCreateCamera: () => void;
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
onDelete: () => void;
onClose: () => void;
@@ -571,8 +558,7 @@ interface ContextMenuWithSubmenuProps {
function ContextMenuWithSubmenu({
x, y, locale, entityId, pluginTemplates,
onCreateEmpty, onCreateSprite, onCreateAnimatedSprite, onCreateCamera,
onCreateFromTemplate, onDelete
onCreateEmpty, onCreateFromTemplate, onDelete
}: ContextMenuWithSubmenuProps) {
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
@@ -580,7 +566,7 @@ function ContextMenuWithSubmenu({
const categoryLabels: Record<string, { zh: string; en: string }> = {
'basic': { zh: '基础', en: 'Basic' },
'rendering': { zh: '渲染', en: 'Rendering' },
'rendering': { zh: '2D 对象', en: '2D Objects' },
'ui': { zh: 'UI', en: 'UI' },
'physics': { zh: '物理', en: 'Physics' },
'audio': { zh: '音频', en: 'Audio' },
@@ -592,6 +578,7 @@ function ContextMenuWithSubmenu({
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
};
// 将模板按类别分组(所有模板现在都来自插件)
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
const cat = template.category || 'other';
if (!acc[cat]) acc[cat] = [];
@@ -599,7 +586,10 @@ function ContextMenuWithSubmenu({
return acc;
}, {} as Record<string, EntityCreationTemplate[]>);
const hasPluginCategories = Object.keys(templatesByCategory).length > 0;
// 按顺序排序每个类别内的模板
Object.values(templatesByCategory).forEach(templates => {
templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
});
const handleSubmenuEnter = (category: string, e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -607,6 +597,14 @@ function ContextMenuWithSubmenu({
setActiveSubmenu(category);
};
// 定义类别显示顺序
const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other'];
const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => {
const orderA = categoryOrder.indexOf(a);
const orderB = categoryOrder.indexOf(b);
return (orderA === -1 ? 999 : orderA) - (orderB === -1 ? 999 : orderB);
});
return (
<div
ref={menuRef}
@@ -618,41 +616,10 @@ function ContextMenuWithSubmenu({
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
</button>
<div className="context-menu-divider" />
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
<div
className="context-menu-item-with-submenu"
onMouseEnter={(e) => handleSubmenuEnter('rendering', e)}
onMouseLeave={() => setActiveSubmenu(null)}
>
<button>
<Image size={12} />
<span>{locale === 'zh' ? '2D 对象' : '2D Objects'}</span>
<ChevronRight size={12} className="submenu-arrow" />
</button>
{activeSubmenu === 'rendering' && (
<div
className="context-submenu"
style={{ left: submenuPosition.x, top: submenuPosition.y }}
onMouseEnter={() => setActiveSubmenu('rendering')}
>
<button onClick={onCreateSprite}>
<Image size={12} />
<span>Sprite</span>
</button>
<button onClick={onCreateAnimatedSprite}>
<Film size={12} />
<span>{locale === 'zh' ? '动画 Sprite' : 'Animated Sprite'}</span>
</button>
<button onClick={onCreateCamera}>
<Camera size={12} />
<span>{locale === 'zh' ? '相机' : 'Camera'}</span>
</button>
</div>
)}
</div>
{hasPluginCategories && Object.entries(templatesByCategory).map(([category, templates]) => (
{/* 按类别渲染所有模板 */}
{sortedCategories.map(([category, templates]) => (
<div
key={category}
className="context-menu-item-with-submenu"
@@ -660,7 +627,7 @@ function ContextMenuWithSubmenu({
onMouseLeave={() => setActiveSubmenu(null)}
>
<button>
{templates[0]?.icon || <Plus size={12} />}
{getIconComponent(categoryIconMap[category], 12)}
<span>{getCategoryLabel(category)}</span>
<ChevronRight size={12} className="submenu-arrow" />
</button>
@@ -672,7 +639,7 @@ function ContextMenuWithSubmenu({
>
{templates.map((template) => (
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
{template.icon || <Plus size={12} />}
{getIconComponent(template.icon as string, 12)}
<span>{template.label}</span>
</button>
))}

View File

@@ -1,17 +1,20 @@
import { useState, useEffect } from 'react';
import { X, Settings as SettingsIcon, ChevronRight } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { SettingsService } from '../services/SettingsService';
import { SettingsRegistry, SettingCategory, SettingDescriptor } from '@esengine/editor-core';
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
import { PluginListSetting } from './PluginListSetting';
import '../styles/SettingsWindow.css';
interface SettingsWindowProps {
onClose: () => void;
settingsRegistry: SettingsRegistry;
initialCategoryId?: string;
}
export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProps) {
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
const [categories, setCategories] = useState<SettingCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(initialCategoryId || null);
const [values, setValues] = useState<Map<string, any>>(new Map());
const [errors, setErrors] = useState<Map<string, string>>(new Map());
@@ -20,19 +23,42 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
setCategories(allCategories);
if (allCategories.length > 0 && !selectedCategoryId) {
const firstCategory = allCategories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
// 如果有 initialCategoryId尝试使用它
if (initialCategoryId && allCategories.some(c => c.id === initialCategoryId)) {
setSelectedCategoryId(initialCategoryId);
} else {
const firstCategory = allCategories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
}
}
}
const settings = SettingsService.getInstance();
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const allSettings = settingsRegistry.getAllSettings();
const initialValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
// Project-scoped settings are loaded from ProjectService
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.width);
} else if (key === 'project.uiDesignResolution.height') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.height);
} else if (key === 'project.uiDesignResolution.preset') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else {
// For other project settings, use default
initialValues.set(key, descriptor.defaultValue);
}
} else {
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
}
}
setValues(initialValues);
@@ -52,17 +78,48 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
setErrors(newErrors);
};
const handleSave = () => {
const handleSave = async () => {
if (errors.size > 0) {
return;
}
const settings = SettingsService.getInstance();
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const changedSettings: Record<string, any> = {};
// Track UI resolution changes for batch saving
let uiResolutionChanged = false;
let newWidth = 1920;
let newHeight = 1080;
for (const [key, value] of values.entries()) {
settings.set(key, value);
changedSettings[key] = value;
// Project-scoped settings are saved to ProjectService
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
newWidth = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.height') {
newHeight = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.preset') {
// Preset changes width and height together
const [w, h] = value.split('x').map(Number);
if (w && h) {
newWidth = w;
newHeight = h;
uiResolutionChanged = true;
}
}
changedSettings[key] = value;
} else {
settings.set(key, value);
changedSettings[key] = value;
}
}
// Save UI resolution if changed
if (uiResolutionChanged && projectService) {
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
}
window.dispatchEvent(new CustomEvent('settings:changed', {
@@ -216,6 +273,23 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
</div>
);
case 'pluginList': {
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
if (!pluginManager) {
return (
<div className="settings-field settings-field-full">
<p className="settings-error">PluginManager </p>
</div>
);
}
return (
<div className="settings-field settings-field-full">
<PluginListSetting pluginManager={pluginManager} />
{error && <span className="settings-error">{error}</span>}
</div>
);
}
default:
return null;
}

View File

@@ -58,7 +58,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
const translations = {
en: {
title: 'ECS Framework Editor',
title: 'ESEngine Editor',
subtitle: 'Professional Game Development Tool',
openProject: 'Open Project',
createProject: 'Create Project',
@@ -72,7 +72,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
later: 'Later'
},
zh: {
title: 'ECS 框架编辑器',
title: 'ESEngine 编辑器',
subtitle: '专业游戏开发工具',
openProject: '打开项目',
createProject: '创建新项目',

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { Github, LogOut, User, LayoutDashboard, Loader2 } from 'lucide-react';
import type { GitHubService, GitHubUser } from '../services/GitHubService';
import '../styles/UserProfile.css';
interface UserProfileProps {
githubService: GitHubService;
onLogin: () => void;
onOpenDashboard: () => void;
locale: string;
}
export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }: UserProfileProps) {
const [user, setUser] = useState<GitHubUser | null>(githubService.getUser());
const [showMenu, setShowMenu] = useState(false);
const [isLoadingUser, setIsLoadingUser] = useState(githubService.isLoadingUserInfo());
const menuRef = useRef<HTMLDivElement>(null);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
login: '登录',
logout: '登出',
dashboard: '个人中心',
profile: '个人信息',
notLoggedIn: '未登录',
loadingUser: '加载中...'
},
en: {
login: 'Login',
logout: 'Logout',
dashboard: 'Dashboard',
profile: 'Profile',
notLoggedIn: 'Not logged in',
loadingUser: 'Loading...'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
// 监听加载状态变化
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
setIsLoadingUser(isLoading);
});
return unsubscribe;
}, [githubService]);
useEffect(() => {
// 监听认证状态变化
const checkUser = () => {
const currentUser = githubService.getUser();
setUser((prevUser) => {
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
return currentUser;
} else if (!currentUser && prevUser) {
return null;
}
return prevUser;
});
};
// 每秒检查一次用户状态
const interval = setInterval(checkUser, 1000);
return () => clearInterval(interval);
}, [githubService]);
const handleLogout = () => {
githubService.logout();
setUser(null);
setShowMenu(false);
};
if (!user) {
return (
<div className="user-profile">
<button
className="login-button"
onClick={onLogin}
disabled={isLoadingUser}
title={isLoadingUser ? t('loadingUser') : undefined}
>
{isLoadingUser ? (
<>
<Loader2 size={16} className="spinning" />
{t('loadingUser')}
</>
) : (
<>
<Github size={16} />
{t('login')}
</>
)}
</button>
</div>
);
}
return (
<div className="user-profile" ref={menuRef}>
<button className="user-avatar-button" onClick={() => setShowMenu(!showMenu)}>
{user.avatar_url ? (
<img src={user.avatar_url} alt={user.name} className="user-avatar" />
) : (
<div className="user-avatar-placeholder">
<User size={20} />
</div>
)}
<span className="user-name">{user.name || user.login}</span>
</button>
{showMenu && (
<div className="user-menu">
<div className="user-menu-header">
<img src={user.avatar_url} alt={user.name} className="user-menu-avatar" />
<div className="user-menu-info">
<div className="user-menu-name">{user.name || user.login}</div>
<div className="user-menu-login">@{user.login}</div>
</div>
</div>
<div className="user-menu-divider" />
<button
className="user-menu-item"
onClick={() => {
setShowMenu(false);
onOpenDashboard();
}}
>
<LayoutDashboard size={16} />
{t('dashboard')}
</button>
<button className="user-menu-item" onClick={handleLogout}>
<LogOut size={16} />
{t('logout')}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
/* 资产选择框 */
.asset-field {
margin-bottom: 6px;
min-width: 0; /* 允许在flex容器中收缩 */
}
.asset-field__label {
@@ -18,6 +19,8 @@
border-radius: 4px;
transition: all 0.15s ease;
position: relative;
min-width: 0; /* 允许在flex容器中收缩 */
overflow: hidden; /* 防止内容溢出 */
}
.asset-field__container.hovered {
@@ -40,6 +43,7 @@
background: #262626;
border-right: 1px solid #333;
color: #888;
flex-shrink: 0; /* 图标不收缩 */
}
.asset-field__container.hovered .asset-field__icon {
@@ -55,7 +59,8 @@
align-items: center;
cursor: pointer;
user-select: none;
min-width: 0;
min-width: 0; /* 关键允许flex项收缩到小于内容宽度 */
overflow: hidden; /* 配合min-width: 0防止溢出 */
}
.asset-field__input:hover {
@@ -68,6 +73,8 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%; /* 确保不超出父容器 */
display: block; /* 让text-overflow生效 */
}
.asset-field__input.empty .asset-field__value {
@@ -81,6 +88,7 @@
align-items: center;
gap: 1px;
padding: 0 1px;
flex-shrink: 0; /* 操作按钮不收缩 */
}
.asset-field__button {

View File

@@ -111,7 +111,6 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
if (!component) return;
const componentName = getComponentTypeName(component.constructor as any);
console.log('Removing component:', componentName);
// Check if any other component depends on this one
const dependentComponents: string[] = [];
@@ -120,12 +119,10 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const dependencies = getComponentDependencies(otherComponent.constructor as any);
const otherName = getComponentTypeName(otherComponent.constructor as any);
console.log('Checking', otherName, 'dependencies:', dependencies);
if (dependencies && dependencies.includes(componentName)) {
dependentComponents.push(otherName);
}
}
console.log('Dependent components:', dependentComponents);
if (dependentComponents.length > 0) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;