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
2118 lines
85 KiB
TypeScript
2118 lines
85 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
|
import {
|
|
FileConflict,
|
|
FileConflictAction,
|
|
SftpFilenameEncoding,
|
|
TransferDirection,
|
|
TransferStatus,
|
|
TransferTask,
|
|
} from "../../../domain/models";
|
|
import {
|
|
canReplaceSftpConflict,
|
|
describeSftpExistingKind,
|
|
describeSftpIncomingKind,
|
|
getSftpConflictTypeKey,
|
|
shouldUnlinkSftpConflictBeforeReplace,
|
|
} from "../../../domain/sftpConflict";
|
|
import {
|
|
findActivePathConflict,
|
|
pathConflictMessage,
|
|
} from "../../../domain/sftpTransferConflicts";
|
|
import { useI18n } from "../../i18n/I18nProvider";
|
|
import { notify } from "../../notification";
|
|
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
|
import { logger } from "../../../lib/logger";
|
|
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
|
import { SftpPane } from "./types";
|
|
import { isMissingStatError } from "./errors";
|
|
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
|
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
|
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
|
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
|
|
import {
|
|
createDirectDownloadTransferTask,
|
|
resolveDirectDirectoryDownloadFinalStatus,
|
|
} from "./downloadTransferTask";
|
|
import {
|
|
releaseTransferPauseTree,
|
|
waitUntilTransferPauseReleased,
|
|
} from "./transferPauseLatch";
|
|
import { bumpTransferControlEpoch } from "./transferControlEpoch";
|
|
import { transferRuntime } from "./transferRuntime";
|
|
import {
|
|
clearTransferCancelled,
|
|
clearTransferCancelledTree,
|
|
isTransferCancelledFlag,
|
|
markTransferCancelled,
|
|
markTransferCancelledTree,
|
|
} from "./transferCancelLatch";
|
|
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
|
|
import type { TransferConnectionLease } from "./transferConnectionPool";
|
|
import {
|
|
captureDeferredTransferAttempt,
|
|
createDeferredTransferAttemptQueue,
|
|
isDeferredTransferAttemptCurrent,
|
|
pruneTransferConflictDefaults,
|
|
type DeferredTransferAttemptQueue,
|
|
type TransferConflictDefaults,
|
|
} from "./transferConflictLifecycle";
|
|
import { getParentPath, joinPath } from "./utils";
|
|
import { promoteDirectoryReplaceStage as promoteDirectoryReplacePaths } from "./directoryReplacePromotion";
|
|
import {
|
|
isExternalDragDropFileUpload,
|
|
retryExternalDragDropFileUpload,
|
|
} from "./externalDragDropRetry";
|
|
|
|
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
|
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
|
if (latched) ref.current.add(taskId);
|
|
else ref.current.delete(taskId);
|
|
}
|
|
|
|
/** Set whose has() also observes process-global cancel flags (tab-close path). */
|
|
class PanelCancelSet extends Set<string> {
|
|
override add(id: string): this {
|
|
super.add(id);
|
|
markTransferCancelled(id);
|
|
return this;
|
|
}
|
|
override delete(id: string): boolean {
|
|
const had = super.delete(id);
|
|
clearTransferCancelled(id);
|
|
return had;
|
|
}
|
|
override has(id: string): boolean {
|
|
return super.has(id) || isTransferCancelledFlag(id);
|
|
}
|
|
}
|
|
|
|
function createPanelCancelSet(): Set<string> {
|
|
return new PanelCancelSet();
|
|
}
|
|
|
|
export async function runTrackedTransferAttempt<T>(
|
|
inFlightTransferIds: Set<string>,
|
|
taskId: string,
|
|
runner: () => Promise<T>,
|
|
): Promise<T> {
|
|
inFlightTransferIds.add(taskId);
|
|
try {
|
|
return await runner();
|
|
} finally {
|
|
// Completion callbacks are caller-owned and may throw. The process-global
|
|
// runtime already releases its walk in finally; keep the panel mirror just
|
|
// as strict so one callback failure cannot permanently block a retry.
|
|
inFlightTransferIds.delete(taskId);
|
|
}
|
|
}
|
|
|
|
export function finishTransferTask(
|
|
task: TransferTask,
|
|
outcome: {
|
|
partialFailure: boolean;
|
|
cancelled: boolean;
|
|
endTime?: number;
|
|
},
|
|
flushPendingProgress: () => void,
|
|
mirrorPanelTask: (task: TransferTask) => void,
|
|
): TransferStatus {
|
|
flushPendingProgress();
|
|
const latestTask = transferRuntime.getTask(task.id) ?? task;
|
|
const endTime = outcome.endTime ?? Date.now();
|
|
const cancelled = outcome.cancelled || latestTask.status === "cancelled";
|
|
const status: TransferStatus = cancelled
|
|
? "cancelled"
|
|
: outcome.partialFailure ? "failed" : "completed";
|
|
const updates: Partial<TransferTask> = cancelled
|
|
? {
|
|
status: "cancelled",
|
|
error: undefined,
|
|
endTime,
|
|
speed: 0,
|
|
}
|
|
: {
|
|
status,
|
|
error: outcome.partialFailure ? "Some files failed to transfer" : undefined,
|
|
retryable: outcome.partialFailure ? false : latestTask.retryable,
|
|
endTime,
|
|
transferredBytes: outcome.partialFailure ? latestTask.transferredBytes : latestTask.totalBytes,
|
|
speed: 0,
|
|
};
|
|
transferRuntime.patchTask(latestTask.id, updates);
|
|
mirrorPanelTask(transferRuntime.getTask(latestTask.id) ?? { ...latestTask, ...updates });
|
|
return status;
|
|
}
|
|
|
|
/** User-facing path exclusivity notice (toast + logs). */
|
|
function notifyPathConflict(
|
|
existing: Pick<TransferTask, "fileName" | "status">,
|
|
t: (key: string, values?: Record<string, string | number>) => string,
|
|
): void {
|
|
const name = existing.fileName || "file";
|
|
const message = existing.status === "paused"
|
|
? t("sftp.transfers.pathConflict.paused", { name })
|
|
: t("sftp.transfers.pathConflict.inProgress", { name });
|
|
logger.warn("[SFTP] path conflict", pathConflictMessage(existing));
|
|
notify.warning(message, t("sftp.transfers.pathConflict.title"));
|
|
}
|
|
|
|
export const useSftpTransfers = ({
|
|
ownerId,
|
|
canPrepareAdoption,
|
|
surfaceVisible = true,
|
|
getActivePane,
|
|
getPaneByConnectionId,
|
|
getTabByConnectionId,
|
|
updateTab,
|
|
refresh,
|
|
clearCacheForConnection,
|
|
sftpSessionsRef,
|
|
connectionCacheKeyMapRef,
|
|
listLocalFiles,
|
|
listRemoteFiles,
|
|
handleSessionError,
|
|
acquireTransferSession,
|
|
}: UseSftpTransfersParams): UseSftpTransfersResult => {
|
|
const { t } = useI18n();
|
|
const tRef = useRef(t);
|
|
tRef.current = t;
|
|
// Ref so progress flushes always see the latest visibility without rebinding.
|
|
const surfaceVisibleRef = useRef(surfaceVisible);
|
|
surfaceVisibleRef.current = surfaceVisible;
|
|
const [transfers, setTransfersState] = useState<TransferTask[]>(() => sftpTransferCenterStore.getOwnerTasks(ownerId));
|
|
// Only re-surface live attention conflicts. Terminal rows may still carry a
|
|
// stale conflict payload after older Skip paths — those must not reopen the dialog.
|
|
const [conflicts, setConflicts] = useState<FileConflict[]>(() => sftpTransferCenterStore
|
|
.getOwnerTasks(ownerId)
|
|
.filter((task) => task.status === "attention" && !!task.conflict)
|
|
.map((task) => task.conflict as FileConflict));
|
|
|
|
// Track cancelled task IDs. has() also sees process-global cancel flags set by
|
|
// the transfer center after the React owner unmounts.
|
|
const cancelledTasksRef = useRef<Set<string>>(createPanelCancelSet());
|
|
// Process-global pause latches (survive panel unmount / tab close). Local ref
|
|
// mirrors the global set so transferDirectoryOps can keep using a MutableRefObject.
|
|
const pausedTasksRef = useRef<Set<string>>(new Set());
|
|
const pauseWaitersRef = useRef<Map<string, Set<() => void>>>(new Map());
|
|
// Track active child transfer IDs per parent (outside React state for immediate visibility)
|
|
const activeChildIdsRef = useRef<Map<string, Set<string>>>(new Map());
|
|
// Live processTransfer walks (esp. directory parents). Resume must unlatch
|
|
// these instead of starting a second full re-walk of the tree.
|
|
const inFlightTransferIdsRef = useRef<Set<string>>(new Set());
|
|
/** While folder is latched, keep hammering pauseTransfer on live children. */
|
|
const folderPauseWatchdogsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
|
// Sole writer after mount is setTransfers (ref-first). Do not clobber with
|
|
// React state on every render — a parent re-render between progress IPC ticks
|
|
// would roll the ref back and freeze the global center bar.
|
|
const transfersRef = useRef(transfers);
|
|
const conflictsRef = useRef(conflicts);
|
|
conflictsRef.current = conflicts;
|
|
|
|
// When the retained panel is re-opened, catch up React state from the ref that
|
|
// kept receiving progress while the surface was hidden.
|
|
useEffect(() => {
|
|
if (!surfaceVisible) return;
|
|
setTransfersState(transfersRef.current);
|
|
}, [surfaceVisible]);
|
|
|
|
// Keep owner lifecycle changes in the global transfer store. Byte progress
|
|
// arrives independently through the unified process-wide event stream.
|
|
//
|
|
// Do not pre-assign transfersRef before publishing the same array reference —
|
|
// identity equality skips the store merge (avoids store↔listener loops).
|
|
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
|
|
const prev = transfersRef.current;
|
|
const next = typeof update === "function"
|
|
? (update as (value: TransferTask[]) => TransferTask[])(prev)
|
|
: update;
|
|
if (next === prev) return;
|
|
sftpTransferCenterStore.publishOwner(ownerId, next);
|
|
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
|
transfersRef.current = canonical;
|
|
setTransfersState(canonical);
|
|
}, [ownerId]);
|
|
|
|
// Coalesce progress-only patches to one paint per animation frame. Without
|
|
// this, each stream tick maps the full transfer list + publishOwner + store
|
|
// emit, and the main-window renderer pegs ~100%+ CPU during large copies.
|
|
//
|
|
// Progress flushes intentionally skip publishOwner: the store is updated via
|
|
// transferRuntime.patchTask (cheap single-row merge). Re-publishing the full
|
|
// owner snapshot on every tick was the freeze path for directory copies.
|
|
const pendingProgressByIdRef = useRef<Map<string, Partial<TransferTask>>>(new Map());
|
|
const progressFlushRafRef = useRef<number | null>(null);
|
|
// Progress coalesce: store is the live authority (SftpTransferItem uses
|
|
// useSftpTransferTask). Batch patchTask to one rAF — do not add another 500ms
|
|
// timer on top of main-process IPC (stacked lag made the bar jump).
|
|
// Do not setTransfersState here — that re-reconciled the retained SFTP tree.
|
|
const flushPendingProgress = useCallback(() => {
|
|
progressFlushRafRef.current = null;
|
|
const pending = pendingProgressByIdRef.current;
|
|
if (pending.size === 0) return;
|
|
pendingProgressByIdRef.current = new Map();
|
|
for (const [taskId, updates] of pending) {
|
|
transferRuntime.patchTask(taskId, updates);
|
|
}
|
|
}, []);
|
|
const scheduleProgressFlush = useCallback(() => {
|
|
if (progressFlushRafRef.current != null) return;
|
|
progressFlushRafRef.current = window.requestAnimationFrame(flushPendingProgress);
|
|
}, [flushPendingProgress]);
|
|
const applyProgress = useCallback((taskId: string, updates: Partial<TransferTask>) => {
|
|
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
|
|
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
|
|
// Keep ref hot for cancel/pause checks without waiting for paint.
|
|
transfersRef.current = transfersRef.current.map((row) => (
|
|
row.id === taskId ? { ...row, ...updates } : row
|
|
));
|
|
scheduleProgressFlush();
|
|
}, [scheduleProgressFlush]);
|
|
useEffect(() => () => {
|
|
if (progressFlushRafRef.current != null) {
|
|
window.cancelAnimationFrame(progressFlushRafRef.current);
|
|
progressFlushRafRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
|
const conflictDefaultsRef = useRef<TransferConflictDefaults>(new Map());
|
|
const deferredConflictAttemptsRef = useRef<DeferredTransferAttemptQueue | null>(null);
|
|
|
|
useEffect(() => {
|
|
const conflictDefaults = conflictDefaultsRef.current;
|
|
const queue = createDeferredTransferAttemptQueue({
|
|
onError: (error) => logger.warn("[SFTP] Deferred conflict transfer failed", error),
|
|
});
|
|
deferredConflictAttemptsRef.current = queue;
|
|
return () => {
|
|
queue.dispose();
|
|
if (deferredConflictAttemptsRef.current === queue) {
|
|
deferredConflictAttemptsRef.current = null;
|
|
}
|
|
conflictDefaults.clear();
|
|
};
|
|
}, [ownerId]);
|
|
|
|
useEffect(() => {
|
|
pruneTransferConflictDefaults(conflictDefaultsRef.current, transfers);
|
|
}, [transfers]);
|
|
|
|
const clearCancelledTask = useCallback((taskId: string) => {
|
|
cancelledTasksRef.current.delete(taskId);
|
|
clearTransferCancelled(taskId);
|
|
}, []);
|
|
|
|
const waitUntilTransferResumed = useCallback(async (taskId: string) => {
|
|
// Process-global latch: survives panel unmount so the transfer center can
|
|
// release a walk that outlived the React owner.
|
|
await waitUntilTransferPauseReleased(taskId);
|
|
syncPausedTasksRef(pausedTasksRef, taskId, false);
|
|
}, []);
|
|
|
|
const stopFolderPauseWatchdog = useCallback((taskId: string) => {
|
|
const timer = folderPauseWatchdogsRef.current.get(taskId);
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
folderPauseWatchdogsRef.current.delete(taskId);
|
|
}
|
|
}, []);
|
|
|
|
/** Every unfinished child id under a folder parent (state + live workers). */
|
|
const collectPauseTreeChildIds = useCallback((rootTaskId: string): string[] => {
|
|
const childIdSet = new Set(activeChildIdsRef.current.get(rootTaskId) ?? []);
|
|
for (const candidate of transfersRef.current) {
|
|
if (
|
|
candidate.parentTaskId === rootTaskId
|
|
&& !["completed", "cancelled", "failed"].includes(candidate.status)
|
|
) {
|
|
childIdSet.add(candidate.id);
|
|
}
|
|
}
|
|
return [...childIdSet];
|
|
}, []);
|
|
|
|
/**
|
|
* Resume/cancel must clear the whole latch tree. Store pause latches parent
|
|
* + children; releasing only the parent left child latches stuck and the
|
|
* directory walk blocked forever (UI "transferring", 0 bytes moving).
|
|
*/
|
|
const releasePausedTransfer = useCallback((taskId: string, childIds?: readonly string[]) => {
|
|
// Invalidate any in-flight soft-drain / watchdog pause IPC for this tree.
|
|
bumpTransferControlEpoch(taskId);
|
|
const kids = childIds ?? collectPauseTreeChildIds(taskId);
|
|
releaseTransferPauseTree(taskId, kids);
|
|
syncPausedTasksRef(pausedTasksRef, taskId, false);
|
|
for (const id of kids) syncPausedTasksRef(pausedTasksRef, id, false);
|
|
stopFolderPauseWatchdog(taskId);
|
|
const wake = (id: string) => {
|
|
const waiters = pauseWaitersRef.current.get(id);
|
|
pauseWaitersRef.current.delete(id);
|
|
for (const resolve of waiters ?? []) resolve();
|
|
};
|
|
wake(taskId);
|
|
for (const id of kids) wake(id);
|
|
}, [collectPauseTreeChildIds, stopFolderPauseWatchdog]);
|
|
|
|
/** Latch the tree and return the pause control epoch for soft-drain gating. */
|
|
const resolveTaskEndpoints = useCallback((task: TransferTask) => {
|
|
const sourceTab = getTabByConnectionId(task.sourceConnectionId);
|
|
const targetTab = task.targetConnectionId === "local"
|
|
? null
|
|
: getTabByConnectionId(task.targetConnectionId);
|
|
const sourceByHost = !sourceTab && task.sourceHostId
|
|
? (["left", "right"] as const).map((side) => {
|
|
const pane = getActivePane(side);
|
|
return pane?.connection?.hostId === task.sourceHostId ? { side, pane } : null;
|
|
}).find(Boolean)
|
|
: null;
|
|
const targetByHost = !targetTab && task.targetHostId
|
|
? (["left", "right"] as const).map((side) => {
|
|
const pane = getActivePane(side);
|
|
return pane?.connection?.hostId === task.targetHostId ? { side, pane } : null;
|
|
}).find(Boolean)
|
|
: null;
|
|
const localTab = (["left", "right"] as const).map((side) => {
|
|
const pane = getActivePane(side);
|
|
return pane?.connection?.isLocal ? { side, pane } : null;
|
|
}).find(Boolean);
|
|
|
|
const source = sourceTab
|
|
? { side: sourceTab.side, pane: sourceTab.pane }
|
|
: sourceByHost
|
|
?? (!task.sourceHostId ? localTab : null);
|
|
const target = targetTab
|
|
? { side: targetTab.side, pane: targetTab.pane }
|
|
: targetByHost
|
|
?? (!task.targetHostId || task.targetConnectionId === "local" ? localTab : null);
|
|
|
|
if (!source?.pane.connection || !target?.pane.connection) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sourceSide: source.side,
|
|
targetSide: target.side,
|
|
sourcePane: source.pane,
|
|
targetPane: target.pane,
|
|
};
|
|
}, [getActivePane, getTabByConnectionId]);
|
|
|
|
const cleanupTaskArtifacts = useCallback(async (task: TransferTask) => {
|
|
const endpoints = resolveTaskEndpoints(task);
|
|
const targetPane = endpoints?.targetPane;
|
|
const targetSftpId = targetPane?.connection && !targetPane.connection.isLocal
|
|
? sftpSessionsRef.current.get(targetPane.connection.id)
|
|
: undefined;
|
|
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
|
|
transferId: task.id,
|
|
sourcePath: task.sourcePath,
|
|
targetPath: task.targetPath,
|
|
targetSftpId,
|
|
targetEncoding: targetPane?.filenameEncoding,
|
|
stagedTargetPath: task.stagedTargetPath,
|
|
});
|
|
}, [resolveTaskEndpoints, sftpSessionsRef]);
|
|
|
|
const isTransferCancelledError = useCallback(
|
|
(error: unknown): boolean =>
|
|
error instanceof Error && error.message === "Transfer cancelled",
|
|
[],
|
|
);
|
|
|
|
const conflictDefaultKey = useCallback(
|
|
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
|
|
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
|
|
[],
|
|
);
|
|
|
|
const buildReplaceTypeMismatchError = useCallback(
|
|
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
|
|
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
|
|
[],
|
|
);
|
|
|
|
const { completeCancelledTask, cancelBackendTransfers, markBatchStopped } = useSftpTransferTaskOps({
|
|
cancelledTasksRef,
|
|
activeChildIdsRef,
|
|
transfersRef,
|
|
completionHandlersRef,
|
|
setConflicts,
|
|
setTransfers,
|
|
releasePausedTransfer,
|
|
cleanupTaskArtifacts,
|
|
});
|
|
|
|
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
|
|
|
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
|
ownerId,
|
|
cancelledTasksRef,
|
|
pausedTasksRef,
|
|
waitUntilTransferResumed,
|
|
activeChildIdsRef,
|
|
transfersRef,
|
|
setTransfers,
|
|
listLocalFiles,
|
|
listRemoteFiles,
|
|
acquireTransferSession,
|
|
});
|
|
|
|
const processTransfer = async (
|
|
task: TransferTask,
|
|
sourcePane: SftpPane,
|
|
targetPane: SftpPane,
|
|
targetSide: "left" | "right",
|
|
): Promise<TransferStatus> => {
|
|
if (cancelledTasksRef.current.has(task.id)) {
|
|
return "cancelled";
|
|
}
|
|
// Guard against concurrent processTransfer on the same id (resume used to
|
|
// re-enter while a paused directory walk was still alive). Process-global
|
|
// registry also covers walks after the SFTP panel / terminal tab unmounts.
|
|
if (inFlightTransferIdsRef.current.has(task.id) || transferRuntime.isWalkInFlight(task.id)) {
|
|
logger.warn("[SFTP] processTransfer already in flight; skipping re-entry", task.id);
|
|
return transfersRef.current.find((candidate) => candidate.id === task.id)?.status
|
|
?? transferRuntime.getTask(task.id)?.status
|
|
?? "transferring";
|
|
}
|
|
return runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, async () => {
|
|
// Runtime is the authority writer for live lifecycle. Also mirror into the
|
|
// panel list when mounted (view only — soft control does not depend on it).
|
|
// Progress-only patches are rAF-coalesced so large-file streams do not
|
|
// force a full React + global-center re-render on every IPC tick.
|
|
const updateTask = (updates: Partial<TransferTask>) => {
|
|
const statusChange = updates.status !== undefined
|
|
&& updates.status !== "transferring"
|
|
&& updates.status !== "paused"
|
|
&& updates.status !== "pausing";
|
|
// Pause-latched progress still counts as progress-only (no publishOwner).
|
|
const progressOnly = !statusChange
|
|
&& updates.error === undefined
|
|
&& updates.conflict === undefined
|
|
&& updates.ownerId === undefined
|
|
&& (
|
|
updates.transferredBytes !== undefined
|
|
|| updates.speed !== undefined
|
|
|| updates.checkpointBytes !== undefined
|
|
|| updates.resumeStage !== undefined
|
|
|| updates.downloadCheckpointBytes !== undefined
|
|
|| updates.uploadCheckpointBytes !== undefined
|
|
|| updates.sourceFingerprint !== undefined
|
|
|| updates.totalBytes !== undefined
|
|
|| updates.phase !== undefined
|
|
|| updates.status === "transferring"
|
|
|| updates.status === "paused"
|
|
|| updates.status === "pausing"
|
|
);
|
|
if (progressOnly) {
|
|
applyProgress(task.id, updates);
|
|
return;
|
|
}
|
|
// Lifecycle transitions: flush any pending progress first, then apply.
|
|
if (pendingProgressByIdRef.current.size > 0) {
|
|
flushPendingProgress();
|
|
}
|
|
transferRuntime.patchTask(task.id, updates);
|
|
setTransfers((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
|
);
|
|
};
|
|
|
|
let walkStatus: TransferStatus = "transferring";
|
|
await transferRuntime.runWalk(task.id, async () => {
|
|
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask);
|
|
});
|
|
return walkStatus;
|
|
});
|
|
};
|
|
|
|
const processTransferBody = async (
|
|
task: TransferTask,
|
|
sourcePane: SftpPane,
|
|
targetPane: SftpPane,
|
|
targetSide: "left" | "right",
|
|
updateTask: (updates: Partial<TransferTask>) => void,
|
|
): Promise<TransferStatus> => {
|
|
|
|
// Initialize encoding early to avoid temporal dead zone issues
|
|
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection?.isLocal
|
|
? "auto"
|
|
: sourcePane.filenameEncoding || "auto";
|
|
const targetEncoding: SftpFilenameEncoding = targetPane.connection?.isLocal
|
|
? "auto"
|
|
: targetPane.filenameEncoding || "auto";
|
|
|
|
// Browse panel sessions may be soft-closed while transfers continue on the
|
|
// dedicated pool. Prefer live browse ids, else open pool sessions for work.
|
|
let sourceSftpId: string | null = sourcePane.connection?.isLocal
|
|
? null
|
|
: (sftpSessionsRef.current.get(sourcePane.connection!.id) ?? null);
|
|
let targetSftpId: string | null = targetPane.connection?.isLocal
|
|
? null
|
|
: (sftpSessionsRef.current.get(targetPane.connection!.id) ?? null);
|
|
|
|
let sourceWorkLease: TransferConnectionLease | null = null;
|
|
let targetWorkLease: TransferConnectionLease | null = null;
|
|
|
|
const releaseWorkLeases = (mode: "release" | "discard" = "release") => {
|
|
if (mode === "discard") {
|
|
sourceWorkLease?.discard();
|
|
targetWorkLease?.discard();
|
|
} else {
|
|
sourceWorkLease?.release();
|
|
targetWorkLease?.release();
|
|
}
|
|
sourceWorkLease = null;
|
|
targetWorkLease = null;
|
|
};
|
|
|
|
// Detect same-host: both sides connected to the same remote endpoint.
|
|
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
|
|
// just hostId, because the same hostId can have different session-time overrides.
|
|
const sourceCacheKey = sourcePane.connection?.id
|
|
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
|
|
: undefined;
|
|
const targetCacheKey = targetPane.connection?.id
|
|
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
|
|
: undefined;
|
|
// sameHost remote-cp needs a live sftpId; pool-only transfers fall back to
|
|
// recursive file copy via transferDirectory.
|
|
const sameHostEndpoints = !!(
|
|
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal
|
|
&& sourceCacheKey && targetCacheKey
|
|
&& sourceCacheKey === targetCacheKey
|
|
);
|
|
|
|
try {
|
|
// Prefer dedicated work leases for remote ends so folder walks/listings
|
|
// survive browse park and tab hide (pool holders, not panel map ids).
|
|
if (!sourcePane.connection?.isLocal) {
|
|
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
|
if (acquireTransferSession && sourceHostId) {
|
|
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
|
sourceSftpId = sourceWorkLease.sftpId;
|
|
}
|
|
if (!sourceSftpId) {
|
|
const sourceSide = targetSide === "left" ? "right" : "left";
|
|
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
|
|
throw new Error("Source SFTP session not found");
|
|
}
|
|
}
|
|
|
|
if (!targetPane.connection?.isLocal) {
|
|
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
|
if (acquireTransferSession && targetHostId) {
|
|
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
|
targetSftpId = targetWorkLease.sftpId;
|
|
}
|
|
if (!targetSftpId) {
|
|
handleSessionError(targetSide, new Error("Target SFTP session lost"));
|
|
throw new Error("Target SFTP session not found");
|
|
}
|
|
}
|
|
|
|
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
|
|
|
const discoverTransferSize = async () => {
|
|
try {
|
|
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
|
|
|
|
if (sourcePane.connection?.isLocal) {
|
|
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
|
if (stat) {
|
|
if (!task.sourceLastModified && stat.lastModified) {
|
|
task.sourceLastModified = stat.lastModified;
|
|
}
|
|
if (!cancelledTasksRef.current.has(task.id)) {
|
|
updateTask({
|
|
totalBytes: stat.size,
|
|
});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (sourceSftpId) {
|
|
const stat = await netcattyBridge.get()?.statSftp?.(
|
|
sourceSftpId,
|
|
task.sourcePath,
|
|
sourceEncoding,
|
|
);
|
|
if (stat) {
|
|
if (!task.sourceLastModified && stat.lastModified) {
|
|
task.sourceLastModified = stat.lastModified;
|
|
}
|
|
if (!cancelledTasksRef.current.has(task.id)) {
|
|
updateTask({
|
|
totalBytes: stat.size,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!isTransferCancelledError(err)) {
|
|
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
const t0 = performance.now();
|
|
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
|
|
|
|
// Directory parents use file counts in transferredBytes/totalBytes. Never
|
|
// seed from byte checkpointBytes (pause clears those, which would show 0/N
|
|
// while completed children are skipped without re-counting).
|
|
const directoryCompletedCount = task.isDirectory
|
|
? (task.directoryResumeCheckpoint?.completedEntries ?? 0) + transfersRef.current.filter(
|
|
(candidate) => candidate.parentTaskId === task.id && candidate.status === "completed",
|
|
).length
|
|
: 0;
|
|
updateTask({
|
|
status: "transferring",
|
|
totalBytes: task.isDirectory
|
|
? directoryCompletedCount
|
|
: Math.max(task.totalBytes, 0),
|
|
transferredBytes: task.isDirectory
|
|
? Math.max(task.transferredBytes, directoryCompletedCount)
|
|
: (task.checkpointBytes ?? task.transferredBytes ?? 0),
|
|
startTime: Date.now(),
|
|
phase: "transferring",
|
|
resumable: task.resumable !== false,
|
|
reconnectRequired: false,
|
|
error: undefined,
|
|
});
|
|
|
|
// Run size discovery and conflict check in parallel
|
|
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
|
|
if (task.skipConflictCheck || !targetPane.connection) return null;
|
|
|
|
const sourceStat: { size: number; mtime: number } | null =
|
|
(task.totalBytes > 0 || task.sourceLastModified)
|
|
? { size: task.totalBytes, mtime: task.sourceLastModified || Date.now() }
|
|
: null;
|
|
|
|
try {
|
|
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
|
|
|
if (existingStat) {
|
|
const applyToAllCount = task.batchId
|
|
? await (async () => {
|
|
const candidates = transfersRef.current.filter((candidate) =>
|
|
candidate.batchId === task.batchId &&
|
|
candidate.isDirectory === task.isDirectory &&
|
|
!candidate.parentTaskId &&
|
|
candidate.status !== "completed" &&
|
|
candidate.status !== "cancelled",
|
|
);
|
|
const matches = await Promise.all(candidates.map(async (candidate) => {
|
|
if (candidate.id === task.id) return true;
|
|
try {
|
|
const candidateStat = await statTargetPath(
|
|
targetPane,
|
|
targetSftpId,
|
|
candidate.targetPath,
|
|
targetEncoding,
|
|
);
|
|
return candidateStat?.type === existingStat.type;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}));
|
|
return Math.max(1, matches.filter(Boolean).length);
|
|
})()
|
|
: 1;
|
|
|
|
return {
|
|
transferId: task.id,
|
|
batchId: task.batchId,
|
|
fileName: task.fileName,
|
|
sourcePath: task.sourcePath,
|
|
targetPath: task.targetPath,
|
|
isDirectory: task.isDirectory,
|
|
existingType: existingStat.type,
|
|
applyToAllCount,
|
|
existingSize: existingStat.size,
|
|
newSize: sourceStat?.size || task.totalBytes || 0,
|
|
existingModified: existingStat.mtime,
|
|
newModified: sourceStat?.mtime || Date.now(),
|
|
};
|
|
}
|
|
} catch (error) {
|
|
// Missing path = no conflict. ENOTSUP / unknown type fail closed.
|
|
if (isMissingStatError(error)) return null;
|
|
throw error;
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
// For single files: fire-and-forget size discovery
|
|
if (!task.isDirectory) {
|
|
void discoverTransferSize();
|
|
}
|
|
|
|
// Only await conflict check (fast single stat call)
|
|
const conflict = await conflictCheckPromise;
|
|
// Cancel/Stop may have won while conflict stats were in flight.
|
|
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
|
|
|
if (conflict) {
|
|
const defaultAction = conflictDefaultsRef.current
|
|
.get(task.batchId ?? "global")
|
|
?.get(getSftpConflictTypeKey(task.isDirectory, conflict.existingType));
|
|
if (defaultAction) {
|
|
if (defaultAction === "stop") {
|
|
await markBatchStopped(task);
|
|
return "cancelled";
|
|
}
|
|
|
|
if (defaultAction === "skip") {
|
|
cancelledTasksRef.current.add(task.id);
|
|
updateTask({ status: "cancelled", endTime: Date.now() });
|
|
await completeCancelledTask(task);
|
|
return "cancelled";
|
|
}
|
|
|
|
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
|
|
updateTask({
|
|
status: "failed",
|
|
endTime: Date.now(),
|
|
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
|
retryable: false,
|
|
});
|
|
return "failed";
|
|
}
|
|
|
|
if (
|
|
defaultAction === "replace"
|
|
&& shouldUnlinkSftpConflictBeforeReplace(conflict.existingType)
|
|
) {
|
|
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding, "symlink");
|
|
}
|
|
|
|
const duplicateTarget = defaultAction === "duplicate"
|
|
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
|
: null;
|
|
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
|
const updatedTask: TransferTask = {
|
|
...task,
|
|
...(duplicateTarget
|
|
? {
|
|
fileName: duplicateTarget.fileName,
|
|
targetPath: duplicateTarget.targetPath,
|
|
}
|
|
: null),
|
|
skipConflictCheck: true,
|
|
replaceExistingTarget: defaultAction === "replace",
|
|
};
|
|
setTransfers((prev) =>
|
|
prev.map((t) =>
|
|
t.id === task.id
|
|
? { ...updatedTask, status: "pending" as TransferStatus }
|
|
: t,
|
|
),
|
|
);
|
|
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
|
}
|
|
|
|
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
|
setConflicts((prev) => [...prev, conflict]);
|
|
updateTask({
|
|
status: "attention",
|
|
totalBytes: conflict.newSize || task.totalBytes || 0,
|
|
conflict,
|
|
});
|
|
return "attention";
|
|
}
|
|
|
|
logger.debug(`[SFTP:perf] starting actual transfer — file=${task.fileName} isDir=${task.isDirectory} — ${(performance.now() - t0).toFixed(0)}ms since start`);
|
|
await waitUntilTransferResumed(task.id);
|
|
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
|
|
|
let dirPartialFailure = false;
|
|
|
|
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
|
|
// "auto" is allowed here — the backend resolves it to the actual encoding
|
|
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
|
|
const encodingSafeForExec =
|
|
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
|
|
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
|
|
|
|
// Try same-host directory optimization first; falls back to recursive transfer
|
|
// if remote cp is unavailable (e.g. Windows SSH servers).
|
|
let dirHandledBySameHost = false;
|
|
if (
|
|
task.isDirectory
|
|
&& !task.replaceExistingTarget
|
|
&& task.resumable === false
|
|
&& sameHost
|
|
&& encodingSafeForExec
|
|
&& sourceSftpId
|
|
) {
|
|
if (cancelledTasksRef.current.has(task.id)) {
|
|
throw new Error("Transfer cancelled");
|
|
}
|
|
const result = await netcattyBridge.require().sameHostCopyDirectory!(
|
|
sourceSftpId,
|
|
task.sourcePath,
|
|
task.targetPath,
|
|
sourceEncoding,
|
|
task.id,
|
|
);
|
|
if (cancelledTasksRef.current.has(task.id)) {
|
|
throw new Error("Transfer cancelled");
|
|
}
|
|
dirHandledBySameHost = result.success;
|
|
}
|
|
|
|
if (task.isDirectory && !dirHandledBySameHost) {
|
|
// For directory transfers, parent task uses:
|
|
// totalBytes = total file count (discovered async)
|
|
// transferredBytes = completed file count (incremented by child completions)
|
|
// Child file tasks are registered in transfers array with their own byte progress.
|
|
|
|
const stagedTargetPath = task.replaceExistingTarget
|
|
? `${task.targetPath}.netcatty-${task.id.replace(/[^A-Za-z0-9_-]/g, "_")}.part`
|
|
: undefined;
|
|
const directoryTask = stagedTargetPath ? { ...task, targetPath: stagedTargetPath } : task;
|
|
if (stagedTargetPath) updateTask({ stagedTargetPath });
|
|
|
|
const dirErrors = await transferDirectory(
|
|
directoryTask,
|
|
sourceSftpId,
|
|
targetSftpId,
|
|
sourcePane.connection!.isLocal,
|
|
targetPane.connection!.isLocal,
|
|
sourceEncoding,
|
|
targetEncoding,
|
|
task.id, // rootTaskId - this is the top-level task
|
|
sameHost,
|
|
);
|
|
|
|
if (dirErrors > 0) {
|
|
dirPartialFailure = true;
|
|
} else if (stagedTargetPath) {
|
|
const bridge = netcattyBridge.require();
|
|
const backupPath = `${task.targetPath}.netcatty-${task.id.replace(/[^A-Za-z0-9_-]/g, "_")}.backup`;
|
|
if (targetPane.connection!.isLocal) {
|
|
if (!bridge.statLocal || !bridge.renameLocalFile || !bridge.deleteLocalFile) {
|
|
throw new Error("Local directory replacement is unavailable");
|
|
}
|
|
await promoteDirectoryReplacePaths({
|
|
targetPath: task.targetPath,
|
|
stagedPath: stagedTargetPath,
|
|
backupPath,
|
|
statPath: bridge.statLocal,
|
|
renamePath: bridge.renameLocalFile,
|
|
deletePath: bridge.deleteLocalFile,
|
|
});
|
|
} else if (targetSftpId) {
|
|
if (!bridge.statSftp || !bridge.renameSftp || !bridge.deleteSftp) {
|
|
throw new Error("Remote directory replacement is unavailable");
|
|
}
|
|
await promoteDirectoryReplacePaths({
|
|
targetPath: task.targetPath,
|
|
stagedPath: stagedTargetPath,
|
|
backupPath,
|
|
statPath: (candidate) => bridge.statSftp!(targetSftpId, candidate, targetEncoding),
|
|
renamePath: (source, target) => bridge.renameSftp!(targetSftpId, source, target, targetEncoding),
|
|
deletePath: (candidate) => bridge.deleteSftp!(targetSftpId, candidate, targetEncoding),
|
|
});
|
|
} else {
|
|
throw new Error("Target SFTP session missing for directory promote");
|
|
}
|
|
updateTask({ stagedTargetPath: undefined });
|
|
}
|
|
} else if (!task.isDirectory) {
|
|
await transferFile(
|
|
task,
|
|
sourceSftpId,
|
|
targetSftpId,
|
|
sourcePane.connection!.isLocal,
|
|
targetPane.connection!.isLocal,
|
|
sourceEncoding,
|
|
targetEncoding,
|
|
task.id, // rootTaskId - this is the top-level task
|
|
sameHost,
|
|
);
|
|
}
|
|
|
|
if (cancelledTasksRef.current.has(task.id)) {
|
|
throw new Error("Transfer cancelled");
|
|
}
|
|
|
|
const fallbackTask = transfersRef.current.find((candidate) => candidate.id === task.id)
|
|
?? task;
|
|
// Publish terminal lifecycle through the runtime-aware writer while the
|
|
// directory walk is still registered. A panel-only setState could be
|
|
// rejected as stale by the global transfer center and remain active at 100%.
|
|
const finalStatus = finishTransferTask(
|
|
fallbackTask,
|
|
{
|
|
partialFailure: dirPartialFailure,
|
|
cancelled: cancelledTasksRef.current.has(task.id),
|
|
},
|
|
() => {
|
|
if (pendingProgressByIdRef.current.size > 0) {
|
|
flushPendingProgress();
|
|
}
|
|
},
|
|
(canonicalTask) => {
|
|
const next = transfersRef.current.map((candidate) => (
|
|
candidate.id === task.id ? canonicalTask : candidate
|
|
));
|
|
transfersRef.current = next;
|
|
setTransfersState(next);
|
|
},
|
|
);
|
|
|
|
// Target contents may have been cached before this transfer started,
|
|
// especially when dropping into a subdirectory like "/tmp" from its parent.
|
|
// Clear the target connection cache so the next navigation reloads fresh data.
|
|
clearCacheForConnection(task.targetConnectionId);
|
|
|
|
const targetTab = getTabByConnectionId(task.targetConnectionId);
|
|
if (targetTab) {
|
|
updateTab(targetTab.side, targetTab.tabId, (prev) => ({
|
|
...prev,
|
|
transferMutationToken: prev.transferMutationToken + 1,
|
|
}));
|
|
}
|
|
|
|
// Refresh the specific target tab, not whichever tab happens to be
|
|
// active now — focus may have switched during the transfer.
|
|
if (getParentPath(task.targetPath) === targetPane.connection!.currentPath) {
|
|
await refresh(targetSide, { tabId: targetPane.id });
|
|
}
|
|
// Clean up tracked child IDs for this transfer
|
|
activeChildIdsRef.current.delete(task.id);
|
|
|
|
const completionHandler = completionHandlersRef.current.get(task.id);
|
|
if (completionHandler) {
|
|
try {
|
|
await completionHandler({
|
|
id: task.id,
|
|
fileName: task.fileName,
|
|
originalFileName: task.originalFileName ?? task.fileName,
|
|
status: finalStatus,
|
|
});
|
|
} finally {
|
|
completionHandlersRef.current.delete(task.id);
|
|
}
|
|
}
|
|
return finalStatus;
|
|
} catch (err) {
|
|
activeChildIdsRef.current.delete(task.id);
|
|
// Check if this was a cancellation
|
|
const isCancelled = cancelledTasksRef.current.has(task.id) ||
|
|
(err instanceof Error && err.message === "Transfer cancelled");
|
|
|
|
if (isCancelled) {
|
|
// Don't update status - cancelTransfer already set it to cancelled
|
|
const completionHandler = completionHandlersRef.current.get(task.id);
|
|
if (completionHandler) {
|
|
try {
|
|
await completionHandler({
|
|
id: task.id,
|
|
fileName: task.fileName,
|
|
originalFileName: task.originalFileName ?? task.fileName,
|
|
status: "cancelled",
|
|
});
|
|
} finally {
|
|
completionHandlersRef.current.delete(task.id);
|
|
}
|
|
}
|
|
clearCancelledTask(task.id);
|
|
return "cancelled";
|
|
}
|
|
|
|
updateTask({
|
|
status: "failed",
|
|
error: err instanceof Error ? err.message : "Transfer failed",
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
});
|
|
const completionHandler = completionHandlersRef.current.get(task.id);
|
|
if (completionHandler) {
|
|
try {
|
|
await completionHandler({
|
|
id: task.id,
|
|
fileName: task.fileName,
|
|
originalFileName: task.originalFileName ?? task.fileName,
|
|
status: "failed",
|
|
});
|
|
} finally {
|
|
completionHandlersRef.current.delete(task.id);
|
|
}
|
|
}
|
|
return "failed";
|
|
}
|
|
} finally {
|
|
// Walk registry cleanup is owned by transferRuntime.runWalk.
|
|
// Drop ephemeral pool holds opened when browse was soft-closed.
|
|
// Per-file transferDirectory leases are released inside transferDirectory.
|
|
releaseWorkLeases();
|
|
}
|
|
};
|
|
|
|
const startTransfer = useCallback(
|
|
async (
|
|
sourceFiles: { name: string; isDirectory: boolean }[],
|
|
sourceSide: "left" | "right",
|
|
targetSide: "left" | "right",
|
|
options?: {
|
|
sourcePane?: SftpPane;
|
|
sourcePath?: string;
|
|
sourceConnectionId?: string;
|
|
targetPath?: string;
|
|
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
|
},
|
|
) => {
|
|
const sourcePane = options?.sourcePane
|
|
?? (options?.sourceConnectionId ? getPaneByConnectionId(options.sourceConnectionId) : null)
|
|
?? getActivePane(sourceSide);
|
|
const targetPane = getActivePane(targetSide);
|
|
|
|
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
|
|
|
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
|
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
|
|
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
|
const batchId = crypto.randomUUID();
|
|
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
|
|
|
|
const newTasks: TransferTask[] = [];
|
|
const skippedResults: TransferResult[] = [];
|
|
|
|
const canReusePaneMetadata = sourcePath === sourcePane.connection.currentPath;
|
|
const fileEntryMap = canReusePaneMetadata
|
|
? new Map(sourcePane.files.map(f => [f.name, f]))
|
|
: null;
|
|
|
|
for (const file of sourceFiles) {
|
|
const direction: TransferDirection =
|
|
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
|
? "upload"
|
|
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
|
|
? "download"
|
|
: "remote-to-remote";
|
|
|
|
// Use cached metadata from the source pane's file list to avoid
|
|
// redundant stat calls over the network, but only when the transfer
|
|
// source matches the pane's currently listed directory.
|
|
const fileEntry = fileEntryMap?.get(file.name);
|
|
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
|
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
|
|
|
const nextSourcePath = joinPath(sourcePath, file.name);
|
|
const nextTargetPath = joinPath(targetPath, file.name);
|
|
const targetIsLocal = targetPane.connection!.isLocal;
|
|
const pathTaken = findActivePathConflict(
|
|
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
|
{
|
|
id: "",
|
|
targetPath: nextTargetPath,
|
|
// Normalize local pane ids to the same sentinel downloadToLocal uses.
|
|
targetConnectionId: targetIsLocal ? "local" : targetPane.connection!.id,
|
|
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
|
|
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
|
|
isDirectory: file.isDirectory,
|
|
},
|
|
);
|
|
if (pathTaken) {
|
|
notifyPathConflict(pathTaken, tRef.current);
|
|
const message = pathConflictMessage(pathTaken);
|
|
const attentionTask: TransferTask = {
|
|
id: crypto.randomUUID(),
|
|
batchId,
|
|
fileName: file.name,
|
|
originalFileName: file.name,
|
|
sourcePath: nextSourcePath,
|
|
targetPath: nextTargetPath,
|
|
sourceConnectionId,
|
|
targetConnectionId: targetPane.connection!.id,
|
|
sourceHostId: sourcePane.connection!.isLocal ? undefined : sourcePane.connection!.hostId,
|
|
sourceHostLabel: sourcePane.connection!.isLocal ? "Local" : sourcePane.connection!.hostLabel,
|
|
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
|
|
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
|
|
direction,
|
|
status: "attention",
|
|
totalBytes: fileSize,
|
|
transferredBytes: 0,
|
|
speed: 0,
|
|
startTime: Date.now(),
|
|
endTime: Date.now(),
|
|
isDirectory: file.isDirectory,
|
|
progressMode: file.isDirectory ? "files" : "bytes",
|
|
sourceLastModified,
|
|
origin: "manual",
|
|
error: message,
|
|
resumable: false,
|
|
};
|
|
newTasks.push(attentionTask);
|
|
const skipped: TransferResult = {
|
|
id: attentionTask.id,
|
|
fileName: attentionTask.fileName,
|
|
originalFileName: attentionTask.originalFileName ?? attentionTask.fileName,
|
|
status: "attention",
|
|
};
|
|
skippedResults.push(skipped);
|
|
if (options?.onTransferComplete) {
|
|
await options.onTransferComplete(skipped);
|
|
}
|
|
continue;
|
|
}
|
|
newTasks.push({
|
|
id: crypto.randomUUID(),
|
|
batchId,
|
|
fileName: file.name,
|
|
originalFileName: file.name,
|
|
sourcePath: nextSourcePath,
|
|
targetPath: nextTargetPath,
|
|
sourceConnectionId,
|
|
targetConnectionId: targetPane.connection!.id,
|
|
sourceHostId: sourcePane.connection!.isLocal ? undefined : sourcePane.connection!.hostId,
|
|
sourceHostLabel: sourcePane.connection!.isLocal ? "Local" : sourcePane.connection!.hostLabel,
|
|
targetHostId: targetPane.connection!.isLocal ? undefined : targetPane.connection!.hostId,
|
|
targetHostLabel: targetPane.connection!.isLocal ? "Local" : targetPane.connection!.hostLabel,
|
|
direction,
|
|
status: "pending" as TransferStatus,
|
|
totalBytes: fileSize,
|
|
transferredBytes: 0,
|
|
speed: 0,
|
|
startTime: Date.now(),
|
|
isDirectory: file.isDirectory,
|
|
progressMode: file.isDirectory ? "files" : "bytes",
|
|
sourceLastModified,
|
|
origin: "manual",
|
|
resumable: !usesLegacyScp,
|
|
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
|
phase: file.isDirectory ? "scanning" : "transferring",
|
|
});
|
|
}
|
|
|
|
setTransfers((prev) => [...prev, ...newTasks]);
|
|
|
|
// Only enqueue runnable tasks — attention path-conflict rows stay visible
|
|
// and must not re-enter processTransfer.
|
|
const runnableTasks = newTasks.filter((task) => task.status !== "attention");
|
|
|
|
if (options?.onTransferComplete) {
|
|
for (const task of runnableTasks) {
|
|
completionHandlersRef.current.set(task.id, options.onTransferComplete);
|
|
}
|
|
}
|
|
|
|
const results = await Promise.all(runnableTasks.map(async (task): Promise<TransferResult> => {
|
|
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
|
return {
|
|
id: task.id,
|
|
fileName: task.fileName,
|
|
originalFileName: task.originalFileName ?? task.fileName,
|
|
status,
|
|
};
|
|
}));
|
|
|
|
return [...skippedResults, ...results];
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab],
|
|
);
|
|
|
|
const cancelTransfer = useCallback(
|
|
async (transferId: string) => {
|
|
const taskToCancel = transfersRef.current.find((task) => task.id === transferId);
|
|
// Add to cancelled set so async operations can check (local + process-global
|
|
// so walks survive panel unmount and still honor center Cancel).
|
|
cancelledTasksRef.current.add(transferId);
|
|
markTransferCancelled(transferId);
|
|
releasePausedTransfer(transferId);
|
|
globalSftpTransferScheduler.cancel(transferId);
|
|
|
|
// Cancel parent + remove child tasks
|
|
const childIdsToCancel = new Set<string>();
|
|
const childrenToCleanup: TransferTask[] = [];
|
|
for (const t of transfersRef.current) {
|
|
if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) {
|
|
childIdsToCancel.add(t.id);
|
|
childrenToCleanup.push(t);
|
|
}
|
|
}
|
|
for (const cid of activeChildIdsRef.current.get(transferId) ?? []) {
|
|
if (!childIdsToCancel.has(cid)) {
|
|
childIdsToCancel.add(cid);
|
|
if (taskToCancel) {
|
|
childrenToCleanup.push({
|
|
...taskToCancel,
|
|
id: cid,
|
|
parentTaskId: transferId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const cid of childIdsToCancel) {
|
|
cancelledTasksRef.current.add(cid);
|
|
markTransferCancelled(cid);
|
|
globalSftpTransferScheduler.cancel(cid);
|
|
}
|
|
markTransferCancelledTree(transferId, [...childIdsToCancel]);
|
|
// Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel.
|
|
const nextTransfers = transfersRef.current
|
|
.filter((t) => t.parentTaskId !== transferId)
|
|
.map((t) =>
|
|
t.id === transferId
|
|
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
|
|
: t,
|
|
);
|
|
setTransfers(nextTransfers);
|
|
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId));
|
|
setConflicts(conflictsRef.current);
|
|
|
|
await cancelBackendTransfers([transferId, ...childIdsToCancel]);
|
|
if (taskToCancel) await cleanupTaskArtifacts(taskToCancel);
|
|
// Child stages are keyed by per-file transferId — clean each known child.
|
|
for (const child of childrenToCleanup) {
|
|
try {
|
|
await cleanupTaskArtifacts(child);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
},
|
|
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
|
|
);
|
|
|
|
// Soft pause/resume: single TransferRuntime entry (store soft-control +
|
|
// dedicated hard reconnect). No panel-local soft-control dual path.
|
|
const resumeTransfer = useCallback(async (transferId: string) => {
|
|
// Clear sticky child cancel latches so re-walk can retry same child ids.
|
|
clearCancelledTask(transferId);
|
|
for (const child of transfersRef.current) {
|
|
if (child.parentTaskId === transferId) clearCancelledTask(child.id);
|
|
}
|
|
for (const childId of activeChildIdsRef.current.get(transferId) ?? []) {
|
|
clearCancelledTask(childId);
|
|
}
|
|
clearTransferCancelledTree(
|
|
transferId,
|
|
[...(activeChildIdsRef.current.get(transferId) ?? [])],
|
|
);
|
|
await transferRuntime.resume(transferId);
|
|
}, [clearCancelledTask]);
|
|
|
|
const prioritizeTransfer = useCallback((transferId: string) => {
|
|
globalSftpTransferScheduler.prioritize(transferId);
|
|
void netcattyBridge.get()?.prioritizeTransfer?.(transferId);
|
|
setTransfers((prev) => {
|
|
const nextPriority = prev.reduce((max, task) => Math.max(max, task.priority ?? 0), 0) + 1;
|
|
return prev.map((task) => task.id === transferId ? { ...task, priority: nextPriority } : task);
|
|
});
|
|
}, [setTransfers]);
|
|
|
|
const retryTransfer = useCallback(
|
|
async (transferId: string) => {
|
|
// Prefer the live owner list; fall back to the center store (drag-drop
|
|
// children are upserted there and mirrored via subscribe).
|
|
const task = transfersRef.current.find((t) => t.id === transferId)
|
|
?? sftpTransferCenterStore.getTask(transferId);
|
|
if (!task || task.retryable === false) return;
|
|
|
|
// Progressive / external drag-drop children never have dual-pane endpoints
|
|
// (sourceConnectionId is "external"). Re-run startStreamTransfer in place.
|
|
if (isExternalDragDropFileUpload(task)) {
|
|
if (inFlightTransferIdsRef.current.has(transferId)) return;
|
|
inFlightTransferIdsRef.current.add(transferId);
|
|
clearTransferCancelled(transferId);
|
|
cancelledTasksRef.current.delete(transferId);
|
|
try {
|
|
const result = await retryExternalDragDropFileUpload(task, {
|
|
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
|
acquireTransferSession: acquireTransferSession
|
|
? (hostId, id) => acquireTransferSession(hostId, id)
|
|
: undefined,
|
|
startStreamTransfer: async (options) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startStreamTransfer) {
|
|
return { error: "Stream transfer is unavailable" };
|
|
}
|
|
return bridge.startStreamTransfer(options);
|
|
},
|
|
clearPendingCancel: (id) => netcattyBridge.get()?.clearPendingTransferCancel?.(id),
|
|
cleanupArtifacts: cleanupTaskArtifacts,
|
|
getTask: (id) => sftpTransferCenterStore.getTask(id)
|
|
?? transfersRef.current.find((row) => row.id === id),
|
|
getChildTasks: (parentId) => {
|
|
const fromStore = sftpTransferCenterStore.getOwnerTasks(ownerId)
|
|
.filter((row) => row.parentTaskId === parentId);
|
|
if (fromStore.length > 0) return fromStore;
|
|
return transfersRef.current.filter((row) => row.parentTaskId === parentId);
|
|
},
|
|
onPatch: (taskId, updates) => {
|
|
transferRuntime.patchTask(taskId, updates);
|
|
setTransfers((prev) => {
|
|
if (!prev.some((row) => row.id === taskId)) {
|
|
// Store-only child: mirror the patch by re-pulling owner rows.
|
|
return sftpTransferCenterStore.getOwnerTasks(ownerId);
|
|
}
|
|
return prev.map((row) => (row.id === taskId ? { ...row, ...updates } : row));
|
|
});
|
|
},
|
|
});
|
|
if (!result.success && result.error && !/cancelled/i.test(result.error)) {
|
|
notify.warning(result.error, "SFTP");
|
|
}
|
|
} finally {
|
|
inFlightTransferIdsRef.current.delete(transferId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await cleanupTaskArtifacts(task);
|
|
|
|
const retriedTask: TransferTask = {
|
|
...task,
|
|
id: crypto.randomUUID(),
|
|
status: "pending" as TransferStatus,
|
|
error: undefined,
|
|
transferredBytes: 0,
|
|
checkpointBytes: 0,
|
|
resumeStage: undefined,
|
|
downloadCheckpointBytes: undefined,
|
|
uploadCheckpointBytes: undefined,
|
|
directoryResumeCheckpoint: undefined,
|
|
speed: 0,
|
|
startTime: Date.now(),
|
|
endTime: undefined,
|
|
lifecycleEpoch: undefined,
|
|
};
|
|
|
|
const endpoints = resolveTaskEndpoints(task);
|
|
if (!endpoints) {
|
|
notify.warning(
|
|
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
|
|
"SFTP",
|
|
);
|
|
return;
|
|
}
|
|
const { targetSide, sourcePane, targetPane } = endpoints;
|
|
|
|
const completionHandler = completionHandlersRef.current.get(transferId);
|
|
if (completionHandler) {
|
|
completionHandlersRef.current.set(retriedTask.id, completionHandler);
|
|
completionHandlersRef.current.delete(transferId);
|
|
}
|
|
|
|
setTransfers((prev) =>
|
|
prev.map((t) =>
|
|
t.id === transferId
|
|
? retriedTask
|
|
: t,
|
|
),
|
|
);
|
|
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
|
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
|
|
);
|
|
|
|
const clearCompletedTransfers = useCallback(() => {
|
|
setTransfers((prev) => {
|
|
const unfinishedParents = new Set(
|
|
prev.filter((t) => !["completed", "cancelled", "failed"].includes(t.status)).map((t) => t.id),
|
|
);
|
|
// Keep non-compacted terminal exceptions of unfinished directory parents;
|
|
// compacted completions already live in the bounded parent checkpoint.
|
|
return prev.filter((t) => {
|
|
if (t.parentTaskId && unfinishedParents.has(t.parentTaskId)) return true;
|
|
return t.status !== "completed" && t.status !== "cancelled";
|
|
});
|
|
});
|
|
}, [setTransfers]);
|
|
|
|
const dismissTransfer = useCallback((transferId: string) => {
|
|
const task = transfersRef.current.find((candidate) => candidate.id === transferId);
|
|
if (task) void cleanupTaskArtifacts(task);
|
|
setTransfers((prev) => prev.filter((t) => t.id !== transferId && t.parentTaskId !== transferId));
|
|
}, [cleanupTaskArtifacts, setTransfers]);
|
|
|
|
const dismissTransfers = useCallback((prunedTasks: readonly TransferTask[]) => {
|
|
if (prunedTasks.length === 0) return;
|
|
const removing = new Set(prunedTasks.map((task) => task.id));
|
|
const artifactTasks = prunedTasks.filter((task) => task.status !== "completed");
|
|
// This callback is used only for automatic history eviction. Completed
|
|
// streams already cleaned their staging files; sending one cleanup IPC per
|
|
// pruned child recreates a main-process event storm at folder completion.
|
|
setTransfers((prev) => prev.filter((task) => (
|
|
!removing.has(task.id) && !removing.has(task.parentTaskId ?? "")
|
|
)));
|
|
// Failed/cancelled transfers can retain resumable staging files. Clean them
|
|
// sequentially in the background so a large eviction never floods IPC.
|
|
if (artifactTasks.length > 0) {
|
|
void (async () => {
|
|
for (const task of artifactTasks) {
|
|
try {
|
|
await cleanupTaskArtifacts(task);
|
|
} catch {
|
|
// best-effort history cleanup
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
}, [cleanupTaskArtifacts, setTransfers]);
|
|
|
|
const isTransferCancelled = useCallback((transferId: string) => {
|
|
return cancelledTasksRef.current.has(transferId) || isTransferCancelledFlag(transferId);
|
|
}, []);
|
|
|
|
const resolveConflict = useCallback(
|
|
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
|
if (cancelledTasksRef.current.has(conflictId)) return;
|
|
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
|
|
if (!conflict) return;
|
|
|
|
const task = transfersRef.current.find((t) => t.id === conflictId);
|
|
if (!task) {
|
|
// Dialog can show a store-owned conflict before the panel adopts the row.
|
|
// Clearing only React state leaves attention+conflict in localStorage and
|
|
// the prompt returns on the next SFTP open (issue #2636).
|
|
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== conflictId);
|
|
setConflicts(conflictsRef.current);
|
|
if (action === "stop" || action === "skip") {
|
|
sftpTransferCenterStore.patchTask(conflictId, {
|
|
status: "cancelled",
|
|
conflict: undefined,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
error: undefined,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
|
|
const affectedConflicts = applyToAll
|
|
? conflictsRef.current.filter((candidate) =>
|
|
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
|
|
)
|
|
: [conflict];
|
|
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
|
const affectedTasks = affectedConflicts
|
|
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
|
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
|
const affectedConflictById = new Map<string, FileConflict>(
|
|
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
|
|
);
|
|
|
|
if (applyToAll) {
|
|
const batchId = conflict.batchId ?? "global";
|
|
const batchDefaults = conflictDefaultsRef.current.get(batchId) ?? new Map<string, FileConflictAction>();
|
|
batchDefaults.set(getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType), action);
|
|
conflictDefaultsRef.current.set(batchId, batchDefaults);
|
|
}
|
|
|
|
// Eagerly clear refs so a double-click cannot schedule two processTransfer calls.
|
|
conflictsRef.current = conflictsRef.current.filter((c) => !affectedConflictIds.has(c.transferId));
|
|
setConflicts(conflictsRef.current);
|
|
|
|
if (affectedTasks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (action === "stop") {
|
|
await markBatchStopped(task);
|
|
return;
|
|
}
|
|
|
|
if (action === "skip") {
|
|
for (const affectedTask of affectedTasks) {
|
|
cancelledTasksRef.current.add(affectedTask.id);
|
|
}
|
|
// Drop conflict so remount / restart cannot rehydrate the dialog from a
|
|
// cancelled row that still carries conflict metadata.
|
|
setTransfers((prev) =>
|
|
prev.map((t) => affectedConflictIds.has(t.id)
|
|
? {
|
|
...t,
|
|
status: "cancelled" as TransferStatus,
|
|
endTime: Date.now(),
|
|
conflict: undefined,
|
|
error: undefined,
|
|
speed: 0,
|
|
}
|
|
: t,
|
|
),
|
|
);
|
|
for (const affectedTask of affectedTasks) {
|
|
await completeCancelledTask(affectedTask);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const updatedTasks: TransferTask[] = [];
|
|
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
|
const failedReplaceTasks: Array<{ task: TransferTask; error: string }> = [];
|
|
|
|
for (const affectedTask of affectedTasks) {
|
|
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
|
|
let updatedTask = { ...affectedTask };
|
|
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
|
|
|
if (action === "duplicate") {
|
|
const endpoints = resolveTaskEndpoints(affectedTask);
|
|
if (!endpoints) continue;
|
|
const targetSftpId = endpoints.targetPane.connection?.isLocal
|
|
? null
|
|
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
|
|
const targetEncoding = endpoints.targetPane.connection?.isLocal
|
|
? "auto"
|
|
: endpoints.targetPane.filenameEncoding || "auto";
|
|
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
|
|
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
|
|
updatedTask = {
|
|
...affectedTask,
|
|
fileName: duplicateTarget.fileName,
|
|
targetPath: duplicateTarget.targetPath,
|
|
skipConflictCheck: true,
|
|
};
|
|
} else if (action === "replace") {
|
|
if (
|
|
affectedConflict &&
|
|
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
|
|
) {
|
|
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
|
continue;
|
|
}
|
|
if (
|
|
affectedConflict
|
|
&& shouldUnlinkSftpConflictBeforeReplace(affectedConflict.existingType)
|
|
) {
|
|
const endpoints = resolveTaskEndpoints(affectedTask);
|
|
if (!endpoints?.targetPane.connection) continue;
|
|
const targetSftpId = endpoints.targetPane.connection.isLocal
|
|
? null
|
|
: sftpSessionsRef.current.get(endpoints.targetPane.connection.id) ?? null;
|
|
const targetEncoding = endpoints.targetPane.connection.isLocal
|
|
? "auto"
|
|
: endpoints.targetPane.filenameEncoding || "auto";
|
|
try {
|
|
await deleteTargetPath(
|
|
affectedTask,
|
|
endpoints.targetPane,
|
|
targetSftpId,
|
|
targetEncoding,
|
|
"symlink",
|
|
);
|
|
} catch (error) {
|
|
failedReplaceTasks.push({
|
|
task: affectedTask,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
updatedTask = {
|
|
...affectedTask,
|
|
skipConflictCheck: true,
|
|
replaceExistingTarget: true,
|
|
};
|
|
} else if (action === "merge") {
|
|
updatedTask = {
|
|
...affectedTask,
|
|
skipConflictCheck: true,
|
|
replaceExistingTarget: false,
|
|
};
|
|
}
|
|
|
|
updatedTasks.push(updatedTask);
|
|
}
|
|
|
|
if (blockedReplaceTasks.length > 0) {
|
|
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
|
|
const blockedErrors = new Map(
|
|
blockedReplaceTasks.map(({ task, conflict }) => [
|
|
task.id,
|
|
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
|
]),
|
|
);
|
|
setTransfers((prev) =>
|
|
prev.map((t) => blockedTaskIds.has(t.id)
|
|
? {
|
|
...t,
|
|
status: "failed" as TransferStatus,
|
|
endTime: Date.now(),
|
|
error: blockedErrors.get(t.id),
|
|
retryable: false,
|
|
conflict: undefined,
|
|
}
|
|
: t,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (failedReplaceTasks.length > 0) {
|
|
const failedErrors = new Map(
|
|
failedReplaceTasks.map(({ task: failedTask, error }) => [failedTask.id, error]),
|
|
);
|
|
setTransfers((prev) =>
|
|
prev.map((candidate) => failedErrors.has(candidate.id)
|
|
? {
|
|
...candidate,
|
|
status: "failed" as TransferStatus,
|
|
endTime: Date.now(),
|
|
error: failedErrors.get(candidate.id),
|
|
conflict: undefined,
|
|
}
|
|
: candidate,
|
|
),
|
|
);
|
|
}
|
|
|
|
const liveUpdatedTasks = updatedTasks.filter((candidate) => !cancelledTasksRef.current.has(candidate.id));
|
|
const updatedTaskMap = new Map(liveUpdatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
|
setTransfers((prev) =>
|
|
prev.map((t) => {
|
|
if (t.status === "cancelled" || cancelledTasksRef.current.has(t.id)) return t;
|
|
const updatedTask = updatedTaskMap.get(t.id);
|
|
return updatedTask
|
|
? { ...updatedTask, status: "pending" as TransferStatus, conflict: undefined }
|
|
: t;
|
|
}),
|
|
);
|
|
|
|
for (const updatedTask of liveUpdatedTasks) {
|
|
const identity = captureDeferredTransferAttempt(
|
|
updatedTask,
|
|
ownerId,
|
|
connectionCacheKeyMapRef.current,
|
|
);
|
|
const resolveCurrentAttempt = () => {
|
|
const current = transfersRef.current.find((candidate) => candidate.id === updatedTask.id);
|
|
if (
|
|
cancelledTasksRef.current.has(updatedTask.id)
|
|
|| !isDeferredTransferAttemptCurrent(current, identity, connectionCacheKeyMapRef.current)
|
|
) return null;
|
|
const endpoints = resolveTaskEndpoints(current!);
|
|
if (!endpoints?.sourcePane.connection || !endpoints.targetPane.connection) return null;
|
|
const sourceMatches = current!.sourceConnectionId === "local"
|
|
? endpoints.sourcePane.connection.isLocal
|
|
: endpoints.sourcePane.connection.id === current!.sourceConnectionId;
|
|
const targetMatches = current!.targetConnectionId === "local"
|
|
? endpoints.targetPane.connection.isLocal
|
|
: endpoints.targetPane.connection.id === current!.targetConnectionId;
|
|
if (!sourceMatches || !targetMatches) return null;
|
|
return { current: current!, endpoints };
|
|
};
|
|
deferredConflictAttemptsRef.current?.schedule(
|
|
updatedTask.id,
|
|
100,
|
|
() => resolveCurrentAttempt() !== null,
|
|
async () => {
|
|
const attempt = resolveCurrentAttempt();
|
|
if (!attempt) return;
|
|
await processTransfer(
|
|
attempt.current,
|
|
attempt.endpoints.sourcePane,
|
|
attempt.endpoints.targetPane,
|
|
attempt.endpoints.targetSide,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
|
|
[
|
|
completeCancelledTask,
|
|
connectionCacheKeyMapRef,
|
|
conflictDefaultKey,
|
|
getDuplicateTarget,
|
|
markBatchStopped,
|
|
ownerId,
|
|
resolveTaskEndpoints,
|
|
sftpSessionsRef,
|
|
],
|
|
);
|
|
|
|
// Only work that is still moving or queued — paused rows must not keep the
|
|
// header "N active" lit or pause feels like it did nothing.
|
|
const activeTransfersCount = useMemo(() => transfers.filter(
|
|
(t) => !t.parentTaskId
|
|
&& ["pending", "queued", "transferring", "pausing"].includes(t.status),
|
|
).length, [transfers]);
|
|
|
|
const downloadToLocal = useCallback(
|
|
async (params: {
|
|
fileName: string;
|
|
sourcePath: string;
|
|
targetPath: string;
|
|
sftpId: string;
|
|
connectionId: string;
|
|
sourceHostId: string;
|
|
sourceHostLabel: string;
|
|
sourceEncoding?: SftpFilenameEncoding;
|
|
isDirectory: boolean;
|
|
totalBytes?: number;
|
|
}): Promise<TransferStatus> => {
|
|
// Same destination concurrent writers corrupt .part / final files.
|
|
// Must pass the local endpoint identity — createDirectDownloadTransferTask
|
|
// stores targetConnectionId:"local"; a bare { targetPath } never matches
|
|
// isLocalTransferDestination and silently skips dedupe (double download).
|
|
const downloadDest = {
|
|
targetPath: params.targetPath,
|
|
targetConnectionId: "local" as const,
|
|
targetHostLabel: "Local",
|
|
isDirectory: params.isDirectory,
|
|
};
|
|
const conflict = findActivePathConflict(
|
|
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current],
|
|
{ id: "", ...downloadDest },
|
|
);
|
|
if (conflict) {
|
|
notifyPathConflict(conflict, tRef.current);
|
|
return conflict.status === "completed" ? "completed" : "attention";
|
|
}
|
|
|
|
const task = createDirectDownloadTransferTask({
|
|
id: crypto.randomUUID(),
|
|
fileName: params.fileName,
|
|
sourcePath: params.sourcePath,
|
|
targetPath: params.targetPath,
|
|
sourceConnectionId: params.connectionId,
|
|
sourceHostId: params.sourceHostId,
|
|
sourceHostLabel: params.sourceHostLabel,
|
|
totalBytes: params.totalBytes ?? 0,
|
|
isDirectory: params.isDirectory,
|
|
});
|
|
|
|
// Reserve the path in transfersRef before any await so concurrent
|
|
// downloadToLocal calls (double-click) see each other.
|
|
setTransfers((prev) => {
|
|
const raced = findActivePathConflict(
|
|
[...sftpTransferCenterStore.getSnapshot().tasks, ...prev],
|
|
{ id: task.id, ...downloadDest },
|
|
);
|
|
if (raced) return prev;
|
|
return [...prev, task];
|
|
});
|
|
if (!transfersRef.current.some((row) => row.id === task.id)) {
|
|
notifyPathConflict(
|
|
findActivePathConflict(
|
|
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current],
|
|
{ id: "", ...downloadDest },
|
|
) ?? task,
|
|
tRef.current,
|
|
);
|
|
return "attention";
|
|
}
|
|
|
|
const patchDownload = (updates: Partial<TransferTask>) => {
|
|
transferRuntime.patchTask(task.id, updates);
|
|
const canonical = transferRuntime.getTask(task.id);
|
|
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
|
|
};
|
|
const executeDownload = async (): Promise<TransferStatus> => {
|
|
const sourceEncoding = params.sourceEncoding ?? "auto";
|
|
// Mutable counter to track child failures outside React state,
|
|
// so the final status check doesn't depend on render timing.
|
|
let childFailureCount = 0;
|
|
|
|
// Dedicated pool only when host id is known — never fall back to browse
|
|
// (tab close would kill the download and freeze the global center).
|
|
let sourceWorkLease: TransferConnectionLease | null = null;
|
|
let workingSourceSftpId = params.sftpId;
|
|
try {
|
|
if (acquireTransferSession && params.sourceHostId) {
|
|
sourceWorkLease = await acquireTransferSession(
|
|
params.sourceHostId,
|
|
`${task.id}:work-source`,
|
|
);
|
|
workingSourceSftpId = sourceWorkLease.sftpId;
|
|
}
|
|
await waitUntilTransferResumed(task.id);
|
|
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
|
|
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
|
|
|
|
if (params.isDirectory) {
|
|
childFailureCount = await transferDirectory(
|
|
task,
|
|
workingSourceSftpId,
|
|
null, // targetSftpId = null (local)
|
|
false, // sourceIsLocal = false
|
|
true, // targetIsLocal = true
|
|
sourceEncoding,
|
|
"auto", // targetEncoding
|
|
task.id,
|
|
false, // sameHost
|
|
0, // symlinkDepth
|
|
true, // followSymlinks — download should expand symlink dirs
|
|
);
|
|
} else {
|
|
await transferFile(
|
|
task,
|
|
workingSourceSftpId,
|
|
null,
|
|
false,
|
|
true,
|
|
sourceEncoding,
|
|
"auto",
|
|
task.id,
|
|
);
|
|
}
|
|
|
|
// Runtime owns lifecycle while the walk is registered. Publish the
|
|
// final root there before mirroring it to the panel.
|
|
const rows = sftpTransferCenterStore.getSnapshot().tasks;
|
|
const liveParent = transferRuntime.getTask(task.id) ?? task;
|
|
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
|
|
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
|
|
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
|
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
|
|
childFailureCount,
|
|
});
|
|
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
|
|
patchDownload({
|
|
status: resolved.status,
|
|
error: resolved.error,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
...(resolved.status === "cancelled" ? {} : {
|
|
totalBytes: finalTotal,
|
|
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
|
|
}),
|
|
});
|
|
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
|
|
activeChildIdsRef.current.delete(task.id);
|
|
return appliedStatus;
|
|
} catch (err) {
|
|
activeChildIdsRef.current.delete(task.id);
|
|
const isCancelled = cancelledTasksRef.current.has(task.id);
|
|
// Clean up cancelled task tracking to prevent memory leak
|
|
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
patchDownload({
|
|
status: isCancelled ? "cancelled" : "failed",
|
|
error: isCancelled ? undefined : errMsg,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
});
|
|
return isCancelled ? "cancelled" : "failed";
|
|
} finally {
|
|
sourceWorkLease?.release();
|
|
sourceWorkLease = null;
|
|
}
|
|
};
|
|
let result: TransferStatus = "failed";
|
|
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
|
|
transferRuntime.runWalk(task.id, async () => {
|
|
result = await executeDownload();
|
|
}),
|
|
);
|
|
return result;
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[sftpSessionsRef, acquireTransferSession],
|
|
);
|
|
|
|
// Publish only on owner change / mount. Do NOT re-publish on every `transfers`
|
|
// React state change: progress flushes use setTransfersState without
|
|
// publishOwner, and a transfers→publishOwner effect reintroduced the full
|
|
// store merge + emit on every tick (hidden retained SFTP panels still
|
|
// remount useSftpTransfers after close).
|
|
// Lifecycle writers already call setTransfers → publishOwner.
|
|
useEffect(() => {
|
|
sftpTransferCenterStore.publishOwner(ownerId, transfersRef.current);
|
|
}, [ownerId]);
|
|
|
|
// The store is the only transfer-task authority. Lifecycle changes update the
|
|
// panel list; byte-only ticks update refs without repainting the whole panel.
|
|
useEffect(() => {
|
|
return sftpTransferCenterStore.subscribe(() => {
|
|
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
|
|
transfersRef.current = ownerRows;
|
|
setTransfersState(ownerRows);
|
|
});
|
|
}, [ownerId]);
|
|
|
|
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
|
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
|
transfersRef.current = transfersRef.current.map((task) => liveById.get(task.id) ?? task);
|
|
}), []);
|
|
|
|
const resolveAdoptionPanes = useCallback((task: TransferTask) => {
|
|
const panes = [getActivePane("left"), getActivePane("right")].filter((pane): pane is SftpPane => !!pane?.connection);
|
|
const sourcePane = panes.find((pane) => task.sourceHostId
|
|
? pane.connection?.hostId === task.sourceHostId
|
|
: pane.connection?.isLocal);
|
|
const targetPane = panes.find((pane) => task.targetHostId
|
|
? pane.connection?.hostId === task.targetHostId
|
|
: pane.connection?.isLocal);
|
|
// Downloads to local only need the remote source; local pane is optional for
|
|
// matching but preferred so processTransfer can run dual-endpoint checks.
|
|
if (sourcePane && targetPane) return { sourcePane, targetPane };
|
|
if (sourcePane && task.sourceHostId && !task.targetHostId) {
|
|
const localPane = panes.find((pane) => pane.connection?.isLocal);
|
|
if (localPane) return { sourcePane, targetPane: localPane };
|
|
}
|
|
if (targetPane && task.targetHostId && !task.sourceHostId) {
|
|
const localPane = panes.find((pane) => pane.connection?.isLocal);
|
|
if (localPane) return { sourcePane: localPane, targetPane };
|
|
}
|
|
return null;
|
|
}, [getActivePane]);
|
|
|
|
const adoptInterruptedTransfer = useCallback(async (task: TransferTask) => {
|
|
const panes = resolveAdoptionPanes(task);
|
|
if (!panes?.sourcePane.connection || !panes.targetPane.connection) return;
|
|
// Keep checkpoint / fingerprint fields so resume restarts from the saved byte offset.
|
|
const hasConflict = !!task.conflict;
|
|
const isDirectoryResume = !!task.isDirectory && !hasConflict;
|
|
const adoptedTask: TransferTask = {
|
|
...task,
|
|
ownerId,
|
|
sourceConnectionId: panes.sourcePane.connection.id,
|
|
targetConnectionId: panes.targetPane.connection.isLocal
|
|
? (task.targetConnectionId === "local" ? "local" : panes.targetPane.connection.id)
|
|
: panes.targetPane.connection.id,
|
|
// Conflict rows stay in attention until resolveConflict applies the action.
|
|
// Other reconnects stay "pending" + reconnectRequired for the spinner.
|
|
// Directory resume: skip re-prompting conflict when the target already
|
|
// exists from the first attempt (merge/continue semantics).
|
|
status: hasConflict ? "attention" : "pending",
|
|
reconnectRequired: !hasConflict,
|
|
error: hasConflict ? task.error : undefined,
|
|
speed: 0,
|
|
// Preserve replace/staging flags so resume keeps the original conflict
|
|
// choice; only force skipConflict when continuing a directory resume.
|
|
skipConflictCheck: isDirectoryResume ? true : task.skipConflictCheck,
|
|
replaceExistingTarget: isDirectoryResume
|
|
? !!task.replaceExistingTarget
|
|
: task.replaceExistingTarget,
|
|
stagedTargetPath: task.stagedTargetPath,
|
|
};
|
|
// Re-home orphaned children under this parent so directory resume can skip
|
|
// already-completed files using persisted checkpoints (same ownerId).
|
|
const storeChildren = sftpTransferCenterStore.getSnapshot().tasks.filter(
|
|
(candidate) => candidate.parentTaskId === task.id,
|
|
);
|
|
const rehomedChildren = storeChildren.map((child) => ({
|
|
...child,
|
|
ownerId,
|
|
status: (child.status === "completed" || child.status === "cancelled" || child.status === "failed")
|
|
? child.status
|
|
: "interrupted" as TransferStatus,
|
|
reconnectRequired: child.status !== "completed" && child.status !== "cancelled" && child.status !== "failed",
|
|
}));
|
|
const nextTransfers = [
|
|
...transfersRef.current.filter(
|
|
(candidate) => candidate.id !== task.id && candidate.parentTaskId !== task.id,
|
|
),
|
|
adoptedTask,
|
|
...rehomedChildren,
|
|
];
|
|
setTransfers(nextTransfers);
|
|
if (task.conflict) {
|
|
// Keep conflictsRef in sync immediately — store.resolveConflict may call
|
|
// controller.resolveConflict in the same turn before React re-renders.
|
|
const nextConflicts = [
|
|
...conflictsRef.current.filter((item) => item.transferId !== task.id),
|
|
task.conflict,
|
|
];
|
|
conflictsRef.current = nextConflicts;
|
|
setConflicts(nextConflicts);
|
|
// Do not auto-resume — caller will apply the chosen conflict action.
|
|
return;
|
|
}
|
|
// Cancel may have won while we were rehoming ownership.
|
|
if (cancelledTasksRef.current.has(task.id)) return;
|
|
// Force resumeTransfer to accept pending (reconnect path) statuses.
|
|
await resumeTransfer(task.id);
|
|
}, [ownerId, resolveAdoptionPanes, resumeTransfer, setTransfers]);
|
|
|
|
const syncOwnedTasksFromStore = useCallback(() => {
|
|
const snap = sftpTransferCenterStore.getSnapshot().tasks;
|
|
setTransfers((prev) => {
|
|
let changed = false;
|
|
const next = prev.map((row) => {
|
|
const storeRow = snap.find((candidate) => candidate.id === row.id);
|
|
if (!storeRow) return row;
|
|
if (
|
|
storeRow.status === row.status
|
|
&& storeRow.speed === row.speed
|
|
&& (storeRow.checkpointBytes ?? 0) === (row.checkpointBytes ?? 0)
|
|
&& (storeRow.transferredBytes ?? 0) === (row.transferredBytes ?? 0)
|
|
&& storeRow.pauseUnavailableReason === row.pauseUnavailableReason
|
|
&& storeRow.reconnectRequired === row.reconnectRequired
|
|
&& storeRow.error === row.error
|
|
&& storeRow.lifecycleEpoch === row.lifecycleEpoch
|
|
) {
|
|
return row;
|
|
}
|
|
changed = true;
|
|
return {
|
|
...row,
|
|
status: storeRow.status,
|
|
speed: storeRow.speed,
|
|
checkpointBytes: storeRow.checkpointBytes,
|
|
transferredBytes: storeRow.transferredBytes,
|
|
pauseUnavailableReason: storeRow.pauseUnavailableReason,
|
|
reconnectRequired: storeRow.reconnectRequired,
|
|
error: storeRow.error,
|
|
phase: storeRow.phase,
|
|
resumeStage: storeRow.resumeStage,
|
|
downloadCheckpointBytes: storeRow.downloadCheckpointBytes,
|
|
uploadCheckpointBytes: storeRow.uploadCheckpointBytes,
|
|
sourceFingerprint: storeRow.sourceFingerprint,
|
|
lifecycleEpoch: storeRow.lifecycleEpoch,
|
|
};
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [setTransfers]);
|
|
|
|
useEffect(() => sftpTransferCenterStore.registerOwner(ownerId, {
|
|
// Soft pause/resume: TransferRuntime (process-global). Owner registration is
|
|
// view/sync + cancel/retry/adopt only — not control authority for live walks.
|
|
pause: async (taskId: string) => {
|
|
await transferRuntime.pause(taskId);
|
|
},
|
|
resume: async (taskId: string) => {
|
|
// Use resumeTransfer so cancel latches are cleared before soft/hard resume.
|
|
await resumeTransfer(taskId);
|
|
},
|
|
cancel: cancelTransfer,
|
|
retry: retryTransfer,
|
|
prioritize: prioritizeTransfer,
|
|
dismiss: dismissTransfer,
|
|
dismissMany: dismissTransfers,
|
|
resolveConflict,
|
|
// Origin/local listing only — soft pause/resume must not key on this.
|
|
ownsTask: (taskId: string) => transfersRef.current.some(
|
|
(row) => row.id === taskId || row.parentTaskId === taskId,
|
|
),
|
|
syncOwnedTasks: syncOwnedTasksFromStore,
|
|
canAdopt: (task) => resolveAdoptionPanes(task) !== null,
|
|
canPrepareAdoption,
|
|
adopt: adoptInterruptedTransfer,
|
|
}), [adoptInterruptedTransfer, canPrepareAdoption, cancelTransfer, dismissTransfer, dismissTransfers, ownerId, prioritizeTransfer, resolveAdoptionPanes, resolveConflict, resumeTransfer, retryTransfer, syncOwnedTasksFromStore]);
|
|
|
|
return {
|
|
transfers,
|
|
conflicts,
|
|
activeTransfersCount,
|
|
startTransfer,
|
|
downloadToLocal,
|
|
cancelTransfer,
|
|
// Single process-level control surface (TransferRuntime).
|
|
pauseTransfer: async (transferId: string) => {
|
|
await transferRuntime.pause(transferId);
|
|
},
|
|
resumeTransfer,
|
|
prioritizeTransfer,
|
|
isTransferCancelled,
|
|
retryTransfer,
|
|
clearCompletedTransfers,
|
|
dismissTransfer,
|
|
resolveConflict,
|
|
};
|
|
};
|