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 { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { Upload, FolderPlus, FilePlus, RefreshCw, Search, Grid3X3, List, Eye, Settings, } 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, 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; } // Linus-style data structure: creation intent completely separated from actual files interface CreateIntent { id: string; type: 'file' | 'directory'; defaultName: string; currentName: string; } // Internal component, uses window manager function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); // State const [currentHost, setCurrentHost] = useState( initialHost || null, ); const [currentPath, setCurrentPath] = useState("/"); 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 [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; isVisible: boolean; files: FileItem[]; }>({ x: 0, y: 0, isVisible: false, files: [], }); // Operation state const [clipboard, setClipboard] = useState<{ files: FileItem[]; operation: "copy" | "cut"; } | null>(null); // Undo history 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([]); // Linus-style state: creation intent separated from file editing const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); // Hooks const { selectedFiles, selectFile, selectAll, clearSelection, setSelection } = useFileSelection(); const { isDragging, dragHandlers } = useDragAndDrop({ onFilesDropped: handleFilesDropped, onError: (error) => toast.error(error), maxFileSize: 5120, // 5GB - support large files like SSH tools should }); // Drag to desktop functionality const dragToDesktop = useDragToDesktop({ sshSessionId: sshSessionId || "", sshHost: currentHost!, }); // System-level drag to desktop functionality (new approach) const systemDrag = useDragToSystemDesktop({ sshSessionId: sshSessionId || "", sshHost: currentHost!, }); // SSH keepalive function const startKeepalive = useCallback(() => { if (!sshSessionId) return; // Clear existing timer if (keepaliveTimerRef.current) { clearInterval(keepaliveTimerRef.current); } // Send keepalive every 30 seconds to match backend SSH settings keepaliveTimerRef.current = setInterval(async () => { if (sshSessionId) { try { await keepSSHAlive(sshSessionId); console.log("SSH keepalive sent successfully"); } catch (error) { console.error("SSH keepalive failed:", error); // If keepalive fails, session might be dead - could trigger reconnect here } } }, 30 * 1000); // 30 seconds - matches backend keepaliveInterval }, [sshSessionId]); const stopKeepalive = useCallback(() => { if (keepaliveTimerRef.current) { clearInterval(keepaliveTimerRef.current); keepaliveTimerRef.current = null; } }, []); // Initialize SSH connection useEffect(() => { if (currentHost) { initializeSSHConnection(); } }, [currentHost]); // Start/stop keepalive based on SSH session useEffect(() => { if (sshSessionId) { startKeepalive(); } else { stopKeepalive(); } // Cleanup on unmount return () => { stopKeepalive(); }; }, [sshSessionId, startKeepalive, stopKeepalive]); // Track if initial directory load is done to prevent duplicate loading const initialLoadDoneRef = useRef(false); // Track last path change to prevent rapid navigation issues const lastPathChangeRef = useRef(""); const pathChangeTimerRef = useRef(null); // Track current loading request to handle cancellation const currentLoadingPathRef = useRef(""); // SSH keepalive timer const keepaliveTimerRef = useRef(null); // Handle file drag to external const handleFileDragStart = useCallback( (files: FileItem[]) => { // Record currently dragged files systemDrag.startDragToSystem(files, { enableToast: true, onSuccess: () => { clearSelection(); }, onError: (error) => { console.error("Drag failed:", error); }, }); }, [systemDrag, clearSelection], ); const handleFileDragEnd = useCallback( (e: DragEvent, draggedFiles: FileItem[]) => { // More conservative detection - only trigger download if clearly outside window const margin = 10; // Very small margin to reduce false positives const isOutside = e.clientX < margin || e.clientX > window.innerWidth - margin || e.clientY < margin || e.clientY > window.innerHeight - margin; // Only trigger download if clearly outside the window bounds if (isOutside) { console.log("Drag ended outside window bounds, triggering download"); console.log("Dragged files:", draggedFiles); console.log("Dragged files length:", draggedFiles.length); if (draggedFiles.length === 0) { console.error("No files to drag - this should not happen"); return; } // Start system drag with the dragged files systemDrag.startDragToSystem(draggedFiles, { enableToast: true, onSuccess: () => { clearSelection(); }, onError: (error) => { console.error("Drag failed:", error); }, }); // Execute immediately to preserve user gesture context systemDrag.handleDragEnd(e); } else { console.log("Drag ended inside window bounds, cancelling download"); // Cancel drag - user probably didn't intend to download systemDrag.cancelDragToSystem(); } }, [systemDrag, clearSelection], ); async function initializeSSHConnection() { if (!currentHost) return; try { setIsLoading(true); // Reset initial load flag for new connections 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, }); setSshSessionId(sessionId); // Load initial directory immediately after connection to prevent jarring transition try { console.log("Loading initial directory:", currentPath); const response = await listSSHFiles(sessionId, currentPath); const files = Array.isArray(response) ? response : response?.files || []; console.log("Initial directory loaded successfully:", files.length, "items"); setFiles(files); clearSelection(); // Mark initial load as completed initialLoadDoneRef.current = true; } catch (dirError: any) { console.error("Failed to load initial directory:", dirError); // Don't show error toast here as it will be handled by the useEffect retry // Also don't close tab here since the connection succeeded, just directory loading failed } } catch (error: any) { console.error("SSH connection failed:", error); toast.error( t("fileManager.failedToConnect") + ": " + (error.message || error), ); // Close the tab when SSH connection fails if (onClose) { onClose(); } } finally { setIsLoading(false); } } const loadDirectory = useCallback(async (path: string) => { if (!sshSessionId) { console.error("Cannot load directory: no SSH session ID"); return; } // Prevent concurrent loading requests if (isLoading && currentLoadingPathRef.current !== path) { console.log("Directory loading already in progress, skipping:", path); return; } // Set current loading path for tracking currentLoadingPathRef.current = path; setIsLoading(true); // Clear createIntent when changing directories setCreateIntent(null); try { console.log("Loading directory:", path); const response = await listSSHFiles(sshSessionId, path); // Check if this is still the current request (avoid race conditions) if (currentLoadingPathRef.current !== path) { console.log("Directory load canceled, newer request in progress:", path); return; } console.log("Directory response received:", response); const files = Array.isArray(response) ? response : response?.files || []; console.log("Directory loaded successfully:", files.length, "items"); setFiles(files); clearSelection(); } catch (error: any) { // Only show error if this is still the current request if (currentLoadingPathRef.current === path) { console.error("Failed to load directory:", error); // Only show toast if this is not the initial load (to prevent duplicate toasts) if (initialLoadDoneRef.current) { toast.error( t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) ); } // Close the tab when directory loading fails due to SSH issues if (error.message?.includes("connection") || error.message?.includes("SSH")) { if (onClose) { onClose(); } } } } finally { // Only clear loading if this is still the current request if (currentLoadingPathRef.current === path) { setIsLoading(false); currentLoadingPathRef.current = ""; } } }, [sshSessionId, isLoading, clearSelection, t]); // Debounced directory loading for path changes const debouncedLoadDirectory = useCallback((path: string) => { // Clear any existing timer if (pathChangeTimerRef.current) { clearTimeout(pathChangeTimerRef.current); } // Set new timer for debounced loading pathChangeTimerRef.current = setTimeout(() => { if (path !== lastPathChangeRef.current && sshSessionId) { console.log("Loading directory after path change:", path); lastPathChangeRef.current = path; loadDirectory(path); } }, 150); // 150ms debounce for path changes }, [sshSessionId, loadDirectory]); // File list update - only reload when path changes, not on initial connection useEffect(() => { if (sshSessionId && currentPath) { // Skip the first load since it's handled in initializeSSHConnection if (!initialLoadDoneRef.current) { initialLoadDoneRef.current = true; lastPathChangeRef.current = currentPath; return; } // Use debounced loading for path changes to prevent rapid clicking issues debouncedLoadDirectory(currentPath); } // Cleanup timer on unmount or dependency change return () => { if (pathChangeTimerRef.current) { clearTimeout(pathChangeTimerRef.current); } }; }, [sshSessionId, currentPath, debouncedLoadDirectory]); // Debounced refresh function - prevent excessive clicking const handleRefreshDirectory = useCallback(() => { const now = Date.now(); const DEBOUNCE_MS = 500; // 500ms debounce if (now - lastRefreshTime < DEBOUNCE_MS) { console.log("Refresh ignored - too frequent"); return; } setLastRefreshTime(now); loadDirectory(currentPath); }, [currentPath, lastRefreshTime, loadDirectory]); // Global keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Check if input box or editable element has focus, skip if so const activeElement = document.activeElement; if ( activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.contentEditable === "true") ) { return; } // Handle Ctrl+Shift+T for opening terminal 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; try { // Ensure SSH connection is valid await ensureSSHConnection(); // Read file content const fileContent = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(reader.error); // Check file type to determine reading method const isTextFile = file.type.startsWith("text/") || file.type === "application/json" || file.type === "application/javascript" || file.type === "application/xml" || file.name.match( /\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/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, // userId - will be handled by backend ); toast.success( t("fileManager.fileUploadedSuccessfully", { name: file.name }), ); handleRefreshDirectory(); } catch (error: any) { 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 { // Ensure SSH connection is valid await ensureSSHConnection(); const response = await downloadSSHFile(sshSessionId, file.path); if (response?.content) { // Convert to blob and trigger download 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: any) { 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; try { // Ensure SSH connection is valid await ensureSSHConnection(); for (const file of files) { await deleteSSHItem( sshSessionId, file.path, file.type === "directory", // isDirectory currentHost?.id, currentHost?.userId?.toString(), ); } // Record deletion history (although cannot truly undo) 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", // Placeholder deletedFiles, targetDirectory: currentPath, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); toast.success( t("fileManager.itemsDeletedSuccessfully", { count: files.length }), ); handleRefreshDirectory(); clearSelection(); } catch (error: any) { 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); } } // Linus-style creation: pure intent, no side effects function handleCreateNewFolder() { const defaultName = generateUniqueName("NewFolder", "directory"); const newCreateIntent = { id: Date.now().toString(), type: 'directory' as const, defaultName, currentName: defaultName }; setCreateIntent(newCreateIntent); } function handleCreateNewFile() { const defaultName = generateUniqueName("NewFile.txt", "file"); const newCreateIntent = { id: Date.now().toString(), type: 'file' as const, defaultName, currentName: defaultName }; setCreateIntent(newCreateIntent); } // Handle symlink resolution const handleSymlinkClick = async (file: FileItem) => { if (!currentHost || !sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } try { // Ensure SSH connection is valid let currentSessionId = sshSessionId; try { 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")); } } } catch (sessionErr) { throw sessionErr; } const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path); if (symlinkInfo.type === "directory") { // If symlink points to directory, navigate to it setCurrentPath(symlinkInfo.target); } else if (symlinkInfo.type === "file") { // If symlink points to file, open file // Calculate window position (slightly offset) const windowCount = Date.now() % 10; const offsetX = 120 + windowCount * 30; const offsetY = 120 + windowCount * 30; // Create target file object const targetFile: FileItem = { ...file, path: symlinkInfo.target, }; // Create window component factory function const createWindowComponent = (windowId: string) => ( ); openWindow({ title: file.name, x: offsetX, y: offsetY, width: 800, height: 600, isMaximized: false, isMinimized: false, component: createWindowComponent, }); } } catch (error: any) { toast.error( error?.response?.data?.error || error?.message || t("fileManager.failedToResolveSymlink"), ); } }; async function handleFileOpen(file: FileItem, editMode: boolean = false) { if (file.type === "directory") { setCurrentPath(file.path); } else if (file.type === "link") { // Handle symlinks await handleSymlinkClick(file); } else { // Open file in new window if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } // Record to recent access for regular files await recordRecentFile(file); // Calculate window position (slightly offset) const windowCount = Date.now() % 10; // Simple offset calculation const offsetX = 120 + windowCount * 30; const offsetY = 120 + windowCount * 30; const windowTitle = file.name; // Remove mode identifier, controlled internally by FileViewer // Create window component factory function const createWindowComponent = (windowId: string) => ( ); openWindow({ title: windowTitle, x: offsetX, y: offsetY, width: 800, height: 600, isMaximized: false, isMinimized: false, component: createWindowComponent, }); } } // Dedicated file editing function function handleFileEdit(file: FileItem) { handleFileOpen(file, true); } // Dedicated file viewing function (read-only) function handleFileView(file: FileItem) { handleFileOpen(file, false); } function handleContextMenu(event: React.MouseEvent, file?: FileItem) { event.preventDefault(); // If right-clicked file is already in selection list, use all selected files // If right-clicked file is not in selection list, use only this file 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; // Handle copy and cut operations let successCount = 0; const copiedItems: string[] = []; for (const file of files) { try { if (operation === "copy") { // Copy operation: call copy API const result = await copySSHItem( sshSessionId, file.path, currentPath, currentHost?.id, currentHost?.userId?.toString(), ); copiedItems.push(result.uniqueName || file.name); successCount++; } else { // Cut operation: move files to target directory const targetPath = currentPath.endsWith("/") ? `${currentPath}${file.name}` : `${currentPath}/${file.name}`; // Only move when target path differs from original path if (file.path !== targetPath) { // Use dedicated moveSSHItem API for cross-directory movement await moveSSHItem( sshSessionId, file.path, targetPath, currentHost?.id, currentHost?.userId?.toString(), ); successCount++; } } } catch (error: any) { 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 }), ); } } // Record undo history 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]); // Keep max 10 undo records } else if (operation === "cut") { // Cut operation: record move info, can be moved back to original position on undo 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, // Reuse copiedFiles field to store move info targetDirectory: currentPath, }, timestamp: Date.now(), }; setUndoHistory((prev) => [...prev.slice(-9), undoAction]); } } // Show success message if (successCount > 0) { const operationText = operation === "copy" ? t("fileManager.copy") : t("fileManager.move"); if (operation === "copy" && copiedItems.length > 0) { // Show detailed copy info, including renamed files 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 })); } } // Refresh file list handleRefreshDirectory(); clearSelection(); // Clear clipboard (after cut operation, copy operation retains clipboard content) if (operation === "cut") { setClipboard(null); } } catch (error: any) { 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(); // Execute undo logic based on different operation types switch (lastAction.type) { case "copy": // Undo copy operation: delete copied target files 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: any) { console.error( `Failed to delete copied file ${copiedFile.targetName}:`, error, ); toast.error( t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, error: error.message }), ); } } if (successCount > 0) { // Remove last undo record 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": // Undo cut operation: move files back to original position if (lastAction.data.copiedFiles) { let successCount = 0; for (const movedFile of lastAction.data.copiedFiles) { try { // Move file from current position back to original position await moveSSHItem( sshSessionId!, movedFile.targetPath, // Current position (target path) movedFile.originalPath, // Move back to original position currentHost?.id, currentHost?.userId?.toString(), ); successCount++; } catch (error: any) { console.error( `Failed to move back file ${movedFile.targetName}:`, error, ); toast.error( t("fileManager.moveBackFileFailed", { name: movedFile.targetName, error: error.message }), ); } } if (successCount > 0) { // Remove last undo record 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": // Delete operation cannot be truly undone (file already deleted from server) toast.info(t("fileManager.undoDeleteNotSupported")); // Still remove history record as user already knows this limitation setUndoHistory((prev) => prev.slice(0, -1)); return; default: toast.error(t("fileManager.undoTypeNotSupported")); return; } // Refresh file list handleRefreshDirectory(); } catch (error: any) { toast.error(`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`); console.error("Undo failed:", error); } } function handleRenameFile(file: FileItem) { setEditingFile(file); } // Ensure SSH connection is valid - simplified version, prevent concurrent reconnection async function ensureSSHConnection() { if (!sshSessionId || !currentHost || isReconnecting) return; try { const status = await getSSHStatus(sshSessionId); if (!status.connected && !isReconnecting) { setIsReconnecting(true); console.log("SSH disconnected, reconnecting..."); 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, }); console.log("SSH reconnection successful"); } } catch (error) { console.log("SSH reconnection failed:", error); // Close the tab when SSH reconnection fails if (onClose) { onClose(); } throw error; } finally { setIsReconnecting(false); } } // Linus-style creation confirmation: pure creation, no mixed logic async function handleConfirmCreate(name: string) { if (!createIntent || !sshSessionId) return; try { await ensureSSHConnection(); console.log(`Creating ${createIntent.type}:`, name); 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); // Clear intent handleRefreshDirectory(); } catch (error: any) { console.error("Create failed:", error); toast.error(t("fileManager.failedToCreateItem")); } } // Linus-style cancel: zero side effects function handleCancelCreate() { setCreateIntent(null); // Just that simple! console.log("Create cancelled - no side effects"); } // Pure rename confirmation: only handle real files async function handleRenameConfirm(file: FileItem, newName: string) { if (!sshSessionId) return; try { await ensureSSHConnection(); console.log("Renaming existing item:", { from: file.path, to: newName, }); await renameSSHItem( sshSessionId, file.path, newName, currentHost?.id, currentHost?.userId?.toString(), ); toast.success(t("fileManager.itemRenamedSuccessfully", { name: newName })); setEditingFile(null); handleRefreshDirectory(); } catch (error: any) { console.error("Rename failed:", error); toast.error(t("fileManager.failedToRenameItem")); } } // Start editing file name function handleStartEdit(file: FileItem) { setEditingFile(file); } // Linus-style cancel edit: pure cancel, no side effects function handleCancelEdit() { setEditingFile(null); // Simple and elegant console.log("Edit cancelled - no side effects"); } // Generate unique name (handle name conflicts) function generateUniqueName( baseName: string, type: "file" | "directory", ): string { const existingNames = files.map((f) => f.name.toLowerCase()); let candidateName = baseName; let counter = 1; // If name already exists, try adding number suffix while (existingNames.includes(candidateName.toLowerCase())) { if (type === "file" && baseName.includes(".")) { // For files, add number between filename and extension const lastDotIndex = baseName.lastIndexOf("."); const nameWithoutExt = baseName.substring(0, lastDotIndex); const extension = baseName.substring(lastDotIndex); candidateName = `${nameWithoutExt}${counter}${extension}`; } else { // For folders or files without extension, add number directly candidateName = `${baseName}${counter}`; } counter++; } console.log(`Generated unique name: ${baseName} -> ${candidateName}`); return candidateName; } // Drag handling: file/folder drag to folder = move operation 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}`; // Only move when target path differs from original path if (file.path !== targetPath) { await moveSSHItem( sshSessionId, file.path, targetPath, currentHost?.id, currentHost?.userId?.toString(), ); movedItems.push(file.name); successCount++; } } catch (error: any) { console.error(`Failed to move file ${file.name}:`, error); toast.error(t("fileManager.moveFileFailed", { name: file.name }) + ": " + error.message); } } if (successCount > 0) { // Record undo history const movedFiles = draggedFiles .slice(0, successCount) .map((file, index) => { 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(); // Clear selection state } } catch (error: any) { console.error("Drag move operation failed:", error); toast.error(t("fileManager.moveOperationFailed") + ": " + error.message); } } // Drag handling: file drag to file = diff comparison operation 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; } // Use dedicated DiffWindow for file comparison console.log("Opening diff comparison:", file1.name, "vs", file2.name); // Calculate window position const offsetX = 100; const offsetY = 80; // Create diff window 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 })); } // Drag to desktop handler function async function handleDragToDesktop(files: FileItem[]) { if (!currentHost || !sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } try { // Prefer new system-level drag approach if (systemDrag.isFileSystemAPISupported) { await systemDrag.handleDragToSystem(files, { enableToast: true, onSuccess: () => { console.log("System-level drag successful"); }, onError: (error) => { console.error("System-level drag failed:", error); }, }); } else { // Fallback to Electron approach if (files.length === 1) { await dragToDesktop.dragFileToDesktop(files[0]); } else if (files.length > 1) { await dragToDesktop.dragFilesToDesktop(files); } } } catch (error: any) { console.error("Drag to desktop failed:", error); toast.error(t("fileManager.dragFailed") + ": " + (error.message || t("fileManager.unknownError"))); } } // Open terminal handler function function handleOpenTerminal(path: string) { if (!currentHost) { toast.error(t("fileManager.noHostSelected")); return; } // Create terminal window 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 }), ); } // Run executable file handler function function handleRunExecutable(file: FileItem) { if (!currentHost) { toast.error(t("fileManager.noHostSelected")); return; } if (file.type !== "file" || !file.executable) { toast.error(t("fileManager.onlyRunExecutableFiles")); return; } // Get file directory const fileDir = file.path.substring(0, file.path.lastIndexOf("/")); const fileName = file.name; const executeCmd = `./${fileName}`; // Create terminal window for execution 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 })); } // Load pinned files list async function loadPinnedFiles() { if (!currentHost?.id) return; try { const pinnedData = await getPinnedFiles(currentHost.id); const pinnedPaths = new Set(pinnedData.map((item: any) => item.path)); setPinnedFiles(pinnedPaths); } catch (error) { console.error("Failed to load pinned files:", error); } } // PIN file 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); // Trigger sidebar refresh toast.success(t("fileManager.filePinnedSuccessfully", { name: file.name })); } catch (error) { console.error("Failed to pin file:", error); toast.error(t("fileManager.pinFileFailed")); } } // UNPIN file 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); // Trigger sidebar refresh toast.success(t("fileManager.fileUnpinnedSuccessfully", { name: file.name })); } catch (error) { console.error("Failed to unpin file:", error); toast.error(t("fileManager.unpinFileFailed")); } } // Add folder shortcut 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); // Trigger sidebar refresh toast.success(t("fileManager.shortcutAddedSuccessfully", { name: folderName })); } catch (error) { console.error("Failed to add shortcut:", error); toast.error(t("fileManager.addShortcutFailed")); } } // Check if file is pinned function isPinnedFile(file: FileItem): boolean { return pinnedFiles.has(file.path); } // Record recently accessed file 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); // Trigger sidebar refresh } catch (error) { console.error("Failed to record recent file:", error); } } // Handle sidebar file opening async function handleSidebarFileOpen(sidebarItem: SidebarItem) { // Convert SidebarItem to FileItem format const file: FileItem = { name: sidebarItem.name, path: sidebarItem.path, type: "file", // Both recent and pinned are file types }; // Call regular file opening handler await handleFileOpen(file); } // Handle file not found - cleanup from recent and pinned lists async function handleFileNotFound(file: FileItem) { if (!currentHost) return; try { // Remove from recent files await removeRecentFile(currentHost.id, file.path); // Remove from pinned files await removePinnedFile(currentHost.id, file.path); // Trigger sidebar refresh to update the UI setSidebarRefreshTrigger(prev => prev + 1); console.log(`Cleaned up missing file from recent/pinned lists: ${file.path}`); } catch (error) { console.error("Failed to cleanup missing file:", error); } } // Clear createIntent when path changes useEffect(() => { setCreateIntent(null); }, [currentPath]); // Load pinned files list (when host or connection changes) useEffect(() => { if (currentHost?.id) { loadPinnedFiles(); } }, [currentHost?.id]); // Linus-style data separation: only filter real files const filteredFiles = files.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase()), ); if (!currentHost) { return (

{t("fileManager.selectHostToStart")}

); } return (
{/* Toolbar */}

{currentHost.name}

{currentHost.ip}:{currentHost.port}
{/* Search */}
setSearchQuery(e.target.value)} className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border" />
{/* View toggle */}
{/* Action buttons */}
{/* Main content area */}
{/* Left sidebar */}
{/* Right file grid */}
{}} // No longer need this callback, use onSelectionChange 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} /> {/* Right-click menu */} 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} />
); } // Main export component, wrapped with WindowManager export function FileManager({ initialHost, onClose, }: FileManagerProps) { return ( ); }