refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
@@ -1,60 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import { useBehaviorTreeStore } from '../stores/behaviorTreeStore';
|
||||
|
||||
const logger = createLogger('BehaviorTreeFileService');
|
||||
|
||||
export interface FileLoadResult {
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BehaviorTreeFileService {
|
||||
private loadingFiles = new Set<string>();
|
||||
|
||||
async loadFile(filePath: string): Promise<FileLoadResult> {
|
||||
try {
|
||||
if (!filePath.endsWith('.btree')) {
|
||||
return {
|
||||
success: false,
|
||||
error: '无效的文件类型'
|
||||
};
|
||||
}
|
||||
|
||||
// 防止重复加载同一个文件(保底机制,通常不应该被触发)
|
||||
if (this.loadingFiles.has(filePath)) {
|
||||
logger.debug('文件正在加载中,跳过重复请求:', filePath);
|
||||
return { success: false, error: '文件正在加载中' };
|
||||
}
|
||||
|
||||
this.loadingFiles.add(filePath);
|
||||
|
||||
try {
|
||||
logger.info('加载行为树文件:', filePath);
|
||||
const json = await invoke<string>('read_behavior_tree_file', { filePath });
|
||||
|
||||
const store = useBehaviorTreeStore.getState();
|
||||
store.importFromJSON(json);
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || '未命名';
|
||||
logger.info('行为树已加载:', fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName
|
||||
};
|
||||
} finally {
|
||||
this.loadingFiles.delete(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载行为树失败', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const behaviorTreeFileService = new BehaviorTreeFileService();
|
||||
998
packages/editor-app/src/services/GitHubService.ts
Normal file
998
packages/editor-app/src/services/GitHubService.ts
Normal file
@@ -0,0 +1,998 @@
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export interface GitHubUser {
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
export interface CreatePROptions {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body: string;
|
||||
head: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
}
|
||||
|
||||
interface GitHubRef {
|
||||
ref: string;
|
||||
node_id: string;
|
||||
url: string;
|
||||
object: {
|
||||
sha: string;
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GitHubFileContent {
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
html_url: string;
|
||||
git_url: string;
|
||||
download_url: string;
|
||||
type: string;
|
||||
content: string;
|
||||
encoding: string;
|
||||
}
|
||||
|
||||
interface GitHubRepository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
owner: GitHubUser;
|
||||
html_url: string;
|
||||
description: string | null;
|
||||
fork: boolean;
|
||||
url: string;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
interface GitHubPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
user: GitHubUser;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
mergeable: boolean | null;
|
||||
mergeable_state: string;
|
||||
head: {
|
||||
ref: string;
|
||||
repo: {
|
||||
full_name: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginVersion {
|
||||
version: string;
|
||||
prUrl: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface PublishedPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
category_type: string;
|
||||
repositoryUrl: string;
|
||||
/** 最新版本号 */
|
||||
latestVersion: string;
|
||||
/** 所有已发布的版本 */
|
||||
versions: PluginVersion[];
|
||||
}
|
||||
|
||||
export interface CheckStatus {
|
||||
conclusion: 'success' | 'failure' | 'pending' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | null;
|
||||
name: string;
|
||||
detailsUrl?: string;
|
||||
output?: {
|
||||
title: string;
|
||||
summary: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GitHubCheckRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: 'success' | 'failure' | 'pending' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | null;
|
||||
html_url: string;
|
||||
output: {
|
||||
title: string;
|
||||
summary: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface GitHubCheckRunsResponse {
|
||||
total_count: number;
|
||||
check_runs: GitHubCheckRun[];
|
||||
}
|
||||
|
||||
interface GitHubComment {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
body: string;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubReview {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
body: string;
|
||||
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED';
|
||||
submitted_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface PRComment {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
body: string;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface PRReview {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
body: string;
|
||||
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED';
|
||||
submitted_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface PendingReview {
|
||||
prNumber: number;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
status: 'open' | 'merged' | 'closed';
|
||||
createdAt: string;
|
||||
prUrl: string;
|
||||
checks?: CheckStatus[];
|
||||
comments?: PRComment[];
|
||||
reviews?: PRReview[];
|
||||
hasConflicts?: boolean;
|
||||
conflictFiles?: string[];
|
||||
headBranch?: string;
|
||||
headRepo?: string;
|
||||
}
|
||||
|
||||
export class GitHubService {
|
||||
private accessToken: string | null = null;
|
||||
private user: GitHubUser | null = null;
|
||||
private retryTimer: number | null = null;
|
||||
private isLoadingUser: boolean = false;
|
||||
private userLoadStateChangeCallbacks: Set<(isLoading: boolean) => void> = new Set();
|
||||
|
||||
private readonly STORAGE_KEY = 'github-access-token';
|
||||
private readonly API_BASE = 'https://api.github.com';
|
||||
// GitHub OAuth App Client ID
|
||||
// 创建于: https://github.com/settings/developers
|
||||
private readonly CLIENT_ID = 'Ov23lianjdTqhHQ8EJkr';
|
||||
|
||||
constructor() {
|
||||
this.loadToken();
|
||||
}
|
||||
|
||||
isLoadingUserInfo(): boolean {
|
||||
return this.isLoadingUser;
|
||||
}
|
||||
|
||||
onUserLoadStateChange(callback: (isLoading: boolean) => void): () => void {
|
||||
this.userLoadStateChangeCallbacks.add(callback);
|
||||
callback(this.isLoadingUser);
|
||||
return () => {
|
||||
this.userLoadStateChangeCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyUserLoadStateChange(isLoading: boolean): void {
|
||||
this.isLoadingUser = isLoading;
|
||||
this.userLoadStateChangeCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(isLoading);
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Error in user load state change callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.accessToken !== null;
|
||||
}
|
||||
|
||||
getUser(): GitHubUser | null {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<GitHubUser> {
|
||||
this.accessToken = token;
|
||||
|
||||
try {
|
||||
const user = await this.fetchUser();
|
||||
this.user = user;
|
||||
this.saveToken(token);
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.accessToken = null;
|
||||
this.user = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.accessToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
}
|
||||
|
||||
async openAuthorizationPage(): Promise<void> {
|
||||
const url =
|
||||
'https://github.com/settings/tokens/new?scopes=repo,workflow&description=ECS%20Editor%20Plugin%20Publisher';
|
||||
await open(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 GitHub Device Flow 进行 OAuth 登录
|
||||
* 返回设备代码信息,包含用户需要访问的 URL 和输入的代码
|
||||
*/
|
||||
async requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
console.log('[GitHubService] Requesting device code...');
|
||||
console.log('[GitHubService] Client ID:', this.CLIENT_ID);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://github.com/login/device/code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.CLIENT_ID,
|
||||
scope: 'repo workflow'
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[GitHubService] Response status:', response.status);
|
||||
console.log('[GitHubService] Response ok:', response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('[GitHubService] Request device code failed:', error);
|
||||
throw new Error(`Failed to request device code (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DeviceCodeResponse;
|
||||
console.log('[GitHubService] Device code received:', {
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Error requesting device code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询 GitHub API 检查用户是否完成授权
|
||||
* 成功后自动保存 token 和用户信息
|
||||
*/
|
||||
async authenticateWithDeviceFlow(
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
onProgress?: (status: 'pending' | 'authorized' | 'error') => void
|
||||
): Promise<GitHubUser> {
|
||||
const pollInterval = (interval || 5) * 1000;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // 最多轮询 5 分钟
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
})
|
||||
});
|
||||
|
||||
const data = (await response.json()) as OAuthTokenResponse;
|
||||
|
||||
if (data.error) {
|
||||
if (data.error === 'authorization_pending') {
|
||||
// 用户还未授权,继续等待
|
||||
onProgress?.('pending');
|
||||
await this.sleep(pollInterval);
|
||||
attempts++;
|
||||
continue;
|
||||
} else if (data.error === 'slow_down') {
|
||||
// 轮询太频繁,增加间隔
|
||||
await this.sleep(pollInterval + 5000);
|
||||
attempts++;
|
||||
continue;
|
||||
} else if (data.error === 'expired_token') {
|
||||
throw new Error('Device code expired. Please try again.');
|
||||
} else if (data.error === 'access_denied') {
|
||||
throw new Error('Authorization denied by user.');
|
||||
} else {
|
||||
throw new Error(`OAuth error: ${data.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.access_token) {
|
||||
// 授权成功,保存 token
|
||||
this.accessToken = data.access_token;
|
||||
const user = await this.fetchUser();
|
||||
this.user = user;
|
||||
this.saveToken(data.access_token);
|
||||
onProgress?.('authorized');
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
onProgress?.('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Authentication timeout. Please try again.');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async forkRepository(owner: string, repo: string): Promise<string> {
|
||||
const response = await this.request<GitHubRepository>(`POST /repos/${owner}/${repo}/forks`);
|
||||
|
||||
return response.full_name;
|
||||
}
|
||||
|
||||
async getRef(owner: string, repo: string, ref: string): Promise<GitHubRef> {
|
||||
return await this.request<GitHubRef>(`GET /repos/${owner}/${repo}/git/ref/${ref}`);
|
||||
}
|
||||
|
||||
async createBranch(owner: string, repo: string, branch: string, fromBranch: string = 'main'): Promise<void> {
|
||||
const refResponse = await this.request<GitHubRef>(`GET /repos/${owner}/${repo}/git/ref/heads/${fromBranch}`);
|
||||
const sha = refResponse.object.sha;
|
||||
|
||||
await this.request<GitHubRef>(`POST /repos/${owner}/${repo}/git/refs`, {
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: sha
|
||||
});
|
||||
}
|
||||
|
||||
async createBranchFromSha(owner: string, repo: string, branch: string, sha: string): Promise<void> {
|
||||
await this.request<GitHubRef>(`POST /repos/${owner}/${repo}/git/refs`, {
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: sha
|
||||
});
|
||||
}
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string): Promise<GitHubRef> {
|
||||
return await this.request<GitHubRef>(`GET /repos/${owner}/${repo}/git/ref/heads/${branch}`);
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
|
||||
await this.request<void>(`DELETE /repos/${owner}/${repo}/git/refs/heads/${branch}`);
|
||||
}
|
||||
|
||||
async createOrUpdateFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
content: string,
|
||||
message: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
let sha: string | undefined;
|
||||
|
||||
try {
|
||||
const existing = await this.request<GitHubFileContent>(`GET /repos/${owner}/${repo}/contents/${path}?ref=${branch}`);
|
||||
sha = existing.sha;
|
||||
} catch {
|
||||
// 文件不存在
|
||||
}
|
||||
|
||||
// GitHub API 要求内容为 base64 编码
|
||||
const utf8Bytes = new TextEncoder().encode(content);
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||
binaryString += String.fromCharCode(utf8Bytes[i]!);
|
||||
}
|
||||
const base64Content = btoa(binaryString);
|
||||
|
||||
const body: Record<string, string> = {
|
||||
message: message,
|
||||
content: base64Content,
|
||||
branch: branch
|
||||
};
|
||||
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
}
|
||||
|
||||
await this.request<GitHubFileContent>(`PUT /repos/${owner}/${repo}/contents/${path}`, body);
|
||||
}
|
||||
|
||||
async createOrUpdateBinaryFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
base64Content: string,
|
||||
message: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
let sha: string | undefined;
|
||||
|
||||
try {
|
||||
const existing = await this.request<GitHubFileContent>(`GET /repos/${owner}/${repo}/contents/${path}?ref=${branch}`);
|
||||
sha = existing.sha;
|
||||
} catch {
|
||||
// 文件不存在
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {
|
||||
message: message,
|
||||
content: base64Content,
|
||||
branch: branch
|
||||
};
|
||||
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
}
|
||||
|
||||
await this.request<GitHubFileContent>(`PUT /repos/${owner}/${repo}/contents/${path}`, body);
|
||||
}
|
||||
|
||||
async getDirectoryContents(owner: string, repo: string, path: string, branch: string = 'main'): Promise<GitHubFileContent[]> {
|
||||
try {
|
||||
const response = await this.request<GitHubFileContent | GitHubFileContent[]>(
|
||||
`GET /repos/${owner}/${repo}/contents/${path}?ref=${branch}`
|
||||
);
|
||||
return Array.isArray(response) ? response : [response];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getFileContent(owner: string, repo: string, path: string, branch: string = 'main'): Promise<string> {
|
||||
const response = await this.request<GitHubFileContent>(
|
||||
`GET /repos/${owner}/${repo}/contents/${path}?ref=${branch}`
|
||||
);
|
||||
|
||||
if (!response.content) {
|
||||
throw new Error(`File ${path} does not have content`);
|
||||
}
|
||||
|
||||
return atob(response.content.replace(/\n/g, ''));
|
||||
}
|
||||
|
||||
async deleteFile(owner: string, repo: string, path: string, message: string, branch: string): Promise<void> {
|
||||
console.log(`[GitHubService] Getting file SHA for: ${owner}/${repo}/${path}?ref=${branch}`);
|
||||
const existing = await this.request<GitHubFileContent>(`GET /repos/${owner}/${repo}/contents/${path}?ref=${branch}`);
|
||||
|
||||
if (!existing || !existing.sha) {
|
||||
throw new Error(`Failed to get file SHA for ${path}`);
|
||||
}
|
||||
|
||||
console.log(`[GitHubService] Deleting file with SHA: ${existing.sha}`);
|
||||
await this.request<void>(`DELETE /repos/${owner}/${repo}/contents/${path}`, {
|
||||
message: message,
|
||||
sha: existing.sha,
|
||||
branch: branch
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFileWithSha(owner: string, repo: string, path: string, sha: string, message: string, branch: string): Promise<void> {
|
||||
await this.request<void>(`DELETE /repos/${owner}/${repo}/contents/${path}`, {
|
||||
message: message,
|
||||
sha: sha,
|
||||
branch: branch
|
||||
});
|
||||
}
|
||||
|
||||
async createPullRequest(options: CreatePROptions): Promise<string> {
|
||||
const response = await this.request<GitHubPullRequest>(`POST /repos/${options.owner}/${options.repo}/pulls`, {
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
head: options.head,
|
||||
base: options.base
|
||||
});
|
||||
|
||||
return response.html_url;
|
||||
}
|
||||
|
||||
async findPullRequestByBranch(owner: string, repo: string, headBranch: string): Promise<GitHubPullRequest | null> {
|
||||
try {
|
||||
const response = await this.request<GitHubPullRequest[]>(`GET /repos/${owner}/${repo}/pulls`, {
|
||||
head: headBranch,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
return response.length > 0 && response[0] ? response[0] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async closePullRequest(owner: string, repo: string, pullNumber: number): Promise<void> {
|
||||
await this.request<GitHubPullRequest>(`PATCH /repos/${owner}/${repo}/pulls/${pullNumber}`, {
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
|
||||
async updatePRBranch(owner: string, repo: string, prNumber: number): Promise<void> {
|
||||
await this.request<any>(`PUT /repos/${owner}/${repo}/pulls/${prNumber}/update-branch`, {
|
||||
expected_head_sha: undefined
|
||||
});
|
||||
}
|
||||
|
||||
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
|
||||
return await this.request<GitHubRepository>(`GET /repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async getUserPullRequests(owner: string, repo: string, state: 'open' | 'closed' | 'all' = 'all'): Promise<GitHubPullRequest[]> {
|
||||
if (!this.user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const prs = await this.request<GitHubPullRequest[]>(`GET /repos/${owner}/${repo}/pulls?state=${state}&per_page=100`);
|
||||
|
||||
return prs.filter((pr) => pr.user.login === this.user!.login);
|
||||
}
|
||||
|
||||
async getPublishedPlugins(): Promise<PublishedPlugin[]> {
|
||||
try {
|
||||
const prs = await this.getUserPullRequests('esengine', 'ecs-editor-plugins', 'closed');
|
||||
const mergedPRs = prs.filter((pr) => pr.merged_at !== null);
|
||||
|
||||
// 存储已删除的插件
|
||||
const deletedPlugins = new Map<string, Date>();
|
||||
// 按插件 ID 分组的版本信息
|
||||
const pluginVersionsMap = new Map<string, {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
category_type: string;
|
||||
repositoryUrl: string;
|
||||
versions: PluginVersion[];
|
||||
}>();
|
||||
|
||||
// 第一遍:收集已删除的插件
|
||||
for (const pr of mergedPRs) {
|
||||
const removeMatch = pr.title.match(/Remove plugin: (.+)/);
|
||||
if (removeMatch && removeMatch[1] && pr.merged_at) {
|
||||
const pluginName = removeMatch[1];
|
||||
const mergedDate = new Date(pr.merged_at);
|
||||
deletedPlugins.set(pluginName, mergedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍:收集所有版本信息
|
||||
for (const pr of mergedPRs) {
|
||||
const match = pr.title.match(/Add plugin: (.+) v([\d.]+)/);
|
||||
if (match && match[1] && match[2]) {
|
||||
const pluginName = match[1];
|
||||
const version = match[2];
|
||||
|
||||
// 检查插件是否已被删除
|
||||
const deletedDate = deletedPlugins.get(pluginName);
|
||||
if (deletedDate && pr.merged_at) {
|
||||
const addedDate = new Date(pr.merged_at);
|
||||
if (deletedDate > addedDate) {
|
||||
console.log(`[GitHubService] Plugin ${pluginName} was deleted after being added, skipping`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取插件信息
|
||||
const repoMatch = pr.body?.match(/\*\*Repository\*\*: (.+)/);
|
||||
const repositoryUrl = repoMatch?.[1] || '';
|
||||
|
||||
const categoryMatch = pr.body?.match(/\*\*Category\*\*: (.+)/);
|
||||
const category = categoryMatch?.[1] || 'community';
|
||||
|
||||
const descMatch = pr.body?.match(/### Description\n\n(.+)\n/);
|
||||
const description = descMatch?.[1] || '';
|
||||
|
||||
const branchName = pr.head.ref;
|
||||
const idMatch = branchName.match(/add-plugin-(.+)-v/);
|
||||
const rawId = idMatch?.[1] || pluginName.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const id = rawId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const categoryType = id.startsWith('esengine-') ? 'official' : 'community';
|
||||
|
||||
// 获取或创建插件记录
|
||||
if (!pluginVersionsMap.has(id)) {
|
||||
pluginVersionsMap.set(id, {
|
||||
id,
|
||||
name: pluginName,
|
||||
description,
|
||||
category,
|
||||
category_type: categoryType,
|
||||
repositoryUrl,
|
||||
versions: []
|
||||
});
|
||||
}
|
||||
|
||||
// 添加版本信息
|
||||
const pluginData = pluginVersionsMap.get(id)!;
|
||||
pluginData.versions.push({
|
||||
version,
|
||||
prUrl: pr.html_url,
|
||||
publishedAt: pr.merged_at || pr.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为最终结果,并对版本排序
|
||||
const plugins: PublishedPlugin[] = Array.from(pluginVersionsMap.values()).map(plugin => {
|
||||
// 按版本号降序排序(最新版本在前)
|
||||
const sortedVersions = plugin.versions.sort((a, b) => {
|
||||
const parseVersion = (v: string) => {
|
||||
const parts = v.split('.').map(Number);
|
||||
return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0);
|
||||
};
|
||||
return parseVersion(b.version) - parseVersion(a.version);
|
||||
});
|
||||
|
||||
return {
|
||||
...plugin,
|
||||
latestVersion: sortedVersions[0]?.version || '0.0.0',
|
||||
versions: sortedVersions
|
||||
};
|
||||
});
|
||||
|
||||
return plugins;
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch published plugins:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getPRCheckStatus(owner: string, repo: string, prNumber: number): Promise<CheckStatus[]> {
|
||||
try {
|
||||
const pr = await this.request<GitHubPullRequest>(`GET /repos/${owner}/${repo}/pulls/${prNumber}`);
|
||||
|
||||
if (!pr.head.repo) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headRepoOwner = pr.head.repo.full_name.split('/')[0];
|
||||
if (!headRepoOwner) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const checkRuns = await this.request<GitHubCheckRunsResponse>(
|
||||
`GET /repos/${headRepoOwner}/${repo}/commits/${pr.head.ref}/check-runs`
|
||||
);
|
||||
|
||||
return checkRuns.check_runs.map((run) => ({
|
||||
conclusion: run.conclusion,
|
||||
name: run.name,
|
||||
detailsUrl: run.html_url,
|
||||
output: run.output
|
||||
? {
|
||||
title: run.output.title || '',
|
||||
summary: run.output.summary || ''
|
||||
}
|
||||
: undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch PR check status:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getPRComments(owner: string, repo: string, prNumber: number): Promise<PRComment[]> {
|
||||
try {
|
||||
const comments = await this.request<GitHubComment[]>(
|
||||
`GET /repos/${owner}/${repo}/issues/${prNumber}/comments`
|
||||
);
|
||||
return comments.map((comment) => ({
|
||||
id: comment.id,
|
||||
user: {
|
||||
login: comment.user.login,
|
||||
avatar_url: comment.user.avatar_url
|
||||
},
|
||||
body: comment.body,
|
||||
created_at: comment.created_at,
|
||||
html_url: comment.html_url
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch PR comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getPRReviews(owner: string, repo: string, prNumber: number): Promise<PRReview[]> {
|
||||
try {
|
||||
const reviews = await this.request<GitHubReview[]>(
|
||||
`GET /repos/${owner}/${repo}/pulls/${prNumber}/reviews`
|
||||
);
|
||||
return reviews
|
||||
.filter((review) => review.body && review.body.trim())
|
||||
.map((review) => ({
|
||||
id: review.id,
|
||||
user: {
|
||||
login: review.user.login,
|
||||
avatar_url: review.user.avatar_url
|
||||
},
|
||||
body: review.body,
|
||||
state: review.state,
|
||||
submitted_at: review.submitted_at,
|
||||
html_url: review.html_url
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch PR reviews:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getPRConflictFiles(owner: string, repo: string, prNumber: number): Promise<{ hasConflicts: boolean; conflictFiles: string[] }> {
|
||||
try {
|
||||
const pr = await this.request<GitHubPullRequest>(`GET /repos/${owner}/${repo}/pulls/${prNumber}`);
|
||||
|
||||
const hasConflicts = pr.mergeable === false || pr.mergeable_state === 'dirty' || pr.mergeable_state === 'conflicts';
|
||||
|
||||
if (!hasConflicts) {
|
||||
return { hasConflicts: false, conflictFiles: [] };
|
||||
}
|
||||
|
||||
const files = await this.request<any[]>(`GET /repos/${owner}/${repo}/pulls/${prNumber}/files`);
|
||||
const conflictFiles = files
|
||||
.filter(file => file.status === 'modified' || file.status === 'added' || file.status === 'deleted')
|
||||
.map(file => file.filename);
|
||||
|
||||
return {
|
||||
hasConflicts: true,
|
||||
conflictFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch PR conflict files:', error);
|
||||
return { hasConflicts: false, conflictFiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingReviews(): Promise<PendingReview[]> {
|
||||
try {
|
||||
const prs = await this.getUserPullRequests('esengine', 'ecs-editor-plugins', 'all');
|
||||
|
||||
const reviewsWithDetails = await Promise.all(
|
||||
prs.map(async (pr) => {
|
||||
let pluginName = 'Unknown Plugin';
|
||||
let version = '0.0.0';
|
||||
|
||||
const addMatch = pr.title.match(/Add plugin: (.+) v([\d.]+)/);
|
||||
const removeMatch = pr.title.match(/Remove plugin: (.+)/);
|
||||
|
||||
if (addMatch && addMatch[1] && addMatch[2]) {
|
||||
pluginName = addMatch[1];
|
||||
version = addMatch[2];
|
||||
} else if (removeMatch && removeMatch[1]) {
|
||||
pluginName = removeMatch[1];
|
||||
version = '(删除请求)';
|
||||
}
|
||||
|
||||
let checks: CheckStatus[] = [];
|
||||
let comments: PRComment[] = [];
|
||||
let reviews: PRReview[] = [];
|
||||
let hasConflicts = false;
|
||||
let conflictFiles: string[] = [];
|
||||
|
||||
if (pr.state === 'open') {
|
||||
const results = await Promise.all([
|
||||
this.getPRCheckStatus('esengine', 'ecs-editor-plugins', pr.number),
|
||||
this.getPRComments('esengine', 'ecs-editor-plugins', pr.number),
|
||||
this.getPRReviews('esengine', 'ecs-editor-plugins', pr.number),
|
||||
this.getPRConflictFiles('esengine', 'ecs-editor-plugins', pr.number)
|
||||
]);
|
||||
|
||||
checks = results[0];
|
||||
comments = results[1];
|
||||
reviews = results[2];
|
||||
hasConflicts = results[3].hasConflicts;
|
||||
conflictFiles = results[3].conflictFiles;
|
||||
}
|
||||
|
||||
const status: 'open' | 'merged' | 'closed' = pr.merged_at
|
||||
? 'merged'
|
||||
: (pr.state as 'open' | 'closed');
|
||||
|
||||
return {
|
||||
prNumber: pr.number,
|
||||
pluginName,
|
||||
version,
|
||||
status,
|
||||
createdAt: pr.created_at,
|
||||
prUrl: pr.html_url,
|
||||
checks,
|
||||
comments,
|
||||
reviews,
|
||||
hasConflicts,
|
||||
conflictFiles,
|
||||
headBranch: pr.head.ref,
|
||||
headRepo: pr.head.repo?.full_name
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return reviewsWithDetails;
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to fetch pending reviews:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUser(): Promise<GitHubUser> {
|
||||
return await this.request<GitHubUser>('GET /user');
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, body?: Record<string, unknown>): Promise<T> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const [method, path] = endpoint.split(' ');
|
||||
const url = `${this.API_BASE}${path}`;
|
||||
|
||||
const options: RequestInit = {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`GitHub API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private loadToken(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
console.log('[GitHubService] Loading stored token...');
|
||||
this.accessToken = stored;
|
||||
this.notifyUserLoadStateChange(true);
|
||||
this.fetchUser()
|
||||
.then((user) => {
|
||||
console.log('[GitHubService] User loaded from stored token:', user.login);
|
||||
this.user = user;
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.notifyUserLoadStateChange(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[GitHubService] Failed to fetch user with stored token:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
|
||||
console.log('[GitHubService] Token is invalid or expired, removing it');
|
||||
this.accessToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
this.notifyUserLoadStateChange(false);
|
||||
} else {
|
||||
console.log('[GitHubService] Temporary error fetching user, will retry in 5 seconds');
|
||||
this.scheduleRetryLoadUser();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[GitHubService] No stored token found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to load token:', error);
|
||||
this.notifyUserLoadStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetryLoadUser(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
}
|
||||
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
console.log('[GitHubService] Retrying to load user...');
|
||||
if (this.accessToken && !this.user) {
|
||||
this.fetchUser()
|
||||
.then((user) => {
|
||||
console.log('[GitHubService] User loaded successfully on retry:', user.login);
|
||||
this.user = user;
|
||||
this.retryTimer = null;
|
||||
this.notifyUserLoadStateChange(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[GitHubService] Retry failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
|
||||
console.log('[GitHubService] Token is invalid, removing it');
|
||||
this.accessToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
this.notifyUserLoadStateChange(false);
|
||||
} else {
|
||||
console.log('[GitHubService] Will retry again in 10 seconds');
|
||||
this.retryTimer = window.setTimeout(() => this.scheduleRetryLoadUser(), 10000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private saveToken(token: string): void {
|
||||
localStorage.setItem(this.STORAGE_KEY, token);
|
||||
}
|
||||
}
|
||||
32
packages/editor-app/src/services/NotificationService.ts
Normal file
32
packages/editor-app/src/services/NotificationService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import type { INotification, NotificationType } from '@esengine/editor-core';
|
||||
|
||||
@singleton()
|
||||
export class NotificationService implements INotification {
|
||||
private showCallback?: (message: string, type?: NotificationType, duration?: number) => void;
|
||||
private hideCallback?: (id: string) => void;
|
||||
|
||||
setCallbacks(
|
||||
showCallback: (message: string, type?: NotificationType, duration?: number) => void,
|
||||
hideCallback: (id: string) => void
|
||||
): void {
|
||||
this.showCallback = showCallback;
|
||||
this.hideCallback = hideCallback;
|
||||
}
|
||||
|
||||
show(message: string, type: NotificationType = 'info', duration: number = 3000): void {
|
||||
if (this.showCallback) {
|
||||
this.showCallback(message, type, duration);
|
||||
} else {
|
||||
console.warn('[NotificationService] showCallback not set, message:', message);
|
||||
}
|
||||
}
|
||||
|
||||
hide(id: string): void {
|
||||
if (this.hideCallback) {
|
||||
this.hideCallback(id);
|
||||
} else {
|
||||
console.warn('[NotificationService] hideCallback not set');
|
||||
}
|
||||
}
|
||||
}
|
||||
68
packages/editor-app/src/services/PluginBuildService.ts
Normal file
68
packages/editor-app/src/services/PluginBuildService.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export interface BuildProgress {
|
||||
step: 'install' | 'build' | 'package' | 'complete';
|
||||
message: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface RustBuildProgress {
|
||||
step: string;
|
||||
output: string | null;
|
||||
}
|
||||
|
||||
export class PluginBuildService {
|
||||
private onProgress?: (progress: BuildProgress) => void;
|
||||
|
||||
setProgressCallback(callback: (progress: BuildProgress) => void) {
|
||||
this.onProgress = callback;
|
||||
}
|
||||
|
||||
async buildPlugin(pluginFolder: string): Promise<string> {
|
||||
const unlisten = await listen<RustBuildProgress>('plugin-build-progress', (event) => {
|
||||
const { step } = event.payload;
|
||||
|
||||
let message = '';
|
||||
let progressStep: BuildProgress['step'] = 'install';
|
||||
|
||||
switch (step) {
|
||||
case 'install':
|
||||
message = '正在安装依赖...';
|
||||
progressStep = 'install';
|
||||
break;
|
||||
case 'build':
|
||||
message = '正在构建项目...';
|
||||
progressStep = 'build';
|
||||
break;
|
||||
case 'package':
|
||||
message = '正在打包 ZIP...';
|
||||
progressStep = 'package';
|
||||
break;
|
||||
case 'complete':
|
||||
message = '构建完成!';
|
||||
progressStep = 'complete';
|
||||
break;
|
||||
}
|
||||
|
||||
this.onProgress?.({
|
||||
step: progressStep,
|
||||
message
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const zipPath = await invoke<string>('build_plugin', {
|
||||
pluginFolder
|
||||
});
|
||||
|
||||
console.log('[PluginBuildService] Build completed, zip path:', zipPath);
|
||||
return zipPath;
|
||||
} catch (error) {
|
||||
console.error('[PluginBuildService] Build failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ interface PluginPackageJson {
|
||||
|
||||
export class PluginLoader {
|
||||
private loadedPluginNames: Set<string> = new Set();
|
||||
private moduleVersions: Map<string, number> = new Map();
|
||||
private loadedModuleUrls: Set<string> = new Set();
|
||||
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
const pluginsPath = `${projectPath}/plugins`;
|
||||
@@ -94,11 +96,16 @@ export class PluginLoader {
|
||||
// 移除开头的 ./
|
||||
entryPoint = entryPoint.replace(/^\.\//, '');
|
||||
|
||||
// 添加时间戳参数强制重新加载模块(避免缓存)
|
||||
// 使用版本号+时间戳确保每次加载都是唯一URL
|
||||
const currentVersion = (this.moduleVersions.get(packageJson.name) || 0) + 1;
|
||||
this.moduleVersions.set(packageJson.name, currentVersion);
|
||||
const timestamp = Date.now();
|
||||
const moduleUrl = `/@user-project/plugins/${pluginDirName}/${entryPoint}?t=${timestamp}`;
|
||||
const moduleUrl = `/@user-project/plugins/${pluginDirName}/${entryPoint}?v=${currentVersion}&t=${timestamp}`;
|
||||
|
||||
console.log(`[PluginLoader] Loading plugin from: ${moduleUrl}`);
|
||||
console.log(`[PluginLoader] Loading plugin from: ${moduleUrl} (version: ${currentVersion})`);
|
||||
|
||||
// 清除可能存在的旧模块缓存
|
||||
this.loadedModuleUrls.add(moduleUrl);
|
||||
|
||||
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||
console.log('[PluginLoader] Module loaded successfully');
|
||||
@@ -206,13 +213,54 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
async unloadProjectPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
console.log('[PluginLoader] Unloading all project plugins...');
|
||||
|
||||
for (const pluginName of this.loadedPluginNames) {
|
||||
try {
|
||||
console.log(`[PluginLoader] Uninstalling plugin: ${pluginName}`);
|
||||
await pluginManager.uninstallEditor(pluginName);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to unload plugin ${pluginName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除Vite模块缓存(如果HMR可用)
|
||||
this.invalidateModuleCache();
|
||||
|
||||
this.loadedPluginNames.clear();
|
||||
this.loadedModuleUrls.clear();
|
||||
|
||||
console.log('[PluginLoader] All project plugins unloaded');
|
||||
}
|
||||
|
||||
private invalidateModuleCache(): void {
|
||||
try {
|
||||
// 尝试使用Vite HMR API无效化模块
|
||||
if (import.meta.hot) {
|
||||
console.log('[PluginLoader] Attempting to invalidate module cache via HMR');
|
||||
import.meta.hot.invalidate();
|
||||
}
|
||||
|
||||
// 清除已加载的模块URL记录
|
||||
for (const url of this.loadedModuleUrls) {
|
||||
console.log(`[PluginLoader] Marking module for reload: ${url}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[PluginLoader] Failed to invalidate module cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getLoadedPluginNames(): string[] {
|
||||
return Array.from(this.loadedPluginNames);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
hot?: {
|
||||
invalidate(): void;
|
||||
accept(callback?: () => void): void;
|
||||
dispose(callback: () => void): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
348
packages/editor-app/src/services/PluginMarketService.ts
Normal file
348
packages/editor-app/src/services/PluginMarketService.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { EditorPluginManager, IEditorPlugin } from '@esengine/editor-core';
|
||||
import JSZip from 'jszip';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export interface PluginAuthor {
|
||||
name: string;
|
||||
github: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface PluginRepository {
|
||||
type?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginVersion {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
changes: string;
|
||||
zipUrl: string;
|
||||
requirements: PluginRequirements;
|
||||
}
|
||||
|
||||
export interface PluginRequirements {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
author: PluginAuthor;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
repository: PluginRepository;
|
||||
license: string;
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
latestVersion: string;
|
||||
versions: PluginVersion[];
|
||||
verified?: boolean;
|
||||
category_type?: 'official' | 'community';
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
cdn: string;
|
||||
plugins: PluginMarketMetadata[];
|
||||
}
|
||||
|
||||
interface InstalledPluginInfo {
|
||||
id: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
export class PluginMarketService {
|
||||
private readonly REGISTRY_URLS = [
|
||||
'https://cdn.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json',
|
||||
'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json',
|
||||
'https://fastly.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json'
|
||||
];
|
||||
|
||||
private readonly GITHUB_DIRECT_URL = 'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json';
|
||||
|
||||
private readonly STORAGE_KEY = 'ecs-editor-installed-marketplace-plugins';
|
||||
private readonly USE_DIRECT_SOURCE_KEY = 'ecs-editor-use-direct-source';
|
||||
|
||||
private pluginManager: EditorPluginManager;
|
||||
private installedPlugins: Map<string, InstalledPluginInfo> = new Map();
|
||||
private projectPath: string | null = null;
|
||||
|
||||
constructor(pluginManager: EditorPluginManager) {
|
||||
this.pluginManager = pluginManager;
|
||||
this.loadInstalledPlugins();
|
||||
}
|
||||
|
||||
setProjectPath(path: string | null): void {
|
||||
this.projectPath = path;
|
||||
console.log(`[PluginMarketService] Project path set to: ${path}`);
|
||||
}
|
||||
|
||||
isUsingDirectSource(): boolean {
|
||||
return localStorage.getItem(this.USE_DIRECT_SOURCE_KEY) === 'true';
|
||||
}
|
||||
|
||||
setUseDirectSource(useDirect: boolean): void {
|
||||
localStorage.setItem(this.USE_DIRECT_SOURCE_KEY, String(useDirect));
|
||||
console.log(`[PluginMarketService] Direct source ${useDirect ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
async fetchPluginList(bypassCache: boolean = false): Promise<PluginMarketMetadata[]> {
|
||||
const useDirectSource = this.isUsingDirectSource();
|
||||
|
||||
if (useDirectSource) {
|
||||
console.log('[PluginMarketService] Using direct GitHub source (bypass CDN)');
|
||||
return await this.fetchFromUrl(this.GITHUB_DIRECT_URL, bypassCache);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < this.REGISTRY_URLS.length; i++) {
|
||||
try {
|
||||
const url = this.REGISTRY_URLS[i];
|
||||
if (!url) continue;
|
||||
|
||||
const plugins = await this.fetchFromUrl(url, bypassCache, i + 1, this.REGISTRY_URLS.length);
|
||||
return plugins;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[PluginMarketService] Failed to fetch from URL ${i + 1}: ${errorMessage}`);
|
||||
errors.push(`URL ${i + 1}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalError = `无法从任何数据源加载插件列表。尝试的错误:\n${errors.join('\n')}`;
|
||||
console.error('[PluginMarketService] All URLs failed:', finalError);
|
||||
throw new Error(finalError);
|
||||
}
|
||||
|
||||
private async fetchFromUrl(
|
||||
baseUrl: string,
|
||||
bypassCache: boolean,
|
||||
urlIndex?: number,
|
||||
totalUrls?: number
|
||||
): Promise<PluginMarketMetadata[]> {
|
||||
let url = baseUrl;
|
||||
if (bypassCache) {
|
||||
url += `?t=${Date.now()}`;
|
||||
if (urlIndex && totalUrls) {
|
||||
console.log(`[PluginMarketService] Bypassing cache with timestamp (URL ${urlIndex}/${totalUrls})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (urlIndex && totalUrls) {
|
||||
console.log(`[PluginMarketService] Trying URL ${urlIndex}/${totalUrls}: ${url}`);
|
||||
} else {
|
||||
console.log(`[PluginMarketService] Fetching from: ${url}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const registry: PluginRegistry = await response.json();
|
||||
|
||||
if (urlIndex) {
|
||||
console.log(`[PluginMarketService] Successfully loaded from URL ${urlIndex}`);
|
||||
} else {
|
||||
console.log(`[PluginMarketService] Successfully loaded`);
|
||||
}
|
||||
console.log(`[PluginMarketService] Loaded ${registry.plugins.length} plugins from registry`);
|
||||
console.log(`[PluginMarketService] Registry generated at: ${registry.generatedAt}`);
|
||||
|
||||
return registry.plugins;
|
||||
}
|
||||
|
||||
async installPlugin(plugin: PluginMarketMetadata, version?: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
const targetVersion = version || plugin.latestVersion;
|
||||
console.log(`[PluginMarketService] Installing plugin: ${plugin.name} v${targetVersion}`);
|
||||
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened. Please open a project first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取指定版本信息
|
||||
const versionInfo = plugin.versions.find(v => v.version === targetVersion);
|
||||
if (!versionInfo) {
|
||||
throw new Error(`Version ${targetVersion} not found for plugin ${plugin.name}`);
|
||||
}
|
||||
|
||||
// 下载 ZIP 文件
|
||||
console.log(`[PluginMarketService] Downloading ZIP: ${versionInfo.zipUrl}`);
|
||||
const zipBlob = await this.downloadZip(versionInfo.zipUrl);
|
||||
|
||||
// 解压到项目 plugins 目录
|
||||
console.log(`[PluginMarketService] Extracting plugin to project...`);
|
||||
await this.extractZipToProject(zipBlob, plugin.id);
|
||||
|
||||
// 标记为已安装
|
||||
this.markAsInstalled(plugin, targetVersion);
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
console.log(`[PluginMarketService] Reloading project plugins...`);
|
||||
await onReload();
|
||||
}
|
||||
|
||||
console.log(`[PluginMarketService] Successfully installed: ${plugin.name} v${targetVersion}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to install plugin ${plugin.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallPlugin(pluginId: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
console.log(`[PluginMarketService] Uninstalling plugin: ${pluginId}`);
|
||||
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened');
|
||||
}
|
||||
|
||||
try {
|
||||
// 从编辑器卸载
|
||||
await this.pluginManager.uninstallEditor(pluginId);
|
||||
|
||||
// 调用 Tauri 后端命令删除插件目录
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('uninstall_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId
|
||||
});
|
||||
|
||||
console.log(`[PluginMarketService] Successfully removed plugin directory`);
|
||||
|
||||
// 从已安装列表移除
|
||||
this.installedPlugins.delete(pluginId);
|
||||
this.saveInstalledPlugins();
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
console.log(`[PluginMarketService] Reloading project plugins...`);
|
||||
await onReload();
|
||||
}
|
||||
|
||||
console.log(`[PluginMarketService] Successfully uninstalled: ${pluginId}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to uninstall plugin ${pluginId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
isInstalled(pluginId: string): boolean {
|
||||
return this.installedPlugins.has(pluginId);
|
||||
}
|
||||
|
||||
getInstalledVersion(pluginId: string): string | undefined {
|
||||
return this.installedPlugins.get(pluginId)?.version;
|
||||
}
|
||||
|
||||
hasUpdate(plugin: PluginMarketMetadata): boolean {
|
||||
const installedVersion = this.getInstalledVersion(plugin.id);
|
||||
if (!installedVersion) return false;
|
||||
|
||||
return this.compareVersions(plugin.latestVersion, installedVersion) > 0;
|
||||
}
|
||||
|
||||
private async downloadZip(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ZIP: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
private async extractZipToProject(zipBlob: Blob, pluginId: string): Promise<void> {
|
||||
if (!this.projectPath) {
|
||||
throw new Error('Project path not set');
|
||||
}
|
||||
|
||||
try {
|
||||
// 将 Blob 转换为 ArrayBuffer
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// 转换为 base64
|
||||
let binary = '';
|
||||
const len = uint8Array.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(uint8Array[i] ?? 0);
|
||||
}
|
||||
const base64Data = btoa(binary);
|
||||
|
||||
// 调用 Tauri 后端命令进行安装
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const pluginDir = await invoke<string>('install_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId,
|
||||
zipDataBase64: base64Data
|
||||
});
|
||||
|
||||
console.log(`[PluginMarketService] Successfully extracted plugin to ${pluginDir}`);
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to extract ZIP:', error);
|
||||
throw new Error(`Failed to extract plugin: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private markAsInstalled(plugin: PluginMarketMetadata, version: string): void {
|
||||
this.installedPlugins.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
version: version,
|
||||
installedAt: new Date().toISOString()
|
||||
});
|
||||
this.saveInstalledPlugins();
|
||||
}
|
||||
|
||||
private loadInstalledPlugins(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
const plugins: InstalledPluginInfo[] = JSON.parse(stored);
|
||||
this.installedPlugins = new Map(plugins.map(p => [p.id, p]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to load installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveInstalledPlugins(): void {
|
||||
try {
|
||||
const plugins = Array.from(this.installedPlugins.values());
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(plugins));
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to save installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
630
packages/editor-app/src/services/PluginPublishService.ts
Normal file
630
packages/editor-app/src/services/PluginPublishService.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import { GitHubService } from './GitHubService';
|
||||
import type { IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
|
||||
export interface PluginPublishInfo {
|
||||
pluginMetadata: IEditorPluginMetadata;
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
repositoryUrl: string;
|
||||
category: 'official' | 'community';
|
||||
tags?: string[];
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
requirements?: {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PublishStep =
|
||||
| 'checking-fork'
|
||||
| 'creating-fork'
|
||||
| 'checking-branch'
|
||||
| 'creating-branch'
|
||||
| 'creating-manifest'
|
||||
| 'uploading-files'
|
||||
| 'creating-pr'
|
||||
| 'complete';
|
||||
|
||||
export interface PublishProgress {
|
||||
step: PublishStep;
|
||||
message: string;
|
||||
progress: number; // 0-100
|
||||
}
|
||||
|
||||
export class PluginPublishService {
|
||||
private readonly REGISTRY_OWNER = 'esengine';
|
||||
private readonly REGISTRY_REPO = 'ecs-editor-plugins';
|
||||
|
||||
private githubService: GitHubService;
|
||||
private progressCallback?: (progress: PublishProgress) => void;
|
||||
|
||||
constructor(githubService: GitHubService) {
|
||||
this.githubService = githubService;
|
||||
}
|
||||
|
||||
setProgressCallback(callback: (progress: PublishProgress) => void): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
private notifyProgress(step: PublishStep, message: string, progress: number): void {
|
||||
console.log(`[PluginPublishService] ${message} (${progress}%)`);
|
||||
this.progressCallback?.({ step, message, progress });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布插件到市场
|
||||
* @param publishInfo 插件发布信息
|
||||
* @param zipPath 插件 ZIP 文件路径(必需)
|
||||
* @returns Pull Request URL
|
||||
*/
|
||||
async publishPlugin(publishInfo: PluginPublishInfo, zipPath: string): Promise<string> {
|
||||
console.log('[PluginPublishService] Publishing plugin with ZIP:', zipPath);
|
||||
console.log('[PluginPublishService] Plugin info:', publishInfo);
|
||||
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
try {
|
||||
const { branchName, existingPR } = await this.preparePublishEnvironment(
|
||||
publishInfo.pluginMetadata.name,
|
||||
publishInfo.version
|
||||
);
|
||||
|
||||
const user = this.githubService.getUser()!;
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
|
||||
// 上传 ZIP 文件
|
||||
await this.uploadZipFile(user.login, branchName, pluginId, publishInfo, zipPath);
|
||||
|
||||
// 生成并上传 manifest
|
||||
this.notifyProgress('creating-manifest', 'Generating manifest.json...', 60);
|
||||
const manifest = this.generateManifest(publishInfo, user.login);
|
||||
const manifestPath = `plugins/${publishInfo.category}/${pluginId}/manifest.json`;
|
||||
|
||||
await this.uploadManifest(user.login, branchName, manifestPath, manifest, publishInfo);
|
||||
|
||||
// 创建或更新 PR
|
||||
return await this.createOrUpdatePR(existingPR, branchName, publishInfo, user.login);
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to publish plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async preparePublishEnvironment(
|
||||
pluginName: string,
|
||||
version: string
|
||||
): Promise<{ branchName: string; existingPR: { number: number; html_url: string } | null }> {
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 10);
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 15);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 12);
|
||||
await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 15);
|
||||
}
|
||||
|
||||
const branchName = `add-plugin-${pluginName}-v${version}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 20);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
headBranch
|
||||
);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress(
|
||||
'checking-branch',
|
||||
`Branch and PR already exist, will update existing PR #${existingPR.number}`,
|
||||
30
|
||||
);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 30);
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 25);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}'...`, 27);
|
||||
try {
|
||||
await this.githubService.createBranch(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
branchName,
|
||||
'main'
|
||||
);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 30);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { branchName, existingPR };
|
||||
}
|
||||
|
||||
private generatePluginId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
private async uploadZipFile(
|
||||
owner: string,
|
||||
branch: string,
|
||||
pluginId: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
zipPath: string
|
||||
): Promise<void> {
|
||||
const { TauriAPI } = await import('../api/tauri');
|
||||
const base64Zip = await TauriAPI.readFileAsBase64(zipPath);
|
||||
|
||||
this.notifyProgress('uploading-files', 'Uploading plugin ZIP file...', 30);
|
||||
|
||||
const zipFilePath = `plugins/${publishInfo.category}/${pluginId}/versions/${publishInfo.version}.zip`;
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateBinaryFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
zipFilePath,
|
||||
base64Zip,
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version} ZIP`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'ZIP file uploaded successfully', 55);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload ZIP: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExistingManifest(
|
||||
pluginId: string,
|
||||
category: 'official' | 'community'
|
||||
): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const manifestPath = `plugins/${category}/${pluginId}/manifest.json`;
|
||||
const content = await this.githubService.getFileContent(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
'main'
|
||||
);
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.log(`[PluginPublishService] No existing manifest found, will create new one`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadManifest(
|
||||
owner: string,
|
||||
branch: string,
|
||||
manifestPath: string,
|
||||
manifest: Record<string, unknown>,
|
||||
publishInfo: PluginPublishInfo
|
||||
): Promise<void> {
|
||||
this.notifyProgress('uploading-files', `Checking for existing manifest...`, 65);
|
||||
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
const existingManifest = await this.getExistingManifest(pluginId, publishInfo.category);
|
||||
|
||||
let finalManifest = manifest;
|
||||
|
||||
if (existingManifest) {
|
||||
this.notifyProgress('uploading-files', `Merging with existing manifest...`, 68);
|
||||
finalManifest = this.mergeManifestVersions(existingManifest, manifest, publishInfo.version);
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', `Uploading manifest to ${manifestPath}...`, 70);
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
JSON.stringify(finalManifest, null, 2),
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'Manifest uploaded successfully', 80);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload manifest: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeManifestVersions(
|
||||
existingManifest: Record<string, any>,
|
||||
newManifest: Record<string, any>,
|
||||
newVersion: string
|
||||
): Record<string, any> {
|
||||
const existingVersions: any[] = Array.isArray(existingManifest.versions)
|
||||
? existingManifest.versions
|
||||
: [];
|
||||
|
||||
const newVersionInfo = (newManifest.versions as any[])[0];
|
||||
|
||||
const versionExists = existingVersions.some((v: any) => v.version === newVersion);
|
||||
|
||||
let updatedVersions: any[];
|
||||
if (versionExists) {
|
||||
updatedVersions = existingVersions.map((v: any) =>
|
||||
v.version === newVersion ? newVersionInfo : v
|
||||
);
|
||||
} else {
|
||||
updatedVersions = [...existingVersions, newVersionInfo];
|
||||
}
|
||||
|
||||
updatedVersions.sort((a: any, b: any) => {
|
||||
const [aMajor, aMinor, aPatch] = a.version.split('.').map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.version.split('.').map(Number);
|
||||
|
||||
if (aMajor !== bMajor) return bMajor - aMajor;
|
||||
if (aMinor !== bMinor) return bMinor - aMinor;
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
|
||||
const mergedManifest: any = {
|
||||
...existingManifest,
|
||||
...newManifest,
|
||||
latestVersion: updatedVersions[0].version,
|
||||
versions: updatedVersions
|
||||
};
|
||||
|
||||
delete mergedManifest.version;
|
||||
delete mergedManifest.distribution;
|
||||
|
||||
return mergedManifest as Record<string, any>;
|
||||
}
|
||||
|
||||
private async createOrUpdatePR(
|
||||
existingPR: { number: number; html_url: string } | null,
|
||||
branchName: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
userLogin: string
|
||||
): Promise<string> {
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Add plugin: ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`;
|
||||
const prBody = this.generatePRDescription(publishInfo);
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${userLogin}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
}
|
||||
|
||||
private generateManifest(publishInfo: PluginPublishInfo, githubUsername: string): Record<string, unknown> {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category, tags, homepage, screenshots, requirements } =
|
||||
publishInfo;
|
||||
|
||||
const repoMatch = repositoryUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!repoMatch || !repoMatch[1] || !repoMatch[2]) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
|
||||
const owner = repoMatch[1];
|
||||
const repo = repoMatch[2];
|
||||
const repoName = repo.replace(/\.git$/, '');
|
||||
|
||||
const pluginId = this.generatePluginId(pluginMetadata.name);
|
||||
|
||||
const zipUrl = `https://cdn.jsdelivr.net/gh/${this.REGISTRY_OWNER}/${this.REGISTRY_REPO}@gh-pages/plugins/${category}/${pluginId}/versions/${version}.zip`;
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
'editor': 'Window',
|
||||
'tool': 'Tool',
|
||||
'inspector': 'Inspector',
|
||||
'system': 'System',
|
||||
'import-export': 'ImportExport'
|
||||
};
|
||||
|
||||
const validCategory = categoryMap[pluginMetadata.category?.toLowerCase() || ''] || 'Tool';
|
||||
|
||||
const versionInfo = {
|
||||
version: version,
|
||||
releaseDate: new Date().toISOString(),
|
||||
changes: releaseNotes || 'No release notes provided',
|
||||
zipUrl: zipUrl,
|
||||
requirements: requirements || {
|
||||
'ecs-version': '>=1.0.0',
|
||||
'editor-version': '>=1.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginMetadata.displayName,
|
||||
latestVersion: version,
|
||||
versions: [versionInfo],
|
||||
author: {
|
||||
name: githubUsername,
|
||||
github: githubUsername
|
||||
},
|
||||
description: pluginMetadata.description || 'No description provided',
|
||||
category: validCategory,
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: repositoryUrl
|
||||
},
|
||||
license: 'MIT',
|
||||
tags: tags || [],
|
||||
icon: pluginMetadata.icon || 'Package',
|
||||
homepage: homepage || repositoryUrl,
|
||||
screenshots: screenshots || []
|
||||
};
|
||||
}
|
||||
|
||||
private generatePRDescription(publishInfo: PluginPublishInfo): string {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category } = publishInfo;
|
||||
|
||||
return `## Plugin Submission
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginMetadata.displayName}
|
||||
- **ID**: ${pluginMetadata.name}
|
||||
- **Version**: ${version}
|
||||
- **Category**: ${category}
|
||||
- **Repository**: ${repositoryUrl}
|
||||
|
||||
### Description
|
||||
|
||||
${pluginMetadata.description || 'No description provided'}
|
||||
|
||||
### Release Notes
|
||||
|
||||
${releaseNotes}
|
||||
|
||||
### Checklist
|
||||
|
||||
- [x] Plugin is built and tested
|
||||
- [x] Repository is publicly accessible
|
||||
- [x] Manifest.json is correctly formatted
|
||||
- [ ] Code has been reviewed for security concerns
|
||||
- [ ] Plugin follows ECS Editor plugin guidelines
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Publisher**
|
||||
`;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async deletePlugin(pluginId: string, pluginName: string, category: 'official' | 'community', reason: string, forceRecreate: boolean = false): Promise<string> {
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 5);
|
||||
|
||||
try {
|
||||
let forkedRepo: string;
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
forkedRepo = `${user.login}/${this.REGISTRY_REPO}`;
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 10);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 7);
|
||||
forkedRepo = await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 10);
|
||||
}
|
||||
|
||||
const branchName = `remove-plugin-${pluginId}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 15);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
if (forceRecreate) {
|
||||
this.notifyProgress('checking-branch', 'Deleting old branch to recreate...', 16);
|
||||
await this.githubService.deleteBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = false;
|
||||
this.notifyProgress('checking-branch', 'Old branch deleted', 17);
|
||||
} else {
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(this.REGISTRY_OWNER, this.REGISTRY_REPO, headBranch);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress('checking-branch', `Branch and PR already exist, will update existing PR #${existingPR.number}`, 20);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 20);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 18);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}' from main repository...`, 19);
|
||||
|
||||
try {
|
||||
const mainRef = await this.githubService.getRef(this.REGISTRY_OWNER, this.REGISTRY_REPO, 'heads/main');
|
||||
await this.githubService.createBranchFromSha(user.login, this.REGISTRY_REPO, branchName, mainRef.object.sha);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 20);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', 'Collecting plugin files...', 25);
|
||||
|
||||
const pluginPath = `plugins/${category}/${pluginId}`;
|
||||
|
||||
const contents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
pluginPath,
|
||||
'main'
|
||||
);
|
||||
|
||||
if (contents.length === 0) {
|
||||
throw new Error(`Plugin directory not found: ${pluginPath}`);
|
||||
}
|
||||
|
||||
const filesToDelete: Array<{ path: string; sha: string }> = [];
|
||||
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
filesToDelete.push({ path: item.path, sha: item.sha });
|
||||
} else if (item.type === 'dir') {
|
||||
const subContents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
item.path,
|
||||
'main'
|
||||
);
|
||||
for (const subItem of subContents) {
|
||||
if (subItem.type === 'file') {
|
||||
filesToDelete.push({ path: subItem.path, sha: subItem.sha });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
throw new Error(`No files found to delete in ${pluginPath}`);
|
||||
}
|
||||
|
||||
console.log(`[PluginPublishService] Files to delete:`, filesToDelete.map(f => f.path));
|
||||
this.notifyProgress('uploading-files', `Deleting ${filesToDelete.length} files...`, 40);
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
console.log(`[PluginPublishService] Deleting file: ${file.path} (SHA: ${file.sha}) from ${user.login}/${this.REGISTRY_REPO}:${branchName}`);
|
||||
await this.githubService.deleteFileWithSha(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
file.path,
|
||||
file.sha,
|
||||
`Remove ${pluginName}`,
|
||||
branchName
|
||||
);
|
||||
deletedCount++;
|
||||
console.log(`[PluginPublishService] Successfully deleted: ${file.path}`);
|
||||
const progress = 40 + Math.floor((deletedCount / filesToDelete.length) * 40);
|
||||
this.notifyProgress('uploading-files', `Deleted ${deletedCount}/${filesToDelete.length} files`, progress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[PluginPublishService] Failed to delete ${file.path}:`, errorMsg);
|
||||
errors.push(`${file.path}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to delete ${errors.length} file(s):\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('No files were deleted');
|
||||
}
|
||||
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Remove plugin: ${pluginName}`;
|
||||
const prBody = `## Plugin Removal Request
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginName}
|
||||
- **ID**: ${pluginId}
|
||||
- **Category**: ${category}
|
||||
|
||||
### Reason for Removal
|
||||
|
||||
${reason}
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Manager**
|
||||
`;
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${user.login}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to delete plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
153
packages/editor-app/src/services/PluginSourceParser.ts
Normal file
153
packages/editor-app/src/services/PluginSourceParser.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import JSZip from 'jszip';
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs';
|
||||
|
||||
/**
|
||||
* 插件 package.json 结构
|
||||
*/
|
||||
export interface PluginPackageJson {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string | { name: string };
|
||||
repository?: string | { url: string };
|
||||
license?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后的插件信息
|
||||
*/
|
||||
export interface ParsedPluginInfo {
|
||||
/** package.json 内容 */
|
||||
packageJson: PluginPackageJson;
|
||||
/** 插件源类型 */
|
||||
sourceType: 'folder' | 'zip';
|
||||
/** 插件源路径(文件夹路径或 zip 文件路径) */
|
||||
sourcePath: string;
|
||||
/** 如果是 zip,这里存储 zip 的路径;如果是文件夹,需要构建后才有 */
|
||||
zipPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件源解析服务
|
||||
*
|
||||
* 统一处理插件来源的解析,支持:
|
||||
* 1. 文件夹(包含 package.json 的插件项目)
|
||||
* 2. ZIP 文件(已构建的插件包)
|
||||
*/
|
||||
export class PluginSourceParser {
|
||||
/**
|
||||
* 从文件夹解析插件信息
|
||||
* @param folderPath 插件文件夹路径
|
||||
* @returns 解析后的插件信息
|
||||
*/
|
||||
async parseFromFolder(folderPath: string): Promise<ParsedPluginInfo> {
|
||||
try {
|
||||
// 读取 package.json
|
||||
const packageJsonPath = `${folderPath}/package.json`;
|
||||
const packageJsonContent = await readTextFile(packageJsonPath);
|
||||
const packageJson = JSON.parse(packageJsonContent) as PluginPackageJson;
|
||||
|
||||
console.log('[PluginSourceParser] Parsed package.json from folder:', packageJson);
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
sourceType: 'folder',
|
||||
sourcePath: folderPath
|
||||
// zipPath 留空,需要构建后才有
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PluginSourceParser] Failed to parse folder:', error);
|
||||
throw new Error(
|
||||
`Failed to read package.json from folder: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 文件解析插件信息
|
||||
* @param zipPath ZIP 文件路径
|
||||
* @returns 解析后的插件信息
|
||||
*/
|
||||
async parseFromZip(zipPath: string): Promise<ParsedPluginInfo> {
|
||||
try {
|
||||
// 读取 ZIP 文件
|
||||
const { readFile } = await import('@tauri-apps/plugin-fs');
|
||||
const zipData = await readFile(zipPath);
|
||||
|
||||
// 解压 ZIP
|
||||
const zip = await JSZip.loadAsync(zipData);
|
||||
|
||||
// 查找 package.json
|
||||
const packageJsonFile = zip.file('package.json');
|
||||
if (!packageJsonFile) {
|
||||
throw new Error('package.json not found in ZIP file');
|
||||
}
|
||||
|
||||
// 读取 package.json 内容
|
||||
const packageJsonContent = await packageJsonFile.async('text');
|
||||
const packageJson = JSON.parse(packageJsonContent) as PluginPackageJson;
|
||||
|
||||
console.log('[PluginSourceParser] Parsed package.json from ZIP:', packageJson);
|
||||
|
||||
// 验证 ZIP 中必须包含 dist 目录
|
||||
const distFiles = Object.keys(zip.files).filter(f => f.startsWith('dist/'));
|
||||
if (distFiles.length === 0) {
|
||||
throw new Error('dist/ directory not found in ZIP file. Please ensure the plugin is properly built.');
|
||||
}
|
||||
|
||||
// 检查是否有入口文件
|
||||
const hasIndexEsm = zip.file('dist/index.esm.js');
|
||||
const hasIndexJs = zip.file('dist/index.js');
|
||||
if (!hasIndexEsm && !hasIndexJs) {
|
||||
throw new Error('No entry file found in dist/. Expected dist/index.esm.js or dist/index.js');
|
||||
}
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
sourceType: 'zip',
|
||||
sourcePath: zipPath,
|
||||
zipPath // ZIP 文件已经可用
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PluginSourceParser] Failed to parse ZIP:', error);
|
||||
throw new Error(
|
||||
`Failed to parse ZIP file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 package.json 的必要字段
|
||||
* @param packageJson package.json 对象
|
||||
* @throws 如果缺少必要字段
|
||||
*/
|
||||
validatePackageJson(packageJson: PluginPackageJson): void {
|
||||
if (!packageJson.name) {
|
||||
throw new Error('package.json must contain "name" field');
|
||||
}
|
||||
if (!packageJson.version) {
|
||||
throw new Error('package.json must contain "version" field');
|
||||
}
|
||||
|
||||
// 验证版本号格式
|
||||
const versionRegex = /^\d+\.\d+\.\d+$/;
|
||||
if (!versionRegex.test(packageJson.version)) {
|
||||
throw new Error(
|
||||
`Invalid version format: "${packageJson.version}". Expected format: X.Y.Z (e.g., 1.0.0)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成插件 ID
|
||||
* 从插件名称生成标准化的 ID(小写,仅包含字母、数字和连字符)
|
||||
* @param name 插件名称
|
||||
* @returns 插件 ID
|
||||
*/
|
||||
generatePluginId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
}
|
||||
86
packages/editor-app/src/services/TauriDialogService.ts
Normal file
86
packages/editor-app/src/services/TauriDialogService.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import type { IDialog, OpenDialogOptions, SaveDialogOptions } from '@esengine/editor-core';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface IDialogExtended extends IDialog {
|
||||
setConfirmCallback(callback: (data: ConfirmDialogData) => void): void;
|
||||
setLocale(locale: string): void;
|
||||
}
|
||||
|
||||
@singleton()
|
||||
export class TauriDialogService implements IDialogExtended {
|
||||
private showConfirmCallback?: (data: ConfirmDialogData) => void;
|
||||
private locale: string = 'zh';
|
||||
|
||||
setConfirmCallback(callback: (data: ConfirmDialogData) => void): void {
|
||||
this.showConfirmCallback = callback;
|
||||
}
|
||||
|
||||
setLocale(locale: string): void {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
async openDialog(options: OpenDialogOptions): Promise<string | string[] | null> {
|
||||
const result = await open({
|
||||
multiple: options.multiple || false,
|
||||
directory: options.directory || false,
|
||||
filters: options.filters,
|
||||
defaultPath: options.defaultPath,
|
||||
title: options.title
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async saveDialog(options: SaveDialogOptions): Promise<string | null> {
|
||||
const result = await save({
|
||||
filters: options.filters,
|
||||
defaultPath: options.defaultPath,
|
||||
title: options.title
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async showMessage(title: string, messageText: string, type: 'info' | 'warning' | 'error' = 'info'): Promise<void> {
|
||||
console.warn('[TauriDialogService] showMessage not implemented with custom UI, use notification instead');
|
||||
}
|
||||
|
||||
async showConfirm(title: string, messageText: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.showConfirmCallback) {
|
||||
let resolved = false;
|
||||
const handleResolve = (result: boolean) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmText = this.locale === 'zh' ? '确定' : 'Confirm';
|
||||
const cancelText = this.locale === 'zh' ? '取消' : 'Cancel';
|
||||
|
||||
this.showConfirmCallback({
|
||||
title,
|
||||
message: messageText,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm: () => handleResolve(true),
|
||||
onCancel: () => handleResolve(false)
|
||||
});
|
||||
} else {
|
||||
console.warn('[TauriDialogService] showConfirmCallback not set');
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
52
packages/editor-app/src/services/TauriFileSystemService.ts
Normal file
52
packages/editor-app/src/services/TauriFileSystemService.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { IFileSystem, FileEntry } from '@esengine/editor-core';
|
||||
|
||||
@singleton()
|
||||
export class TauriFileSystemService implements IFileSystem {
|
||||
async readFile(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
await invoke('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
async writeBinary(path: string, data: Uint8Array): Promise<void> {
|
||||
await invoke('write_binary_file', { filePath: path, content: Array.from(data) });
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke('read_file_content', { path });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createDirectory(path: string): Promise<void> {
|
||||
await invoke('create_directory', { path });
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||
const entries = await invoke<Array<{ name: string; isDir: boolean }>>('list_directory', { path });
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDir,
|
||||
path: `${path}/${entry.name}`
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
await invoke('delete_file', { path });
|
||||
}
|
||||
|
||||
async deleteDirectory(path: string): Promise<void> {
|
||||
await invoke('delete_directory', { path });
|
||||
}
|
||||
|
||||
async scanFiles(basePath: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_files', { basePath, pattern });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user