Files
NetMesh/components/GlobalSftpTransferCenter.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

955 lines
39 KiB
TypeScript

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<TransferTask, "status" | "reconnectRequired">): 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<TransferTask, "error" | "pauseUnavailableReason">) {
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<TransferTask, "isDirectory" | "parentTaskId" | "progressMode">,
): 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<TransferTask, "status" | "totalBytes" | "transferredBytes">,
): 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<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed" | "phase">,
t: (key: string, params?: Record<string, string | number>) => 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<TransferTask, "totalBytes" | "transferredBytes" | "status">): 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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", destructive && "text-destructive hover:text-destructive")}
aria-label={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
/** 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 (
<Tooltip>
<TooltipTrigger asChild>
<Tag className={cn("min-w-0 truncate", className)}>{text}</Tag>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm break-all">
{tip}
</TooltipContent>
</Tooltip>
);
}
function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetPath" | "sourceHostLabel" | "targetHostLabel">): 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
? <FolderUp size={15} />
: task.direction === "download"
? <ArrowDownToLine size={15} />
: <ArrowUpFromLine size={15} />;
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 (
<div
className={cn("border-border/40 px-3 py-2.5 hover:bg-muted/30", isLast ? "border-b-0" : "border-b")}
data-section="global-sftp-transfer-row"
data-transfer-status={task.status}
data-directory-parent={isDirParent ? "true" : undefined}
>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
{directionIcon}
</div>
<button type="button" className="min-w-0 flex-1 overflow-hidden text-left" onClick={() => openTarget()}>
<div className="flex min-w-0 items-center gap-1.5">
<TruncatedTextWithTooltip text={task.fileName} className="text-xs font-medium" />
{task.background && (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] text-muted-foreground">
{t("sftp.transferCenter.background")}
</span>
)}
</div>
<TruncatedTextWithTooltip
as="div"
text={formatTransferPathLine(task)}
className="mt-0.5 text-[10px] text-muted-foreground"
/>
</button>
<div className="flex shrink-0 items-center gap-0.5">
{canToggleChildren && (
<TransferAction
label={expanded ? t("sftp.transfers.collapseChildren") : t("sftp.transfers.expandChildren")}
onClick={onToggleExpanded}
>
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</TransferAction>
)}
{isResuming && (
<div className="flex h-8 w-8 items-center justify-center" role="status" aria-label={t("sftp.transferCenter.status.resuming")}>
<Loader2 size={13} className="animate-spin text-primary" />
</div>
)}
{canPause && (
<TransferAction label={t("sftp.transferCenter.pause")} onClick={() => { void transferRuntime.pause(task.id); }}>
<Pause size={13} />
</TransferAction>
)}
{task.status === "pausing" && (
<div className="flex h-8 w-8 items-center justify-center" role="status" aria-label={t("sftp.transferCenter.status.pausing")}>
<Loader2 size={13} className="animate-spin text-amber-500" />
</div>
)}
{canResume && (
<TransferAction label={t("sftp.transferCenter.resume")} onClick={resumeTask}>
<Play size={13} />
</TransferAction>
)}
{task.status === "queued" && canControl && (
<TransferAction label={t("sftp.transferCenter.prioritize")} onClick={() => { void sftpTransferCenterStore.prioritize(task.id); }}>
<ArrowUpFromLine size={13} />
</TransferAction>
)}
{canRetry && (
<TransferAction label={t("sftp.transfers.retryAction")} onClick={() => { void sftpTransferCenterStore.retry(task.id); }}>
<RefreshCw size={13} />
</TransferAction>
)}
{isTerminal && (
<TransferAction label={t("sftp.transfers.dismissAction")} onClick={() => sftpTransferCenterStore.dismiss(task.id)}>
<Trash2 size={13} />
</TransferAction>
)}
{canCancel && (
<TransferAction destructive label={t("common.cancel")} onClick={() => { void transferRuntime.cancel(task.id); }}>
<X size={13} />
</TransferAction>
)}
<TransferAction label={t("sftp.transfers.copyTargetPath")} onClick={() => { void navigator.clipboard.writeText(task.targetPath); }}>
<ClipboardCopy size={13} />
</TransferAction>
<TransferAction label={t("sftp.transfers.openTargetFolder")} onClick={() => openTarget()}>
<FolderOpen size={13} />
</TransferAction>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary"
role="progressbar"
aria-label={task.fileName}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress.indeterminate ? undefined : Math.round(progress.percent)}
>
<div
className={cn(
"h-full rounded-full",
// Animate only while actively transferring — soft-drain width
// freezes still looked like a twitch with transition.
!isPausing && task.status !== "paused" && task.status !== "interrupted"
&& "transition-[width] duration-200 ease-linear",
progress.indeterminate && "animate-pulse bg-primary/60",
!progress.indeterminate && (
task.status === "failed"
? "bg-destructive"
: task.status === "paused" || task.status === "interrupted" || isPausing
? "bg-amber-500"
: "bg-primary"
),
)}
style={{ width: barWidth }}
/>
</div>
<span className="w-12 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{progress.indeterminate ? "…" : displayTask.totalBytes > 0 || displayTask.status === "completed" ? `${progress.percent.toFixed(1)}%` : "—"}
</span>
</div>
<div className="mt-1 flex min-w-0 items-center justify-between gap-3 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip
text={statusText}
className={cn(
"min-w-0 flex-1",
(task.status === "failed" || task.status === "attention") && "text-destructive",
)}
/>
<span className="min-w-0 shrink truncate text-right font-mono" title={progress.detail || undefined}>
{progress.detail}
</span>
</div>
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
{isDirParent && !expanded && displayChildren.length > 0 && (
<div
className={cn(
"mt-1.5 space-y-1 border-l-2 pl-2",
isPausing || task.status === "paused" ? "border-amber-500/40" : "border-primary/30",
)}
data-section="global-sftp-transfer-active-children"
>
{displayChildren.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
const childFrozen = isPausing
|| task.status === "paused"
|| child.status === "pausing"
|| child.status === "paused";
return (
<div key={child.id} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip text={child.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono">
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
</div>
{child.totalBytes > 0 && (
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full",
child.status === "completed"
? "bg-emerald-500/80"
: childFrozen
? "bg-amber-500/80"
: "bg-primary/80",
)}
style={{ width: `${child.status === "completed" ? 100 : childPercent}%` }}
/>
</div>
)}
</div>
);
})}
</div>
)}
{isDirParent && expanded && childTasks.length > 0 && (
<div
className="mt-2 max-h-48 space-y-1 overflow-y-auto border-t border-border/40 pt-2"
data-section="global-sftp-transfer-child-list"
>
{childTasks.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
return (
<div
key={child.id}
className="flex items-center gap-2 rounded px-1 py-1 text-[10px] hover:bg-muted/40"
data-transfer-status={child.status}
>
<TruncatedTextWithTooltip
text={child.fileName}
tooltip={child.targetPath || child.fileName}
className="flex-1 text-muted-foreground"
/>
<span className="shrink-0 font-mono text-muted-foreground">
{childDetail
|| (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
{child.status === "transferring" && (
<Loader2 size={10} className="shrink-0 animate-spin text-primary" />
)}
</div>
);
})}
</div>
)}
{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 (
<Button
key={applyToAll ? `all-${action}` : action}
variant={presentation.variant}
size="sm"
className={cn(
"h-6 px-2 text-[10px]",
presentation.destructiveReplace
&& "border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
aria-describedby={presentation.destructiveReplace ? folderReplaceWarningId : undefined}
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, applyToAll); }}
>
{t(`sftp.conflict.action.${action}`)}
{applyToAll && <> · {t("sftp.transferCenter.applyAll")}</>}
</Button>
);
};
return (
<div className="mt-2 space-y-2">
{destructiveDirectoryReplace && (
<div
id={folderReplaceWarningId}
className="flex items-start gap-1.5 rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[10px] leading-4"
>
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-destructive" />
<p>
{t("sftp.conflict.folderMergeHint")}{" "}
<span className="font-medium text-destructive">
{t("sftp.conflict.folderReplaceWarning")}
</span>
</p>
</div>
)}
{unresolvedFolderType && (
<div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4">
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />
<p>{t("sftp.conflict.folderUnknownDesc")}</p>
</div>
)}
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => renderConflictAction(action))}
{(conflict.applyToAllCount ?? 0) > 1
&& applyAllActions.map((action) => renderConflictAction(action, true))}
</div>
</div>
);
})()}
</div>
);
}
function TransferList({ tasks, childrenByParent, empty }: {
tasks: readonly TransferTask[];
childrenByParent: ReadonlyMap<string, TransferTask[]>;
empty: React.ReactNode;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// Keep folder expansion when a row scrolls out of the mounted viewport.
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() => 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) => (
<TransferRow
key={task.id}
task={task}
childTasks={childrenByParent.get(task.id) ?? []}
expanded={expandedIds.has(task.id)}
isLast={index === tasks.length - 1}
onToggleExpanded={() => setExpandedIds((previous) => {
const next = new Set(previous);
if (next.has(task.id)) next.delete(task.id);
else next.add(task.id);
return next;
})}
/>
);
return (
<div ref={scrollRef} className="max-h-[460px] overflow-auto" data-section="global-sftp-transfer-list">
{tasks.length === 0 ? empty : !virtual ? tasks.map(renderRow) : (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
ref={virtualizer.measureElement}
data-index={row.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${row.start}px)` }}
>
{renderRow(tasks[row.index], row.index)}
</div>
))}
</div>
)}
</div>
);
}
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<GlobalTransferBucket>("all");
const [showBackground, setShowBackground] = useState(false);
const childrenByParent = useMemo(() => {
const map = new Map<string, TransferTask[]>();
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<GlobalTransferBucket, number>, [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 (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
style={{ color: "var(--top-tabs-muted, hsl(var(--muted-foreground)))" }}
aria-label={t("sftp.transferCenter.title")}
data-section="global-sftp-transfer-toggle"
>
<ArrowDownUp size={15} />
{badge.count > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex min-h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] leading-3 text-primary-foreground">
{badge.count > 99 ? "99+" : badge.count}
</span>
)}
{badge.hasAttention && (
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-destructive" />
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.transferCenter.title")}</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
sideOffset={5}
className="w-[min(460px,calc(100vw-24px))] overflow-hidden p-0 app-no-drag"
data-section="global-sftp-transfer-center"
>
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
<div className="min-w-0 pr-2 text-sm font-semibold">
{t("sftp.transferCenter.title")}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
</Button>
</div>
</div>
<div className="flex gap-0 px-2 pt-1" role="tablist" aria-label={t("sftp.transferCenter.title")}>
{BUCKETS.map((item) => (
<button
key={item}
type="button"
className={cn(
// Selected: keep a clear accent underline (not just text color).
// Unselected: gray label only — no shared full-width hairline.
"relative min-w-0 flex-1 truncate px-1.5 py-2 text-[11px] transition-colors",
bucket === item
? "font-medium text-primary after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary"
: "text-muted-foreground hover:text-foreground/80",
)}
onClick={() => setBucket(item)}
role="tab"
aria-selected={bucket === item}
>
{t(`sftp.transferCenter.bucket.${item}`)}
{counts[item] > 0 && <span className="ml-1 text-[10px] opacity-80">{counts[item]}</span>}
</button>
))}
</div>
<TransferList
key={`${bucket}-${showBackground}`}
tasks={displayed}
childrenByParent={childrenByParent}
empty={(
<div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
{badge.hasAttention && bucket !== "attention" && bucket !== "all" ? <AlertCircle size={22} /> : <ArrowDownUp size={22} />}
<span className="mt-2 text-xs">{t("sftp.transferCenter.empty")}</span>
</div>
)}
/>
{(() => {
const showBackgroundToggle = collapsed.length > 0;
const showClear = bucket === "completed" && counts.completed > 0;
if (!showBackgroundToggle && !showClear) return null;
return (
<div className="flex items-center justify-between px-3 py-2">
<div>
{showBackgroundToggle && (
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={() => setShowBackground((value) => !value)}>
{showBackground
? t("sftp.transferCenter.hideBackground")
: t("sftp.transferCenter.showBackground", { count: collapsed.length })}
</Button>
)}
</div>
{showClear && (
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={() => {
sftpTransferCenterStore.clearTerminal("completed");
sftpTransferCenterStore.clearTerminal("cancelled");
}}>
<Trash2 size={11} className="mr-1" />{t("sftp.transferCenter.clear")}
</Button>
)}
</div>
);
})()}
</PopoverContent>
</Popover>
);
}