更新下载git
This commit is contained in:
parent
fc41d96a1f
commit
90a7fbd0b0
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
.cache/
|
||||
项目功能文档.md
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -1,9 +1,26 @@
|
|||
"use strict";
|
||||
const electron = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const fsSync = require("fs");
|
||||
const Store = require("electron-store");
|
||||
const crypto = require("crypto");
|
||||
function _interopNamespaceDefault(e) {
|
||||
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
||||
if (e) {
|
||||
for (const k in e) {
|
||||
if (k !== "default") {
|
||||
const d = Object.getOwnPropertyDescriptor(e, k);
|
||||
Object.defineProperty(n, k, d.get ? d : {
|
||||
enumerable: true,
|
||||
get: () => e[k]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
n.default = e;
|
||||
return Object.freeze(n);
|
||||
}
|
||||
const fsSync__namespace = /* @__PURE__ */ _interopNamespaceDefault(fsSync);
|
||||
let store = null;
|
||||
function initStore() {
|
||||
if (store) {
|
||||
|
|
@ -113,7 +130,7 @@ class CacheManager {
|
|||
const exeDir = path.dirname(exePath);
|
||||
this.cacheDir = path.join(exeDir, "cache");
|
||||
}
|
||||
await fs.promises.mkdir(this.cacheDir, { recursive: true });
|
||||
await fsSync.promises.mkdir(this.cacheDir, { recursive: true });
|
||||
await this.loadMetadata();
|
||||
this.initialized = true;
|
||||
console.log("CacheManager 初始化完成");
|
||||
|
|
@ -161,9 +178,9 @@ class CacheManager {
|
|||
async loadMetadata() {
|
||||
try {
|
||||
const metadataPath = this.getMetadataPath();
|
||||
const exists = await fs.promises.access(metadataPath).then(() => true).catch(() => false);
|
||||
const exists = await fsSync.promises.access(metadataPath).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
const content = await fs.promises.readFile(metadataPath, "utf-8");
|
||||
const content = await fsSync.promises.readFile(metadataPath, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
this.metadataMap = new Map(Object.entries(data));
|
||||
console.log(`加载了 ${this.metadataMap.size} 条缓存元数据`);
|
||||
|
|
@ -180,7 +197,7 @@ class CacheManager {
|
|||
try {
|
||||
const metadataPath = this.getMetadataPath();
|
||||
const data = Object.fromEntries(this.metadataMap);
|
||||
await fs.promises.writeFile(metadataPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
await fsSync.promises.writeFile(metadataPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
} catch (error) {
|
||||
console.error("保存缓存元数据失败:", error);
|
||||
}
|
||||
|
|
@ -204,11 +221,11 @@ class CacheManager {
|
|||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
if (namespace) {
|
||||
const namespaceDir = path.join(this.cacheDir, namespace);
|
||||
await fs.promises.mkdir(namespaceDir, { recursive: true });
|
||||
await fsSync.promises.mkdir(namespaceDir, { recursive: true });
|
||||
}
|
||||
const jsonString = JSON.stringify(data, null, 2);
|
||||
await fs.promises.writeFile(filePath, jsonString, "utf-8");
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
await fsSync.promises.writeFile(filePath, jsonString, "utf-8");
|
||||
const stats = await fsSync.promises.stat(filePath);
|
||||
const metadata = {
|
||||
key,
|
||||
type: "json",
|
||||
|
|
@ -243,7 +260,7 @@ class CacheManager {
|
|||
}
|
||||
const namespace = config == null ? void 0 : config.namespace;
|
||||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
const content = await fsSync.promises.readFile(filePath, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
console.log(`读取缓存: ${key}`);
|
||||
return data;
|
||||
|
|
@ -263,10 +280,10 @@ class CacheManager {
|
|||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
if (namespace) {
|
||||
const namespaceDir = path.join(this.cacheDir, namespace);
|
||||
await fs.promises.mkdir(namespaceDir, { recursive: true });
|
||||
await fsSync.promises.mkdir(namespaceDir, { recursive: true });
|
||||
}
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
await fsSync.promises.writeFile(filePath, buffer);
|
||||
const stats = await fsSync.promises.stat(filePath);
|
||||
const metadata = {
|
||||
key,
|
||||
type,
|
||||
|
|
@ -301,7 +318,7 @@ class CacheManager {
|
|||
}
|
||||
const namespace = config == null ? void 0 : config.namespace;
|
||||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
const buffer = await fsSync.promises.readFile(filePath);
|
||||
console.log(`读取二进制缓存: ${key}`);
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
|
|
@ -323,7 +340,7 @@ class CacheManager {
|
|||
}
|
||||
const namespace = config == null ? void 0 : config.namespace;
|
||||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
const exists = await fs.promises.access(filePath).then(() => true).catch(() => false);
|
||||
const exists = await fsSync.promises.access(filePath).then(() => true).catch(() => false);
|
||||
return exists;
|
||||
} catch (error) {
|
||||
console.error(`检查缓存失败 [${key}]:`, error);
|
||||
|
|
@ -337,7 +354,7 @@ class CacheManager {
|
|||
this.ensureInitialized();
|
||||
try {
|
||||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
await fs.promises.unlink(filePath).catch(() => {
|
||||
await fsSync.promises.unlink(filePath).catch(() => {
|
||||
});
|
||||
this.metadataMap.delete(key);
|
||||
await this.saveMetadata();
|
||||
|
|
@ -353,7 +370,7 @@ class CacheManager {
|
|||
this.ensureInitialized();
|
||||
try {
|
||||
const namespaceDir = path.join(this.cacheDir, namespace);
|
||||
await fs.promises.rm(namespaceDir, { recursive: true, force: true });
|
||||
await fsSync.promises.rm(namespaceDir, { recursive: true, force: true });
|
||||
const keysToDelete = [];
|
||||
this.metadataMap.forEach((metadata, key) => {
|
||||
const filePath = this.getCacheFilePath(key, namespace);
|
||||
|
|
@ -374,14 +391,14 @@ class CacheManager {
|
|||
async clearAll() {
|
||||
this.ensureInitialized();
|
||||
try {
|
||||
const files = await fs.promises.readdir(this.cacheDir, { withFileTypes: true });
|
||||
const files = await fsSync.promises.readdir(this.cacheDir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
const filePath = path.join(this.cacheDir, file.name);
|
||||
if (file.name === "_metadata.json") continue;
|
||||
if (file.isDirectory()) {
|
||||
await fs.promises.rm(filePath, { recursive: true, force: true });
|
||||
await fsSync.promises.rm(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.promises.unlink(filePath);
|
||||
await fsSync.promises.unlink(filePath);
|
||||
}
|
||||
}
|
||||
this.metadataMap.clear();
|
||||
|
|
@ -435,7 +452,10 @@ const IPC_CHANNELS = {
|
|||
// 窗口操作
|
||||
WINDOW_MINIMIZE: "window:minimize",
|
||||
WINDOW_MAXIMIZE: "window:maximize",
|
||||
WINDOW_CLOSE: "window:close"
|
||||
WINDOW_CLOSE: "window:close",
|
||||
// Git/System 操作
|
||||
DIALOG_OPEN_DIRECTORY: "dialog:openDirectory",
|
||||
GIT_CLONE: "git:clone"
|
||||
};
|
||||
function setupIpcHandlers() {
|
||||
electron.ipcMain.handle(
|
||||
|
|
@ -444,7 +464,7 @@ function setupIpcHandlers() {
|
|||
try {
|
||||
const desktopPath = electron.app.getPath("desktop");
|
||||
const filePath = path.join(desktopPath, filename);
|
||||
await fs.promises.writeFile(filePath, content, "utf-8");
|
||||
await fsSync.promises.writeFile(filePath, content, "utf-8");
|
||||
return {
|
||||
success: true,
|
||||
message: `文件已保存到: ${filePath}`,
|
||||
|
|
@ -468,7 +488,7 @@ function setupIpcHandlers() {
|
|||
const desktopPath = electron.app.getPath("desktop");
|
||||
const filePath = path.join(desktopPath, filename);
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
await fsSync.promises.access(filePath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -476,7 +496,7 @@ function setupIpcHandlers() {
|
|||
content: null
|
||||
};
|
||||
}
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
const content = await fsSync.promises.readFile(filePath, "utf-8");
|
||||
return {
|
||||
success: true,
|
||||
message: "文件读取成功",
|
||||
|
|
@ -750,6 +770,91 @@ function setupIpcHandlers() {
|
|||
return { success: false, message: errorMessage };
|
||||
}
|
||||
});
|
||||
electron.ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY, async (event) => {
|
||||
try {
|
||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
if (!window) return { success: false, message: "无法找到窗口" };
|
||||
const result = await electron.dialog.showOpenDialog(window, {
|
||||
properties: ["openDirectory", "createDirectory"]
|
||||
});
|
||||
if (result.canceled) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
canceled: false
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
console.error("打开目录失败:", errorMessage);
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
});
|
||||
electron.ipcMain.handle(IPC_CHANNELS.GIT_CLONE, async (event, repoUrl, targetDir) => {
|
||||
return new Promise((resolve) => {
|
||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
try {
|
||||
if (!fsSync__namespace.existsSync(targetDir)) {
|
||||
fsSync__namespace.mkdirSync(targetDir, { recursive: true });
|
||||
} else {
|
||||
const files = fsSync__namespace.readdirSync(targetDir);
|
||||
if (files.length > 0) {
|
||||
return resolve({
|
||||
success: false,
|
||||
message: `目标文件夹不为空。请选择一个空文件夹。`
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return resolve({ success: false, message: `检查目录失败: ${err}` });
|
||||
}
|
||||
console.log(`开始 Git Clone (spawn): ${repoUrl} -> ${targetDir}`);
|
||||
const { spawn } = require("child_process");
|
||||
const child = spawn("git", ["clone", "--progress", repoUrl, "."], { cwd: targetDir });
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stdoutData += text;
|
||||
console.log("Git Stdout:", text);
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stderrData += text;
|
||||
if (window) {
|
||||
window.webContents.send("git:progress", {
|
||||
repoUrl,
|
||||
raw: text
|
||||
});
|
||||
}
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
console.log(`Git clone process exited with code ${code}`);
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: "项目克隆成功",
|
||||
stdout: stdoutData
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
message: `Git clone 失败 (Code ${code})`,
|
||||
stdout: stderrData
|
||||
// git 错误通常在 stderr
|
||||
});
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error("Git spawn error:", err);
|
||||
resolve({
|
||||
success: false,
|
||||
message: `启动 Git 进程失败: ${err.message}`
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
let mainWindow = null;
|
||||
|
|
@ -802,7 +907,7 @@ function createWindow() {
|
|||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
const fs2 = require("fs");
|
||||
const fs = require("fs");
|
||||
const possiblePaths = [
|
||||
path.join(electron.app.getAppPath(), "dist-renderer", "index.html"),
|
||||
// asar 内部(主要路径)
|
||||
|
|
@ -813,14 +918,14 @@ function createWindow() {
|
|||
];
|
||||
console.log("尝试加载页面,检查以下路径:");
|
||||
possiblePaths.forEach((p) => {
|
||||
const exists = fs2.existsSync(p);
|
||||
const exists = fs.existsSync(p);
|
||||
console.log(` ${exists ? "✓" : "✗"} ${p}`);
|
||||
});
|
||||
console.log("process.resourcesPath:", process.resourcesPath);
|
||||
console.log("app.getAppPath():", electron.app.getAppPath());
|
||||
let indexPath = null;
|
||||
for (const testPath of possiblePaths) {
|
||||
if (fs2.existsSync(testPath)) {
|
||||
if (fs.existsSync(testPath)) {
|
||||
indexPath = testPath;
|
||||
console.log("找到页面文件:", indexPath);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ const IPC_CHANNELS = {
|
|||
// 窗口操作
|
||||
WINDOW_MINIMIZE: "window:minimize",
|
||||
WINDOW_MAXIMIZE: "window:maximize",
|
||||
WINDOW_CLOSE: "window:close"
|
||||
WINDOW_CLOSE: "window:close",
|
||||
// Git/System 操作
|
||||
DIALOG_OPEN_DIRECTORY: "dialog:openDirectory",
|
||||
GIT_CLONE: "git:clone"
|
||||
};
|
||||
const electronAPI = {
|
||||
// ============================================
|
||||
|
|
@ -39,6 +42,40 @@ const electronAPI = {
|
|||
*/
|
||||
read: (filename) => {
|
||||
return electron.ipcRenderer.invoke(IPC_CHANNELS.READ_FILE, filename);
|
||||
},
|
||||
/**
|
||||
* 打开目录选择对话框
|
||||
*/
|
||||
openDirectory: () => {
|
||||
return electron.ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY);
|
||||
}
|
||||
},
|
||||
// ============================================
|
||||
// Git 操作 API
|
||||
// ============================================
|
||||
git: {
|
||||
/**
|
||||
* 克隆 Git 仓库
|
||||
* @param repoUrl - 仓库地址
|
||||
* @param targetDir - 目标目录
|
||||
*/
|
||||
clone: (repoUrl, targetDir) => {
|
||||
return electron.ipcRenderer.invoke(IPC_CHANNELS.GIT_CLONE, repoUrl, targetDir);
|
||||
},
|
||||
/**
|
||||
* 监听 Git 进度
|
||||
*/
|
||||
onProgress: (callback) => {
|
||||
electron.ipcRenderer.removeAllListeners("git:progress");
|
||||
electron.ipcRenderer.on("git:progress", (_event, data) => {
|
||||
callback(data);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 移除监听器
|
||||
*/
|
||||
removeListener: () => {
|
||||
electron.ipcRenderer.removeAllListeners("git:progress");
|
||||
}
|
||||
},
|
||||
// ============================================
|
||||
|
|
|
|||
123
electron/ipc.ts
123
electron/ipc.ts
|
|
@ -2,8 +2,11 @@
|
|||
* IPC 通信处理器
|
||||
* 安全地暴露主进程功能给渲染进程
|
||||
*/
|
||||
import { ipcMain, app, BrowserWindow } from 'electron'
|
||||
import { ipcMain, app, BrowserWindow, dialog } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as fsSync from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import util from 'util'
|
||||
import path from 'path'
|
||||
import { getSettings, saveSettings } from './store'
|
||||
import { cacheManager } from './cache-manager'
|
||||
|
|
@ -29,7 +32,10 @@ export const IPC_CHANNELS = {
|
|||
// 窗口操作
|
||||
WINDOW_MINIMIZE: 'window:minimize',
|
||||
WINDOW_MAXIMIZE: 'window:maximize',
|
||||
WINDOW_CLOSE: 'window:close'
|
||||
WINDOW_CLOSE: 'window:close',
|
||||
// Git/System 操作
|
||||
DIALOG_OPEN_DIRECTORY: 'dialog:openDirectory',
|
||||
GIT_CLONE: 'git:clone'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
|
@ -447,4 +453,117 @@ export function setupIpcHandlers() {
|
|||
return { success: false, message: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Git/System 操作相关 IPC
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 打开目录选择对话框
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY, async (event) => {
|
||||
try {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) return { success: false, message: '无法找到窗口' }
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
canceled: false
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
console.error('打开目录失败:', errorMessage)
|
||||
return { success: false, message: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 执行 Git Clone (使用 spawn 以支持进度流)
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.GIT_CLONE, async (event, repoUrl: string, targetDir: string) => {
|
||||
return new Promise((resolve) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
// 检查目录是否存在
|
||||
try {
|
||||
if (!fsSync.existsSync(targetDir)) { // 使用同步检查避免 async/await 复杂性
|
||||
fsSync.mkdirSync(targetDir, { recursive: true })
|
||||
} else {
|
||||
// 如果目录存在,必须为空
|
||||
const files = fsSync.readdirSync(targetDir)
|
||||
if (files.length > 0) {
|
||||
return resolve({
|
||||
success: false,
|
||||
message: `目标文件夹不为空。请选择一个空文件夹。`
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return resolve({ success: false, message: `检查目录失败: ${err}` })
|
||||
}
|
||||
|
||||
console.log(`开始 Git Clone (spawn): ${repoUrl} -> ${targetDir}`)
|
||||
|
||||
// 这里的 spawn 命令不需要 util.promisify
|
||||
const { spawn } = require('child_process')
|
||||
const child = spawn('git', ['clone', '--progress', repoUrl, '.'], { cwd: targetDir })
|
||||
|
||||
let stdoutData = ''
|
||||
let stderrData = ''
|
||||
|
||||
child.stdout.on('data', (data: any) => {
|
||||
const text = data.toString()
|
||||
stdoutData += text
|
||||
// git 的标准输出通常是静默的,除非有错误或特定配置
|
||||
console.log('Git Stdout:', text)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data: any) => {
|
||||
const text = data.toString()
|
||||
stderrData += text
|
||||
|
||||
// 发送进度事件到前端
|
||||
if (window) {
|
||||
window.webContents.send('git:progress', {
|
||||
repoUrl,
|
||||
raw: text
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
child.on('close', (code: number) => {
|
||||
console.log(`Git clone process exited with code ${code}`)
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: '项目克隆成功',
|
||||
stdout: stdoutData
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
message: `Git clone 失败 (Code ${code})`,
|
||||
stdout: stderrData // git 错误通常在 stderr
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (err: any) => {
|
||||
console.error('Git spawn error:', err)
|
||||
resolve({
|
||||
success: false,
|
||||
message: `启动 Git 进程失败: ${err.message}`
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ const IPC_CHANNELS = {
|
|||
// 窗口操作
|
||||
WINDOW_MINIMIZE: 'window:minimize',
|
||||
WINDOW_MAXIMIZE: 'window:maximize',
|
||||
WINDOW_CLOSE: 'window:close'
|
||||
WINDOW_CLOSE: 'window:close',
|
||||
// Git/System 操作
|
||||
DIALOG_OPEN_DIRECTORY: 'dialog:openDirectory',
|
||||
GIT_CLONE: 'git:clone'
|
||||
} as const
|
||||
|
||||
// API 响应类型定义
|
||||
|
|
@ -39,6 +42,13 @@ interface FileOperationResult {
|
|||
message: string
|
||||
path?: string | null
|
||||
content?: string | null
|
||||
canceled?: boolean
|
||||
}
|
||||
|
||||
interface GitOperationResult {
|
||||
success: boolean
|
||||
message: string
|
||||
stdout?: string
|
||||
}
|
||||
|
||||
interface SettingsOperationResult {
|
||||
|
|
@ -66,6 +76,13 @@ interface ElectronAPI {
|
|||
file: {
|
||||
save: (content: string, filename?: string) => Promise<FileOperationResult>
|
||||
read: (filename?: string) => Promise<FileOperationResult>
|
||||
openDirectory: () => Promise<FileOperationResult>
|
||||
}
|
||||
// Git 操作
|
||||
git: {
|
||||
clone: (repoUrl: string, targetDir: string) => Promise<GitOperationResult>
|
||||
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => void
|
||||
removeListener: () => void
|
||||
}
|
||||
// 设置操作
|
||||
settings: {
|
||||
|
|
@ -113,6 +130,44 @@ const electronAPI: ElectronAPI = {
|
|||
*/
|
||||
read: (filename?: string) => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.READ_FILE, filename)
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开目录选择对话框
|
||||
*/
|
||||
openDirectory: () => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY)
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// Git 操作 API
|
||||
// ============================================
|
||||
git: {
|
||||
/**
|
||||
* 克隆 Git 仓库
|
||||
* @param repoUrl - 仓库地址
|
||||
* @param targetDir - 目标目录
|
||||
*/
|
||||
clone: (repoUrl: string, targetDir: string) => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.GIT_CLONE, repoUrl, targetDir)
|
||||
},
|
||||
/**
|
||||
* 监听 Git 进度
|
||||
*/
|
||||
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => {
|
||||
// 移除旧的监听器以防止重复 (简化处理,实际应该支持多监听器或由调用者管理)
|
||||
ipcRenderer.removeAllListeners('git:progress')
|
||||
|
||||
ipcRenderer.on('git:progress', (_event, data) => {
|
||||
callback(data)
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 移除监听器
|
||||
*/
|
||||
removeListener: () => {
|
||||
ipcRenderer.removeAllListeners('git:progress')
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
49
src/App.tsx
49
src/App.tsx
|
|
@ -7,6 +7,8 @@ import ProjectLibrary from '@/pages/projects/ProjectLibrary'
|
|||
import NewProject from '@/pages/projects/NewProject'
|
||||
import { ToastProvider } from '@/components/ui/toast-provider'
|
||||
|
||||
import { DownloadManagerProvider } from '@/components/DownloadManager'
|
||||
|
||||
// 其他页面的占位符,以避免演示期间出现 404
|
||||
const PlaceholderPage = ({ title }: { title: string }) => (
|
||||
<div className="flex items-center justify-center h-full text-slate-400">
|
||||
|
|
@ -20,32 +22,35 @@ const PlaceholderPage = ({ title }: { title: string }) => (
|
|||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/upload" element={<UploadResource />} />
|
||||
<Route path="/app/projects/new" element={<NewProject />} />
|
||||
<DownloadManagerProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/upload" element={<UploadResource />} />
|
||||
<Route path="/app/projects/new" element={<NewProject />} />
|
||||
|
||||
{/* Main Application Routes */}
|
||||
<Route path="/app" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to="/app/plugins" replace />} />
|
||||
<Route path="plugins" element={<AssetLibrary />} />
|
||||
<Route path="components" element={<PlaceholderPage title="组件库" />} />
|
||||
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
|
||||
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
|
||||
<Route path="design" element={<PlaceholderPage title="设计库" />} />
|
||||
<Route path="projects" element={<ProjectLibrary />} />
|
||||
<Route path="files" element={<PlaceholderPage title="文件库" />} />
|
||||
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
|
||||
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
|
||||
</Route>
|
||||
{/* Main Application Routes */}
|
||||
<Route path="/app" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to="/app/plugins" replace />} />
|
||||
<Route path="plugins" element={<AssetLibrary />} />
|
||||
<Route path="components" element={<PlaceholderPage title="组件库" />} />
|
||||
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
|
||||
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
|
||||
<Route path="design" element={<PlaceholderPage title="设计库" />} />
|
||||
<Route path="projects" element={<ProjectLibrary />} />
|
||||
<Route path="files" element={<PlaceholderPage title="文件库" />} />
|
||||
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
|
||||
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy/Redirects */}
|
||||
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
{/* Legacy/Redirects */}
|
||||
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</DownloadManagerProvider>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const API_TOKEN = '';
|
|||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(API_TOKEN ? { Authorization: `token ${API_TOKEN}` } : {}),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,22 @@ export const giteaApi = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Repository Archive (Download)
|
||||
* @param format - archive format (zip, tar.gz, etc)
|
||||
*/
|
||||
getRepoArchive: async (owner: string, repo: string, ref: string = 'HEAD', format: string = 'zip'): Promise<Blob> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/repos/${owner}/${repo}/archive/${ref}.${format}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to download archive for ${owner}/${repo}`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 递归获取仓库的完整目录树
|
||||
* 使用 Git Tree API,一次请求获取所有文件和目录
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||
import { DownloadProgress } from './DownloadProgress'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
interface DownloadItem {
|
||||
id: string
|
||||
repoUrl: string
|
||||
targetDir: string
|
||||
status: 'pending' | 'cloning' | 'success' | 'error'
|
||||
progress: number
|
||||
phase: string
|
||||
logs: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface DownloadContextType {
|
||||
startDownload: (repoUrl: string, targetDir: string) => Promise<void>
|
||||
activeDownloads: DownloadItem[]
|
||||
}
|
||||
|
||||
const DownloadContext = createContext<DownloadContextType | undefined>(undefined)
|
||||
|
||||
export const useDownloadManager = () => {
|
||||
const context = useContext(DownloadContext)
|
||||
if (!context) {
|
||||
throw new Error('useDownloadManager must be used within a DownloadManagerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function DownloadManagerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeDownloads, setActiveDownloads] = useState<DownloadItem[]>([])
|
||||
const { toast } = useToast()
|
||||
|
||||
// 用于在回调中访问最新状态
|
||||
const activeDownloadsRef = useRef<DownloadItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
activeDownloadsRef.current = activeDownloads
|
||||
}, [activeDownloads])
|
||||
|
||||
useEffect(() => {
|
||||
// 监听 Git 进度
|
||||
window.electronAPI.git.onProgress((data) => {
|
||||
const { repoUrl, raw } = data
|
||||
|
||||
// 解析进度信息
|
||||
// Git progress output examples:
|
||||
// "Receiving objects: 12% (3/25)"
|
||||
// "Resolving deltas: 100% (5/5), done."
|
||||
|
||||
let phase = ''
|
||||
let progress = 0
|
||||
|
||||
if (raw.includes('Receiving objects')) {
|
||||
phase = '正在下载对象'
|
||||
const match = raw.match(/(\d+)%/)
|
||||
if (match) progress = parseInt(match[1])
|
||||
} else if (raw.includes('Resolving deltas')) {
|
||||
phase = '正在处理增量'
|
||||
const match = raw.match(/(\d+)%/)
|
||||
if (match) progress = 50 + Math.floor(parseInt(match[1]) * 0.5) // 处理增量算后半程
|
||||
} else if (raw.includes('Cloning into')) {
|
||||
phase = '开始克隆...'
|
||||
}
|
||||
|
||||
setActiveDownloads(prev => prev.map(item => {
|
||||
if (item.repoUrl === repoUrl && item.status === 'cloning') {
|
||||
const newLogs = [...item.logs, raw]
|
||||
// 保持日志长度不过大
|
||||
if (newLogs.length > 200) newLogs.shift()
|
||||
|
||||
return {
|
||||
...item,
|
||||
phase: phase || item.phase,
|
||||
progress: progress > item.progress ? progress : item.progress,
|
||||
logs: newLogs
|
||||
}
|
||||
}
|
||||
return item
|
||||
}))
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.electronAPI.git.removeListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startDownload = async (repoUrl: string, targetDir: string) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
const newItem: DownloadItem = {
|
||||
id,
|
||||
repoUrl,
|
||||
targetDir,
|
||||
status: 'cloning',
|
||||
progress: 0,
|
||||
phase: '准备中...',
|
||||
logs: []
|
||||
}
|
||||
|
||||
setActiveDownloads(prev => [...prev, newItem])
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.git.clone(repoUrl, targetDir)
|
||||
|
||||
if (result.success) {
|
||||
setActiveDownloads(prev => prev.map(item =>
|
||||
item.id === id ? { ...item, status: 'success', progress: 100, phase: '完成' } : item
|
||||
))
|
||||
|
||||
// 3秒后移除成功的下载
|
||||
setTimeout(() => {
|
||||
setActiveDownloads(prev => prev.filter(item => item.id !== id))
|
||||
toast(`下载完成: 项目已成功下载到 ${targetDir}`, { type: 'success' })
|
||||
}, 3000)
|
||||
} else {
|
||||
setActiveDownloads(prev => prev.map(item =>
|
||||
item.id === id ? {
|
||||
...item,
|
||||
status: 'error',
|
||||
error: result.message,
|
||||
phase: '失败',
|
||||
// 将 Git 错误详情添加到日志中
|
||||
logs: [...item.logs, `\n[错误信息]: ${result.message}\n${result.stdout ? `[详情]:\n${result.stdout}` : ''}`]
|
||||
} : item
|
||||
))
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setActiveDownloads(prev => prev.map(item =>
|
||||
item.id === id ? {
|
||||
...item,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
phase: '出错',
|
||||
logs: [...item.logs, `\n[系统错误]: ${msg}`]
|
||||
} : item
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={{ startDownload, activeDownloads }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-4 w-[380px]">
|
||||
{activeDownloads.map(item => (
|
||||
<DownloadProgress key={item.id} item={item} onClose={() => {
|
||||
setActiveDownloads(prev => prev.filter(i => i.id !== item.id))
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</DownloadContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { X, ChevronDown, ChevronUp, Terminal, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { Progress } from './ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DownloadItem {
|
||||
id: string
|
||||
repoUrl: string
|
||||
targetDir: string
|
||||
status: 'pending' | 'cloning' | 'success' | 'error'
|
||||
progress: number
|
||||
phase: string
|
||||
logs: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface DownloadProgressProps {
|
||||
item: DownloadItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DownloadProgress({ item, onClose }: DownloadProgressProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const logsEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll logs
|
||||
useEffect(() => {
|
||||
if (expanded && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [item.logs, expanded])
|
||||
|
||||
const repoName = item.repoUrl.split('/').pop()?.replace('.git', '') || 'Unknown Repo'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-white rounded-lg shadow-xl border border-slate-200 overflow-hidden transition-all duration-300",
|
||||
expanded ? "h-[320px]" : "h-[80px]"
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="h-[80px] p-4 flex items-center gap-4">
|
||||
{/* Icon Status */}
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center shrink-0",
|
||||
item.status === 'cloning' && "bg-blue-50 text-blue-500",
|
||||
item.status === 'success' && "bg-green-50 text-green-500",
|
||||
item.status === 'error' && "bg-red-50 text-red-500",
|
||||
)}>
|
||||
{item.status === 'cloning' && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{item.status === 'success' && <CheckCircle2 className="w-5 h-5" />}
|
||||
{item.status === 'error' && <AlertCircle className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-medium text-slate-800 truncate" title={item.targetDir}>
|
||||
{repoName}
|
||||
</h4>
|
||||
<span className="text-xs text-slate-500 font-mono">{item.progress}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={item.progress} className="h-1.5 mb-1.5"
|
||||
indicatorClassName={cn(
|
||||
item.status === 'success' && "bg-green-500",
|
||||
item.status === 'error' && "bg-red-500",
|
||||
item.status === 'cloning' && "bg-blue-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span className="truncate max-w-[180px]">{item.phase}</span>
|
||||
<Button variant="ghost" size="icon" className="h-4 w-4 text-slate-400 hover:text-slate-600"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
{(item.status === 'success' || item.status === 'error') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2 text-slate-400 hover:text-slate-600"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Details (Logs) */}
|
||||
<div className="bg-slate-900 h-[240px] overflow-hidden flex flex-col">
|
||||
<div className="h-8 bg-slate-800 flex items-center px-3 gap-2 border-b border-slate-700">
|
||||
<Terminal className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="text-xs text-slate-300">Git Output</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 font-mono text-xs text-slate-300 space-y-1">
|
||||
{item.logs.length === 0 && <span className="text-slate-600 italic">No output yet...</span>}
|
||||
{item.logs.map((log, idx) => (
|
||||
<div key={idx} className="whitespace-pre-wrap break-all border-l-2 border-transparent pl-1 hover:bg-white/5 hover:border-slate-500">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: number | null
|
||||
indicatorClassName?: string
|
||||
}
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full w-full flex-1 bg-slate-900 transition-all dark:bg-slate-50", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -28,6 +28,7 @@ import { giteaApi } from '@/api/gitea'
|
|||
import { GiteaRepository, GiteaFile, GiteaTreeNode, RepoTreeCache, TreeDiffResult, GiteaCommit } from '@/api/types'
|
||||
import { useCache, cacheUtils } from '@/hooks/useCache'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useDownloadManager } from '@/components/DownloadManager'
|
||||
|
||||
// 格式化字节大小的辅助函数
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
|
|
@ -73,6 +74,7 @@ interface TreeItem {
|
|||
|
||||
export default function ProjectLibrary() {
|
||||
const { success, error: showError } = useToast()
|
||||
const { startDownload } = useDownloadManager()
|
||||
|
||||
// 使用缓存Hook获取仓库列表
|
||||
const fetchRepos = useCallback(() => giteaApi.getRepositories(), [])
|
||||
|
|
@ -81,7 +83,6 @@ export default function ProjectLibrary() {
|
|||
const {
|
||||
data: repos,
|
||||
loading: repoLoading,
|
||||
error: repoError,
|
||||
refresh: refreshRepos
|
||||
} = useCache<GiteaRepository[]>(
|
||||
'project-library-repos', // 缓存键
|
||||
|
|
@ -500,6 +501,28 @@ export default function ProjectLibrary() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理项目下载
|
||||
*/
|
||||
const handleDownloadProject = async () => {
|
||||
if (!selectedRepo) return
|
||||
|
||||
try {
|
||||
// 1. 选择保存目录
|
||||
const result = await window.electronAPI.file.openDirectory()
|
||||
if (result.canceled || !result.path) return
|
||||
|
||||
const targetPath = result.path
|
||||
|
||||
// 2. 委托给 DownloadManager 开始下载(支持排队和进度显示)
|
||||
startDownload(selectedRepo.clone_url, targetPath)
|
||||
|
||||
} catch (error) {
|
||||
console.error('打开下载目录失败:', error)
|
||||
showError('无法启动下载')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTreeSelect = (item: TreeItem) => {
|
||||
setSelectedTreeId(item.id)
|
||||
|
||||
|
|
@ -712,7 +735,9 @@ export default function ProjectLibrary() {
|
|||
<Plus className="w-4 h-4" />
|
||||
新建项目
|
||||
</Button>
|
||||
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]">
|
||||
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]"
|
||||
onClick={handleDownloadProject}
|
||||
>
|
||||
<ArrowDownToLine className="w-4 h-4" />
|
||||
下载项目
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -9,18 +9,25 @@ interface FileOperationResult {
|
|||
message: string
|
||||
path?: string | null
|
||||
content?: string | null
|
||||
canceled?: boolean
|
||||
}
|
||||
|
||||
interface SettingsOperationResult {
|
||||
interface GitOperationResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: unknown
|
||||
message: string
|
||||
stdout?: string
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
file: {
|
||||
save: (content: string, filename?: string) => Promise<FileOperationResult>
|
||||
read: (filename?: string) => Promise<FileOperationResult>
|
||||
openDirectory: () => Promise<FileOperationResult>
|
||||
}
|
||||
git: {
|
||||
clone: (repoUrl: string, targetDir: string) => Promise<GitOperationResult>
|
||||
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => void
|
||||
removeListener: () => void
|
||||
}
|
||||
settings: {
|
||||
get: (key?: string) => Promise<SettingsOperationResult>
|
||||
|
|
@ -34,4 +41,4 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
export { }
|
||||
|
|
|
|||
Loading…
Reference in New Issue