import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react"; import { Button } from "../ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "../ui/context-menu"; import { cn } from "../../lib/utils"; import { getParentPath, joinPath } from "../../application/state/sftp/utils"; import type { SftpFileEntry } from "../../types"; import type { SftpPane } from "../../application/state/sftp/types"; import type { SftpTransferSource } from "./SftpContext"; import { sftpListOrderStore } from "./hooks/useSftpListOrderStore"; import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting"; import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils"; import { isKnownBinaryFile } from "../../lib/sftpFileUtils"; import { isExtractableArchive } from "../../domain/sftpArchive"; import { SftpFileRow } from "./SftpFileRow"; import type { SftpListDensity } from "../../domain/sftpListDensity"; import { SftpColumnMenuItems } from "./SftpColumnMenuItems"; import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList"; import { getSftpListUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey, shouldShowSftpUploadFolderMenu, shouldShowSftpUploadFilesMenu, } from "./sftpUploadMenu"; interface SftpPaneFileListProps { t: (key: string, params?: Record) => string; pane: SftpPane; side: "left" | "right"; isPaneFocused: boolean; sorting: UseSftpPaneSortingResult; fileListRef: React.RefObject; handleFileListScroll: (e: React.UIEvent) => void; shouldVirtualize: boolean; totalHeight: number; sortedDisplayFiles: SftpFileEntry[]; isDragOverPane: boolean; draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null; onRefresh: () => void; onNavigateTo: (path: string) => void; onClearSelection: () => void; setShowNewFolderDialog: (open: boolean) => void; setShowNewFileDialog: (open: boolean) => void; getNextUntitledName: (existingNames: string[]) => string; setNewFileName: (value: string) => void; setFileNameError: (value: string | null) => void; // Row rendering dragOverEntry: string | null; handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void; handleRowOpen: (entry: SftpFileEntry) => void; handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void; onDragEnd: () => void; handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void; handleRowDragLeave: () => void; handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void; onCopyToOtherPane: (files: SftpTransferSource[]) => void; onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise; onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void; onOpenFileWith?: (entry: SftpFileEntry) => void; onEditFile?: (entry: SftpFileEntry) => void; onDownloadFile?: (entry: SftpFileEntry) => void; onDownloadFiles?: (entries: SftpFileEntry[]) => void; onExtractArchive?: (entry: SftpFileEntry) => void; onEditPermissions?: (entry: SftpFileEntry) => void; onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise | void; onUploadExternalFolder?: (targetPath?: string) => Promise | void; // Whether this pane is rendering a local filesystem. Upload menu items only // make sense for remote (SFTP) panes, so they are suppressed when isLocal. isLocal?: boolean; openRenameDialog: (name: string) => void; openDeleteConfirm: (targets: string[]) => void; rowHeight: number; visibleRows: { entry: SftpFileEntry; index: number; top: number }[]; listDensity?: SftpListDensity; } const SftpErrorWithLogs: React.FC<{ error: string; connectionLogs: string[]; onRetry: () => void; t: (key: string) => string; }> = ({ error, connectionLogs, onRetry, t }) => { const [showLogs, setShowLogs] = useState(connectionLogs.length > 0); return (
{t(error)}
{connectionLogs.length > 0 && ( )}
{showLogs && connectionLogs.length > 0 && (
{connectionLogs.map((log, i) => (
{log}
))}
)}
); }; export const SftpPaneFileList: React.FC = React.memo(({ t, pane, side, isPaneFocused, sorting, fileListRef, handleFileListScroll, shouldVirtualize, totalHeight, sortedDisplayFiles, isDragOverPane, draggedFiles, onRefresh, onNavigateTo, onClearSelection, setShowNewFolderDialog, setShowNewFileDialog, getNextUntitledName, setNewFileName, setFileNameError, dragOverEntry, handleRowSelect, handleRowOpen, handleFileDragStart, onDragEnd, handleEntryDragOver, handleRowDragLeave, handleEntryDrop, onCopyToOtherPane, onMoveEntriesToPath, onOpenFileWithSystemDefault, onOpenFileWith, onEditFile, onDownloadFile, onDownloadFiles, onExtractArchive, onEditPermissions, onUploadExternalFileList, onUploadExternalFolder, isLocal = false, openRenameDialog, openDeleteConfirm, rowHeight, visibleRows, listDensity = "comfortable", }) => { const { columnWidths, visibleColumns, directoriesFirst, sortField, sortOrder, handleSort, handleResizeStart, toggleColumnVisibility, toggleDirectoriesFirst, } = sorting; const filesByName = useMemo(() => { const map = new Map(); sortedDisplayFiles.forEach((entry) => { map.set(entry.name, entry); }); return map; }, [sortedDisplayFiles]); // Push sorted file names into the list order store for keyboard navigation useEffect(() => { const names = sortedDisplayFiles .filter((f) => f.name !== "..") .map((f) => f.name); sftpListOrderStore.setItems(pane.id, names); return () => sftpListOrderStore.clearPane(pane.id); }, [sortedDisplayFiles, pane.id]); useEffect(() => { if (pane.selectedFiles.size !== 1) return; const selectedName = Array.from(pane.selectedFiles)[0]; if (!selectedName) return; const container = fileListRef.current; if (!container) return; const row = Array.from(container.querySelectorAll('[data-sftp-row="true"]')) .find((element) => element.dataset.entryName === selectedName); if (row) { row.scrollIntoView({ block: "nearest" }); return; } if (!shouldVirtualize || rowHeight <= 0) return; const itemIndex = sortedDisplayFiles.findIndex((entry) => entry.name === selectedName); if (itemIndex < 0) return; container.scrollTop = getSftpVirtualListScrollTop({ itemIndex, rowHeight, currentScrollTop: container.scrollTop, viewportHeight: container.clientHeight, }); }, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]); // Use refs for frequently-changing values in context-menu actions const selectedFilesRef = useRef(pane.selectedFiles); selectedFilesRef.current = pane.selectedFiles; const handleBackgroundClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-sftp-row="true"]')) return; if (pane.selectedFiles.size === 0) return; onClearSelection(); }, [onClearSelection, pane.selectedFiles.size]); // Hidden file input backing the "Upload File(s)" context menu item. It sends // the original FileList through uploadFromFileList so Electron can still // resolve local paths for stream uploads. const uploadEnabled = shouldShowSftpUploadFilesMenu({ isLocal, hasFileListUpload: !!onUploadExternalFileList, }); const folderUploadEnabled = shouldShowSftpUploadFolderMenu({ isLocal, hasFolderUpload: !!onUploadExternalFolder, }); const uploadInputRef = useRef(null); const uploadTargetPathRef = useRef(undefined); const triggerUploadPicker = useCallback((targetPath?: string) => { if (isLocal || !onUploadExternalFileList) return; const input = uploadInputRef.current; if (!input) return; uploadTargetPathRef.current = targetPath; // Reset value so selecting the same files twice still fires onChange. input.value = ""; input.click(); }, [isLocal, onUploadExternalFileList]); const handleUploadInputChange = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) { uploadTargetPathRef.current = undefined; return; } if (!onUploadExternalFileList) { uploadTargetPathRef.current = undefined; return; } const targetPath = uploadTargetPathRef.current; uploadTargetPathRef.current = undefined; void onUploadExternalFileList(files, targetPath); }, [onUploadExternalFileList]); const renderRow = useCallback( (entry: SftpFileEntry, index: number) => ( {entry.name !== ".." && ( handleRowOpen(entry)}> {isNavigableDirectory(entry) ? ( <> {t("sftp.context.open")} ) : ( <> {" "} {t("sftp.context.open")} )} {isNavigableDirectory(entry) && ( onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}> {t("sftp.context.navigateTo")} )} {!isNavigableDirectory(entry) && onOpenFileWithSystemDefault && ( onOpenFileWithSystemDefault(entry)}> {" "} {t("sftp.context.openWithDefault")} )} {!isNavigableDirectory(entry) && onOpenFileWith && ( onOpenFileWith(entry)}> {" "} {t("sftp.context.openWith")} )} {!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && ( onEditFile(entry)}> {" "} {t("sftp.context.edit")} )} {onDownloadFile && (!isNavigableDirectory(entry) || !pane.connection?.isLocal) && ( { const currentSelected = selectedFilesRef.current; if ( onDownloadFiles && currentSelected.has(entry.name) && currentSelected.size > 1 ) { const entries = Array.from(currentSelected) .map((name) => filesByName.get(String(name))) .filter((f): f is SftpFileEntry => !!f); onDownloadFiles(entries); } else { onDownloadFile(entry); } }} > {" "} {t("sftp.context.download")} )} {!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && ( onExtractArchive(entry)}> {" "} {t("sftp.context.extract")} )} { const currentSelected = selectedFilesRef.current; const files = currentSelected.has(entry.name) ? Array.from(currentSelected) : [entry.name]; const fileData = files.map((name) => { const fileName = String(name); const file = filesByName.get(fileName); return { name: fileName, isDirectory: file ? isNavigableDirectory(file) : false, sourceConnectionId: pane.connection?.id, sourcePath: pane.connection?.currentPath, }; }); onCopyToOtherPane(fileData); }} > {" "} {t("sftp.context.copyToOtherPane")} { navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name)); }} > {" "} {t("sftp.context.copyPath")} {(() => { const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name)); const targetParent = getParentPath(sourceParent); if (sourceParent === targetParent) return null; return ( { const currentSelected = selectedFilesRef.current; const sourcePaths = currentSelected.has(entry.name) ? Array.from(currentSelected as Set).map((n) => joinPath(pane.connection?.currentPath ?? "", n)) : [joinPath(pane.connection?.currentPath ?? "", entry.name)]; void onMoveEntriesToPath(sourcePaths, targetParent); }} > {" "} {t("sftp.context.moveToParent")} ); })()} openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}> {t("common.rename")} {onEditPermissions && pane.connection && !pane.connection.isLocal && ( onEditPermissions(entry)}> {" "} {t("sftp.context.permissions")} )} { const currentSelected = selectedFilesRef.current; const files = currentSelected.has(entry.name) ? Array.from(currentSelected as Set).map((n) => joinPath(pane.connection?.currentPath ?? "", n)) : [joinPath(pane.connection?.currentPath ?? "", entry.name)]; openDeleteConfirm(files); }} > {t("action.delete")} {t("common.refresh")} setShowNewFolderDialog(true)}> {t("sftp.newFolder")} setShowNewFileDialog(true)}> {t("sftp.newFile")} {uploadEnabled && onUploadExternalFileList && ( { const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? ""); triggerUploadPicker(target); }} > {" "} {t(getSftpUploadFilesLabelKey(entry))} )} {folderUploadEnabled && onUploadExternalFolder && ( { const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? ""); void onUploadExternalFolder(target); }} > {" "} {t(getSftpUploadFolderLabelKey(entry))} )} )} ), [ columnWidths, visibleColumns, filesByName, handleEntryDragOver, handleEntryDrop, handleFileDragStart, handleRowDragLeave, handleRowOpen, handleRowSelect, dragOverEntry, isPaneFocused, onCopyToOtherPane, onMoveEntriesToPath, onDownloadFile, onDownloadFiles, onExtractArchive, onDragEnd, onEditFile, onEditPermissions, onNavigateTo, onOpenFileWithSystemDefault, onOpenFileWith, onRefresh, onUploadExternalFileList, onUploadExternalFolder, uploadEnabled, folderUploadEnabled, openDeleteConfirm, openRenameDialog, pane.connection, pane.selectedFiles, listDensity, setShowNewFolderDialog, setShowNewFileDialog, t, triggerUploadPicker, ], ); const fileRows = useMemo( () => shouldVirtualize ? visibleRows.map(({ entry, index, top }) => (
{renderRow(entry, index)}
)) : sortedDisplayFiles.map((entry, index) => ( {renderRow(entry, index)} )), [ renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows, ], ); return ( <> {/* File list header */}
{ if (!isSftpColumnMenuKey(e.key, e.shiftKey)) return; e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); e.currentTarget.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: rect.left + 16, clientY: rect.top + rect.height / 2, })); }} style={{ display: "grid", gridTemplateColumns: buildSftpColumnTemplate(columnWidths, visibleColumns), }} >
handleSort("name")} > {t("sftp.columns.name")} {sortField === "name" && ( {sortOrder === "asc" ? "↑" : "↓"} )}
handleResizeStart("name", e)} />
{visibleColumns.modified && (
handleSort("modified")} > {t("sftp.columns.modified")} {sortField === "modified" && ( {sortOrder === "asc" ? "↑" : "↓"} )}
handleResizeStart("modified", e)} />
)} {visibleColumns.size && (
handleSort("size")} > {sortField === "size" && ( {sortOrder === "asc" ? "↑" : "↓"} )} {t("sftp.columns.size")}
handleResizeStart("size", e)} />
)} {visibleColumns.type && (
handleSort("type")} > {sortField === "type" && ( {sortOrder === "asc" ? "↑" : "↓"} )} {t("sftp.columns.kind")}
handleResizeStart("type", e)} />
)} {visibleColumns.owner && (
handleSort("owner")} > {sortField === "owner" && ( {sortOrder === "asc" ? "↑" : "↓"} )} {t("sftp.columns.owner")}
)}
{/* File list with empty area context menu */}
{pane.loading && sortedDisplayFiles.length === 0 ? (
{pane.connectionLogs.length > 0 && (
{pane.connectionLogs.map((log, i) => (
{log}
))}
)}
) : pane.error && !pane.reconnecting ? ( ) : sortedDisplayFiles.length === 0 ? (
{t("sftp.emptyDirectory")}
) : (
{fileRows}
)} {/* Drop overlay */} {isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
{t("sftp.dropFilesHere")}
)}
{t("sftp.context.refresh")} setShowNewFolderDialog(true)}> {t("sftp.newFolder")} { const defaultName = getNextUntitledName(pane.files.map(f => f.name)); setNewFileName(defaultName); setFileNameError(null); setShowNewFileDialog(true); }}> {t("sftp.newFile")} {uploadEnabled && onUploadExternalFileList && ( triggerUploadPicker(undefined)}> {t("sftp.context.uploadFiles")} )} {folderUploadEnabled && onUploadExternalFolder && ( void onUploadExternalFolder(undefined)}> {t("sftp.context.uploadFolder")} )}
{/* Hidden file input backing the "Upload File(s)" context menu item. */} {uploadEnabled && onUploadExternalFileList && ( )} {/* Footer */}
{t("sftp.itemsCount", { count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0), })} {pane.selectedFiles.size > 0 && ` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`} {pane.connection.currentPath}
{/* Loading overlay - covers entire pane when navigating or reconnecting */} {pane.loading && !pane.connection?.reusedConnection && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
{pane.connectionLogs.length > 0 && (
{pane.connectionLogs.map((log, i) => (
{log}
))}
)}
)} {/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */} {pane.reconnecting && (
{t("sftp.reconnecting.title")}
{t("sftp.reconnecting.desc")}
)} ); });