import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/dialog.tsx"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; import { Label } from "@/components/label.tsx"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { ArrowRightLeft, Bookmark, ChevronDown, ChevronRight, ChevronUp, Clock, Folder, Trash2, } from "lucide-react"; import { getSSHStatus, getSSHHosts, getTransferRecent, getFolderShortcuts, addFolderShortcut, removeFolderShortcut, browseSSHDirectory, ensureSSHSessionForHost, getTransferMethodPreview, type TransferDestination, type HostConnectionState, type TransferMethodPreference, type TransferMethodPreview, } from "@/main-axios.ts"; import type { SSHHost } from "@/types"; interface FileItem { name: string; type: "file" | "directory" | "link"; path: string; } interface BrowseEntry { name: string; path: string; } interface FolderShortcutEntry { id: number; name: string; path: string; } interface TransferToHostDialogProps { open: boolean; onOpenChange: (open: boolean) => void; files: FileItem[]; move: boolean; sourceHost: SSHHost; sourceSessionId: string | null; onConfirm: ( destSessionId: string, destHostId: number, destPath: string, destPathLabel: string, methodPreference: TransferMethodPreference, parallelSegmentCount: number, ) => void; } function formatByteSize(bytes: number): string { if (bytes <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } const formatted = size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString(); return `${formatted} ${units[unitIndex]}`; } function formatTruncatedDestination(hostLabel: string, path: string): string { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "") || "/"; if (normalized === "/") { return `${hostLabel} — /`; } const segments = normalized.split("/").filter(Boolean); const pathPart = segments.length <= 2 ? `/${segments.join("/")}` : `.../${segments.slice(-2).join("/")}`; return `${hostLabel} — ${pathPart}`; } function longestCommonPrefix(strings: string[]): string { if (strings.length === 0) return ""; let prefix = strings[0]; for (let i = 1; i < strings.length; i++) { while (!strings[i].startsWith(prefix)) { prefix = prefix.slice(0, -1); if (!prefix) return ""; } } return prefix; } function splitPathForCompletion( fullPath: string, cursorPos: number, ): { dirPath: string; partial: string; replaceStart: number } { const beforeCursor = fullPath.slice(0, cursorPos); const lastSlash = beforeCursor.lastIndexOf("/"); if (lastSlash === -1) { return { dirPath: "/", partial: beforeCursor, replaceStart: 0 }; } const dirPath = beforeCursor.slice(0, lastSlash) || "/"; const partial = beforeCursor.slice(lastSlash + 1); return { dirPath, partial, replaceStart: lastSlash + 1 }; } function connectionLabel( state: HostConnectionState, t: (key: string) => string, ): string { switch (state) { case "ready": return t("transfer.hostReady"); case "connecting": return t("transfer.hostConnecting"); case "auth_required": return t("transfer.hostAuthRequired"); case "error": return t("transfer.hostConnectionFailed"); default: return t("transfer.hostDisconnected"); } } export function TransferToHostDialog({ open, onOpenChange, files, move, sourceHost, sourceSessionId, onConfirm, }: TransferToHostDialogProps) { const { t } = useTranslation(); const [availableHosts, setAvailableHosts] = useState([]); const [loadingHosts, setLoadingHosts] = useState(false); const [selectedHostId, setSelectedHostId] = useState(""); const [connectionStates, setConnectionStates] = useState< Record >({}); const [connectionErrors, setConnectionErrors] = useState< Record >({}); const [destPath, setDestPath] = useState(""); const [shortcuts, setShortcuts] = useState([]); const [recents, setRecents] = useState([]); const [browsePath, setBrowsePath] = useState("/"); const [browseEntries, setBrowseEntries] = useState([]); const [browseLoading, setBrowseLoading] = useState(false); const [browseStatus, setBrowseStatus] = useState< "ok" | "not_found" | "error" | "idle" >("idle"); const skipDestBrowseSyncRef = useRef(false); const destPathDebounceRef = useRef | null>( null, ); const pendingBrowsePathRef = useRef(null); const lastBrowseHostIdRef = useRef(null); const destPathInputRef = useRef(null); const [methodPreference, setMethodPreference] = useState("auto"); const [parallelSegmentCount, setParallelSegmentCount] = useState("2"); const [methodPreview, setMethodPreview] = useState(null); const [methodPreviewLoading, setMethodPreviewLoading] = useState(false); const [methodPreviewError, setMethodPreviewError] = useState( null, ); const [recentsCollapsed, setRecentsCollapsed] = useState(true); const methodPreviewLockKeyRef = useRef(null); const archiveSourcePathsKey = useMemo( () => files.map((f) => f.path).join("\0"), [files], ); const isSingleFile = files.length === 1 && files[0].type === "file"; const isArchiveTransfer = !isSingleFile; const sourceLabel = useMemo(() => { if (files.length === 1) { return `${sourceHost.name || sourceHost.ip}:${files[0].path}`; } return `${sourceHost.name || sourceHost.ip}: ${t("transfer.itemsSummary", { count: files.length })}`; }, [files, sourceHost, t]); const selectedHost = availableHosts.find( (h) => h.id.toString() === selectedHostId, ); const selectedConnectionState = selectedHost ? (connectionStates[selectedHost.id] ?? "disconnected") : "disconnected"; const isHostReady = selectedConnectionState === "ready"; const loadAvailableHosts = useCallback(async () => { setLoadingHosts(true); try { const hosts = await getSSHHosts(); const candidates = hosts.filter( (h) => h.id !== sourceHost.id && h.enableFileManager !== false && h.connectionType !== "rdp" && h.connectionType !== "vnc", ); setAvailableHosts(candidates); const initialStates: Record = {}; await Promise.all( candidates.map(async (host) => { try { const status = await getSSHStatus(host.id.toString()); initialStates[host.id] = status?.connected ? "ready" : "disconnected"; } catch { initialStates[host.id] = "disconnected"; } }), ); setConnectionStates(initialStates); } catch { setAvailableHosts([]); } finally { setLoadingHosts(false); } }, [sourceHost.id]); const ensureHostConnection = useCallback(async (host: SSHHost) => { setConnectionStates((prev) => ({ ...prev, [host.id]: "connecting" })); setConnectionErrors((prev) => { const next = { ...prev }; delete next[host.id]; return next; }); const result = await ensureSSHSessionForHost(host); setConnectionStates((prev) => ({ ...prev, [host.id]: result.state })); if (result.error) { setConnectionErrors((prev) => ({ ...prev, [host.id]: result.error!, })); } }, []); const loadRecents = useCallback(async () => { if (!sourceHost.id) return; try { const recentData = await getTransferRecent(sourceHost.id); setRecents(recentData); } catch { setRecents([]); } }, [sourceHost.id]); const loadShortcutsForHost = useCallback(async (hostId: number) => { try { const data = await getFolderShortcuts(hostId); setShortcuts((Array.isArray(data) ? data : []) as FolderShortcutEntry[]); } catch { setShortcuts([]); } }, []); const syncDestPathFromBrowse = useCallback((path: string) => { skipDestBrowseSyncRef.current = true; setDestPath(path); }, []); const loadBrowseDirectory = useCallback( async (sessionId: string, path: string, syncDest = true) => { setBrowseLoading(true); try { const result = await browseSSHDirectory(sessionId, path); if (result.status === "ok") { const dirs = result.files .filter( (entry) => entry.type === "directory" && entry.name !== "." && entry.name !== "..", ) .map((entry) => { const base = result.path.endsWith("/") ? result.path.slice(0, -1) : result.path; const childPath = base === "/" ? `/${entry.name}` : `${base}/${entry.name}`; return { name: entry.name, path: childPath }; }) .sort((a, b) => a.name.localeCompare(b.name)); setBrowsePath(result.path); setBrowseEntries(dirs); setBrowseStatus("ok"); if (syncDest) { syncDestPathFromBrowse(result.path); } } else if (result.status === "not_found") { setBrowsePath(result.path); setBrowseEntries([]); setBrowseStatus("not_found"); if (syncDest) { syncDestPathFromBrowse(result.path); } } else { setBrowseEntries([]); setBrowseStatus("error"); } } catch { setBrowseEntries([]); setBrowseStatus("error"); } finally { setBrowseLoading(false); } }, [syncDestPathFromBrowse], ); useEffect(() => { if (open) { void loadAvailableHosts(); void loadRecents(); setSelectedHostId(""); setDestPath(""); setShortcuts([]); setBrowsePath("/"); setBrowseEntries([]); setBrowseStatus("idle"); setConnectionErrors({}); setMethodPreference("auto"); setParallelSegmentCount("2"); setMethodPreview(null); setMethodPreviewError(null); methodPreviewLockKeyRef.current = null; setRecentsCollapsed(true); pendingBrowsePathRef.current = null; lastBrowseHostIdRef.current = null; } }, [open, loadAvailableHosts, loadRecents]); useEffect(() => { if (!open || !selectedHost?.id) { setShortcuts([]); return; } void loadShortcutsForHost(selectedHost.id); }, [open, selectedHost?.id, loadShortcutsForHost]); useEffect(() => { if (!selectedHost || !isHostReady) return; const hostId = selectedHost.id; if (lastBrowseHostIdRef.current === hostId) return; const path = pendingBrowsePathRef.current ?? (destPath.trim() || "/"); pendingBrowsePathRef.current = null; lastBrowseHostIdRef.current = hostId; void loadBrowseDirectory(hostId.toString(), path); }, [selectedHost, isHostReady, destPath, loadBrowseDirectory]); useEffect(() => { if (selectedHost && selectedConnectionState === "disconnected") { void ensureHostConnection(selectedHost); } }, [selectedHost, selectedConnectionState, ensureHostConnection]); useEffect(() => { if (!selectedHost || !isHostReady || !open) return; if (skipDestBrowseSyncRef.current) { skipDestBrowseSyncRef.current = false; return; } const typed = destPath.trim(); if (!typed) return; if (destPathDebounceRef.current) { clearTimeout(destPathDebounceRef.current); } destPathDebounceRef.current = setTimeout(() => { void loadBrowseDirectory(selectedHost.id.toString(), typed, false); }, 400); return () => { if (destPathDebounceRef.current) { clearTimeout(destPathDebounceRef.current); } }; }, [destPath, selectedHost, isHostReady, open, loadBrowseDirectory]); const hostName = (hostId: number) => availableHosts.find((h) => h.id === hostId)?.name || availableHosts.find((h) => h.id === hostId)?.ip || hostId.toString(); const handleSelectDestination = (destHostId: number, path: string) => { pendingBrowsePathRef.current = path; setSelectedHostId(destHostId.toString()); setDestPath(path); const host = availableHosts.find((h) => h.id === destHostId); if (host && connectionStates[host.id] === "ready") { lastBrowseHostIdRef.current = host.id; void loadBrowseDirectory(host.id.toString(), path); } else { lastBrowseHostIdRef.current = null; } }; useEffect(() => { if (!open || !isArchiveTransfer || !sourceSessionId) { if (!open || !isArchiveTransfer) { setMethodPreview(null); setMethodPreviewError(null); methodPreviewLockKeyRef.current = null; } return; } const lockKey = `${archiveSourcePathsKey}|${methodPreference}|${selectedHost?.id}|${destPath.trim() || browsePath}`; if (methodPreviewLockKeyRef.current === lockKey) { return; } if (!selectedHost || !isHostReady) { return; } const sourcePaths = files.map((f) => f.path); const previewPath = (destPath.trim() || browsePath || "/").replace(/\/+$/, "") || "/"; let cancelled = false; setMethodPreviewLoading(true); setMethodPreviewError(null); void getTransferMethodPreview( sourceSessionId, sourcePaths, selectedHost.id.toString(), previewPath, methodPreference, ) .then((preview) => { if (!cancelled) { setMethodPreview(preview); methodPreviewLockKeyRef.current = lockKey; } }) .catch((err: { message?: string }) => { if (!cancelled) { setMethodPreview(null); setMethodPreviewError(err.message ?? "Preview failed"); } }) .finally(() => { if (!cancelled) setMethodPreviewLoading(false); }); return () => { cancelled = true; }; }, [ open, isArchiveTransfer, sourceSessionId, selectedHost, isHostReady, archiveSourcePathsKey, methodPreference, destPath, browsePath, files, ]); const handleHostChange = (hostId: string) => { setSelectedHostId(hostId); setBrowsePath("/"); setDestPath(""); setShortcuts([]); setMethodPreview(null); setMethodPreviewError(null); methodPreviewLockKeyRef.current = null; pendingBrowsePathRef.current = null; lastBrowseHostIdRef.current = null; }; const handleBrowseInto = (path: string) => { if (!selectedHost || !isHostReady) return; void loadBrowseDirectory(selectedHost.id.toString(), path, true); }; const handleBrowseUp = () => { if (!selectedHost || !isHostReady || browsePath === "/") return; const trimmed = browsePath.replace(/\/+$/, ""); const idx = trimmed.lastIndexOf("/"); const parent = idx <= 0 ? "/" : trimmed.substring(0, idx); void loadBrowseDirectory(selectedHost.id.toString(), parent, true); }; const formatBrowsePathLabel = (path: string): string => { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "") || "/"; if (normalized === "/") return "/"; const segments = normalized.split("/").filter(Boolean); if (segments.length <= 3) return normalized; return `.../${segments.slice(-2).join("/")}`; }; const handleAddShortcut = async () => { if (!selectedHost || !destPath.trim()) { return; } const trimmedPath = destPath.trim(); if (shortcuts.some((entry) => entry.path === trimmedPath)) { return; } const folderName = trimmedPath.split("/").pop() || trimmedPath; try { await addFolderShortcut(selectedHost.id, trimmedPath, folderName); await loadShortcutsForHost(selectedHost.id); toast.success( t("fileManager.shortcutAddedSuccessfully", { name: folderName }), ); } catch { /* toast handled by API */ } }; const handleRemoveShortcut = async (path: string) => { if (!selectedHost) return; try { await removeFolderShortcut(selectedHost.id, path); await loadShortcutsForHost(selectedHost.id); const folderName = path.split("/").pop() || path; toast.success(t("fileManager.removedShortcut", { name: folderName })); } catch { /* toast handled by API */ } }; const handleDestPathKeyDown = ( event: React.KeyboardEvent, ) => { if ( event.key !== "Tab" || event.shiftKey || !selectedHost || !isHostReady ) { return; } event.preventDefault(); const input = destPathInputRef.current; if (!input) return; const currentPath = input.value; const cursorPos = input.selectionStart ?? currentPath.length; const { dirPath, partial, replaceStart } = splitPathForCompletion( currentPath, cursorPos, ); if (!partial) return; void browseSSHDirectory(selectedHost.id.toString(), dirPath).then( (result) => { if (result.status !== "ok") return; const dirNames = result.files .filter( (entry) => entry.type === "directory" && entry.name !== "." && entry.name !== "..", ) .map((entry) => entry.name); const matches = dirNames.filter((name) => name.startsWith(partial)); if (matches.length === 0) return; let completedName: string; let appendSlash = false; if (matches.length === 1) { completedName = matches[0]; appendSlash = true; } else { completedName = longestCommonPrefix(matches); if (completedName.length <= partial.length) return; appendSlash = matches.every((name) => name === completedName); } const suffix = currentPath.slice(cursorPos); const newPath = currentPath.slice(0, replaceStart) + completedName + (appendSlash ? "/" : "") + suffix; const newCursor = replaceStart + completedName.length + (appendSlash ? 1 : 0); skipDestBrowseSyncRef.current = true; setDestPath(newPath); requestAnimationFrame(() => { input.setSelectionRange(newCursor, newCursor); }); }, ); }; const buildDestPath = (): string => { const base = destPath.replace(/\/+$/, "") || "/"; if (isSingleFile) { return base.endsWith("/") ? `${base}${files[0].name}` : `${base}/${files[0].name}`; } return base; }; const handleConfirm = () => { if (!selectedHost || !destPath.trim() || !isHostReady) return; const fullPath = buildDestPath(); onConfirm( selectedHost.id.toString(), selectedHost.id, fullPath, destPath.trim(), methodPreference, Math.max(1, Math.min(8, parseInt(parallelSegmentCount, 10) || 2)), ); onOpenChange(false); }; return ( {move ? files.length > 1 ? t("transfer.moveItemsToHost", { count: files.length }) : t("transfer.moveToHost") : files.length > 1 ? t("transfer.copyItemsToHost", { count: files.length }) : t("transfer.copyToHost")} {sourceLabel}
{loadingHosts ? (

{t("fileManager.connecting")}

) : availableHosts.length === 0 ? (

{t("transfer.noHostsConnected")}

{t("transfer.noHostsConnectedHint")}

) : ( <> {/* Destination host */}
{selectedHost && (

{connectionLabel(selectedConnectionState, t)} {connectionErrors[selectedHost.id] ? `: ${connectionErrors[selectedHost.id]}` : ""}

)}
{/* Shortcuts */} {selectedHost && shortcuts.length > 0 && (
{shortcuts.map((entry, i) => (
))}
)} {/* Recent destinations */} {recents.length > 0 && (
{!recentsCollapsed && (
{recents.map((recent, i) => ( ))}
)}
)} {/* Folder browser */} {selectedHost && isHostReady && (
{formatBrowsePathLabel(browsePath)}
{browseLoading ? (

{t("fileManager.connecting")}

) : browseStatus === "not_found" ? (

{t("transfer.browsePathWillBeCreated")}

) : browseStatus === "error" ? (

{t("transfer.browsePathError")}

) : browseEntries.length === 0 ? (

{t("fileManager.emptyFolder")}

) : ( browseEntries.map((entry, i) => ( )) )}
)} {/* Destination path input */}
setDestPath(e.target.value)} onKeyDown={handleDestPathKeyDown} placeholder="/home/user" disabled={!selectedHost} className="rounded-none bg-muted/50 border-border font-mono text-xs" /> {isArchiveTransfer && (

{t("transfer.destMustBeDirectory")}

)}
{/* Bookmark button */} {selectedHost && destPath.trim() && ( )} {/* Transfer method + parallel lanes (archive transfers only) */} {isArchiveTransfer && ( <>

{methodPreference === "auto" ? t("transfer.methodAutoHint") : methodPreference === "tar" ? t("transfer.methodTarHint") : t("transfer.methodItemSftpHint")}

{t("transfer.parallelSegmentsHint")}

{methodPreviewLoading && (

{t("transfer.methodPreviewLoading")}

)} {methodPreviewError && (

{t("transfer.methodPreviewError")}

)} {methodPreview && !methodPreviewLoading && (

{methodPreview.resolvedMethod === "tar" ? t("transfer.methodPreviewWillUseTar") : t("transfer.methodPreviewWillUseItemSftp")}

{t(`transfer.methodReason.${methodPreview.reasonKey}`, { fileCount: methodPreview.summary.fileCount, totalSize: formatByteSize( methodPreview.summary.totalBytes, ), largestSize: formatByteSize( methodPreview.summary.largestFileBytes, ), })}

{t("transfer.methodPreviewScanSummary", { fileCount: methodPreview.summary.fileCount, totalSize: formatByteSize( methodPreview.summary.totalBytes, ), })}

{methodPreview.resolvedMethod === "item_sftp" && (

{t("transfer.methodItemSftpLimitation")}

)}
)} )}

{t("transfer.jumpHostLimitation")}

)}
); }