import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, } from "react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowLeftRight, File as FileIcon, Folder, RefreshCw, Server, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/alert-dialog"; import { Button } from "@/components/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/dialog"; import { Input } from "@/components/input"; import { Label } from "@/components/label"; import { addTransferRecent, browseSSHDirectory, changeSSHPermissions, createSSHFolder, deleteSSHItem, ensureSSHSessionForHost, getSSHHosts, renameSSHItem, transferToHost, type HostConnectionState, type TransferProgressResponse, } from "@/main-axios"; import type { SSHHost } from "@/types"; import { PermissionsDialog } from "@/features/file-manager/components/PermissionsDialog"; import { formatFileSize } from "@/features/file-manager/file-manager-utils"; import { beginTransferProgressMonitoring } from "@/features/file-manager/transferProgressMonitor"; import { createFormatTransferMetrics } from "@/features/file-manager/transferMetricsFormat"; import { hasSameHostTransferConflict, joinRemotePath, normalizeRemoteDir, } from "./sftp-transfer-utils"; import { Select2 } from "@/components/select2"; type EntryType = "file" | "directory" | "link" | "other"; type PaneId = "source" | "dest"; interface BrowserEntry { name: string; path: string; type: EntryType; size?: number; modified?: string; modifiedTimestamp?: number; permissions?: string; owner?: string; group?: string; } interface RemotePaneState { hostId: string; sessionId: string | null; connectionState: HostConnectionState; error: string | null; path: string; entries: BrowserEntry[]; selectedPaths: Set; loading: boolean; } interface ContextMenuState { x: number; y: number; paneId: PaneId; entry?: BrowserEntry; } interface NameDialogState { kind: "rename" | "mkdir"; paneId: PaneId; entry?: BrowserEntry; value: string; } interface PermissionsTarget { paneId: PaneId; entry: BrowserEntry; } const defaultRemotePane = (): RemotePaneState => ({ hostId: "", sessionId: null, connectionState: "disconnected", error: null, path: "/", entries: [], selectedPaths: new Set(), loading: false, }); function formatModified(entry: BrowserEntry): string { if (entry.modifiedTimestamp !== undefined) { const date = new Date(entry.modifiedTimestamp * 1000); if (!Number.isNaN(date.getTime())) { const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } } if (entry.modified) { const date = new Date(entry.modified); if (!Number.isNaN(date.getTime())) { const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } } return "-"; } function parentRemotePath(remotePath: string): string { const normalized = normalizeRemoteDir(remotePath); if (normalized === "/") return "/"; const index = normalized.lastIndexOf("/"); return index <= 0 ? "/" : normalized.slice(0, index); } function connectionLabel(state: HostConnectionState, t: TFunction): 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"); } } function FileRow({ entry, selected, onToggle, onOpen, draggable = false, onDragStart, onDragEnd, onDropPayload, acceptsDrop, onContextMenu, t, }: { entry: BrowserEntry; selected: boolean; onToggle: () => void; onOpen?: () => void; draggable?: boolean; onDragStart?: () => void; onDragEnd?: () => void; onDropPayload?: () => void; acceptsDrop?: boolean; onContextMenu?: ( event: MouseEvent, entry: BrowserEntry, ) => void; t: TFunction; }) { const Icon = entry.type === "directory" ? Folder : FileIcon; const canDrop = entry.type === "directory" && !!onDropPayload && acceptsDrop; return (
{ if (!draggable || !onDragStart) return; event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData("text/plain", entry.path); onDragStart(); }} onDragEnd={onDragEnd} onDragOver={(event) => { if (!canDrop) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDrop={(event) => { if (!canDrop) return; event.preventDefault(); event.stopPropagation(); onDropPayload(); }} onContextMenu={(event) => onContextMenu?.(event, entry)} >
{formatModified(entry)}
{entry.type === "directory" && onOpen ? ( ) : ( {formatFileSize(entry.size)} )}
); } function ContextMenuButton({ children, disabled, onClick, }: { children: string; disabled?: boolean; onClick: () => void; }) { return ( ); } function RemotePane({ title, hosts, pane, setPane, dragActive, onDragStart, onDragEnd, onDropPayload, onPaneContextMenu, onEntryContextMenu, t, }: { title: string; hosts: SSHHost[]; pane: RemotePaneState; setPane: (updater: (pane: RemotePaneState) => RemotePaneState) => void; dragActive: boolean; onDragStart: (paths: string[], pane: RemotePaneState) => void; onDragEnd: () => void; onDropPayload?: (destinationPath: string) => void; onPaneContextMenu: (event: MouseEvent) => void; onEntryContextMenu: ( event: MouseEvent, entry: BrowserEntry, ) => void; t: TFunction; }) { const selectedHost = hosts.find((host) => String(host.id) === pane.hostId); const acceptsDrop = dragActive && !!pane.sessionId && !!onDropPayload; const loadRemotePath = useCallback( async (sessionId: string, nextPath: string) => { setPane((current) => ({ ...current, loading: true, error: null })); const result = await browseSSHDirectory(sessionId, nextPath); if (result.status !== "ok") { setPane((current) => ({ ...current, entries: [], loading: false, error: t("sftpTransfer.failedToListFiles"), })); return; } const base = normalizeRemoteDir(result.path || nextPath); setPane((current) => ({ ...current, path: base, entries: (result.files as BrowserEntry[]) .filter((entry) => entry.name !== "." && entry.name !== "..") .map((entry) => ({ name: entry.name, type: entry.type, path: joinRemotePath(base, entry.name), size: entry.size, modified: entry.modified, modifiedTimestamp: entry.modifiedTimestamp, })) .sort((a, b) => { if (a.type === "directory" && b.type !== "directory") return -1; if (a.type !== "directory" && b.type === "directory") return 1; return a.name.localeCompare(b.name); }), selectedPaths: new Set(), loading: false, })); }, [setPane, t], ); const connect = useCallback( async (host: SSHHost) => { setPane((current) => ({ ...current, hostId: String(host.id), sessionId: null, connectionState: "connecting", error: null, entries: [], selectedPaths: new Set(), })); const result = await ensureSSHSessionForHost(host); if (result.state !== "ready" || !result.sessionId) { setPane((current) => ({ ...current, connectionState: result.state, error: result.error || null, sessionId: result.sessionId || null, })); return; } setPane((current) => ({ ...current, connectionState: "ready", sessionId: result.sessionId || String(host.id), path: host.defaultPath || "/", })); await loadRemotePath( result.sessionId || String(host.id), host.defaultPath || "/", ); }, [loadRemotePath, setPane], ); const toggle = (entryPath: string) => { setPane((current) => { const next = new Set(current.selectedPaths); if (next.has(entryPath)) next.delete(entryPath); else next.add(entryPath); return { ...current, selectedPaths: next }; }); }; const getDragPaths = (entryPath: string): string[] => { if (pane.selectedPaths.has(entryPath) && pane.selectedPaths.size > 0) { return [...pane.selectedPaths]; } setPane((current) => ({ ...current, selectedPaths: new Set([entryPath]), })); return [entryPath]; }; return (
{ if (!acceptsDrop) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDrop={(event) => { if (!acceptsDrop) return; event.preventDefault(); onDropPayload?.(pane.path); }} >
{title}
{ const host = hosts.find( (item) => String(item.id) === event.target.value, ); if (host) void connect(host); }} className="h-8 w-full rounded-none border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" > {hosts.map((host) => ( ))}
setPane((current) => ({ ...current, path: event.target.value })) } onKeyDown={(event) => { if (event.key === "Enter" && pane.sessionId) { void loadRemotePath(pane.sessionId, pane.path); } }} disabled={!pane.sessionId} className="h-8 rounded-none border-border bg-muted/40 font-mono text-xs" />
{selectedHost ? connectionLabel(pane.connectionState, t) : t("sftpTransfer.noHostSelected")} {pane.error ? `: ${pane.error}` : ""}
{t("sftpTransfer.name")} {t("sftpTransfer.modified")} {t("sftpTransfer.size")}
{pane.entries.length === 0 && !pane.loading ? (
{t("sftpTransfer.noFiles")}
) : ( pane.entries.map((entry) => ( toggle(entry.path)} draggable onDragStart={() => onDragStart(getDragPaths(entry.path), pane)} onDragEnd={onDragEnd} acceptsDrop={acceptsDrop} onDropPayload={ entry.type === "directory" ? () => onDropPayload?.(entry.path) : undefined } onOpen={ entry.type === "directory" && pane.sessionId ? () => void loadRemotePath(pane.sessionId!, entry.path) : undefined } onContextMenu={onEntryContextMenu} t={t} /> )) )}
); } export function SftpTransferTab() { const { t } = useTranslation(); const formatTransferMetrics = useMemo( () => createFormatTransferMetrics(t), [t], ); const [hosts, setHosts] = useState([]); const [hostsLoading, setHostsLoading] = useState(false); const [sourcePane, setSourcePaneState] = useState(defaultRemotePane); const [destPane, setDestPaneState] = useState(defaultRemotePane); const [move, setMove] = useState(false); const [transferring, setTransferring] = useState(false); const [dragActive, setDragActive] = useState(false); const [contextMenu, setContextMenu] = useState(null); const [nameDialog, setNameDialog] = useState(null); const [deleteTarget, setDeleteTarget] = useState( null, ); const [permissionsTarget, setPermissionsTarget] = useState(null); const setSourcePane = useCallback( (updater: (pane: RemotePaneState) => RemotePaneState) => setSourcePaneState(updater), [], ); const setDestPane = useCallback( (updater: (pane: RemotePaneState) => RemotePaneState) => setDestPaneState(updater), [], ); useEffect(() => { setHostsLoading(true); getSSHHosts({ includeStatus: false }) .then((data) => setHosts( data.filter( (host) => host.enableFileManager !== false && host.connectionType !== "rdp" && host.connectionType !== "vnc", ), ), ) .catch(() => setHosts([])) .finally(() => setHostsLoading(false)); }, []); useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); window.addEventListener("click", close); window.addEventListener("keydown", close); return () => { window.removeEventListener("click", close); window.removeEventListener("keydown", close); }; }, [contextMenu]); const sourceSelectionCount = sourcePane.selectedPaths.size; const getPane = (paneId: PaneId): RemotePaneState => paneId === "source" ? sourcePane : destPane; const getContextPaths = (menu: ContextMenuState): string[] => { if (!menu.entry) return []; const selectedPaths = getPane(menu.paneId).selectedPaths; if (selectedPaths.has(menu.entry.path) && selectedPaths.size > 0) { return [...selectedPaths]; } return [menu.entry.path]; }; const getContextEntries = (menu: ContextMenuState): BrowserEntry[] => { const paths = new Set(getContextPaths(menu)); return getPane(menu.paneId).entries.filter((entry) => paths.has(entry.path), ); }; const refreshRemotePane = async (paneId: PaneId) => { const pane = getPane(paneId); const setPane = paneId === "source" ? setSourcePane : setDestPane; if (!pane.sessionId) return; setPane((current) => ({ ...current, loading: true, error: null })); const refreshed = await browseSSHDirectory(pane.sessionId, pane.path); if (refreshed.status !== "ok") { setPane((current) => ({ ...current, loading: false, error: t("sftpTransfer.failedToRefresh"), })); return; } const base = normalizeRemoteDir(refreshed.path || pane.path); setPane((current) => ({ ...current, path: base, entries: refreshed.files .filter((entry) => entry.name !== "." && entry.name !== "..") .map((entry) => ({ ...entry, type: entry.type, modified: entry.modified, modifiedTimestamp: entry.modifiedTimestamp, path: joinRemotePath(base, entry.name), })), selectedPaths: new Set(), loading: false, })); }; const openPaneContextMenu = ( event: MouseEvent, paneId: PaneId, ) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, paneId }); }; const openEntryContextMenu = ( event: MouseEvent, paneId: PaneId, entry: BrowserEntry, ) => { event.preventDefault(); event.stopPropagation(); if (!getPane(paneId).selectedPaths.has(entry.path)) { const setPane = paneId === "source" ? setSourcePane : setDestPane; setPane((current) => ({ ...current, selectedPaths: new Set([entry.path]), })); } setContextMenu({ x: event.clientX, y: event.clientY, paneId, entry }); }; const runTransfer = async ({ paths, destinationPath, sourceSessionId, sourceHostId, destinationSessionId, destinationHostId, move: moveFiles, refreshTarget, sourceRefreshTarget, }: { paths: string[]; destinationPath: string; sourceSessionId: string | null; sourceHostId: string; destinationSessionId: string | null; destinationHostId: string; move: boolean; refreshTarget?: PaneId; sourceRefreshTarget?: PaneId; }) => { if (!sourceSessionId || !destinationSessionId || paths.length === 0) { return; } const destDir = normalizeRemoteDir(destinationPath); if ( sourceHostId === destinationHostId && hasSameHostTransferConflict(paths, destDir) ) { toast.error(t("sftpTransfer.destinationInsideSource")); return; } setTransferring(true); try { const { transferId } = await transferToHost( sourceSessionId, paths, destinationSessionId, destDir, moveFiles, "auto", ); const monitorHandle = beginTransferProgressMonitoring(transferId, t, { formatTransferMetrics, }); if (!monitorHandle) return; const finalStatus: TransferProgressResponse = await monitorHandle.waitForCompletion; if ( finalStatus.status === "success" || finalStatus.status === "partial" ) { void addTransferRecent( Number(sourceHostId), Number(destinationHostId), destDir, destDir, ); if (refreshTarget) void refreshRemotePane(refreshTarget); if (moveFiles && sourceRefreshTarget) { void refreshRemotePane(sourceRefreshTarget); } } } catch (error) { toast.error( error instanceof Error ? error.message : t("transfer.transferError"), ); } finally { setTransferring(false); } }; const transferSelection = async ( paths = [...sourcePane.selectedPaths], destinationPath = destPane.path, sourceSessionId = sourcePane.sessionId, sourceHostId = sourcePane.hostId, ) => { await runTransfer({ paths, destinationPath, sourceSessionId, sourceHostId, destinationSessionId: destPane.sessionId, destinationHostId: destPane.hostId, move, refreshTarget: "dest", sourceRefreshTarget: "source", }); }; const dragSourceRef = useRef<{ paths: string[]; hostId: string; sessionId: string; paneId: PaneId; }>({ paths: [], hostId: "", sessionId: "", paneId: "source" }); const startRemoteDrag = ( paneId: PaneId, paths: string[], pane: RemotePaneState, ) => { if (!pane.sessionId) return; setDragActive(true); dragSourceRef.current = { paths, hostId: pane.hostId, sessionId: pane.sessionId, paneId, }; }; const handleDestinationDrop = (destinationPath: string) => { if (!dragActive || transferring) return; setDragActive(false); const { paths, hostId, sessionId, paneId } = dragSourceRef.current; void runTransfer({ paths, destinationPath, sourceSessionId: sessionId, sourceHostId: hostId, destinationSessionId: destPane.sessionId, destinationHostId: destPane.hostId, move, refreshTarget: "dest", sourceRefreshTarget: paneId, }); }; const handleCopyToTarget = async (menu: ContextMenuState) => { const paths = getContextPaths(menu); setContextMenu(null); if (paths.length === 0) return; if (menu.paneId === "source") { await runTransfer({ paths, destinationPath: destPane.path, sourceSessionId: sourcePane.sessionId, sourceHostId: sourcePane.hostId, destinationSessionId: destPane.sessionId, destinationHostId: destPane.hostId, move: false, refreshTarget: "dest", }); return; } await runTransfer({ paths, destinationPath: sourcePane.path, sourceSessionId: destPane.sessionId, sourceHostId: destPane.hostId, destinationSessionId: sourcePane.sessionId, destinationHostId: sourcePane.hostId, move: false, refreshTarget: "source", }); }; const handleRefreshPane = async (paneId: PaneId) => { setContextMenu(null); await refreshRemotePane(paneId); }; const handleNameDialogSubmit = async () => { if (!nameDialog) return; const value = nameDialog.value.trim(); if (!value) return; const pane = getPane(nameDialog.paneId); if (!pane.sessionId) return; try { if (nameDialog.kind === "mkdir") { await createSSHFolder(pane.sessionId, pane.path, value); await refreshRemotePane(nameDialog.paneId); toast.success(t("sftpTransfer.folderCreated")); } else if (nameDialog.entry) { await renameSSHItem(pane.sessionId, nameDialog.entry.path, value); await refreshRemotePane(nameDialog.paneId); toast.success(t("sftpTransfer.itemRenamed")); } setNameDialog(null); } catch (error) { toast.error( error instanceof Error ? error.message : t("sftpTransfer.actionFailed"), ); } }; const handleDeleteConfirmed = async () => { if (!deleteTarget) return; const entries = getContextEntries(deleteTarget); const paneId = deleteTarget.paneId; setDeleteTarget(null); if (entries.length === 0) return; const pane = getPane(paneId); if (!pane.sessionId) return; try { for (const entry of entries) { await deleteSSHItem( pane.sessionId, entry.path, entry.type === "directory", ); } await refreshRemotePane(paneId); toast.success(t("sftpTransfer.itemsDeleted", { count: entries.length })); } catch (error) { toast.error( error instanceof Error ? error.message : t("sftpTransfer.deleteFailed"), ); } }; const handlePermissionsSave = async ( entry: BrowserEntry, permissions: string, ) => { if (!permissionsTarget) return; const pane = getPane(permissionsTarget.paneId); if (!pane.sessionId) throw new Error(t("sftpTransfer.noRemoteSessionConnected")); await changeSSHPermissions(pane.sessionId, entry.path, permissions); await refreshRemotePane(permissionsTarget.paneId); toast.success(t("sftpTransfer.permissionsUpdated")); }; const canTransfer = sourcePane.selectedPaths.size > 0 && !!sourcePane.sessionId && !!destPane.sessionId && !transferring; const contextEntries = contextMenu ? getContextEntries(contextMenu) : []; const contextPaths = contextMenu ? getContextPaths(contextMenu) : []; const singleContextEntry = contextEntries.length === 1 ? contextEntries[0] : contextMenu?.entry; const contextPane = contextMenu ? getPane(contextMenu.paneId) : null; const otherPane = contextMenu ? getPane(contextMenu.paneId === "source" ? "dest" : "source") : null; const canCopyContext = !!contextMenu?.entry && contextPaths.length > 0 && !transferring && !!contextPane?.sessionId && !!otherPane?.sessionId; const canMutateContextPane = !!contextPane?.sessionId; const permissionsDialogFile = permissionsTarget ? { ...permissionsTarget.entry, type: permissionsTarget.entry.type === "other" ? "file" : permissionsTarget.entry.type, } : null; return (

{t("sftpTransfer.title")}

{t("sftpTransfer.description")}

{t("sftpTransfer.itemsSelected", { count: sourceSelectionCount })} {hostsLoading ? t("sftpTransfer.loadingHosts") : t("sftpTransfer.ready")}
startRemoteDrag("source", paths, pane)} onDragEnd={() => setDragActive(false)} onPaneContextMenu={(event) => openPaneContextMenu(event, "source")} onEntryContextMenu={(event, entry) => openEntryContextMenu(event, "source", entry) } t={t} /> startRemoteDrag("dest", paths, pane)} onDragEnd={() => setDragActive(false)} onDropPayload={handleDestinationDrop} onPaneContextMenu={(event) => openPaneContextMenu(event, "dest")} onEntryContextMenu={(event, entry) => openEntryContextMenu(event, "dest", entry) } t={t} />
{contextMenu && (
event.preventDefault()} > {contextMenu.entry ? ( <> void handleCopyToTarget(contextMenu)} > {t("sftpTransfer.copyToTarget")} { if (!singleContextEntry) return; setContextMenu(null); setNameDialog({ kind: "rename", paneId: contextMenu.paneId, entry: singleContextEntry, value: singleContextEntry.name, }); }} > {t("sftpTransfer.rename")} { setDeleteTarget(contextMenu); setContextMenu(null); }} > {t("sftpTransfer.delete")} { if (!singleContextEntry) return; setContextMenu(null); setPermissionsTarget({ paneId: contextMenu.paneId, entry: singleContextEntry, }); }} > {t("sftpTransfer.editPermissions")} ) : ( <> void handleRefreshPane(contextMenu.paneId)} > {t("sftpTransfer.refresh")} { setNameDialog({ kind: "mkdir", paneId: contextMenu.paneId, value: "", }); setContextMenu(null); }} > {t("sftpTransfer.createNewFolder")} )}
)} !open && setNameDialog(null)} > {nameDialog?.kind === "mkdir" ? t("sftpTransfer.createNewFolder") : t("sftpTransfer.rename")} setNameDialog((current) => current ? { ...current, value: event.target.value } : current, ) } onKeyDown={(event) => { if (event.key === "Enter") void handleNameDialogSubmit(); }} className="h-9 rounded-none border-border bg-muted/40 text-xs" /> !open && setDeleteTarget(null)} > {t("sftpTransfer.deleteSelectedItems")} {t("sftpTransfer.deleteConfirm", { count: deleteTarget ? getContextEntries(deleteTarget).length : 0, })} {t("sftpTransfer.cancel")} void handleDeleteConfirmed()} > {t("sftpTransfer.delete")} !open && setPermissionsTarget(null)} onSave={handlePermissionsSave} />
); }