import React, { useState, useEffect, useRef, useCallback } from "react"; import { FileManagerGrid } from "./FileManagerGrid"; import { FileManagerSidebar } from "./FileManagerSidebar"; import { FileManagerContextMenu } from "./FileManagerContextMenu"; import { useFileSelection } from "./hooks/useFileSelection"; 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 { useConfirmation } from "@/hooks/use-confirmation.ts"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/components/TOTPDialog"; import { Upload, FolderPlus, FilePlus, RefreshCw, Search, Grid3X3, List, } from "lucide-react"; import { TerminalWindow } from "./components/TerminalWindow"; import type { SSHHost, FileItem } from "../../../types/index.js"; import { listSSHFiles, uploadSSHFile, downloadSSHFile, createSSHFile, createSSHFolder, deleteSSHItem, copySSHItem, renameSSHItem, moveSSHItem, connectSSH, verifySSHTOTP, getSSHStatus, keepSSHAlive, identifySSHSymlink, addRecentFile, addPinnedFile, removePinnedFile, removeRecentFile, addFolderShortcut, getPinnedFiles, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; interface FileManagerProps { initialHost?: SSHHost | null; onClose?: () => void; } interface CreateIntent { id: string; type: "file" | "directory"; defaultName: string; currentName: string; } function formatFileSize(bytes?: number): string { if (bytes === undefined || bytes === null) return "-"; if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } const formattedSize = size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString(); return `${formattedSize} ${units[unitIndex]}`; } function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); const [currentHost] = useState(initialHost || null); const [currentPath, setCurrentPath] = useState( initialHost?.defaultPath || "/", ); const [files, setFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [sshSessionId, setSshSessionId] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [lastRefreshTime, setLastRefreshTime] = useState(0); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [totpRequired, setTotpRequired] = useState(false); const [totpSessionId, setTotpSessionId] = useState(null); const [totpPrompt, setTotpPrompt] = useState(""); const [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [isClosing, setIsClosing] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; isVisible: boolean; files: FileItem[]; }>({ x: 0, y: 0, isVisible: false, files: [], }); const [clipboard, setClipboard] = useState<{ files: FileItem[]; operation: "copy" | "cut"; } | null>(null); interface UndoAction { type: "copy" | "cut" | "delete"; description: string; data: { operation: "copy" | "cut"; copiedFiles?: { originalPath: string; targetPath: string; targetName: string; }[]; deletedFiles?: { path: string; name: string }[]; targetDirectory?: string; }; timestamp: number; } const [undoHistory, setUndoHistory] = useState([]); const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); const { dragHandlers } = useDragAndDrop({ onFilesDropped: handleFilesDropped, onError: (error) => toast.error(error), maxFileSize: 5120, }); const dragToDesktop = useDragToDesktop({ sshSessionId: sshSessionId || "", sshHost: currentHost!, }); const systemDrag = useDragToSystemDesktop({ sshSessionId: sshSessionId || "", sshHost: currentHost!, }); const startKeepalive = useCallback(() => { if (!sshSessionId) return; if (keepaliveTimerRef.current) { clearInterval(keepaliveTimerRef.current); } keepaliveTimerRef.current = setInterval(async () => { if (sshSessionId) { try { await keepSSHAlive(sshSessionId); } catch (error) { console.error("SSH keepalive failed:", error); } } }, 30 * 1000); }, [sshSessionId]); const stopKeepalive = useCallback(() => { if (keepaliveTimerRef.current) { clearInterval(keepaliveTimerRef.current); keepaliveTimerRef.current = null; } }, []); const handleCloseWithError = useCallback( (errorMessage: string) => { if (isClosing) return; setIsClosing(true); toast.error(errorMessage); if (onClose) { onClose(); } }, [isClosing, onClose], ); useEffect(() => { if (currentHost) { initializeSSHConnection(); } }, [currentHost]); useEffect(() => { if (sshSessionId) { startKeepalive(); } else { stopKeepalive(); } return () => { stopKeepalive(); }; }, [sshSessionId, startKeepalive, stopKeepalive]); const initialLoadDoneRef = useRef(false); const lastPathChangeRef = useRef(""); const pathChangeTimerRef = useRef(null); const currentLoadingPathRef = useRef(""); const keepaliveTimerRef = useRef(null); const handleFileDragStart = useCallback( (files: FileItem[]) => { systemDrag.startDragToSystem(files, { enableToast: true, onSuccess: () => { clearSelection(); }, onError: (error) => { console.error("Drag failed:", error); }, }); }, [systemDrag, clearSelection], ); const handleFileDragEnd = useCallback( (e: DragEvent, draggedFiles: FileItem[]) => { const isOutside = e.clientX < 0 || e.clientX > window.innerWidth || e.clientY < 0 || e.clientY > window.innerHeight; if (isOutside) { if (draggedFiles.length === 0) { console.error("No files to drag - this should not happen"); return; } systemDrag.startDragToSystem(draggedFiles, { enableToast: true, onSuccess: () => { clearSelection(); }, onError: (error) => { console.error("Drag failed:", error); }, }); systemDrag.handleDragEnd(e); } else { systemDrag.cancelDragToSystem(); } }, [systemDrag, clearSelection], ); async function initializeSSHConnection() { if (!currentHost) return; try { setIsLoading(true); initialLoadDoneRef.current = false; const sessionId = currentHost.id.toString(); const result = await connectSSH(sessionId, { hostId: currentHost.id, ip: currentHost.ip, port: currentHost.port, username: currentHost.username, password: currentHost.password, sshKey: currentHost.key, keyPassword: currentHost.keyPassword, authType: currentHost.authType, credentialId: currentHost.credentialId, userId: currentHost.userId, }); if (result?.requires_totp) { setTotpRequired(true); setTotpSessionId(sessionId); setTotpPrompt(result.prompt || "Verification code:"); setIsLoading(false); return; } setSshSessionId(sessionId); try { const response = await listSSHFiles(sessionId, currentPath); const files = Array.isArray(response) ? response : response?.files || []; setFiles(files); clearSelection(); initialLoadDoneRef.current = true; } catch (dirError: unknown) { console.error("Failed to load initial directory:", dirError); } } catch (error: unknown) { console.error("SSH connection failed:", error); handleCloseWithError( t("fileManager.failedToConnect") + ": " + (error.message || error), ); } finally { setIsLoading(false); } } const loadDirectory = useCallback( async (path: string) => { if (!sshSessionId) { console.error("Cannot load directory: no SSH session ID"); return; } if (isLoading && currentLoadingPathRef.current !== path) { return; } currentLoadingPathRef.current = path; setIsLoading(true); setCreateIntent(null); try { const response = await listSSHFiles(sshSessionId, path); if (currentLoadingPathRef.current !== path) { return; } const files = Array.isArray(response) ? response : response?.files || []; setFiles(files); clearSelection(); } catch (error: unknown) { if (currentLoadingPathRef.current === path) { console.error("Failed to load directory:", error); if (initialLoadDoneRef.current) { toast.error( t("fileManager.failedToLoadDirectory") + ": " + (error.message || error), ); } if ( error.message?.includes("connection") || error.message?.includes("SSH") ) { handleCloseWithError( t("fileManager.failedToLoadDirectory") + ": " + (error.message || error), ); } } } finally { if (currentLoadingPathRef.current === path) { setIsLoading(false); currentLoadingPathRef.current = ""; } } }, [sshSessionId, isLoading, clearSelection, t], ); const debouncedLoadDirectory = useCallback( (path: string) => { if (pathChangeTimerRef.current) { clearTimeout(pathChangeTimerRef.current); } pathChangeTimerRef.current = setTimeout(() => { if (path !== lastPathChangeRef.current && sshSessionId) { lastPathChangeRef.current = path; loadDirectory(path); } }, 150); }, [sshSessionId, loadDirectory], ); useEffect(() => { if (sshSessionId && currentPath) { if (!initialLoadDoneRef.current) { initialLoadDoneRef.current = true; lastPathChangeRef.current = currentPath; return; } debouncedLoadDirectory(currentPath); } return () => { if (pathChangeTimerRef.current) { clearTimeout(pathChangeTimerRef.current); } }; }, [sshSessionId, currentPath, debouncedLoadDirectory]); const handleRefreshDirectory = useCallback(() => { const now = Date.now(); const DEBOUNCE_MS = 500; if (now - lastRefreshTime < DEBOUNCE_MS) { return; } setLastRefreshTime(now); loadDirectory(currentPath); }, [currentPath, lastRefreshTime, loadDirectory]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement; if ( activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.contentEditable === "true") ) { return; } if (event.key === "T" && event.ctrlKey && event.shiftKey) { event.preventDefault(); handleOpenTerminal(currentPath); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [currentPath]); function handleFilesDropped(fileList: FileList) { if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } Array.from(fileList).forEach((file) => { handleUploadFile(file); }); } async function handleUploadFile(file: File) { if (!sshSessionId) return; const progressToast = toast.loading( t("fileManager.uploadingFile", { name: file.name, size: formatFileSize(file.size), }), { duration: Infinity }, ); try { await ensureSSHConnection(); const fileContent = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(reader.error); const isTextFile = file.type.startsWith("text/") || file.type === "application/json" || file.type === "application/javascript" || file.type === "application/xml" || file.type === "image/svg+xml" || file.name.match( /\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i, ); if (isTextFile) { reader.onload = () => { if (reader.result) { resolve(reader.result as string); } else { reject(new Error("Failed to read text file content")); } }; reader.readAsText(file); } else { reader.onload = () => { if (reader.result instanceof ArrayBuffer) { const bytes = new Uint8Array(reader.result); let binary = ""; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); resolve(base64); } else { reject(new Error("Failed to read binary file")); } }; reader.readAsArrayBuffer(file); } }); await uploadSSHFile( sshSessionId, currentPath, file.name, fileContent, currentHost?.id, undefined, ); toast.dismiss(progressToast); toast.success( t("fileManager.fileUploadedSuccessfully", { name: file.name }), ); handleRefreshDirectory(); } catch (error: unknown) { toast.dismiss(progressToast); if ( error.message?.includes("connection") || error.message?.includes("established") ) { toast.error( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, ); } else { toast.error(t("fileManager.failedToUploadFile")); } console.error("Upload failed:", error); } } async function handleDownloadFile(file: FileItem) { if (!sshSessionId) return; try { await ensureSSHConnection(); const response = await downloadSSHFile(sshSessionId, file.path); if (response?.content) { const byteCharacters = atob(response.content); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const blob = new Blob([byteArray], { type: response.mimeType || "application/octet-stream", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = response.fileName || file.name; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); toast.success( t("fileManager.fileDownloadedSuccessfully", { name: file.name }), ); } } catch (error: unknown) { if ( error.message?.includes("connection") || error.message?.includes("established") ) { toast.error( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, ); } else { toast.error(t("fileManager.failedToDownloadFile")); } console.error("Download failed:", error); } } async function handleDeleteFiles(files: FileItem[]) { if (!sshSessionId || files.length === 0) return; let confirmMessage: string; if (files.length === 1) { const file = files[0]; if (file.type === "directory") { confirmMessage = t("fileManager.confirmDeleteFolder", { name: file.name, }); } else { confirmMessage = t("fileManager.confirmDeleteSingleItem", { name: file.name, }); } } else { const hasDirectory = files.some((file) => file.type === "directory"); const translationKey = hasDirectory ? "fileManager.confirmDeleteMultipleItemsWithFolders" : "fileManager.confirmDeleteMultipleItems"; confirmMessage = t(translationKey, { count: files.length, }); } const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`; confirmWithToast( fullMessage, async () => { try { await ensureSSHConnection(); for (const file of files) { await deleteSSHItem( sshSessionId, file.path, file.type === "directory", currentHost?.id, currentHost?.userId?.toString(), ); } const deletedFiles = files.map((file) => ({ path: file.path, name: file.name, })); const undoAction: UndoAction = { type: "delete", description: t("fileManager.deletedItems", { count: files.length }), data: { operation: "cut", deletedFiles, targetDirectory: currentPath, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); toast.success( t("fileManager.itemsDeletedSuccessfully", { count: files.length }), ); handleRefreshDirectory(); clearSelection(); } catch (error: unknown) { if ( error.message?.includes("connection") || error.message?.includes("established") ) { toast.error( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, ); } else { toast.error(t("fileManager.failedToDeleteItems")); } console.error("Delete failed:", error); } }, "destructive", ); } function handleCreateNewFolder() { const defaultName = generateUniqueName( t("fileManager.newFolderDefault"), "directory", ); const newCreateIntent = { id: Date.now().toString(), type: "directory" as const, defaultName, currentName: defaultName, }; setCreateIntent(newCreateIntent); } function handleCreateNewFile() { const defaultName = generateUniqueName( t("fileManager.newFileDefault"), "file", ); const newCreateIntent = { id: Date.now().toString(), type: "file" as const, defaultName, currentName: defaultName, }; setCreateIntent(newCreateIntent); } const handleSymlinkClick = async (file: FileItem) => { if (!currentHost || !sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } try { const currentSessionId = sshSessionId; const status = await getSSHStatus(currentSessionId); if (!status.connected) { const result = await connectSSH(currentSessionId, { hostId: currentHost.id, host: currentHost.ip, port: currentHost.port, username: currentHost.username, authType: currentHost.authType, password: currentHost.password, key: currentHost.key, keyPassword: currentHost.keyPassword, credentialId: currentHost.credentialId, }); if (!result.success) { throw new Error(t("fileManager.failedToReconnectSSH")); } } const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path); if (symlinkInfo.type === "directory") { setCurrentPath(symlinkInfo.target); } else if (symlinkInfo.type === "file") { const windowCount = Date.now() % 10; const offsetX = 120 + windowCount * 30; const offsetY = 120 + windowCount * 30; const targetFile: FileItem = { ...file, path: symlinkInfo.target, }; const createWindowComponent = (windowId: string) => ( ); openWindow({ title: file.name, x: offsetX, y: offsetY, width: 800, height: 600, isMaximized: false, isMinimized: false, component: createWindowComponent, }); } } catch (error: unknown) { toast.error( error?.response?.data?.error || error?.message || t("fileManager.failedToResolveSymlink"), ); } }; async function handleFileOpen(file: FileItem) { if (file.type === "directory") { setCurrentPath(file.path); } else if (file.type === "link") { await handleSymlinkClick(file); } else { if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } await recordRecentFile(file); const windowCount = Date.now() % 10; const baseOffsetX = 120 + windowCount * 30; const baseOffsetY = 120 + windowCount * 30; const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100); const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100); const offsetX = Math.min(baseOffsetX, maxOffsetX); const offsetY = Math.min(baseOffsetY, maxOffsetY); const windowTitle = file.name; const createWindowComponent = (windowId: string) => ( ); openWindow({ title: windowTitle, x: offsetX, y: offsetY, width: 800, height: 600, isMaximized: false, isMinimized: false, component: createWindowComponent, }); } } function handleContextMenu(event: React.MouseEvent, file?: FileItem) { event.preventDefault(); let files: FileItem[]; if (file) { const isFileSelected = selectedFiles.some((f) => f.path === file.path); files = isFileSelected ? selectedFiles : [file]; } else { files = selectedFiles; } setContextMenu({ x: event.clientX, y: event.clientY, isVisible: true, files, }); } function handleCopyFiles(files: FileItem[]) { setClipboard({ files, operation: "copy" }); toast.success( t("fileManager.filesCopiedToClipboard", { count: files.length }), ); } function handleCutFiles(files: FileItem[]) { setClipboard({ files, operation: "cut" }); toast.success( t("fileManager.filesCutToClipboard", { count: files.length }), ); } async function handlePasteFiles() { if (!clipboard || !sshSessionId) return; try { await ensureSSHConnection(); const { files, operation } = clipboard; let successCount = 0; const copiedItems: string[] = []; for (const file of files) { try { if (operation === "copy") { const result = await copySSHItem( sshSessionId, file.path, currentPath, currentHost?.id, currentHost?.userId?.toString(), ); copiedItems.push(result.uniqueName || file.name); successCount++; } else { const targetPath = currentPath.endsWith("/") ? `${currentPath}${file.name}` : `${currentPath}/${file.name}`; if (file.path !== targetPath) { await moveSSHItem( sshSessionId, file.path, targetPath, currentHost?.id, currentHost?.userId?.toString(), ); successCount++; } } } catch (error: unknown) { console.error(`Failed to ${operation} file ${file.name}:`, error); toast.error( t("fileManager.operationFailed", { operation: operation === "copy" ? t("fileManager.copy") : t("fileManager.move"), name: file.name, error: error.message, }), ); } } if (successCount > 0) { if (operation === "copy") { const copiedFiles = files .slice(0, successCount) .map((file, index) => ({ originalPath: file.path, targetPath: `${currentPath}/${copiedItems[index] || file.name}`, targetName: copiedItems[index] || file.name, })); const undoAction: UndoAction = { type: "copy", description: t("fileManager.copiedItems", { count: successCount }), data: { operation: "copy", copiedFiles, targetDirectory: currentPath, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); } else if (operation === "cut") { const movedFiles = files.slice(0, successCount).map((file) => { const targetPath = currentPath.endsWith("/") ? `${currentPath}${file.name}` : `${currentPath}/${file.name}`; return { originalPath: file.path, targetPath: targetPath, targetName: file.name, }; }); const undoAction: UndoAction = { type: "cut", description: t("fileManager.movedItems", { count: successCount }), data: { operation: "cut", copiedFiles: movedFiles, targetDirectory: currentPath, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); } } if (successCount > 0) { const operationText = operation === "copy" ? t("fileManager.copy") : t("fileManager.move"); if (operation === "copy" && copiedItems.length > 0) { const hasRenamed = copiedItems.some( (name) => !files.some((file) => file.name === name), ); if (hasRenamed) { toast.success( t("fileManager.operationCompletedSuccessfully", { operation: operationText, count: successCount, }), ); } else { toast.success( t("fileManager.operationCompleted", { operation: operationText, count: successCount, }), ); } } else { toast.success( t("fileManager.operationCompleted", { operation: operationText, count: successCount, }), ); } } handleRefreshDirectory(); clearSelection(); if (operation === "cut") { setClipboard(null); } } catch (error: unknown) { toast.error( `${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`, ); } } async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); return; } const lastAction = undoHistory[undoHistory.length - 1]; try { await ensureSSHConnection(); switch (lastAction.type) { case "copy": if (lastAction.data.copiedFiles) { let successCount = 0; for (const copiedFile of lastAction.data.copiedFiles) { try { const isDirectory = files.find((f) => f.path === copiedFile.targetPath)?.type === "directory"; await deleteSSHItem( sshSessionId!, copiedFile.targetPath, isDirectory, currentHost?.id, currentHost?.userId?.toString(), ); successCount++; } catch (error: unknown) { console.error( `Failed to delete copied file ${copiedFile.targetName}:`, error, ); toast.error( t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, error: error.message, }), ); } } if (successCount > 0) { setUndoHistory((prev) => prev.slice(0, -1)); toast.success( t("fileManager.undoCopySuccess", { count: successCount }), ); } else { toast.error(t("fileManager.undoCopyFailedDelete")); return; } } else { toast.error(t("fileManager.undoCopyFailedNoInfo")); return; } break; case "cut": if (lastAction.data.copiedFiles) { let successCount = 0; for (const movedFile of lastAction.data.copiedFiles) { try { await moveSSHItem( sshSessionId!, movedFile.targetPath, movedFile.originalPath, currentHost?.id, currentHost?.userId?.toString(), ); successCount++; } catch (error: unknown) { console.error( `Failed to move back file ${movedFile.targetName}:`, error, ); toast.error( t("fileManager.moveBackFileFailed", { name: movedFile.targetName, error: error.message, }), ); } } if (successCount > 0) { setUndoHistory((prev) => prev.slice(0, -1)); toast.success( t("fileManager.undoMoveSuccess", { count: successCount }), ); } else { toast.error(t("fileManager.undoMoveFailedMove")); return; } } else { toast.error(t("fileManager.undoMoveFailedNoInfo")); return; } break; case "delete": toast.info(t("fileManager.undoDeleteNotSupported")); setUndoHistory((prev) => prev.slice(0, -1)); return; default: toast.error(t("fileManager.undoTypeNotSupported")); return; } handleRefreshDirectory(); } catch (error: unknown) { toast.error( `${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`, ); console.error("Undo failed:", error); } } function handleRenameFile(file: FileItem) { setEditingFile(file); } async function ensureSSHConnection() { if (!sshSessionId || !currentHost || isReconnecting) return; try { const status = await getSSHStatus(sshSessionId); if (!status.connected && !isReconnecting) { setIsReconnecting(true); await connectSSH(sshSessionId, { hostId: currentHost.id, ip: currentHost.ip, port: currentHost.port, username: currentHost.username, password: currentHost.password, sshKey: currentHost.key, keyPassword: currentHost.keyPassword, authType: currentHost.authType, credentialId: currentHost.credentialId, userId: currentHost.userId, }); } } catch (error) { handleCloseWithError( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, ); throw error; } finally { setIsReconnecting(false); } } async function handleConfirmCreate(name: string) { if (!createIntent || !sshSessionId) return; try { await ensureSSHConnection(); if (createIntent.type === "file") { await createSSHFile( sshSessionId, currentPath, name, "", currentHost?.id, currentHost?.userId?.toString(), ); toast.success(t("fileManager.fileCreatedSuccessfully", { name })); } else { await createSSHFolder( sshSessionId, currentPath, name, currentHost?.id, currentHost?.userId?.toString(), ); toast.success(t("fileManager.folderCreatedSuccessfully", { name })); } setCreateIntent(null); handleRefreshDirectory(); } catch (error: unknown) { console.error("Create failed:", error); toast.error(t("fileManager.failedToCreateItem")); } } function handleCancelCreate() { setCreateIntent(null); } async function handleRenameConfirm(file: FileItem, newName: string) { if (!sshSessionId) return; try { await ensureSSHConnection(); await renameSSHItem( sshSessionId, file.path, newName, currentHost?.id, currentHost?.userId?.toString(), ); toast.success( t("fileManager.itemRenamedSuccessfully", { name: newName }), ); setEditingFile(null); handleRefreshDirectory(); } catch (error: unknown) { console.error("Rename failed:", error); toast.error(t("fileManager.failedToRenameItem")); } } function handleStartEdit(file: FileItem) { setEditingFile(file); } function handleCancelEdit() { setEditingFile(null); } async function handleTotpSubmit(code: string) { if (!totpSessionId || !code) return; try { setIsLoading(true); const result = await verifySSHTOTP(totpSessionId, code); if (result?.status === "success") { setTotpRequired(false); setTotpPrompt(""); setSshSessionId(totpSessionId); setTotpSessionId(null); try { const response = await listSSHFiles(totpSessionId, currentPath); const files = Array.isArray(response) ? response : response?.files || []; setFiles(files); clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); } catch (dirError: unknown) { console.error("Failed to load initial directory:", dirError); } } } catch (error: unknown) { console.error("TOTP verification failed:", error); toast.error(t("fileManager.totpVerificationFailed")); } finally { setIsLoading(false); } } function handleTotpCancel() { setTotpRequired(false); setTotpPrompt(""); setTotpSessionId(null); if (onClose) onClose(); } function generateUniqueName( baseName: string, type: "file" | "directory", ): string { const existingNames = files.map((f) => f.name.toLowerCase()); let candidateName = baseName; let counter = 1; while (existingNames.includes(candidateName.toLowerCase())) { if (type === "file" && baseName.includes(".")) { const lastDotIndex = baseName.lastIndexOf("."); const nameWithoutExt = baseName.substring(0, lastDotIndex); const extension = baseName.substring(lastDotIndex); candidateName = `${nameWithoutExt}${counter}${extension}`; } else { candidateName = `${baseName}${counter}`; } counter++; } return candidateName; } async function handleFileDrop( draggedFiles: FileItem[], targetFolder: FileItem, ) { if (!sshSessionId || targetFolder.type !== "directory") return; try { await ensureSSHConnection(); let successCount = 0; const movedItems: string[] = []; for (const file of draggedFiles) { try { const targetPath = targetFolder.path.endsWith("/") ? `${targetFolder.path}${file.name}` : `${targetFolder.path}/${file.name}`; if (file.path !== targetPath) { await moveSSHItem( sshSessionId, file.path, targetPath, currentHost?.id, currentHost?.userId?.toString(), ); movedItems.push(file.name); successCount++; } } catch (error: unknown) { console.error(`Failed to move file ${file.name}:`, error); toast.error( t("fileManager.moveFileFailed", { name: file.name }) + ": " + error.message, ); } } if (successCount > 0) { const movedFiles = draggedFiles.slice(0, successCount).map((file) => { const targetPath = targetFolder.path.endsWith("/") ? `${targetFolder.path}${file.name}` : `${targetFolder.path}/${file.name}`; return { originalPath: file.path, targetPath: targetPath, targetName: file.name, }; }); const undoAction: UndoAction = { type: "cut", description: t("fileManager.dragMovedItems", { count: successCount, target: targetFolder.name, }), data: { operation: "cut", copiedFiles: movedFiles, targetDirectory: targetFolder.path, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); toast.success( t("fileManager.successfullyMovedItems", { count: successCount, target: targetFolder.name, }), ); handleRefreshDirectory(); clearSelection(); } } catch (error: unknown) { console.error("Drag move operation failed:", error); toast.error(t("fileManager.moveOperationFailed") + ": " + error.message); } } function handleFileDiff(file1: FileItem, file2: FileItem) { if (file1.type !== "file" || file2.type !== "file") { toast.error(t("fileManager.canOnlyCompareFiles")); return; } if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } const offsetX = 100; const offsetY = 80; const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const createWindowComponent = (windowId: string) => ( ); openWindow({ id: windowId, type: "diff", title: t("fileManager.fileComparison", { file1: file1.name, file2: file2.name, }), isMaximized: false, component: createWindowComponent, zIndex: Date.now(), }); toast.success( t("fileManager.comparingFiles", { file1: file1.name, file2: 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, onError: (error) => { console.error("System-level drag failed:", error); }, }); } else { if (files.length === 1) { await dragToDesktop.dragFileToDesktop(files[0]); } else if (files.length > 1) { await dragToDesktop.dragFilesToDesktop(files); } } } catch (error: unknown) { console.error("Drag to desktop failed:", error); toast.error( t("fileManager.dragFailed") + ": " + (error.message || t("fileManager.unknownError")), ); } } function handleOpenTerminal(path: string) { if (!currentHost) { toast.error(t("fileManager.noHostSelected")); return; } const windowCount = Date.now() % 10; const offsetX = 200 + windowCount * 40; const offsetY = 150 + windowCount * 40; const createTerminalComponent = (windowId: string) => ( ); openWindow({ title: t("fileManager.terminal", { host: currentHost.name, path }), x: offsetX, y: offsetY, width: 800, height: 500, isMaximized: false, isMinimized: false, component: createTerminalComponent, }); toast.success( t("terminal.terminalWithPath", { host: currentHost.name, path }), ); } function handleRunExecutable(file: FileItem) { if (!currentHost) { toast.error(t("fileManager.noHostSelected")); return; } if (file.type !== "file" || !file.executable) { toast.error(t("fileManager.onlyRunExecutableFiles")); return; } const fileDir = file.path.substring(0, file.path.lastIndexOf("/")); const fileName = file.name; const executeCmd = `./${fileName}`; const windowCount = Date.now() % 10; const offsetX = 250 + windowCount * 40; const offsetY = 200 + windowCount * 40; const createExecutionTerminal = (windowId: string) => ( ); openWindow({ title: t("fileManager.runningFile", { file: file.name }), x: offsetX, y: offsetY, width: 800, height: 500, isMaximized: false, isMinimized: false, component: createExecutionTerminal, }); toast.success(t("fileManager.runningFile", { file: file.name })); } async function loadPinnedFiles() { if (!currentHost?.id) return; try { const pinnedData = await getPinnedFiles(currentHost.id); const pinnedPaths = new Set( pinnedData.map((item: Record) => item.path), ); setPinnedFiles(pinnedPaths); } catch (error) { console.error("Failed to load pinned files:", error); } } async function handlePinFile(file: FileItem) { if (!currentHost?.id) return; try { await addPinnedFile(currentHost.id, file.path, file.name); setPinnedFiles((prev) => new Set([...prev, file.path])); setSidebarRefreshTrigger((prev) => prev + 1); toast.success( t("fileManager.filePinnedSuccessfully", { name: file.name }), ); } catch (error) { console.error("Failed to pin file:", error); toast.error(t("fileManager.pinFileFailed")); } } async function handleUnpinFile(file: FileItem) { if (!currentHost?.id) return; try { await removePinnedFile(currentHost.id, file.path); setPinnedFiles((prev) => { const newSet = new Set(prev); newSet.delete(file.path); return newSet; }); setSidebarRefreshTrigger((prev) => prev + 1); toast.success( t("fileManager.fileUnpinnedSuccessfully", { name: file.name }), ); } catch (error) { console.error("Failed to unpin file:", error); toast.error(t("fileManager.unpinFileFailed")); } } async function handleAddShortcut(path: string) { if (!currentHost?.id) return; try { const folderName = path.split("/").pop() || path; await addFolderShortcut(currentHost.id, path, folderName); setSidebarRefreshTrigger((prev) => prev + 1); toast.success( t("fileManager.shortcutAddedSuccessfully", { name: folderName }), ); } catch (error) { console.error("Failed to add shortcut:", error); toast.error(t("fileManager.addShortcutFailed")); } } function isPinnedFile(file: FileItem): boolean { return pinnedFiles.has(file.path); } async function recordRecentFile(file: FileItem) { if (!currentHost?.id || file.type === "directory") return; try { await addRecentFile(currentHost.id, file.path, file.name); setSidebarRefreshTrigger((prev) => prev + 1); } catch (error) { console.error("Failed to record recent file:", error); } } async function handleSidebarFileOpen(sidebarItem: SidebarItem) { const file: FileItem = { name: sidebarItem.name, path: sidebarItem.path, type: "file", }; await handleFileOpen(file); } async function handleFileNotFound(file: FileItem) { if (!currentHost) return; try { await removeRecentFile(currentHost.id, file.path); await removePinnedFile(currentHost.id, file.path); setSidebarRefreshTrigger((prev) => prev + 1); } catch (error) { console.error("Failed to cleanup missing file:", error); } } useEffect(() => { setCreateIntent(null); }, [currentPath]); useEffect(() => { if (currentHost?.id) { loadPinnedFiles(); } }, [currentHost?.id]); const filteredFiles = files.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase()), ); if (!currentHost) { return (

{t("fileManager.selectHostToStart")}

); } return (

{currentHost.name}

{currentHost.ip}:{currentHost.port}
setSearchQuery(e.target.value)} className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border" />
{}} onFileOpen={handleFileOpen} onSelectionChange={setSelection} currentPath={currentPath} isLoading={isLoading} onPathChange={setCurrentPath} onRefresh={handleRefreshDirectory} onUpload={handleFilesDropped} onDownload={(files) => files.forEach(handleDownloadFile)} onContextMenu={handleContextMenu} viewMode={viewMode} onRename={handleRenameConfirm} editingFile={editingFile} onStartEdit={handleStartEdit} onCancelEdit={handleCancelEdit} onDelete={handleDeleteFiles} onCopy={handleCopyFiles} onCut={handleCutFiles} onPaste={handlePasteFiles} onUndo={handleUndo} hasClipboard={!!clipboard} onFileDrop={handleFileDrop} onFileDiff={handleFileDiff} onSystemDragStart={handleFileDragStart} onSystemDragEnd={handleFileDragEnd} createIntent={createIntent} onConfirmCreate={handleConfirmCreate} onCancelCreate={handleCancelCreate} /> setContextMenu((prev) => ({ ...prev, isVisible: false })) } onDownload={(files) => files.forEach(handleDownloadFile)} onRename={handleRenameFile} onCopy={handleCopyFiles} onCut={handleCutFiles} onPaste={handlePasteFiles} onDelete={handleDeleteFiles} onUpload={() => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.onchange = (e) => { const files = (e.target as HTMLInputElement).files; if (files) handleFilesDropped(files); }; input.click(); }} onNewFolder={handleCreateNewFolder} onNewFile={handleCreateNewFile} onRefresh={handleRefreshDirectory} hasClipboard={!!clipboard} onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} onOpenTerminal={(path) => handleOpenTerminal(path)} onRunExecutable={(file) => handleRunExecutable(file)} onPinFile={handlePinFile} onUnpinFile={handleUnpinFile} onAddShortcut={handleAddShortcut} isPinned={isPinnedFile} currentPath={currentPath} />
); } export function FileManager({ initialHost, onClose }: FileManagerProps) { return ( ); }