diff --git a/electron/main.cjs b/electron/main.cjs index 7c42cdf5..532eb535 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,6 +1,7 @@ -const { app, BrowserWindow, shell, ipcMain } = require("electron"); +const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron"); const path = require("path"); const fs = require("fs"); +const os = require("os"); let mainWindow = null; @@ -317,8 +318,185 @@ app.on("activate", () => { } }); +// ================== 拖拽功能实现 ================== + +// 临时文件管理 +const tempFiles = new Map(); // 存储临时文件路径映射 + +// 创建临时文件 +ipcMain.handle("create-temp-file", async (event, fileData) => { + try { + const { fileName, content, encoding = 'base64' } = fileData; + + // 创建临时目录 + const tempDir = path.join(os.tmpdir(), 'termix-drag-files'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // 生成临时文件路径 + const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); + const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`); + + // 写入文件内容 + if (encoding === 'base64') { + const buffer = Buffer.from(content, 'base64'); + fs.writeFileSync(tempFilePath, buffer); + } else { + fs.writeFileSync(tempFilePath, content, 'utf8'); + } + + // 记录临时文件 + tempFiles.set(tempId, { + path: tempFilePath, + fileName: fileName, + createdAt: Date.now() + }); + + console.log(`Created temp file: ${tempFilePath}`); + return { success: true, tempId, path: tempFilePath }; + } catch (error) { + console.error("Error creating temp file:", error); + return { success: false, error: error.message }; + } +}); + +// 开始拖拽到桌面 +ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => { + try { + const tempFile = tempFiles.get(tempId); + if (!tempFile) { + throw new Error("Temporary file not found"); + } + + // 使用Electron的startDrag API + const iconPath = path.join(__dirname, "..", "public", "icon.png"); + const iconExists = fs.existsSync(iconPath); + + mainWindow.webContents.startDrag({ + file: tempFile.path, + icon: iconExists ? iconPath : undefined + }); + + console.log(`Started drag for: ${tempFile.path}`); + return { success: true }; + } catch (error) { + console.error("Error starting drag:", error); + return { success: false, error: error.message }; + } +}); + +// 清理临时文件 +ipcMain.handle("cleanup-temp-file", async (event, tempId) => { + try { + const tempFile = tempFiles.get(tempId); + if (tempFile && fs.existsSync(tempFile.path)) { + fs.unlinkSync(tempFile.path); + tempFiles.delete(tempId); + console.log(`Cleaned up temp file: ${tempFile.path}`); + } + return { success: true }; + } catch (error) { + console.error("Error cleaning up temp file:", error); + return { success: false, error: error.message }; + } +}); + +// 批量清理过期临时文件(5分钟过期) +const cleanupExpiredTempFiles = () => { + const now = Date.now(); + const maxAge = 5 * 60 * 1000; // 5分钟 + + for (const [tempId, tempFile] of tempFiles.entries()) { + if (now - tempFile.createdAt > maxAge) { + try { + if (fs.existsSync(tempFile.path)) { + fs.unlinkSync(tempFile.path); + } + tempFiles.delete(tempId); + console.log(`Auto-cleaned expired temp file: ${tempFile.path}`); + } catch (error) { + console.error("Error auto-cleaning temp file:", error); + } + } + } +}; + +// 每分钟清理一次过期临时文件 +setInterval(cleanupExpiredTempFiles, 60 * 1000); + +// 创建临时文件夹拖拽支持 +ipcMain.handle("create-temp-folder", async (event, folderData) => { + try { + const { folderName, files } = folderData; + + // 创建临时目录 + const tempDir = path.join(os.tmpdir(), 'termix-drag-folders'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); + const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`); + + // 递归创建文件夹结构 + const createFolderStructure = (basePath, fileList) => { + for (const file of fileList) { + const fullPath = path.join(basePath, file.relativePath); + const dirPath = path.dirname(fullPath); + + // 确保目录存在 + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + // 写入文件 + if (file.encoding === 'base64') { + const buffer = Buffer.from(file.content, 'base64'); + fs.writeFileSync(fullPath, buffer); + } else { + fs.writeFileSync(fullPath, file.content, 'utf8'); + } + } + }; + + fs.mkdirSync(tempFolderPath, { recursive: true }); + createFolderStructure(tempFolderPath, files); + + // 记录临时文件夹 + tempFiles.set(tempId, { + path: tempFolderPath, + fileName: folderName, + createdAt: Date.now(), + isFolder: true + }); + + console.log(`Created temp folder: ${tempFolderPath}`); + return { success: true, tempId, path: tempFolderPath }; + } catch (error) { + console.error("Error creating temp folder:", error); + return { success: false, error: error.message }; + } +}); + app.on("before-quit", () => { console.log("App is quitting..."); + + // 清理所有临时文件 + for (const [tempId, tempFile] of tempFiles.entries()) { + try { + if (fs.existsSync(tempFile.path)) { + if (tempFile.isFolder) { + fs.rmSync(tempFile.path, { recursive: true, force: true }); + } else { + fs.unlinkSync(tempFile.path); + } + } + } catch (error) { + console.error("Error cleaning up temp file on quit:", error); + } + } + tempFiles.clear(); }); app.on("will-quit", () => { diff --git a/electron/preload.js b/electron/preload.js index e1e436d8..02565c9c 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -22,6 +22,20 @@ contextBridge.exposeInMainWorld("electronAPI", { isDev: process.env.NODE_ENV === "development", invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + + // ================== 拖拽API ================== + + // 创建临时文件用于拖拽 + createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData), + + // 创建临时文件夹用于拖拽 + createTempFolder: (folderData) => ipcRenderer.invoke("create-temp-folder", folderData), + + // 开始拖拽到桌面 + startDragToDesktop: (dragData) => ipcRenderer.invoke("start-drag-to-desktop", dragData), + + // 清理临时文件 + cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), }); window.IS_ELECTRON = true; diff --git a/package-lock.json b/package-lock.json index 720db82a..6c5a9cdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", "@types/speakeasy": "^2.0.10", @@ -59,6 +60,7 @@ "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", + "jszip": "^3.10.1", "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", @@ -5174,6 +5176,15 @@ "@types/node": "*" } }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -7620,7 +7631,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -10667,6 +10677,12 @@ "dev": true, "license": "MIT" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10962,7 +10978,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -11226,6 +11241,48 @@ "extsprintf": "^1.2.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/junk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", @@ -11328,6 +11385,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -12782,7 +12848,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -13413,7 +13478,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -14483,6 +14547,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index 9fa0bed4..37c38f52 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", "@types/speakeasy": "^2.0.10", @@ -74,6 +75,7 @@ "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", + "jszip": "^3.10.1", "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 00000000..3e615387 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,66 @@ +export interface ElectronAPI { + getAppVersion: () => Promise; + getPlatform: () => Promise; + + getServerConfig: () => Promise; + saveServerConfig: (config: any) => Promise; + testServerConnection: (serverUrl: string) => Promise; + + showSaveDialog: (options: any) => Promise; + showOpenDialog: (options: any) => Promise; + + onUpdateAvailable: (callback: Function) => void; + onUpdateDownloaded: (callback: Function) => void; + + removeAllListeners: (channel: string) => void; + isElectron: boolean; + isDev: boolean; + + invoke: (channel: string, ...args: any[]) => Promise; + + // 拖拽API + createTempFile: (fileData: { + fileName: string; + content: string; + encoding?: 'base64' | 'utf8'; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + createTempFolder: (folderData: { + folderName: string; + files: Array<{ + relativePath: string; + content: string; + encoding?: 'base64' | 'utf8'; + }>; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + startDragToDesktop: (dragData: { + tempId: string; + fileName: string; + }) => Promise<{ + success: boolean; + error?: string; + }>; + + cleanupTempFile: (tempId: string) => Promise<{ + success: boolean; + error?: string; + }>; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + IS_ELECTRON: boolean; + } +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx index 88865d3f..dbbbc72b 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx @@ -13,7 +13,8 @@ import { RefreshCw, Clipboard, Eye, - Share + Share, + ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -47,6 +48,7 @@ interface ContextMenuProps { onPaste?: () => void; onPreview?: (file: FileItem) => void; hasClipboard?: boolean; + onDragToDesktop?: () => void; } interface MenuItem { @@ -77,7 +79,8 @@ export function FileManagerContextMenu({ onRefresh, onPaste, onPreview, - hasClipboard = false + hasClipboard = false, + onDragToDesktop }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -204,6 +207,19 @@ export function FileManagerContextMenu({ }); } + // 拖拽到桌面菜单项(支持浏览器和桌面应用) + if (hasFiles && onDragToDesktop) { + const isModernBrowser = 'showSaveFilePicker' in window; + menuItems.push({ + icon: , + label: isMultipleFiles + ? `保存 ${files.length} 个文件到系统` + : "保存到系统", + action: () => onDragToDesktop(), + shortcut: isModernBrowser ? "选择位置保存" : "下载到默认位置" + }); + } + menuItems.push({ separator: true } as MenuItem); if (isSingleFile && onRename) { diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index 8284d531..43e5585b 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -70,6 +70,8 @@ interface FileManagerGridProps { onUndo?: () => void; onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void; onFileDiff?: (file1: FileItem, file2: FileItem) => void; + onSystemDragStart?: (files: FileItem[]) => void; + onSystemDragEnd?: (e: DragEvent) => void; } const getFileIcon = (file: FileItem, viewMode: 'grid' | 'list' = 'grid') => { @@ -165,7 +167,9 @@ export function FileManagerGrid({ onPaste, onUndo, onFileDrop, - onFileDiff + onFileDiff, + onSystemDragStart, + onSystemDragEnd }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -227,6 +231,9 @@ export function FileManagerGrid({ files: filesToDrag.map(f => f.path) }; e.dataTransfer.setData('text/plain', JSON.stringify(dragData)); + + // 触发系统级拖拽开始 + onSystemDragStart?.(filesToDrag); e.dataTransfer.effectAllowed = 'move'; }; @@ -280,9 +287,12 @@ export function FileManagerGrid({ setDraggedFiles([]); }; - const handleFileDragEnd = () => { + const handleFileDragEnd = (e: React.DragEvent) => { setDraggedFiles([]); setDragOverTarget(null); + + // 触发系统级拖拽结束检测 + onSystemDragEnd?.(e.nativeEvent); }; const [isSelecting, setIsSelecting] = useState(false); diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx index 0689569f..35b68209 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { FileManagerGrid } from "./FileManagerGrid"; import { FileManagerContextMenu } from "./FileManagerContextMenu"; import { useFileSelection } from "./hooks/useFileSelection"; @@ -6,6 +6,9 @@ import { useDragAndDrop } from "./hooks/useDragAndDrop"; import { WindowManager, useWindowManager } from "./components/WindowManager"; import { FileWindow } from "./components/FileWindow"; import { DiffWindow } from "./components/DiffWindow"; +import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; +import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; +import { DragIndicator } from "../../../components/DragIndicator"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; @@ -110,6 +113,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { maxFileSize: 100 // 100MB }); + // 拖拽到桌面功能 + const dragToDesktop = useDragToDesktop({ + sshSessionId: sshSessionId || '', + sshHost: currentHost! + }); + + // 系统级拖拽到桌面功能(新方案) + const systemDrag = useDragToSystemDesktop({ + sshSessionId: sshSessionId || '', + sshHost: currentHost! + }); + // 初始化SSH连接 useEffect(() => { if (currentHost) { @@ -124,6 +139,41 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { } }, [sshSessionId, currentPath]); + // 文件拖拽到外部处理 + const handleFileDragStart = useCallback((files: FileItem[]) => { + // 记录当前拖拽的文件 + systemDrag.startDragToSystem(files, { + enableToast: true, + onSuccess: () => { + clearSelection(); + }, + onError: (error) => { + console.error('拖拽失败:', error); + } + }); + }, [systemDrag, clearSelection]); + + const handleFileDragEnd = useCallback((e: DragEvent) => { + // 检查是否拖拽到窗口外 + const margin = 50; + const isOutside = ( + e.clientX < margin || + e.clientX > window.innerWidth - margin || + e.clientY < margin || + e.clientY > window.innerHeight - margin + ); + + if (isOutside) { + // 延迟执行,避免与其他事件冲突 + setTimeout(() => { + systemDrag.handleDragEnd(e); + }, 100); + } else { + // 取消拖拽 + systemDrag.cancelDragToSystem(); + } + }, [systemDrag]); + async function initializeSSHConnection() { if (!currentHost) return; @@ -1055,6 +1105,39 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { toast.success(`正在对比文件: ${file1.name} 与 ${file2.name}`); } + // 拖拽到桌面处理函数 + async function handleDragToDesktop(files: FileItem[]) { + if (!currentHost || !sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + // 优先使用新的系统级拖拽方案 + if (systemDrag.isFileSystemAPISupported) { + await systemDrag.handleDragToSystem(files, { + enableToast: true, + onSuccess: () => { + console.log('系统级拖拽成功'); + }, + onError: (error) => { + console.error('系统级拖拽失败:', error); + } + }); + } else { + // 降级到Electron方案 + if (files.length === 1) { + await dragToDesktop.dragFileToDesktop(files[0]); + } else if (files.length > 1) { + await dragToDesktop.dragFilesToDesktop(files); + } + } + } catch (error: any) { + console.error('拖拽到桌面失败:', error); + toast.error(`拖拽失败: ${error.message || '未知错误'}`); + } + } + // 过滤文件并添加新建的临时项目 let filteredFiles = files.filter(file => file.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -1205,6 +1288,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { onUndo={handleUndo} onFileDrop={handleFileDrop} onFileDiff={handleFileDiff} + onSystemDragStart={handleFileDragStart} + onSystemDragEnd={handleFileDragEnd} /> {/* 右键菜单 */} @@ -1234,6 +1319,31 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { onNewFile={handleCreateNewFile} onRefresh={() => loadDirectory(currentPath)} hasClipboard={!!clipboard} + onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} + /> + + {/* 拖拽到桌面指示器 */} + diff --git a/src/ui/components/DragIndicator.tsx b/src/ui/components/DragIndicator.tsx new file mode 100644 index 00000000..eb7334a6 --- /dev/null +++ b/src/ui/components/DragIndicator.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { + Download, + FileDown, + FolderDown, + Loader2, + CheckCircle, + AlertCircle +} from 'lucide-react'; + +interface DragIndicatorProps { + isVisible: boolean; + isDragging: boolean; + isDownloading: boolean; + progress: number; + fileName?: string; + fileCount?: number; + error?: string | null; + className?: string; +} + +export function DragIndicator({ + isVisible, + isDragging, + isDownloading, + progress, + fileName, + fileCount = 1, + error, + className +}: DragIndicatorProps) { + if (!isVisible) return null; + + const getIcon = () => { + if (error) { + return ; + } + + if (isDragging) { + return ; + } + + if (isDownloading) { + return ; + } + + if (fileCount > 1) { + return ; + } + + return ; + }; + + const getStatusText = () => { + if (error) { + return `错误: ${error}`; + } + + if (isDragging) { + return `正在拖拽${fileName ? ` ${fileName}` : ''}到桌面...`; + } + + if (isDownloading) { + return `正在准备拖拽${fileName ? ` ${fileName}` : ''}...`; + } + + return `准备拖拽${fileCount > 1 ? ` ${fileCount} 个文件` : fileName ? ` ${fileName}` : ''}`; + }; + + return ( +
+
+ {/* 图标 */} +
+ {getIcon()} +
+ + {/* 内容 */} +
+ {/* 标题 */} +
+ {fileCount > 1 ? '批量拖拽到桌面' : '拖拽到桌面'} +
+ + {/* 状态文字 */} +
+ {getStatusText()} +
+ + {/* 进度条 */} + {(isDownloading || isDragging) && !error && ( +
+
+
+ )} + + {/* 进度百分比 */} + {(isDownloading || isDragging) && !error && ( +
+ {progress.toFixed(0)}% +
+ )} + + {/* 拖拽提示 */} + {isDragging && !error && ( +
+ + 现在可以拖拽到桌面任意位置 +
+ )} +
+
+ + {/* 动画效果的背景 */} + {isDragging && !error && ( +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/ui/hooks/useDragToDesktop.ts b/src/ui/hooks/useDragToDesktop.ts new file mode 100644 index 00000000..b00c73c9 --- /dev/null +++ b/src/ui/hooks/useDragToDesktop.ts @@ -0,0 +1,281 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; +import { downloadSSHFile } from '@/ui/main-axios'; +import type { FileItem, SSHHost } from '../../types/index.js'; + +interface DragToDesktopState { + isDragging: boolean; + isDownloading: boolean; + progress: number; + error: string | null; +} + +interface UseDragToDesktopProps { + sshSessionId: string; + sshHost: SSHHost; +} + +interface DragToDesktopOptions { + enableToast?: boolean; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProps) { + const [state, setState] = useState({ + isDragging: false, + isDownloading: false, + progress: 0, + error: null + }); + + // 检查是否在Electron环境中 + const isElectron = () => { + return typeof window !== 'undefined' && + window.electronAPI && + window.electronAPI.isElectron; + }; + + // 拖拽单个文件到桌面 + const dragFileToDesktop = useCallback(async ( + file: FileItem, + options: DragToDesktopOptions = {} + ) => { + const { enableToast = true, onSuccess, onError } = options; + + if (!isElectron()) { + const error = '拖拽到桌面功能仅在桌面应用中可用'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + if (file.type !== 'file') { + const error = '只能拖拽文件到桌面'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + try { + setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); + + // 下载文件内容 + const response = await downloadSSHFile(sshSessionId, file.path); + + if (!response?.content) { + throw new Error('无法获取文件内容'); + } + + setState(prev => ({ ...prev, progress: 50 })); + + // 创建临时文件 + const tempResult = await window.electronAPI.createTempFile({ + fileName: file.name, + content: response.content, + encoding: 'base64' + }); + + if (!tempResult.success) { + throw new Error(tempResult.error || '创建临时文件失败'); + } + + setState(prev => ({ ...prev, progress: 80, isDragging: true })); + + // 开始拖拽 + const dragResult = await window.electronAPI.startDragToDesktop({ + tempId: tempResult.tempId, + fileName: file.name + }); + + if (!dragResult.success) { + throw new Error(dragResult.error || '开始拖拽失败'); + } + + setState(prev => ({ ...prev, progress: 100 })); + + if (enableToast) { + toast.success(`正在拖拽 ${file.name} 到桌面`); + } + + onSuccess?.(); + + // 延迟清理临时文件(给用户时间完成拖拽) + setTimeout(async () => { + await window.electronAPI.cleanupTempFile(tempResult.tempId); + setState(prev => ({ + ...prev, + isDragging: false, + isDownloading: false, + progress: 0 + })); + }, 10000); // 10秒后清理 + + return true; + } catch (error: any) { + console.error('拖拽到桌面失败:', error); + const errorMessage = error.message || '拖拽失败'; + + setState(prev => ({ + ...prev, + isDownloading: false, + isDragging: false, + progress: 0, + error: errorMessage + })); + + if (enableToast) { + toast.error(`拖拽失败: ${errorMessage}`); + } + + onError?.(errorMessage); + return false; + } + }, [sshSessionId, sshHost]); + + // 拖拽多个文件到桌面(批量操作) + const dragFilesToDesktop = useCallback(async ( + files: FileItem[], + options: DragToDesktopOptions = {} + ) => { + const { enableToast = true, onSuccess, onError } = options; + + if (!isElectron()) { + const error = '拖拽到桌面功能仅在桌面应用中可用'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + const fileList = files.filter(f => f.type === 'file'); + if (fileList.length === 0) { + const error = '没有可拖拽的文件'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + if (fileList.length === 1) { + return dragFileToDesktop(fileList[0], options); + } + + try { + setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); + + // 批量下载文件 + const downloadPromises = fileList.map(file => + downloadSSHFile(sshSessionId, file.path) + ); + + const responses = await Promise.all(downloadPromises); + setState(prev => ({ ...prev, progress: 40 })); + + // 创建临时文件夹结构 + const folderName = `Files_${Date.now()}`; + const filesData = fileList.map((file, index) => ({ + relativePath: file.name, + content: responses[index]?.content || '', + encoding: 'base64' + })); + + const tempResult = await window.electronAPI.createTempFolder({ + folderName, + files: filesData + }); + + if (!tempResult.success) { + throw new Error(tempResult.error || '创建临时文件夹失败'); + } + + setState(prev => ({ ...prev, progress: 80, isDragging: true })); + + // 开始拖拽文件夹 + const dragResult = await window.electronAPI.startDragToDesktop({ + tempId: tempResult.tempId, + fileName: folderName + }); + + if (!dragResult.success) { + throw new Error(dragResult.error || '开始拖拽失败'); + } + + setState(prev => ({ ...prev, progress: 100 })); + + if (enableToast) { + toast.success(`正在拖拽 ${fileList.length} 个文件到桌面`); + } + + onSuccess?.(); + + // 延迟清理临时文件夹 + setTimeout(async () => { + await window.electronAPI.cleanupTempFile(tempResult.tempId); + setState(prev => ({ + ...prev, + isDragging: false, + isDownloading: false, + progress: 0 + })); + }, 15000); // 15秒后清理 + + return true; + } catch (error: any) { + console.error('批量拖拽到桌面失败:', error); + const errorMessage = error.message || '批量拖拽失败'; + + setState(prev => ({ + ...prev, + isDownloading: false, + isDragging: false, + progress: 0, + error: errorMessage + })); + + if (enableToast) { + toast.error(`批量拖拽失败: ${errorMessage}`); + } + + onError?.(errorMessage); + return false; + } + }, [sshSessionId, sshHost, dragFileToDesktop]); + + // 拖拽文件夹到桌面 + const dragFolderToDesktop = useCallback(async ( + folder: FileItem, + options: DragToDesktopOptions = {} + ) => { + const { enableToast = true, onSuccess, onError } = options; + + if (!isElectron()) { + const error = '拖拽到桌面功能仅在桌面应用中可用'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + if (folder.type !== 'directory') { + const error = '只能拖拽文件夹类型'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + if (enableToast) { + toast.info('文件夹拖拽功能开发中...'); + } + + // TODO: 实现文件夹递归下载和拖拽 + // 这需要额外的API来递归获取文件夹内容 + + return false; + }, [sshSessionId, sshHost]); + + return { + ...state, + isElectron: isElectron(), + dragFileToDesktop, + dragFilesToDesktop, + dragFolderToDesktop + }; +} \ No newline at end of file diff --git a/src/ui/hooks/useDragToSystemDesktop.ts b/src/ui/hooks/useDragToSystemDesktop.ts new file mode 100644 index 00000000..b3eaed0a --- /dev/null +++ b/src/ui/hooks/useDragToSystemDesktop.ts @@ -0,0 +1,309 @@ +import { useState, useCallback, useRef } from 'react'; +import { toast } from 'sonner'; +import { downloadSSHFile } from '@/ui/main-axios'; +import type { FileItem, SSHHost } from '../../types/index.js'; + +interface DragToSystemState { + isDragging: boolean; + isDownloading: boolean; + progress: number; + error: string | null; +} + +interface UseDragToSystemProps { + sshSessionId: string; + sshHost: SSHHost; +} + +interface DragToSystemOptions { + enableToast?: boolean; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSystemProps) { + const [state, setState] = useState({ + isDragging: false, + isDownloading: false, + progress: 0, + error: null + }); + + const dragDataRef = useRef<{ files: FileItem[], options: DragToSystemOptions } | null>(null); + + // 目录记忆功能 + const getLastSaveDirectory = async () => { + try { + if ('indexedDB' in window) { + const request = indexedDB.open('termix-dirs', 1); + return new Promise((resolve) => { + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction(['directories'], 'readonly'); + const store = transaction.objectStore('directories'); + const getRequest = store.get('lastSaveDir'); + getRequest.onsuccess = () => resolve(getRequest.result?.handle || null); + }; + request.onerror = () => resolve(null); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('directories')) { + db.createObjectStore('directories'); + } + }; + }); + } + } catch (error) { + console.log('无法获取上次保存目录:', error); + } + return null; + }; + + const saveLastDirectory = async (fileHandle: any) => { + try { + if ('indexedDB' in window && fileHandle.getParent) { + const dirHandle = await fileHandle.getParent(); + const request = indexedDB.open('termix-dirs', 1); + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction(['directories'], 'readwrite'); + const store = transaction.objectStore('directories'); + store.put({ handle: dirHandle }, 'lastSaveDir'); + }; + } + } catch (error) { + console.log('无法保存目录记录:', error); + } + }; + + // 检查File System Access API支持 + const isFileSystemAPISupported = () => { + return 'showSaveFilePicker' in window; + }; + + // 检查拖拽是否离开窗口边界 + const isDraggedOutsideWindow = (e: DragEvent) => { + const margin = 50; // 增加容差边距 + return ( + e.clientX < margin || + e.clientX > window.innerWidth - margin || + e.clientY < margin || + e.clientY > window.innerHeight - margin + ); + }; + + // 创建文件blob + const createFileBlob = async (file: FileItem): Promise => { + const response = await downloadSSHFile(sshSessionId, file.path); + if (!response?.content) { + throw new Error(`无法获取文件 ${file.name} 的内容`); + } + + // base64转换为blob + const binaryString = atob(response.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return new Blob([bytes]); + }; + + // 创建ZIP文件(用于多文件下载) + const createZipBlob = async (files: FileItem[]): Promise => { + // 这里需要一个轻量级的zip库,先用简单方案 + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (const file of files) { + const blob = await createFileBlob(file); + zip.file(file.name, blob); + } + + return await zip.generateAsync({ type: 'blob' }); + }; + + // 使用File System Access API保存文件 + const saveFileWithSystemAPI = async (blob: Blob, suggestedName: string) => { + try { + // 获取上次保存的目录句柄 + const lastDirHandle = await getLastSaveDirectory(); + + const fileHandle = await (window as any).showSaveFilePicker({ + suggestedName, + startIn: lastDirHandle || 'desktop', // 优先使用上次目录,否则桌面 + types: [ + { + description: '文件', + accept: { + '*/*': ['.txt', '.jpg', '.png', '.pdf', '.zip', '.tar', '.gz'] + } + } + ] + }); + + // 保存当前目录句柄以便下次使用 + await saveLastDirectory(fileHandle); + + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + + return true; + } catch (error: any) { + if (error.name === 'AbortError') { + return false; // 用户取消 + } + throw error; + } + }; + + // 降级方案:传统下载 + const fallbackDownload = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // 处理拖拽到系统桌面 + const handleDragToSystem = useCallback(async ( + files: FileItem[], + options: DragToSystemOptions = {} + ) => { + const { enableToast = true, onSuccess, onError } = options; + + if (files.length === 0) { + const error = '没有可拖拽的文件'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + // 过滤出文件类型 + const fileList = files.filter(f => f.type === 'file'); + if (fileList.length === 0) { + const error = '只能拖拽文件到桌面'; + if (enableToast) toast.error(error); + onError?.(error); + return false; + } + + try { + setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); + + let blob: Blob; + let fileName: string; + + if (fileList.length === 1) { + // 单文件 + blob = await createFileBlob(fileList[0]); + fileName = fileList[0].name; + setState(prev => ({ ...prev, progress: 70 })); + } else { + // 多文件打包成ZIP + blob = await createZipBlob(fileList); + fileName = `files_${Date.now()}.zip`; + setState(prev => ({ ...prev, progress: 70 })); + } + + setState(prev => ({ ...prev, progress: 90 })); + + // 优先使用File System Access API + if (isFileSystemAPISupported()) { + const saved = await saveFileWithSystemAPI(blob, fileName); + if (!saved) { + // 用户取消了 + setState(prev => ({ ...prev, isDownloading: false, progress: 0 })); + return false; + } + } else { + // 降级到传统下载 + fallbackDownload(blob, fileName); + if (enableToast) { + toast.info('由于浏览器限制,文件将下载到默认下载目录'); + } + } + + setState(prev => ({ ...prev, progress: 100 })); + + if (enableToast) { + toast.success( + fileList.length === 1 + ? `${fileName} 已保存到指定位置` + : `${fileList.length} 个文件已打包保存` + ); + } + + onSuccess?.(); + + // 重置状态 + setTimeout(() => { + setState(prev => ({ ...prev, isDownloading: false, progress: 0 })); + }, 1000); + + return true; + } catch (error: any) { + console.error('拖拽到桌面失败:', error); + const errorMessage = error.message || '保存失败'; + + setState(prev => ({ + ...prev, + isDownloading: false, + progress: 0, + error: errorMessage + })); + + if (enableToast) { + toast.error(`保存失败: ${errorMessage}`); + } + + onError?.(errorMessage); + return false; + } + }, [sshSessionId]); + + // 开始拖拽(记录拖拽数据) + const startDragToSystem = useCallback((files: FileItem[], options: DragToSystemOptions = {}) => { + dragDataRef.current = { files, options }; + setState(prev => ({ ...prev, isDragging: true, error: null })); + }, []); + + // 结束拖拽检测 + const handleDragEnd = useCallback((e: DragEvent) => { + if (!dragDataRef.current) return; + + const { files, options } = dragDataRef.current; + + // 检查是否拖拽到窗口外 + if (isDraggedOutsideWindow(e)) { + // 延迟执行,避免与其他拖拽事件冲突 + setTimeout(() => { + handleDragToSystem(files, options); + }, 100); + } + + // 清理拖拽状态 + dragDataRef.current = null; + setState(prev => ({ ...prev, isDragging: false })); + }, [handleDragToSystem]); + + // 取消拖拽 + const cancelDragToSystem = useCallback(() => { + dragDataRef.current = null; + setState(prev => ({ ...prev, isDragging: false, error: null })); + }, []); + + return { + ...state, + isFileSystemAPISupported: isFileSystemAPISupported(), + startDragToSystem, + handleDragEnd, + cancelDragToSystem, + handleDragToSystem // 直接调用版本 + }; +} \ No newline at end of file