diff --git a/.gitignore b/.gitignore index cbf0615..8f55e23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ .cache/ +项目功能文档.md diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/dist-electron/main.js b/dist-electron/main.js index ecf72dc..353e8df 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -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; diff --git a/dist-electron/preload.js b/dist-electron/preload.js index 31ee969..4f8b7c3 100644 --- a/dist-electron/preload.js +++ b/dist-electron/preload.js @@ -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"); } }, // ============================================ diff --git a/electron/ipc.ts b/electron/ipc.ts index fdacb77..1333d2f 100644 --- a/electron/ipc.ts +++ b/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}` + }) + }) + }) + }) } diff --git a/electron/preload.ts b/electron/preload.ts index 871cacb..37ec25b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 read: (filename?: string) => Promise + openDirectory: () => Promise + } + // Git 操作 + git: { + clone: (repoUrl: string, targetDir: string) => Promise + 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') } }, diff --git a/src/App.tsx b/src/App.tsx index d9a28d5..5ecb566 100644 --- a/src/App.tsx +++ b/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 }) => (
@@ -20,32 +22,35 @@ const PlaceholderPage = ({ title }: { title: string }) => ( function App() { return ( - - - } /> - } /> - } /> + + + + } /> + } /> + } /> - {/* Main Application Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Main Application Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Legacy/Redirects */} - } /> - - + {/* Legacy/Redirects */} + } /> + + + ) } + export default App diff --git a/src/api/client.ts b/src/api/client.ts index 657a2de..ba945b2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,16 +2,16 @@ import axios from 'axios'; // 配置 // 在开发环境中使用代理路径,在生产环境(Electron)中使用完整 URL -export const API_BASE_URL = import.meta.env.DEV +export const API_BASE_URL = import.meta.env.DEV ? '/api/v1' // 开发环境:使用 Vite 代理 : 'http://172.16.1.12/api/v1'; // 生产环境:直接访问(Electron 不受 CORS 限制) // 如果需要,可以将其更改为真实令牌,或实现登录系统 -export const API_TOKEN = ''; +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}` } : {}), diff --git a/src/api/gitea.ts b/src/api/gitea.ts index 4189afa..9bd658a 100644 --- a/src/api/gitea.ts +++ b/src/api/gitea.ts @@ -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 => { + 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,一次请求获取所有文件和目录 diff --git a/src/components/DownloadManager.tsx b/src/components/DownloadManager.tsx new file mode 100644 index 0000000..b718bf2 --- /dev/null +++ b/src/components/DownloadManager.tsx @@ -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 + activeDownloads: DownloadItem[] +} + +const DownloadContext = createContext(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([]) + const { toast } = useToast() + + // 用于在回调中访问最新状态 + const activeDownloadsRef = useRef([]) + + 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 ( + + {children} +
+ {activeDownloads.map(item => ( + { + setActiveDownloads(prev => prev.filter(i => i.id !== item.id)) + }} /> + ))} +
+
+ ) +} diff --git a/src/components/DownloadProgress.tsx b/src/components/DownloadProgress.tsx new file mode 100644 index 0000000..1fc2bd3 --- /dev/null +++ b/src/components/DownloadProgress.tsx @@ -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(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 ( +
+ {/* Header */} +
+ {/* Icon Status */} +
+ {item.status === 'cloning' && } + {item.status === 'success' && } + {item.status === 'error' && } +
+ + {/* Info */} +
+
+

+ {repoName} +

+ {item.progress}% +
+ + + +
+ {item.phase} + +
+
+ + {/* Close Button */} + {(item.status === 'success' || item.status === 'error') && ( + + )} +
+ + {/* Expanded Details (Logs) */} +
+
+ + Git Output +
+
+ {item.logs.length === 0 && No output yet...} + {item.logs.map((log, idx) => ( +
+ {log} +
+ ))} +
+
+
+
+ ) +} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..dcb0223 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + value?: number | null + indicatorClassName?: string + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( +
+
+
+)) +Progress.displayName = "Progress" + +export { Progress } diff --git a/src/pages/projects/ProjectLibrary.tsx b/src/pages/projects/ProjectLibrary.tsx index ec36e3b..caccb08 100644 --- a/src/pages/projects/ProjectLibrary.tsx +++ b/src/pages/projects/ProjectLibrary.tsx @@ -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( '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() { 新建项目 - diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 7c2cd06..99ca0ab 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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 read: (filename?: string) => Promise + openDirectory: () => Promise + } + git: { + clone: (repoUrl: string, targetDir: string) => Promise + onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => void + removeListener: () => void } settings: { get: (key?: string) => Promise @@ -34,4 +41,4 @@ declare global { } } -export {} +export { }