import { ArrowUp, ChevronRight, Database, Download, Edit2, ExternalLink, File, FileArchive, FileAudio, FileCode, FileImage, FileSpreadsheet, FileText, FileType, FileVideo, Folder, FolderOpen, Globe, Home, Key, Link, Loader2, Lock, MoreHorizontal, Plus, RefreshCw, Settings, Shield, Terminal, Trash2, Upload, X, } from "lucide-react"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useSftpBackend } from "../application/state/useSftpBackend"; import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations"; import { useSettingsState } from "../application/state/useSettingsState"; import { logger } from "../lib/logger"; import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils"; import { cn } from "../lib/utils"; import { Host, RemoteFile } from "../types"; import { filterHiddenFiles } from "./sftp"; import { DistroAvatar } from "./DistroAvatar"; import FileOpenerDialog from "./FileOpenerDialog"; import TextEditorModal from "./TextEditorModal"; import { Button } from "./ui/button"; import { toast } from "./ui/toast"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "./ui/context-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; import { Input } from "./ui/input"; // Comprehensive file icon helper const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => { if (isDirectory) return ( ); // For symlink files (not directories), show a special symlink icon if (isSymlink) { return ; } const ext = fileName.split(".").pop()?.toLowerCase() || ""; const iconClass = "text-muted-foreground"; // Documents if (["doc", "docx", "rtf", "odt"].includes(ext)) return ; if (["xls", "xlsx", "csv", "ods"].includes(ext)) return ; if (["ppt", "pptx", "odp"].includes(ext)) return ; if (["pdf"].includes(ext)) return ; // Code/Scripts if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext)) return ; if (["py", "pyc", "pyw"].includes(ext)) return ; if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext)) return ; if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext)) return ; if (["java", "class", "jar"].includes(ext)) return ; if (["go"].includes(ext)) return ; if (["rs"].includes(ext)) return ; if (["rb"].includes(ext)) return ; if (["php"].includes(ext)) return ; if (["html", "htm", "xhtml"].includes(ext)) return ; if (["css", "scss", "sass", "less"].includes(ext)) return ; if (["vue", "svelte"].includes(ext)) return ; // Config/Data if (["json", "json5"].includes(ext)) return ; if (["xml", "xsl", "xslt"].includes(ext)) return ; if (["yml", "yaml"].includes(ext)) return ; if (["toml", "ini", "conf", "cfg", "config"].includes(ext)) return ; if (["env"].includes(ext)) return ; if (["sql", "sqlite", "db"].includes(ext)) return ; // Images if ( [ "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico", "tiff", "tif", "heic", "heif", "avif", ].includes(ext) ) return ; // Videos if ( [ "mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp", "mpeg", "mpg", ].includes(ext) ) return ; // Audio if ( ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes( ext, ) ) return ; // Archives if ( [ "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz", "tbz2", "lz", "lzma", "cab", "iso", "dmg", ].includes(ext) ) return ; // Executables if (["exe", "msi", "app", "deb", "rpm", "apk", "ipa"].includes(ext)) return ; if (["dll", "so", "dylib"].includes(ext)) return ; // Keys/Certs if (["pem", "crt", "cer", "key", "pub", "ppk"].includes(ext)) return ; // Text/Markdown if (["md", "markdown", "mdx"].includes(ext)) return ; if (["txt", "log", "text"].includes(ext)) return ; // Default return ; }; // Format bytes with appropriate unit (B, KB, MB, GB) const formatBytes = (bytes: number | string): string => { const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes; if (isNaN(numBytes) || numBytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(numBytes) / Math.log(1024)); const size = numBytes / Math.pow(1024, i); return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }; const formatDate = (dateStr: string | number | undefined, locale?: string): string => { if (!dateStr) return "--"; const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr); if (isNaN(date.getTime())) return String(dateStr); return date.toLocaleString(locale || undefined); }; interface SFTPModalProps { host: Host; credentials: { username?: string; hostname: string; port?: number; password?: string; privateKey?: string; certificate?: string; passphrase?: string; publicKey?: string; keyId?: string; keySource?: 'generated' | 'imported'; proxy?: NetcattyProxyConfig; jumpHosts?: NetcattyJumpHost[]; }; open: boolean; onClose: () => void; /** Initial path to open in SFTP. If not accessible, falls back to home directory. */ initialPath?: string; } // Sort configuration type SortField = "name" | "size" | "modified"; type SortOrder = "asc" | "desc"; // Transfer task type for modal interface UploadTask { id: string; fileName: string; status: "pending" | "uploading" | "completed" | "failed"; progress: number; totalBytes: number; transferredBytes: number; speed: number; // bytes per second startTime: number; error?: string; } const SFTPModal: React.FC = ({ host, credentials, open, onClose, initialPath, }) => { const { openSftp, closeSftp: closeSftpBackend, listSftp, readSftp, writeSftpBinaryWithProgress, writeSftpBinary, writeSftp, deleteSftp, mkdirSftp, renameSftp, chmodSftp, statSftp, listLocalDir, readLocalFile, writeLocalFile, deleteLocalFile, mkdirLocal, getHomeDir, selectApplication, downloadSftpToTempAndOpen, } = useSftpBackend(); const { t, resolvedLocale } = useI18n(); const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState(); const isLocalSession = host.protocol === "local"; const [currentPath, setCurrentPath] = useState("/"); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [dragActive, setDragActive] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [uploading, setUploading] = useState(false); const [uploadTasks, setUploadTasks] = useState([]); const inputRef = useRef(null); const sftpIdRef = useRef(null); const initializedRef = useRef(false); const lastInitialPathRef = useRef(undefined); const navigatingRef = useRef(false); const lastSelectedIndexRef = useRef(null); const localHomeRef = useRef(null); // Reconnect state const [reconnecting, setReconnecting] = useState(false); const reconnectingRef = useRef(false); const reconnectAttemptsRef = useRef(0); const MAX_RECONNECT_ATTEMPTS = 3; // Rename dialog state const [showRenameDialog, setShowRenameDialog] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [renameName, setRenameName] = useState(""); const [isRenaming, setIsRenaming] = useState(false); // Permissions dialog state const [showPermissionsDialog, setShowPermissionsDialog] = useState(false); const [permissionsTarget, setPermissionsTarget] = useState(null); const [permissions, setPermissions] = useState({ owner: { read: false, write: false, execute: false }, group: { read: false, write: false, execute: false }, others: { read: false, write: false, execute: false }, }); const [isChangingPermissions, setIsChangingPermissions] = useState(false); // File opener state const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations(); const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false); const [fileOpenerTarget, setFileOpenerTarget] = useState(null); // Text editor state const [showTextEditor, setShowTextEditor] = useState(false); const [textEditorTarget, setTextEditorTarget] = useState(null); const [textEditorContent, setTextEditorContent] = useState(""); const [loadingTextContent, setLoadingTextContent] = useState(false); // Virtual scrolling refs and state const fileListRef = useRef(null); const scrollFrameRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const [rowHeight, setRowHeight] = useState(40); // Default row height estimate // Directory listing cache + load sequence to avoid stale updates const DIR_CACHE_TTL_MS = 10_000; const dirCacheRef = useRef< Map >(new Map()); const loadSeqRef = useRef(0); // Sorting state const [sortField, setSortField] = useState("name"); const [sortOrder, setSortOrder] = useState("asc"); // Column widths (percentages) const [columnWidths, setColumnWidths] = useState({ name: 45, size: 15, modified: 25, actions: 15, }); const resizingRef = useRef<{ field: string; startX: number; startWidth: number; } | null>(null); // Editable path state const [isEditingPath, setIsEditingPath] = useState(false); const [editingPathValue, setEditingPathValue] = useState(""); const pathInputRef = useRef(null); // Breadcrumb truncation constant const MAX_VISIBLE_BREADCRUMB_PARTS = 4; const isWindowsPath = useCallback((path: string): boolean => { return /^[A-Za-z]:/.test(path); }, []); const normalizeWindowsRoot = useCallback((path: string): string => { const normalized = path.replace(/\//g, "\\"); if (/^[A-Za-z]:\\$/.test(normalized)) return normalized; if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`; return normalized; }, []); const joinPath = useCallback( (base: string, name: string): string => { if (isLocalSession && isWindowsPath(base)) { const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, ""); return `${normalizedBase}\\${name}`; } if (!isLocalSession) { if (base === "/") return `/${name}`; return `${base}/${name}`; } // Local unix-like path if (base === "/") return `/${name}`; return `${base}/${name}`; }, [isLocalSession, isWindowsPath, normalizeWindowsRoot], ); const isRootPath = useCallback( (path: string): boolean => { if (isLocalSession && isWindowsPath(path)) { return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\")); } return path === "/"; }, [isLocalSession, isWindowsPath], ); const getParentPath = useCallback( (path: string): string => { if (isLocalSession && isWindowsPath(path)) { const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, ""); const drive = normalized.slice(0, 2); if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) { return `${drive}\\`; } const rest = normalized.slice(2).replace(/^[\\]+/, ""); const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : []; if (parts.length <= 1) return `${drive}\\`; parts.pop(); return `${drive}\\${parts.join("\\")}`; } if (path === "/") return "/"; const parts = path.split("/").filter(Boolean); parts.pop(); return parts.length ? `/${parts.join("/")}` : "/"; }, [isLocalSession, isWindowsPath, normalizeWindowsRoot], ); const getRootPath = useCallback( (path: string): string => { if (isLocalSession && isWindowsPath(path)) { const drive = path.replace(/\//g, "\\").slice(0, 2); return `${drive}\\`; } return "/"; }, [isLocalSession, isWindowsPath], ); const getWindowsDrive = useCallback( (path: string): string | null => { if (!isWindowsPath(path)) return null; const normalized = path.replace(/\//g, "\\"); return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null; }, [isWindowsPath], ); const getBreadcrumbs = useCallback( (path: string): string[] => { if (isLocalSession && isWindowsPath(path)) { const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, ""); const rest = normalized.slice(2).replace(/^[\\]+/, ""); const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : []; return parts; } return path === "/" ? [] : path.split("/").filter(Boolean); }, [isLocalSession, isWindowsPath, normalizeWindowsRoot], ); const breadcrumbPathAt = useCallback( (breadcrumbs: string[], idx: number): string => { if (isLocalSession) { const drive = getWindowsDrive(currentPath); if (drive) { const rest = breadcrumbs.slice(0, idx + 1).join("\\"); return rest ? `${drive}\\${rest}` : `${drive}\\`; } } return "/" + breadcrumbs.slice(0, idx + 1).join("/"); }, [currentPath, getWindowsDrive, isLocalSession], ); const ensureSftp = useCallback(async () => { if (isLocalSession) throw new Error("Local session does not use SFTP"); if (sftpIdRef.current) return sftpIdRef.current; const sftpId = await openSftp({ sessionId: `sftp-modal-${host.id}`, hostname: credentials.hostname, username: credentials.username || "root", port: credentials.port || 22, password: credentials.password, privateKey: credentials.privateKey, certificate: credentials.certificate, passphrase: credentials.passphrase, publicKey: credentials.publicKey, keyId: credentials.keyId, keySource: credentials.keySource, proxy: credentials.proxy, jumpHosts: credentials.jumpHosts, }); sftpIdRef.current = sftpId; return sftpId; }, [ isLocalSession, host.id, credentials.hostname, credentials.username, credentials.port, credentials.password, credentials.privateKey, credentials.certificate, credentials.passphrase, credentials.publicKey, credentials.keyId, credentials.keySource, credentials.proxy, credentials.jumpHosts, openSftp, ]); // Check if an error indicates a stale/lost SFTP session const isSessionError = useCallback((err: unknown): boolean => { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); return ( msg.includes("session not found") || msg.includes("sftp session") || msg.includes("not found") || msg.includes("closed") || msg.includes("connection reset") || msg.includes("eof") ); }, []); // Handle session error - triggers auto-reconnect const handleSessionError = useCallback(() => { if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { toast.error(t("sftp.error.reconnectFailed"), "SFTP"); setReconnecting(false); reconnectingRef.current = false; return; } // Clear stale session reference sftpIdRef.current = null; // Set reconnecting state reconnectingRef.current = true; reconnectAttemptsRef.current++; setReconnecting(true); }, [t]); const loadFiles = useCallback( async (path: string, options?: { force?: boolean }) => { const requestId = ++loadSeqRef.current; const cacheKey = `${host.id}::${path}`; const cached = options?.force ? undefined : dirCacheRef.current.get(cacheKey); if ( cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS && cached.files ) { setFiles(cached.files); setSelectedFiles(new Set()); setLoading(false); return; } try { setLoading(true); const list = isLocalSession ? await listLocalDir(path) : await listSftp(await ensureSftp(), path); if (loadSeqRef.current !== requestId) return; dirCacheRef.current.set(cacheKey, { files: list, timestamp: Date.now(), }); setFiles(list); setSelectedFiles(new Set()); } catch (e) { if (loadSeqRef.current !== requestId) return; // Check if this is a session error that can trigger auto-reconnect if (!isLocalSession && isSessionError(e) && files.length > 0) { logger.info("[SFTP] Session lost, attempting to reconnect..."); handleSessionError(); return; } logger.error("Failed to load files", e); toast.error( e instanceof Error ? e.message : t("sftp.error.loadFailed"), "SFTP", ); setFiles([]); } finally { if (loadSeqRef.current === requestId) { setLoading(false); } } }, [ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length], ); useLayoutEffect(() => { if (!open) return; const cacheKey = `${host.id}::${currentPath}`; const cached = dirCacheRef.current.get(cacheKey); const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS; if (!isFresh) { setFiles([]); setSelectedFiles(new Set()); } setScrollTop(0); }, [currentPath, host.id, open]); const closeSftpSession = useCallback(async () => { if (!isLocalSession && sftpIdRef.current) { try { await closeSftpBackend(sftpIdRef.current); } catch { // Silently ignore close errors - connection may already be closed } } sftpIdRef.current = null; }, [closeSftpBackend, isLocalSession]); useEffect(() => { return () => { // Cleanup on unmount void closeSftpSession(); }; }, [closeSftpSession]); // Auto-reconnect effect useEffect(() => { if (!reconnecting || !reconnectingRef.current || isLocalSession) return; const attemptReconnect = async () => { // Small delay before reconnecting await new Promise((resolve) => setTimeout(resolve, 1000)); if (!reconnectingRef.current) return; // May have been cancelled try { // Re-establish SFTP connection const sftpId = await openSftp({ sessionId: `sftp-modal-${host.id}`, hostname: credentials.hostname, username: credentials.username || "root", port: credentials.port || 22, password: credentials.password, privateKey: credentials.privateKey, certificate: credentials.certificate, passphrase: credentials.passphrase, publicKey: credentials.publicKey, keyId: credentials.keyId, keySource: credentials.keySource, proxy: credentials.proxy, jumpHosts: credentials.jumpHosts, }); sftpIdRef.current = sftpId; // Refresh current directory const list = await listSftp(sftpId, currentPath); dirCacheRef.current.set(`${host.id}::${currentPath}`, { files: list, timestamp: Date.now(), }); setFiles(list); setSelectedFiles(new Set()); // Reconnect successful reconnectingRef.current = false; reconnectAttemptsRef.current = 0; setReconnecting(false); toast.success(t("sftp.reconnected"), "SFTP"); } catch (e) { logger.error("[SFTP] Reconnect failed", e); // Check if we can retry if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) { // Trigger another attempt sftpIdRef.current = null; reconnectingRef.current = false; // Reset to allow handleSessionError to work handleSessionError(); } else { // Max retries reached reconnectingRef.current = false; setReconnecting(false); toast.error(t("sftp.error.reconnectFailed"), "SFTP"); } } finally { setLoading(false); } }; attemptReconnect(); }, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]); useEffect(() => { if (open) { // Check if we need to reinitialize (either first time or initialPath changed) const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current; if (needsReinit) { initializedRef.current = true; lastInitialPathRef.current = initialPath; if (isLocalSession) { void (async () => { let home = localHomeRef.current; if (!home) { const fetchedHome = await getHomeDir(); home = fetchedHome ?? null; localHomeRef.current = home; } const startPath = home || (navigator.platform.toLowerCase().includes("win") ? "C:\\" : "/"); setCurrentPath(startPath); loadFiles(startPath); })(); } else { // For remote sessions, try initialPath first, then fall back to home directory void (async () => { const username = credentials.username || 'root'; // Root user's home is /root, other users' home is /home/username const homePath = username === 'root' ? '/root' : `/home/${username}`; // Set loading state immediately for better UX setLoading(true); // If initialPath is provided, try to use it first if (initialPath) { try { const sftpId = await ensureSftp(); const list = await listSftp(sftpId, initialPath); setCurrentPath(initialPath); setFiles(list); setSelectedFiles(new Set()); dirCacheRef.current.set(`${host.id}::${initialPath}`, { files: list, timestamp: Date.now(), }); setLoading(false); return; // Successfully opened at initialPath } catch { // initialPath not accessible, fall back to home directory logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`); } } try { const sftpId = await ensureSftp(); const list = await listSftp(sftpId, homePath); setCurrentPath(homePath); setFiles(list); setSelectedFiles(new Set()); dirCacheRef.current.set(`${host.id}::${homePath}`, { files: list, timestamp: Date.now(), }); setLoading(false); } catch { // Home directory not accessible, fall back to / logger.warn(`[SFTP] Home ${homePath} not accessible, using /`); try { const sftpId = await ensureSftp(); const list = await listSftp(sftpId, '/'); setCurrentPath('/'); setFiles(list); setSelectedFiles(new Set()); dirCacheRef.current.set(`${host.id}::/`, { files: list, timestamp: Date.now(), }); } catch (e) { logger.error('[SFTP] Failed to load root directory', e); toast.error(t("sftp.error.loadFailed"), "SFTP"); } finally { setLoading(false); } } })(); } return; } loadFiles(currentPath); } else { // Invalidate any in-flight directory load loadSeqRef.current += 1; void closeSftpSession(); initializedRef.current = false; } }, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]); const handleNavigate = useCallback((path: string) => { // Prevent double navigation (e.g., from double-click race condition) if (navigatingRef.current) return; navigatingRef.current = true; setCurrentPath(path); // Reset lock after a short delay setTimeout(() => { navigatingRef.current = false; }, 300); }, []); const handleUp = () => { if (isRootPath(currentPath)) return; setCurrentPath(getParentPath(currentPath)); }; const handleDownload = useCallback( async (file: RemoteFile) => { try { const fullPath = joinPath(currentPath, file.name); setLoading(true); const content = isLocalSession ? await readLocalFile(fullPath) : await readSftp(await ensureSftp(), fullPath); const blob = new Blob([content], { type: "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.downloadFailed"), "SFTP", ); } finally { setLoading(false); } }, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t], ); const handleUploadFile = async ( file: File, taskId: string, ): Promise => { const startTime = Date.now(); // Update task to uploading with start time setUploadTasks((prev) => prev.map((t) => t.id === taskId ? { ...t, status: "uploading" as const, totalBytes: file.size, startTime, speed: 0, } : t, ), ); try { const arrayBuffer = await file.arrayBuffer(); const fullPath = joinPath(currentPath, file.name); if (isLocalSession) { await writeLocalFile(fullPath, arrayBuffer); const totalTime = (Date.now() - startTime) / 1000; const finalSpeed = totalTime > 0 ? file.size / totalTime : 0; setUploadTasks((prev) => prev.map((t) => t.id === taskId ? { ...t, status: "completed" as const, progress: 100, transferredBytes: file.size, speed: finalSpeed, } : t, ), ); return true; } const sftpId = await ensureSftp(); // Use real-time progress API if available const progressResult = await writeSftpBinaryWithProgress( sftpId, fullPath, arrayBuffer, taskId, // Real-time progress callback (transferred: number, total: number, speed: number) => { const progress = total > 0 ? Math.round((transferred / total) * 100) : 0; setUploadTasks((prev) => prev.map((t) => t.id === taskId && t.status === "uploading" ? { ...t, transferredBytes: transferred, progress, speed, } : t, ), ); }, // Complete callback () => { const totalTime = (Date.now() - startTime) / 1000; const finalSpeed = totalTime > 0 ? file.size / totalTime : 0; setUploadTasks((prev) => prev.map((t) => t.id === taskId ? { ...t, status: "completed" as const, progress: 100, transferredBytes: file.size, speed: finalSpeed, } : t, ), ); }, // Error callback (error: string) => { setUploadTasks((prev) => prev.map((t) => t.id === taskId ? { ...t, status: "failed" as const, error, } : t, ), ); }, ); if (progressResult) return true; try { // Fallback to non-progress API await writeSftpBinary(sftpId, fullPath, arrayBuffer); } catch { // Fallback: read as text (works for text files) const text = await file.text(); await writeSftp(sftpId, fullPath, text); } // Calculate final speed (for fallback methods) const totalTime = (Date.now() - startTime) / 1000; const finalSpeed = totalTime > 0 ? file.size / totalTime : 0; // Update task to completed setUploadTasks((prev) => prev.map((t) => t.id === taskId ? { ...t, status: "completed" as const, progress: 100, transferredBytes: file.size, speed: finalSpeed, } : t, ), ); return true; } catch (e) { setUploadTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "failed" as const, error: e instanceof Error ? e.message : t("sftp.error.uploadFailed"), } : task, ), ); return false; } }; const handleUploadMultiple = async (fileList: FileList) => { if (fileList.length === 0) return; // Create all tasks upfront const newTasks: UploadTask[] = Array.from(fileList).map((file) => ({ id: crypto.randomUUID(), fileName: file.name, status: "pending" as const, progress: 0, totalBytes: file.size, transferredBytes: 0, speed: 0, startTime: 0, })); setUploadTasks((prev) => [...prev, ...newTasks]); setUploading(true); // Upload files sequentially const filesToUpload = Array.from(fileList); for (let i = 0; i < filesToUpload.length; i++) { await handleUploadFile(filesToUpload[i], newTasks[i].id); } setUploading(false); await loadFiles(currentPath, { force: true }); // Auto-clear completed tasks after 3 seconds setTimeout(() => { setUploadTasks((prev) => prev.filter((t) => t.status !== "completed")); }, 3000); }; const handleDelete = async (file: RemoteFile) => { if (!confirm(t("sftp.confirm.deleteOne", { name: file.name }))) return; try { const fullPath = joinPath(currentPath, file.name); if (isLocalSession) { await deleteLocalFile(fullPath); } else { // Use deleteSftp which handles both files and directories await deleteSftp(await ensureSftp(), fullPath); } await loadFiles(currentPath, { force: true }); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.deleteFailed"), "SFTP", ); } }; const handleCreateFolder = async () => { const folderName = prompt(t("sftp.prompt.newFolderName")); if (!folderName) return; try { const fullPath = joinPath(currentPath, folderName); if (isLocalSession) { await mkdirLocal(fullPath); } else { await mkdirSftp(await ensureSftp(), fullPath); } await loadFiles(currentPath, { force: true }); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.createFolderFailed"), "SFTP", ); } }; // Open rename dialog const openRenameDialog = useCallback((file: RemoteFile) => { setRenameTarget(file); setRenameName(file.name); setShowRenameDialog(true); }, []); // Handle rename const handleRename = async () => { if (!renameTarget || !renameName.trim() || isRenaming) return; if (renameName.trim() === renameTarget.name) { setShowRenameDialog(false); return; } setIsRenaming(true); try { const oldPath = joinPath(currentPath, renameTarget.name); const newPath = joinPath(currentPath, renameName.trim()); if (isLocalSession) { // For local, use renameLocalFile if available, otherwise show error toast.error("Local rename not implemented", "SFTP"); } else { await renameSftp(await ensureSftp(), oldPath, newPath); } setShowRenameDialog(false); setRenameTarget(null); setRenameName(""); await loadFiles(currentPath, { force: true }); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.renameFailed"), "SFTP", ); } finally { setIsRenaming(false); } }; // Parse permissions string to object // Supports both symbolic format (rwxr-xr-x) and octal format (755) const parsePermissions = useCallback((perms: string | undefined) => { const defaultPerms = { owner: { read: false, write: false, execute: false }, group: { read: false, write: false, execute: false }, others: { read: false, write: false, execute: false }, }; if (!perms) return defaultPerms; // Check if it's octal format (e.g., "755", "644") if (/^[0-7]{3,4}$/.test(perms)) { const octal = perms.length === 4 ? perms.slice(1) : perms; const ownerBits = parseInt(octal[0], 10); const groupBits = parseInt(octal[1], 10); const othersBits = parseInt(octal[2], 10); return { owner: { read: (ownerBits & 4) !== 0, write: (ownerBits & 2) !== 0, execute: (ownerBits & 1) !== 0, }, group: { read: (groupBits & 4) !== 0, write: (groupBits & 2) !== 0, execute: (groupBits & 1) !== 0, }, others: { read: (othersBits & 4) !== 0, write: (othersBits & 2) !== 0, execute: (othersBits & 1) !== 0, }, }; } // Handle symbolic rwxrwxrwx format const pStr = perms.length === 10 ? perms.slice(1) : perms; if (pStr.length >= 9) { return { owner: { read: pStr[0] === 'r', write: pStr[1] === 'w', execute: pStr[2] === 'x' || pStr[2] === 's', }, group: { read: pStr[3] === 'r', write: pStr[4] === 'w', execute: pStr[5] === 'x' || pStr[5] === 's', }, others: { read: pStr[6] === 'r', write: pStr[7] === 'w', execute: pStr[8] === 'x' || pStr[8] === 't', }, }; } return defaultPerms; }, []); // Open permissions dialog const openPermissionsDialog = useCallback(async (file: RemoteFile) => { if (isLocalSession) { toast.error("Permissions not available for local files", "SFTP"); return; } setPermissionsTarget(file); // Always fetch fresh permissions via stat for accuracy let permsStr = file.permissions; try { const fullPath = joinPath(currentPath, file.name); const stat = await statSftp(await ensureSftp(), fullPath); if (stat.permissions) { permsStr = stat.permissions; } } catch (e) { console.warn("Failed to fetch file permissions:", e); } setPermissions(parsePermissions(permsStr)); setShowPermissionsDialog(true); }, [isLocalSession, currentPath, joinPath, statSftp, parsePermissions, ensureSftp]); // Toggle permission const togglePermission = useCallback((role: 'owner' | 'group' | 'others', perm: 'read' | 'write' | 'execute') => { setPermissions(prev => ({ ...prev, [role]: { ...prev[role], [perm]: !prev[role][perm] } })); }, []); // Get octal permissions string const getOctalPermissions = useCallback(() => { const getNum = (p: { read: boolean; write: boolean; execute: boolean }) => (p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0); return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`; }, [permissions]); // Get symbolic permissions string const getSymbolicPermissions = useCallback(() => { const getSym = (p: { read: boolean; write: boolean; execute: boolean }) => `${p.read ? 'r' : '-'}${p.write ? 'w' : '-'}${p.execute ? 'x' : '-'}`; return getSym(permissions.owner) + getSym(permissions.group) + getSym(permissions.others); }, [permissions]); // Handle permissions save const handleSavePermissions = async () => { if (!permissionsTarget || isChangingPermissions) return; setIsChangingPermissions(true); try { const fullPath = joinPath(currentPath, permissionsTarget.name); await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions()); setShowPermissionsDialog(false); setPermissionsTarget(null); await loadFiles(currentPath, { force: true }); toast.success(t("sftp.permissions.success"), "SFTP"); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.permissions.failed"), "SFTP", ); } finally { setIsChangingPermissions(false); } }; // File opener handlers const openFileOpenerDialog = useCallback((file: RemoteFile) => { setFileOpenerTarget(file); setShowFileOpenerDialog(true); }, []); const handleEditFile = useCallback(async (file: RemoteFile) => { try { setLoadingTextContent(true); setTextEditorTarget(file); const fullPath = joinPath(currentPath, file.name); const content = isLocalSession ? await readLocalFile(fullPath).then(buf => new TextDecoder().decode(buf)) : await readSftp(await ensureSftp(), fullPath); setTextEditorContent(content); setShowTextEditor(true); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.loadFailed"), "SFTP", ); } finally { setLoadingTextContent(false); } }, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]); const handleSaveTextFile = useCallback(async (content: string) => { if (!textEditorTarget) return; const fullPath = joinPath(currentPath, textEditorTarget.name); if (isLocalSession) { const encoder = new TextEncoder(); await writeLocalFile(fullPath, encoder.encode(content).buffer); } else { await writeSftp(await ensureSftp(), fullPath, content); } }, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]); const handleOpenFile = useCallback(async (file: RemoteFile) => { const savedOpener = getOpenerForFile(file.name); if (savedOpener) { // Use saved opener based on openerType if (savedOpener.openerType === 'builtin-editor') { handleEditFile(file); } else if (savedOpener.openerType === 'system-app' && savedOpener.systemApp) { // Open with saved system application try { const fullPath = joinPath(currentPath, file.name); if (isLocalSession) { // For local files, open directly const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; if (bridge?.openWithApplication) { await bridge.openWithApplication(fullPath, savedOpener.systemApp.path); } } else { const sftpId = await ensureSftp(); await downloadSftpToTempAndOpen(sftpId, fullPath, file.name, savedOpener.systemApp.path, { enableWatch: sftpAutoSync }); } } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.openFailed"), "SFTP" ); } } } else { // Show opener dialog openFileOpenerDialog(file); } }, [getOpenerForFile, handleEditFile, openFileOpenerDialog, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]); const handleFileOpenerSelect = useCallback(async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => { if (!fileOpenerTarget) return; if (setAsDefault) { const ext = getFileExtension(fileOpenerTarget.name); console.log('[SFTPModal] Saving file association for extension:', ext, 'openerType:', openerType, 'systemApp:', systemApp); setOpenerForExtension(ext, openerType, systemApp); } setShowFileOpenerDialog(false); if (openerType === 'builtin-editor') { handleEditFile(fileOpenerTarget); } else if (openerType === 'system-app' && systemApp) { // Download and open with system application try { const fullPath = joinPath(currentPath, fileOpenerTarget.name); if (isLocalSession) { // For local files, open directly const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; if (bridge?.openWithApplication) { await bridge.openWithApplication(fullPath, systemApp.path); } } else { const sftpId = await ensureSftp(); await downloadSftpToTempAndOpen(sftpId, fullPath, fileOpenerTarget.name, systemApp.path, { enableWatch: sftpAutoSync }); } } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.openFailed"), "SFTP" ); } } setFileOpenerTarget(null); }, [fileOpenerTarget, setOpenerForExtension, handleEditFile, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]); // Callback for FileOpenerDialog to select a system application const handleSelectSystemApp = useCallback(async (): Promise => { const result = await selectApplication(); if (result) { return { path: result.path, name: result.name }; } return null; }, [selectApplication]); const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { handleUploadMultiple(e.target.files); } // Reset input so same file can be selected again e.target.value = ""; }; // Drag and Drop handlers const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === "dragenter" || e.type === "dragover") { setDragActive(true); } else if (e.type === "dragleave") { setDragActive(false); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleUploadMultiple(e.dataTransfer.files); } }; const handleClose = async () => { await closeSftpSession(); setIsEditingPath(false); onClose(); }; // Display files with parent entry (like SftpView) const displayFiles = useMemo(() => { // Filter hidden files using utility function const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles); // Check if we're at root const atRoot = isRootPath(currentPath); if (atRoot) return visibleFiles; // Add ".." parent directory entry at the top (only if not at root) const parentEntry: RemoteFile = { name: "..", type: "directory", size: "--", lastModified: undefined, }; return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")]; }, [files, currentPath, isRootPath, sftpShowHiddenFiles]); // Sorted files const sortedFiles = useMemo(() => { if (!displayFiles.length) return displayFiles; // Keep ".." at the top, sort the rest const parentEntry = displayFiles.find((f) => f.name === ".."); const otherFiles = displayFiles.filter((f) => f.name !== ".."); const sorted = [...otherFiles].sort((a, b) => { // Directories and symlinks pointing to directories come first const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory"); const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory"); if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; let cmp = 0; switch (sortField) { case "name": cmp = a.name.localeCompare(b.name); break; case "size": { const sizeA = typeof a.size === "number" ? a.size : parseInt(String(a.size), 10) || 0; const sizeB = typeof b.size === "number" ? b.size : parseInt(String(b.size), 10) || 0; cmp = sizeA - sizeB; break; } case "modified": { const dateA = new Date(a.lastModified || 0).getTime(); const dateB = new Date(b.lastModified || 0).getTime(); cmp = dateA - dateB; break; } } return sortOrder === "asc" ? cmp : -cmp; }); return parentEntry ? [parentEntry, ...sorted] : sorted; }, [displayFiles, sortField, sortOrder]); // Virtual scrolling: measure viewport and row height useLayoutEffect(() => { const container = fileListRef.current; if (!container || !open) return; const update = () => setViewportHeight(container.clientHeight); update(); const raf = window.requestAnimationFrame(update); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(container); return () => { resizeObserver.disconnect(); window.cancelAnimationFrame(raf); }; }, [open, sortedFiles.length]); useLayoutEffect(() => { const container = fileListRef.current; if (!container || !open || sortedFiles.length === 0) return; const raf = window.requestAnimationFrame(() => { const rowElement = container.querySelector( '[data-sftp-modal-row="true"]', ) as HTMLElement | null; if (!rowElement) return; const nextHeight = Math.round(rowElement.getBoundingClientRect().height); if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) { setRowHeight(nextHeight); } }); return () => window.cancelAnimationFrame(raf); }, [open, sortedFiles.length, rowHeight]); // Cleanup scroll frame on unmount useEffect(() => { return () => { if (scrollFrameRef.current !== null) { window.cancelAnimationFrame(scrollFrameRef.current); } }; }, []); // Virtual scrolling scroll handler const handleFileListScroll = useCallback( (e: React.UIEvent) => { const nextTop = e.currentTarget.scrollTop; if (scrollFrameRef.current !== null) return; scrollFrameRef.current = window.requestAnimationFrame(() => { scrollFrameRef.current = null; setScrollTop(nextTop); }); }, [], ); // Virtual scrolling calculations const overscan = 6; const canVirtualize = open && viewportHeight > 0 && rowHeight > 0; const shouldVirtualize = canVirtualize && sortedFiles.length > 50; // Only virtualize for larger lists const totalHeight = shouldVirtualize ? sortedFiles.length * rowHeight : 0; const startIndex = shouldVirtualize ? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan) : 0; const endIndex = shouldVirtualize ? Math.min( sortedFiles.length - 1, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan, ) : sortedFiles.length - 1; const visibleRows = shouldVirtualize ? sortedFiles .slice(startIndex, endIndex + 1) .map((file, idx) => ({ file, index: startIndex + idx, top: (startIndex + idx) * rowHeight, })) : sortedFiles.map((file, index) => ({ file, index, top: 0, })); const handleSort = (field: SortField) => { if (sortField === field) { setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); } else { setSortField(field); setSortOrder("asc"); } }; // Column resize handlers const handleResizeStart = (field: string, e: React.MouseEvent) => { e.preventDefault(); resizingRef.current = { field, startX: e.clientX, startWidth: columnWidths[field as keyof typeof columnWidths], }; document.addEventListener("mousemove", handleResizeMove); document.addEventListener("mouseup", handleResizeEnd); }; const handleResizeMove = useCallback((e: MouseEvent) => { if (!resizingRef.current) return; const diff = e.clientX - resizingRef.current.startX; const newWidth = Math.max( 10, Math.min(60, resizingRef.current.startWidth + diff / 5), ); setColumnWidths((prev) => ({ ...prev, [resizingRef.current!.field]: newWidth, })); }, []); const handleResizeEnd = useCallback(() => { resizingRef.current = null; document.removeEventListener("mousemove", handleResizeMove); document.removeEventListener("mouseup", handleResizeEnd); }, [handleResizeMove]); // Path editing handlers const handlePathDoubleClick = () => { setEditingPathValue(currentPath); setIsEditingPath(true); setTimeout(() => pathInputRef.current?.select(), 0); }; const handlePathSubmit = () => { const fallbackPath = (isLocalSession && localHomeRef.current) || getRootPath(currentPath); const newPath = editingPathValue.trim() || fallbackPath; setIsEditingPath(false); if (newPath !== currentPath) { if (isLocalSession) { handleNavigate(newPath); } else { handleNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`); } } }; const handlePathKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handlePathSubmit(); } else if (e.key === "Escape") { setIsEditingPath(false); } }; // Breadcrumbs const breadcrumbs = getBreadcrumbs(currentPath); // Compute visible/hidden breadcrumbs for truncation (always truncate, no expansion) const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } = useMemo(() => { if (breadcrumbs.length <= MAX_VISIBLE_BREADCRUMB_PARTS) { return { visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })), hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[], needsBreadcrumbTruncation: false }; } // Show first part + ellipsis + last (MAX_VISIBLE_BREADCRUMB_PARTS - 1) parts const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }]; const lastPartsCount = MAX_VISIBLE_BREADCRUMB_PARTS - 1; const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({ part, originalIndex: breadcrumbs.length - lastPartsCount + idx })); const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({ part, originalIndex: idx + 1 })); return { visibleBreadcrumbs: [...firstPart, ...lastParts], hiddenBreadcrumbs: hidden, needsBreadcrumbTruncation: true }; }, [breadcrumbs]); const handleFileClick = ( file: RemoteFile, index: number, e: React.MouseEvent, ) => { // Don't allow selecting ".." entry if (file.name === "..") return; if (file.type === "directory") { // Double click to enter directory is handled by onDoubleClick // Single click just selects if (e.shiftKey && lastSelectedIndexRef.current !== null) { // Shift-click: range select const start = Math.min(lastSelectedIndexRef.current, index); const end = Math.max(lastSelectedIndexRef.current, index); const newSelection = new Set(); for (let i = start; i <= end; i++) { if (files[i] && files[i].type !== "directory") { newSelection.add(files[i].name); } } setSelectedFiles(newSelection); } else if (e.ctrlKey || e.metaKey) { // Ctrl/Cmd click: toggle selection setSelectedFiles((prev) => { const next = new Set(prev); // Don't select directories return next; }); } return; } // For files if (e.shiftKey && lastSelectedIndexRef.current !== null) { // Shift-click: range select const start = Math.min(lastSelectedIndexRef.current, index); const end = Math.max(lastSelectedIndexRef.current, index); const newSelection = new Set(); for (let i = start; i <= end; i++) { if (files[i] && files[i].type !== "directory") { newSelection.add(files[i].name); } } setSelectedFiles(newSelection); } else if (e.ctrlKey || e.metaKey) { // Ctrl/Cmd click: toggle selection setSelectedFiles((prev) => { const next = new Set(prev); if (next.has(file.name)) { next.delete(file.name); } else { next.add(file.name); } return next; }); lastSelectedIndexRef.current = index; } else { // Normal click: single select setSelectedFiles(new Set([file.name])); lastSelectedIndexRef.current = index; } }; const handleFileDoubleClick = (file: RemoteFile) => { // Handle ".." special case - go to parent directory if (file.name === "..") { handleUp(); return; } // Navigate into directories, or symlinks that point to directories if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) { handleNavigate(joinPath(currentPath, file.name)); } else { // For files: open them (equivalent to clicking the "Open" button) handleOpenFile(file); } }; const handleDeleteSelected = async () => { if (selectedFiles.size === 0) return; const fileNames = Array.from(selectedFiles); if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return; try { for (const fileName of fileNames) { const fullPath = joinPath(currentPath, fileName); if (isLocalSession) { await deleteLocalFile(fullPath); } else { await deleteSftp(await ensureSftp(), fullPath); } } await loadFiles(currentPath, { force: true }); setSelectedFiles(new Set()); } catch (e) { toast.error( e instanceof Error ? e.message : t("sftp.error.deleteFailed"), "SFTP", ); } }; const handleDownloadSelected = async () => { if (selectedFiles.size === 0) return; for (const fileName of selectedFiles) { const file = files.find((f) => f.name === fileName); if (file && file.type === "file") { await handleDownload(file); } } }; return ( !isOpen && handleClose()}> {/* Header */}
{host.label}
{credentials.username || "root"}@{credentials.hostname}: {credentials.port || 22}
{/* Toolbar */}
{/* Editable Breadcrumbs */}
{isEditingPath ? ( setEditingPathValue(e.target.value)} onBlur={handlePathSubmit} onKeyDown={handlePathKeyDown} className="h-7 text-sm bg-background" autoFocus /> ) : (
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => { const isLast = originalIndex === breadcrumbs.length - 1; const showEllipsisBefore = needsBreadcrumbTruncation && displayIdx === 1; return ( {showEllipsisBefore && ( <> h.part).join(" > ")}`} > )} ); })}
)}
{/* Action buttons moved here */}
{/* Table Header with sortable columns and resize handles - OUTSIDE scroll container */}
handleSort("name")} > {t("sftp.columns.name")} {sortField === "name" && ( {sortOrder === "asc" ? "^" : "v"} )}
handleResizeStart("name", e)} />
handleSort("size")} > {t("sftp.columns.size")} {sortField === "size" && ( {sortOrder === "asc" ? "^" : "v"} )}
handleResizeStart("size", e)} />
handleSort("modified")} > {t("sftp.columns.modified")} {sortField === "modified" && ( {sortOrder === "asc" ? "^" : "v"} )}
handleResizeStart("modified", e)} />
{t("sftp.columns.actions")}
{/* File List with Virtual Scrolling */}
{dragActive && (
{t("sftp.dropFilesHere")}
)} {loading && files.length === 0 && (
)} {loadingTextContent && (
{t("sftp.status.loading")}
)} {/* Reconnecting overlay */} {reconnecting && (
{t("sftp.reconnecting.title")}
{t("sftp.reconnecting.desc")}
)} {files.length === 0 && !loading && (
{t("sftp.emptyDirectory")}
{t("sftp.dragDropToUpload")}
)} {/* File rows with virtual scrolling */}
{visibleRows.map(({ file, index: idx, top }) => { // Check if this entry is navigable like a directory const isNavigableDirectory = file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory"); const isDownloadableFile = file.type === "file" || (file.type === "symlink" && file.linkTarget === "file"); const isParentEntry = file.name === ".."; return (
handleFileClick(file, idx, e)} onDoubleClick={() => handleFileDoubleClick(file)} >
{getFileIcon(file.name, isNavigableDirectory, file.type === "symlink" && !isNavigableDirectory)} {file.type === "symlink" && }
{file.name} {file.type === "symlink" && (symbolic link)}
{isNavigableDirectory ? "--" : formatBytes(file.size)}
{formatDate(file.lastModified, resolvedLocale)}
{isDownloadableFile && ( )} {!isParentEntry && ( )}
{isParentEntry ? ( { // Navigate to parent directory const segments = currentPath.split("/").filter(Boolean); segments.pop(); const parentPath = segments.length === 0 ? "/" : `/${segments.join("/")}`; handleNavigate(parentPath); }} > {t("sftp.context.open")} ) : ( <> {(file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) && ( handleNavigate( currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`, ) } > {t("sftp.context.open")} )} {(file.type === "file" || (file.type === "symlink" && file.linkTarget === "file")) && ( <> {/* Open - uses saved association or shows dialog */} handleOpenFile(file)}> {t("sftp.context.open")} {/* Open with - always shows dialog */} openFileOpenerDialog(file)}> {t("sftp.context.openWith")} {/* Edit - for text files (not known binary) */} {!isKnownBinaryFile(file.name) && ( handleEditFile(file)}> {t("sftp.context.edit")} )} handleDownload(file)}> {t("sftp.context.download")} )} openRenameDialog(file)}> {t("sftp.context.rename")} {!isLocalSession && ( openPermissionsDialog(file)}> {t("sftp.context.permissions")} )} handleDelete(file)} > {t("sftp.context.delete")} )}
); })}
{t("sftp.newFolder")} inputRef.current?.click()}> {t("sftp.uploadFiles")} loadFiles(currentPath, { force: true })}> {t("sftp.context.refresh")} {selectedFiles.size > 0 && ( <> {t("sftp.context.downloadSelected", { count: selectedFiles.size })} {t("sftp.context.deleteSelected", { count: selectedFiles.size })} )}
{/* Upload progress at bottom */} {uploadTasks.length > 0 && (
{uploadTasks.map((task) => { // Format speed const formatSpeed = (bytesPerSec: number) => { if (bytesPerSec <= 0) return ""; if (bytesPerSec >= 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; if (bytesPerSec >= 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; return `${Math.round(bytesPerSec)} B/s`; }; // Format bytes const formatBytes = (bytes: number) => { if (bytes === 0) return "0 B"; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`; }; // Remaining time const remainingBytes = task.totalBytes - task.transferredBytes; const remainingTime = task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0; const remainingStr = remainingTime > 60 ? `~${Math.ceil(remainingTime / 60)}m left` : remainingTime > 0 ? `~${remainingTime}s left` : ""; return (
{task.status === "uploading" && ( )} {task.status === "pending" && ( )} {task.status === "completed" && ( )} {task.status === "failed" && ( )}
{task.fileName} {task.status === "uploading" && task.speed > 0 && ( {formatSpeed(task.speed)} )} {task.status === "uploading" && remainingStr && ( {remainingStr} )}
{(task.status === "uploading" || task.status === "pending") && (
{task.status === "uploading" ? `${task.progress}%` : "..."}
)} {task.status === "uploading" && task.totalBytes > 0 && (
{formatBytes(task.transferredBytes)} /{" "} {formatBytes(task.totalBytes)}
)} {task.status === "completed" && (
Completed �?{formatBytes(task.totalBytes)}
)} {task.status === "failed" && task.error && (
{task.error}
)}
{task.status === "pending" && t("sftp.task.waiting")}
); })}
)} {/* Footer */}
{t("sftp.itemsCount", { count: files.length })} {selectedFiles.size > 0 && ( <> | {t("sftp.selectedCount", { count: selectedFiles.size })} )} {loading ? t("sftp.status.loading") : uploading ? t("sftp.status.uploading") : t("sftp.status.ready")}
{/* Rename Dialog */} {t("sftp.rename.title")} {renameTarget?.name}
setRenameName(e.target.value)} placeholder={t("sftp.rename.placeholder")} onKeyDown={(e) => { if (e.key === "Enter") handleRename(); }} autoFocus />
{/* Permissions Dialog */} {t("sftp.permissions.title")} {permissionsTarget?.name}
{(['owner', 'group', 'others'] as const).map((role) => (
{t(`sftp.permissions.${role}`)}
{(['read', 'write', 'execute'] as const).map((perm) => ( ))}
))}
{t("sftp.permissions.octal")}: {getOctalPermissions()}
{t("sftp.permissions.symbolic")}: {getSymbolicPermissions()}
{/* File Opener Dialog */} { setShowFileOpenerDialog(false); setFileOpenerTarget(null); }} fileName={fileOpenerTarget?.name || ""} onSelect={handleFileOpenerSelect} onSelectSystemApp={handleSelectSystemApp} /> {/* Text Editor Modal */} { setShowTextEditor(false); setTextEditorTarget(null); setTextEditorContent(""); }} fileName={textEditorTarget?.name || ""} initialContent={textEditorContent} onSave={handleSaveTextFile} />
); }; export default SFTPModal;