Files
NetMesh/components/sftp/SftpTransferItem.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

664 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<HTMLDivElement>) => 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 }) => (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
);
const IconButtonWithTooltip: React.FC<{
label: string;
children: React.ReactElement;
}> = ({ label, children }) => (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side="top" className="pointer-events-none">{label}</TooltipContent>
</Tooltip>
);
/** 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<SftpTransferItemProps> = ({
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
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pausing'
? <Loader2 size={12} className="animate-spin text-amber-500" />
: task.status === 'transferring'
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pending' || task.status === 'queued'
? (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />)
: task.status === 'completed'
? <CheckCircle2 size={12} className="text-green-500" />
: task.status === 'paused' || task.status === 'interrupted' || task.status === 'attention'
? <Pause size={12} className="text-amber-500" />
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
const childProgressBar = (
<div
className="relative h-full overflow-hidden border border-border/60 bg-secondary/70"
>
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/35 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: task.status === 'completed'
? "bg-emerald-500/80"
: task.status === 'failed'
? "bg-destructive/70"
: task.status === 'cancelled'
? "bg-muted-foreground/45"
: task.status === 'paused' || task.status === 'interrupted'
? "bg-amber-500/80"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: progressBarWidth,
// Match ~200ms IPC ticks so the bar eases between samples.
transition: 'width 220ms linear',
}}
>
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-2">
<span className="truncate whitespace-nowrap text-[10px] font-medium text-foreground">
{progressOverlayText}
</span>
</div>
</div>
);
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<HTMLDivElement>) => {
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 = (
<div className="flex items-center gap-1 shrink-0">
{canRevealTarget && onRevealTarget && (
<IconButtonWithTooltip label={revealTargetLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRevealTarget} aria-label={actionAriaLabel(revealTargetLabel)}>
<FolderOpen size={12} />
</Button>
</IconButtonWithTooltip>
)}
{canCopyTargetPath && onCopyTargetPath && (
<IconButtonWithTooltip label={copyTargetPathLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onCopyTargetPath} aria-label={actionAriaLabel(copyTargetPathLabel)}>
<ClipboardCopy size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'failed' && task.retryable !== false && (
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
<RefreshCw size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'transferring' && task.resumable !== false && onPause && !isResuming && (
<IconButtonWithTooltip label={pauseActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
data-action="pause-transfer"
{...oncePerActivationHandlers(onPause)}
aria-label={actionAriaLabel(pauseActionLabel)}
>
<Pause size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'pausing' && (
<IconButtonWithTooltip label={pausingLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
disabled
aria-label={actionAriaLabel(pausingLabel)}
aria-busy="true"
>
<Loader2 size={12} className="animate-spin text-amber-500" />
</Button>
</IconButtonWithTooltip>
)}
{isResuming && (
<IconButtonWithTooltip label={resumingLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
disabled
aria-label={actionAriaLabel(resumingLabel)}
aria-busy="true"
>
<Loader2 size={12} className="animate-spin text-primary" />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'paused' || task.status === 'interrupted') && onResume && !isResuming && (
<IconButtonWithTooltip label={resumeActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
data-action="resume-transfer"
{...oncePerActivationHandlers(() => {
onResume();
})}
aria-label={actionAriaLabel(resumeActionLabel)}
>
<Play size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(['pending', 'queued', 'transferring', 'pausing', 'paused', 'interrupted', 'attention'] as const).includes(task.status as never) && (
<IconButtonWithTooltip label={cancelActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(actionButtonClass, "text-destructive hover:text-destructive")}
data-action="cancel-transfer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCancel();
}}
aria-label={actionAriaLabel(cancelActionLabel)}
>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<IconButtonWithTooltip label={dismissActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
</div>
);
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
style={{
// Last column reserves space for Pause + Cancel so the
// progress bar never paints under the action buttons.
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) ${CHILD_ACTIONS_COLUMN_PX}px`,
}}
>
<div className="flex h-full items-center justify-center text-muted-foreground">
{task.isDirectory ? <FolderUp size={12} /> : <File size={12} />}
</div>
<div className="flex min-w-0 items-center pr-2">
<TruncatedTextWithTooltip
text={task.fileName}
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onMouseDown={onResizeNameColumn}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-label={resizeNameColumnLabel}
aria-orientation="vertical"
aria-valuemin={childNameColumnMinWidth}
aria-valuemax={childNameColumnMaxWidth}
aria-valuenow={childNameColumnWidth}
tabIndex={resizeHandleTabIndex}
>
<GripVertical size={10} />
</div>
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0 overflow-hidden">
{childProgressBar}
</div>
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
{actionButtons}
</div>
</div>
) : (() => {
// 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 = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
className="text-[12px] font-medium leading-5"
/>
<ArrowRight size={11} className="shrink-0 text-muted-foreground/70" />
<TruncatedTextWithTooltip
text={targetDirectoryPath}
className={cn(
"min-w-0 text-[11px]",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
</div>
);
const toggleChildrenButton = canToggleChildren ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onToggleChildren}
aria-label={toggleChildrenLabel}
aria-expanded={isExpanded}
aria-controls={childListId}
>
{toggleChildrenLabel}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</TooltipTrigger>
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
</Tooltip>
) : null;
return (
<div
className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
>
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}
</div>
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
aria-label={actionAriaLabel(revealTargetLabel)}
>
{titleBlock}
</button>
) : (
<div className="min-w-0 flex-1">
{titleBlock}
</div>
)}
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto min-w-0 max-w-[50%] truncate whitespace-nowrap text-right text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
</span>
)}
{/* Keep pause/cancel outside the progress summary so long
"N done · M found" labels never crowd the action buttons. */}
<div className="ml-1 shrink-0">
{actionButtons}
</div>
</div>
{showBelowParentProgress && (
<div className="mt-2 ml-7">
<div className="h-1.5 overflow-hidden bg-secondary/80">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: isPausedLike
? "bg-amber-500/80"
: "bg-gradient-to-r from-primary via-primary/90 to-primary",
)}
style={{
width: progressBarWidth,
// Match ~200ms IPC ticks so the bar eases between samples.
transition: 'width 220ms linear',
}}
/>
</div>
</div>
)}
{hasFooterContent && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{showTransferSizeCalculation && (
<span className="text-muted-foreground">{t('sftp.transfers.calculatingTotal')}</span>
)}
{showFailedError && (
<span className="text-destructive">{task.error}</span>
)}
{showPauseUnavailable && (
<span className="text-amber-600 dark:text-amber-400">{task.pauseUnavailableReason}</span>
)}
</div>
)}
</div>
);
})();
return (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
{content}
</TooltipProvider>
);
};
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';