import { AlertCircle, AlertTriangle, ArrowDownToLine, ArrowDownUp, ArrowUpFromLine, ChevronDown, ChevronUp, ClipboardCopy, FolderOpen, FolderUp, Loader2, Pause, Play, RefreshCw, Trash2, X, } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n } from "../application/i18n/I18nProvider"; import { sftpTransferCenterStore, useSftpTransferCenterBadge, useSftpTransferResuming, type SftpTransferCenterSnapshot, } from "../application/state/sftpTransferCenterStore"; import { transferRuntime } from "../application/state/sftp/transferRuntime"; import { useGlobalSftpTransferActions } from "../application/state/useGlobalSftpTransferActions"; export { getGlobalTransferBatchEligibility } from "../domain/sftpTransferActions"; import type { FileConflictAction, TransferTask } from "../domain/models"; import { canReplaceSftpConflict } from "../domain/sftpConflict"; import { estimateTransferEtaSeconds, formatFileSize, formatTransferEta } from "../application/state/sftp/utils"; import { cn } from "../lib/utils"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; /** Stable empty snapshot while the popover is closed — progress ticks must not * re-render TopTabs just to update hidden list rows. */ const CLOSED_TRANSFER_CENTER_SNAPSHOT: SftpTransferCenterSnapshot = { tasks: [], activeCount: 0, queuedCount: 0, attentionCount: 0, }; /** * Full transfer-center list only while the popover is open. When closed, the * getter always returns the same CLOSED snapshot so progress store notifies do * not re-render this TopTabs child (badge uses a separate stable subscription). */ function useSftpTransferCenterWhenOpen(open: boolean): SftpTransferCenterSnapshot { const openRef = useRef(open); openRef.current = open; return useSyncExternalStore( (onStoreChange) => { // Lifecycle + progress: when the popover is open the list must move with // bytes; when closed getSnapshot returns a stable CLOSED constant so // progress ticks are cheap no-ops for this subscriber. const unsubLifecycle = sftpTransferCenterStore.subscribe(onStoreChange); const unsubProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange); return () => { unsubLifecycle(); unsubProgress(); }; }, () => ( openRef.current ? sftpTransferCenterStore.getSnapshot() : CLOSED_TRANSFER_CENTER_SNAPSHOT ), () => CLOSED_TRANSFER_CENTER_SNAPSHOT, ); } export type GlobalTransferBucket = "all" | "active" | "queued" | "paused" | "attention" | "completed"; export function getGlobalTransferBucket(task: Pick): GlobalTransferBucket { // Reconnect/resume preparation should stay visible with the unfinished work. if (task.status === "pending" && task.reconnectRequired) return "attention"; if (task.status === "transferring" || task.status === "pausing") return "active"; if (task.status === "pending" || task.status === "queued") return "queued"; if (task.status === "paused") return "paused"; if (task.status === "interrupted" || task.status === "attention" || task.status === "failed") return "attention"; return "completed"; } export function getGlobalTransferBadge(tasks: readonly TransferTask[]) { const topLevelTasks = tasks.filter((task) => !task.parentTaskId); return { count: topLevelTasks.filter((task) => ["pending", "queued", "transferring", "pausing", "paused", "interrupted"].includes(task.status) ).length, // Interrupted after restart and conflict attention both need the user. hasAttention: topLevelTasks.some((task) => task.status === "attention" || task.status === "failed" || task.status === "interrupted" || task.reconnectRequired === true ), }; } export function splitBackgroundTransfers(tasks: readonly TransferTask[]) { const collapsed = tasks.filter((task) => task.background && task.status === "completed"); const collapsedIds = new Set(collapsed.map((task) => task.id)); return { visible: tasks.filter((task) => !collapsedIds.has(task.id)), collapsed, }; } export function getGlobalTransferStatusOverride(task: Pick) { return task.error || task.pauseUnavailableReason; } export function getGlobalConflictActionPresentation( action: FileConflictAction, destructiveDirectoryReplace: boolean, ) { const safeMerge = destructiveDirectoryReplace && action === "merge"; const destructiveReplace = destructiveDirectoryReplace && action === "replace"; return { variant: safeMerge || (!destructiveDirectoryReplace && action === "replace") ? "default" : "outline", destructiveReplace, } as const; } const BUCKETS: readonly GlobalTransferBucket[] = ["all", "active", "queued", "paused", "attention", "completed"]; export function getTasksForGlobalTransferBucket( tasks: readonly TransferTask[], bucket: GlobalTransferBucket, ): TransferTask[] { return tasks.filter((task) => !task.parentTaskId && (bucket === "all" || getGlobalTransferBucket(task) === bucket)); } /** Folder parent rows use file-count progress (same model as the SFTP side queue). */ export function isDirectoryParentTask( task: Pick, ): boolean { if (!task.isDirectory || task.parentTaskId) return false; // Explicit bytes mode would be aggregate size; default folder uploads use files. return task.progressMode !== "bytes"; } export function listChildTasksForParent( tasks: readonly TransferTask[], parentId: string, ): TransferTask[] { return tasks .filter((task) => task.parentTaskId === parentId && task.status !== "cancelled") .sort((a, b) => a.startTime - b.startTime); } /** * Prefer live children, then queued — for the collapsed "current file" summary. * Callers must not use this for paused/interrupted parents (those hide the row). */ export function pickActiveChildSummaries( children: readonly TransferTask[], limit = 2, ): TransferTask[] { const live = children.filter((task) => task.status === "transferring" || task.status === "pausing"); if (live.length > 0) return live.slice(0, limit); const waiting = children.filter((task) => task.status === "pending" || task.status === "queued"); if (waiting.length > 0) return waiting.slice(0, limit); return []; } /** Collapsed mini-rows only while the folder is actively moving or soft-draining. */ export function shouldShowCollapsedActiveChildren( parentStatus: TransferTask["status"], ): boolean { return parentStatus === "transferring" || parentStatus === "pausing"; } export function getGlobalTransferProgressPercent( task: Pick, ): number { if (task.status === "completed") return 100; if (task.totalBytes <= 0) return 0; return Math.max(0, Math.min(100, (task.transferredBytes / task.totalBytes) * 100)); } export type GlobalTransferProgressDisplay = { percent: number; /** Right-side progress label (file count or bytes). */ detail: string; /** True when the bar should pulse (total still unknown). */ indeterminate: boolean; }; /** * Build progress labels for a top-level row. Directory parents use file counts * so we never show "1 Bytes / 12 Bytes" for n/m files. * * Folder walks interleave discovery with transfer: totalBytes is "found so far", * not a fixed grand total. Active rows use progressive copy so the UI does not * look like a lying overall percentage. */ export function buildGlobalTransferProgressDisplay( task: Pick, t: (key: string, params?: Record) => string, ): GlobalTransferProgressDisplay { const isDirParent = isDirectoryParentTask(task); const percent = getGlobalTransferProgressPercent(task); const hasTotal = task.totalBytes > 0; const discovered = Math.max(task.totalBytes, task.transferredBytes); if (isDirParent) { const isActive = task.status === "transferring" || task.status === "pausing" || task.status === "queued" || task.status === "pending"; // Still discovering, or no completed files yet — pulse rather than freeze a fake %. const indeterminate = isActive && ( task.phase === "scanning" || !hasTotal || task.transferredBytes <= 0 ); let detail = ""; if (isActive) { if (discovered > 0) { detail = t("sftp.transfers.filesDiscoveredProgress", { completed: task.transferredBytes, discovered, }); } } else if (task.status === "completed" && hasTotal) { detail = t("sftp.transfers.filesCount", { count: task.totalBytes }); } else if (discovered > 0) { detail = t("sftp.transfers.filesDiscoveredProgress", { completed: task.transferredBytes, discovered, }); } // Soft percent of files found so far (discovered may still grow). const softPercent = discovered > 0 ? Math.min(100, (task.transferredBytes / discovered) * 100) : 0; return { percent: task.status === "completed" ? 100 : softPercent, detail, indeterminate, }; } const detailParts: string[] = []; if (hasTotal) { detailParts.push(`${formatFileSize(task.transferredBytes)} / ${formatFileSize(task.totalBytes)}`); } else if (task.transferredBytes > 0) { detailParts.push(formatFileSize(task.transferredBytes)); } if (task.status === "transferring" && task.speed > 0) { detailParts.push(`${formatFileSize(task.speed)}/s`); } if (task.status === "transferring" && task.speed > 0 && hasTotal) { const eta = formatTransferEta(estimateTransferEtaSeconds(task.totalBytes - task.transferredBytes, task.speed)); if (eta) detailParts.push(eta); } return { percent, detail: detailParts.join(" · "), indeterminate: task.status === "transferring" && !hasTotal, }; } function formatChildByteProgress(task: Pick): string { if (task.totalBytes > 0) { return `${formatFileSize(task.transferredBytes)} / ${formatFileSize(task.totalBytes)}`; } if (task.transferredBytes > 0) return formatFileSize(task.transferredBytes); return ""; } function statusLabelKey(status: TransferTask["status"]): string { return `sftp.transferCenter.status.${status}`; } function TransferAction({ label, onClick, children, destructive = false }: { label: string; onClick: () => void; children: React.ReactNode; destructive?: boolean; }) { return ( {label} ); } /** Truncated label with hover tooltip for the full text (paths, names, errors). */ function TruncatedTextWithTooltip({ text, tooltip, className, as: Tag = "span", }: { text: string; /** Defaults to `text`. Use when the visible label differs from the full string. */ tooltip?: string; className?: string; as?: "span" | "div"; }) { if (!text) return null; const tip = tooltip || text; return ( {text} {tip} ); } function formatTransferPathLine(task: Pick): string { const source = `${task.sourceHostLabel ? `${task.sourceHostLabel}: ` : ""}${task.sourcePath}`; const target = `${task.targetHostLabel ? `${task.targetHostLabel}: ` : ""}${task.targetPath}`; return `${source} → ${target}`; } function TransferRow({ task, childTasks = [], expanded, onToggleExpanded, isLast, }: { task: TransferTask; childTasks?: readonly TransferTask[]; expanded: boolean; onToggleExpanded: () => void; isLast: boolean; }) { const { t } = useI18n(); const folderReplaceWarningId = React.useId(); // Optimistic spinner from click until store status moves off paused/interrupted. const isDirParent = isDirectoryParentTask(task); const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status); const activeChildren = useMemo( () => (showCollapsedChildren ? pickActiveChildSummaries(childTasks, 2) : []), [childTasks, showCollapsedChildren], ); // Soft-drain still completes ranges / children after Pause. Snapshot the // visible parent bar + child mini-rows on first "pausing" paint so the row // does not twitch (file-count bumps, child list swaps, width animation). // When the parent reaches "paused", hide the mini-rows entirely — partial // children flipping paused↔transferring used to blink the report line. const pausingSnapshotRef = useRef<{ task: TransferTask; children: TransferTask[]; } | null>(null); if (task.status === "pausing") { if (!pausingSnapshotRef.current) { pausingSnapshotRef.current = { task: { ...task }, children: activeChildren.map((child) => ({ ...child })), }; } } else if (pausingSnapshotRef.current) { pausingSnapshotRef.current = null; } const displayTask = task.status === "pausing" && pausingSnapshotRef.current ? { ...pausingSnapshotRef.current.task, status: "pausing" as const, speed: 0, phase: undefined } : task; const displayChildren = !showCollapsedChildren ? [] : (task.status === "pausing" && pausingSnapshotRef.current ? pausingSnapshotRef.current.children : activeChildren); const progress = buildGlobalTransferProgressDisplay(displayTask, t); const canToggleChildren = isDirParent && childTasks.length > 0; const canControl = sftpTransferCenterStore.canControl(task.id); // Keep the play button as a spinner for the whole reconnect window, not only // the brief "pending" status before a dedicated session opens. const storeResuming = task.reconnectRequired === true && ["pending", "queued", "transferring"].includes(task.status) && !task.error; const sharedResuming = useSftpTransferResuming(task.id); const isResuming = sharedResuming || storeResuming; const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming; // Orphaned tasks after app restart (interrupted / attention / paused without a // live panel owner) must still expose resume/cancel from the global center. // Conflict rows must use resolveConflict — Resume would overwrite blindly. // Non-resumable attention rows (e.g. duplicate-destination refusals) are // terminal: resuming them would start a second writer on the same path. // Do not show Resume during soft-drain "pausing" — play + spinner together // makes the row twitch as buttons appear/disappear mid-drain. const canResume = !isResuming && !task.conflict && ( ["paused", "interrupted"].includes(task.status) || (task.status === "attention" && task.resumable !== false) || (task.status === "failed" && task.resumable !== false && (task.checkpointBytes ?? 0) > 0) ) && canControl; const isPausing = task.status === "pausing"; const canCancel = ["pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention"].includes(task.status) && canControl; const canRetry = task.status === "failed" && task.retryable !== false && canControl; const isTerminal = ["completed", "failed", "cancelled"].includes(task.status); // Status line is for lifecycle/error only — not pause capability notes. // "cannot be paused safely" used to render here during healthy progress and // looked like a failure even while bytes were flowing. const statusText = (() => { const override = getGlobalTransferStatusOverride(task); if (override) return override; if (isResuming) return t("sftp.transferCenter.status.resuming"); // Lifecycle pause labels beat phase ("transferring") so pausing never reads // as 传输中 while soft-drain finishes the current range. if (task.status === "pausing" || task.status === "paused" || task.status === "interrupted") { return t(statusLabelKey(task.status)); } // Scanning runs as pending/transferring; never override terminal statuses // (cancelled/failed/completed) that still carry a stale phase value. if ( task.phase === "scanning" && (task.status === "pending" || task.status === "queued" || task.status === "transferring") ) { return t("sftp.transferCenter.phase.scanning"); } if (task.phase && task.status === "transferring") { return t(`sftp.transferCenter.phase.${task.phase}`); } return t(statusLabelKey(task.status)); })(); const directionIcon = isDirParent ? : task.direction === "download" ? : ; const openTarget = (forResume = false) => { window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-target", { detail: { task, forResume }, })); }; const resumeTask = () => { // Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP. // Only force-open the panel when the row still needs a live owner/adoption // (e.g. conflict) — not on every remote-to-remote resume click. if (task.conflict || (task.status === "attention" && !task.reconnectRequired && task.direction === "remote-to-remote")) { openTarget(true); } // Single process-level resume (soft live walk / hard reconnect internal). void transferRuntime.resume(task.id); }; const barWidth = progress.indeterminate ? "100%" : `${displayTask.status === "completed" ? 100 : progress.percent}%`; return (
{directionIcon}
{canToggleChildren && ( {expanded ? : } )} {isResuming && (
)} {canPause && ( { void transferRuntime.pause(task.id); }}> )} {task.status === "pausing" && (
)} {canResume && ( )} {task.status === "queued" && canControl && ( { void sftpTransferCenterStore.prioritize(task.id); }}> )} {canRetry && ( { void sftpTransferCenterStore.retry(task.id); }}> )} {isTerminal && ( sftpTransferCenterStore.dismiss(task.id)}> )} {canCancel && ( { void transferRuntime.cancel(task.id); }}> )} { void navigator.clipboard.writeText(task.targetPath); }}> openTarget()}>
{progress.indeterminate ? "…" : displayTask.totalBytes > 0 || displayTask.status === "completed" ? `${progress.percent.toFixed(1)}%` : "—"}
{progress.detail}
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */} {isDirParent && !expanded && displayChildren.length > 0 && (
{displayChildren.map((child) => { const childPercent = getGlobalTransferProgressPercent(child); const childDetail = formatChildByteProgress(child); const childFrozen = isPausing || task.status === "paused" || child.status === "pausing" || child.status === "paused"; return (
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
{child.totalBytes > 0 && (
)}
); })}
)} {isDirParent && expanded && childTasks.length > 0 && (
{childTasks.map((child) => { const childPercent = getGlobalTransferProgressPercent(child); const childDetail = formatChildByteProgress(child); return (
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))} {child.status === "transferring" && ( )}
); })}
)} {task.status === "attention" && task.conflict && canControl && (() => { const conflict = task.conflict!; const canMerge = conflict.isDirectory && conflict.existingType === "directory"; const destructiveDirectoryReplace = canMerge; const unresolvedFolderType = conflict.isDirectory && !conflict.existingType; const canReplace = canReplaceSftpConflict(conflict.isDirectory, conflict.existingType); const actions = [ "stop", "skip", "duplicate", ...(canMerge ? (["merge"] as const) : []), ...(canReplace ? (["replace"] as const) : []), ] as const; const applyAllActions = [ "skip", "duplicate", ...(canMerge ? (["merge"] as const) : []), ...(canReplace ? (["replace"] as const) : []), ] as const; const renderConflictAction = (action: FileConflictAction, applyToAll = false) => { const presentation = getGlobalConflictActionPresentation(action, destructiveDirectoryReplace); return ( ); }; return (
{destructiveDirectoryReplace && (

{t("sftp.conflict.folderMergeHint")}{" "} {t("sftp.conflict.folderReplaceWarning")}

)} {unresolvedFolderType && (

{t("sftp.conflict.folderUnknownDesc")}

)}
{actions.map((action) => renderConflictAction(action))} {(conflict.applyToAllCount ?? 0) > 1 && applyAllActions.map((action) => renderConflictAction(action, true))}
); })()}
); } function TransferList({ tasks, childrenByParent, empty }: { tasks: readonly TransferTask[]; childrenByParent: ReadonlyMap; empty: React.ReactNode; }) { const scrollRef = useRef(null); // Keep folder expansion when a row scrolls out of the mounted viewport. const [expandedIds, setExpandedIds] = useState>(() => new Set()); const virtual = tasks.length > 20; const virtualizer = useVirtualizer({ count: tasks.length, getScrollElement: () => scrollRef.current, estimateSize: () => 112, getItemKey: (index) => tasks[index].id, overscan: 4, enabled: virtual, }); const renderRow = (task: TransferTask, index: number) => ( setExpandedIds((previous) => { const next = new Set(previous); if (next.has(task.id)) next.delete(task.id); else next.add(task.id); return next; })} /> ); return (
{tasks.length === 0 ? empty : !virtual ? tasks.map(renderRow) : (
{virtualizer.getVirtualItems().map((row) => (
{renderRow(tasks[row.index], row.index)}
))}
)}
); } export function GlobalSftpTransferCenter() { const { t } = useI18n(); // Badge is a stable store snapshot (identity unchanged on pure progress). // Full task list is only needed while the popover is open — when closed, // progress ticks used to re-render this TopTabs child on every byte. const badge = useSftpTransferCenterBadge(); const [open, setOpen] = useState(false); // Folder drops fire this so the scanning row is visible immediately without // requiring the user to notice the badge first. useEffect(() => { const openCenter = () => setOpen(true); window.addEventListener("netcatty:open-sftp-transfer-center", openCenter); return () => window.removeEventListener("netcatty:open-sftp-transfer-center", openCenter); }, []); const snapshot = useSftpTransferCenterWhenOpen(open); const [bucket, setBucket] = useState("all"); const [showBackground, setShowBackground] = useState(false); const childrenByParent = useMemo(() => { const map = new Map(); for (const task of snapshot.tasks) { if (!task.parentTaskId || task.status === "cancelled") continue; const list = map.get(task.parentTaskId) ?? []; list.push(task); map.set(task.parentTaskId, list); } for (const list of map.values()) { list.sort((a, b) => a.startTime - b.startTime); } return map; }, [snapshot.tasks]); const counts = useMemo(() => Object.fromEntries(BUCKETS.map((item) => [ item, getTasksForGlobalTransferBucket(snapshot.tasks, item).length, ])) as Record, [snapshot.tasks]); const bucketTasks = useMemo(() => getTasksForGlobalTransferBucket(snapshot.tasks, bucket) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]); const { visible, collapsed } = splitBackgroundTransfers(bucketTasks); const displayed = showBackground ? [...visible, ...collapsed] : visible; const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks); return ( {t("sftp.transferCenter.title")}
{t("sftp.transferCenter.title")}
{BUCKETS.map((item) => ( ))}
{badge.hasAttention && bucket !== "attention" && bucket !== "all" ? : } {t("sftp.transferCenter.empty")}
)} /> {(() => { const showBackgroundToggle = collapsed.length > 0; const showClear = bucket === "completed" && counts.completed > 0; if (!showBackgroundToggle && !showClear) return null; return (
{showBackgroundToggle && ( )}
{showClear && ( )}
); })()} ); }