/** * SFTP Transfer item component for transfer queue */ import { ArrowDown, ArrowRight, CheckCircle2, ChevronDown, ChevronUp, ClipboardCopy, File, FolderOpen, FolderUp, GripVertical, Loader2, Pause, Play, RefreshCw, X, XCircle, } from 'lucide-react'; import React, { memo } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { getParentPath } from '../../application/state/sftp/utils'; import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore'; import { cn } from '../../lib/utils'; import { TransferTask } from '../../types'; import { buildGlobalTransferProgressDisplay, isDirectoryParentTask, } from '../GlobalSftpTransferCenter'; import { Button } from '../ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; import { formatSpeed, formatTransferBytes } from './utils'; /** Child rows need room for Pause + Cancel (2×24px icons + gap). */ const CHILD_ACTIONS_COLUMN_PX = 56; interface SftpTransferItemProps { task: TransferTask; isChild?: boolean; childNameColumnWidth?: number; onResizeNameColumn?: (event: React.MouseEvent) => void; onCancel: () => void; onPause?: () => void; onResume?: () => void; onRetry: () => void; onDismiss: () => void; canRevealTarget?: boolean; onRevealTarget?: () => void; canCopyTargetPath?: boolean; onCopyTargetPath?: () => void; canToggleChildren?: boolean; isExpanded?: boolean; visibleChildCount?: number; onToggleChildren?: () => void; onSetNameColumnWidth?: (width: number) => void; childNameColumnMinWidth?: number; childNameColumnMaxWidth?: number; childListId?: string; resizeHandleTabIndex?: number; } const TruncatedTextWithTooltip: React.FC<{ text: string; className?: string; }> = ({ text, className }) => ( {text} {text} ); const IconButtonWithTooltip: React.FC<{ label: string; children: React.ReactElement; }> = ({ label, children }) => ( {children} {label} ); /** Pointer activates on pointerdown (Tooltip/parent may eat click); keyboard uses click detail 0. */ const oncePerActivationHandlers = (activate: () => void) => ({ onPointerDown: (event: React.PointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); activate(); }, onClick: (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); // Mouse/touch already ran on pointerdown; only keyboard click (detail 0) remains. if (event.detail > 0) return; activate(); }, }); const SftpTransferItemInner: React.FC = ({ task: propsTask, isChild = false, childNameColumnWidth = 260, onResizeNameColumn, onCancel, onPause, onResume, onRetry, onDismiss, canRevealTarget = false, onRevealTarget, canCopyTargetPath = false, onCopyTargetPath, canToggleChildren = false, isExpanded = false, visibleChildCount: _visibleChildCount = 0, onToggleChildren, onSetNameColumnWidth, childNameColumnMinWidth = 160, childNameColumnMaxWidth = 480, childListId, resizeHandleTabIndex = 0, }) => { const { t } = useI18n(); // Progress bytes live in the center store (patchTask). Avoid depending on // panel setTransfersState for every tick — that re-rendered the whole SFTP // tree and pegged the renderer during large copies. const task = useSftpTransferTask(propsTask.id, propsTask); // Same progress model as the global transfer center (done · found for folders). const isDirParent = isDirectoryParentTask(task); const centerProgress = buildGlobalTransferProgressDisplay(task, t); const hasKnownTotal = isDirParent ? task.totalBytes > 0 && task.transferredBytes > 0 && task.phase !== 'scanning' : task.totalBytes > 0 || !!task.sourceLastModified; const progress = isDirParent ? centerProgress.percent : hasKnownTotal ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0; const isIndeterminate = isDirParent ? centerProgress.indeterminate && (task.status === 'transferring' || task.status === 'pending' || task.status === 'queued' || task.status === 'pausing') : task.status === 'transferring' && !hasKnownTotal; const isActiveTransfer = task.status === 'transferring' || task.status === 'pausing'; // Reconnect / dedicated resume window — keep the action slot as a spinner // until the first real progress clears reconnectRequired. const storeResuming = task.reconnectRequired === true && ['pending', 'queued', 'transferring'].includes(task.status) && !task.error; const sharedResuming = useSftpTransferResuming(task.id); const isResuming = sharedResuming || storeResuming; const effectiveSpeed = task.status === 'transferring' ? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0) : 0; const isPausedLike = task.status === 'paused' || task.status === 'interrupted'; const bytesDisplay = isDirParent ? '' : (isActiveTransfer || isPausedLike) && hasKnownTotal ? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}` : isActiveTransfer || isPausedLike ? formatTransferBytes(task.transferredBytes) : task.status === 'completed' && hasKnownTotal ? formatTransferBytes(task.totalBytes) : ''; // Prefer the transfer-center detail string so the panel never lags behind // "N done · M found" while status is still pending during progressive walks. const fileCountDisplay = isDirParent ? centerProgress.detail : ''; const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : ''; const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath); // Pausing must show explicit copy — spinner-only looked like a no-op while // the backend drained in-flight chunks ("finish current step"). const pausingLabel = t('sftp.transferCenter.status.pausing'); const resumingLabel = t('sftp.transferCenter.status.resuming'); const isLiveScanning = task.phase === 'scanning' && (task.status === 'pending' || task.status === 'queued' || task.status === 'transferring'); const progressOverlayText = isResuming ? resumingLabel : isLiveScanning ? (fileCountDisplay ? `${t('sftp.transferCenter.phase.scanning')} · ${fileCountDisplay}` : t('sftp.transferCenter.phase.scanning')) : task.status === 'pausing' ? pausingLabel : isDirParent ? (fileCountDisplay || (task.status === 'pending' || task.status === 'queued' ? t('sftp.task.waiting') : isIndeterminate ? '...' : `${Math.round(progress)}%`)) : task.status === 'pending' ? t('sftp.task.waiting') : isIndeterminate ? t('sftp.transfer.preparing') : bytesDisplay ? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}` : hasKnownTotal ? `${Math.round(progress)}%` : '...'; const progressBarWidth = isDirParent ? (centerProgress.indeterminate || isLiveScanning ? '100%' : `${progress}%`) : task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate ? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`) : `${progress}%`; const statusIcon = isResuming ? : task.status === 'pausing' ? : task.status === 'transferring' ? : task.status === 'pending' || task.status === 'queued' ? (task.isDirectory ? : ) : task.status === 'completed' ? : task.status === 'paused' || task.status === 'interrupted' || task.status === 'attention' ? : ; const childProgressBar = (
{progressOverlayText}
); const progressSummaryText = isResuming || isActiveTransfer || isPausedLike || task.status === 'pending' || task.status === 'queued' || (isDirParent && !!fileCountDisplay) ? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ') : ''; const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent; const showFailedError = task.status === 'failed' && !!task.error; // Surface hard pause misses (e.g. "cannot be paused yet") so the panel // pause button never looks dead when the backend refuses. const showPauseUnavailable = !!task.pauseUnavailableReason && (task.status === 'transferring' || task.status === 'queued' || task.status === 'pending'); const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable; const retryActionLabel = t('sftp.transfers.retryAction'); const cancelActionLabel = t('common.cancel'); const pauseActionLabel = t('sftp.transferCenter.pause'); const resumeActionLabel = t('sftp.transferCenter.resume'); const dismissActionLabel = t('sftp.transfers.dismissAction'); const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn'); const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList'); const revealTargetLabel = t('sftp.transfers.openTargetFolder'); const copyTargetPathLabel = t('sftp.transfers.copyTargetPath'); const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50"; const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`; const setNameColumnWidth = (width: number) => { const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width)); onSetNameColumnWidth?.(nextWidth); }; const handleResizeKeyDown = (event: React.KeyboardEvent) => { if (!onSetNameColumnWidth) return; const step = event.shiftKey ? 40 : 10; if (event.key === 'ArrowLeft') { event.preventDefault(); setNameColumnWidth(childNameColumnWidth - step); } else if (event.key === 'ArrowRight') { event.preventDefault(); setNameColumnWidth(childNameColumnWidth + step); } else if (event.key === 'Home') { event.preventDefault(); setNameColumnWidth(childNameColumnMinWidth); } else if (event.key === 'End') { event.preventDefault(); setNameColumnWidth(childNameColumnMaxWidth); } }; const actionButtons = (
{canRevealTarget && onRevealTarget && ( )} {canCopyTargetPath && onCopyTargetPath && ( )} {task.status === 'failed' && task.retryable !== false && ( )} {task.status === 'transferring' && task.resumable !== false && onPause && !isResuming && ( )} {task.status === 'pausing' && ( )} {isResuming && ( )} {(task.status === 'paused' || task.status === 'interrupted') && onResume && !isResuming && ( )} {(['pending', 'queued', 'transferring', 'pausing', 'paused', 'interrupted', 'attention'] as const).includes(task.status as never) && ( )} {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && ( )}
); const content = isChild ? (
{task.isDirectory ? : }
{resizeNameColumnLabel}
{childProgressBar}
{actionButtons}
) : (() => { // Keep the bar visible while paused/interrupted so checkpoint progress // stays readable; shimmer only runs on active/resuming states. const showBelowParentProgress = isResuming || task.status === 'transferring' || task.status === 'pausing' || task.status === 'pending' || task.status === 'paused' || task.status === 'interrupted'; const titleBlock = (
); const toggleChildrenButton = canToggleChildren ? ( {toggleChildrenLabel} ) : null; return (
{statusIcon}
{canRevealTarget && onRevealTarget ? ( ) : (
{titleBlock}
)} {toggleChildrenButton} {progressSummaryText && ( {progressSummaryText} )} {/* Keep pause/cancel outside the progress summary so long "N done · M found" labels never crowd the action buttons. */}
{actionButtons}
{showBelowParentProgress && (
)} {hasFooterContent && (
{showTransferSizeCalculation && ( {t('sftp.transfers.calculatingTotal')} )} {showFailedError && ( {task.error} )} {showPauseUnavailable && ( {task.pauseUnavailableReason} )}
)}
); })(); return ( {content} ); }; const arePropsEqual = ( prevProps: SftpTransferItemProps, nextProps: SftpTransferItemProps, ): boolean => { const prev = prevProps.task; const next = nextProps.task; if (prev.status !== next.status) return false; if (prev.error !== next.error) return false; if (prev.pauseUnavailableReason !== next.pauseUnavailableReason) return false; if (prev.reconnectRequired !== next.reconnectRequired) return false; if (prev.resumable !== next.resumable) return false; if (prev.fileName !== next.fileName) return false; if (prev.targetPath !== next.targetPath) return false; if (prev.totalBytes !== next.totalBytes) return false; if (prev.transferredBytes !== next.transferredBytes) return false; if (prev.phase !== next.phase) return false; if (prev.progressMode !== next.progressMode) return false; if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false; if ((prevProps.canCopyTargetPath ?? false) !== (nextProps.canCopyTargetPath ?? false)) return false; if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false; if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false; if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false; if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false; if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false; if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false; if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false; if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false; if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false; if (next.status === 'transferring' || next.status === 'pausing' || next.status === 'pending' || next.status === 'queued') { if (next.speed !== prev.speed) return false; } return true; }; export const SftpTransferItem = memo(SftpTransferItemInner, arePropsEqual); SftpTransferItem.displayName = 'SftpTransferItem';