[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
954
components/GlobalSftpTransferCenter.tsx
Normal file
954
components/GlobalSftpTransferCenter.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user