import { useRef, useSyncExternalStore } from "react"; import type { FileConflictAction, TransferTask } from "../../domain/models"; import { deserializeSftpTransferCenter, pruneSftpTransferHistory, serializeSftpTransferCenter, } from "../../domain/sftpTransferCenter"; import { findActivePathConflict, pathConflictMessage, } from "../../domain/sftpTransferConflicts"; import { STORAGE_KEY_SFTP_TRANSFER_CENTER } from "../../infrastructure/config/storageKeys"; import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter"; import { netcattyBridge } from "../../infrastructure/services/netcattyBridge"; import { notify } from "../notification"; import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler"; import { isTransferOrRootPauseLatched, isTransferPauseLatched, releaseTransferPauseTree, } from "./sftp/transferPauseLatch"; import { bumpTransferControlEpoch, getTransferControlEpoch, isTransferControlEpochCurrent, settleTransferControlEpochTree, } from "./sftp/transferControlEpoch"; import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry"; import { isTransferOrRootCancelled, markTransferCancelledTree, settleTransferCancelTree, } from "./sftp/transferCancelLatch"; import { defaultTransferControlBridge, softPauseTransfer, softResumeTransfer, type TransferControlHost, } from "./sftp/globalSftpTransferControl"; import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration"; import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime"; type Listener = () => void; // Ordinary bounded history restores synchronously so existing callers receive // it immediately. Legacy directory snapshots can contain tens of thousands of // rows, so they migrate after first paint in cooperative slices. const ASYNC_TRANSFER_HISTORY_RESTORE_THRESHOLD_BYTES = 512 * 1024; export interface SftpTransferOwnerControls { pause: (taskId: string) => void | Promise; resume: (taskId: string) => void | Promise; cancel: (taskId: string) => void | Promise; retry: (taskId: string) => void | Promise; prioritize: (taskId: string) => void | Promise; dismiss: (taskId: string, task?: TransferTask) => void; /** Remove a pruned group in one owner update to avoid publish/dismiss feedback loops. */ dismissMany?: (tasks: readonly TransferTask[]) => void; /** * True when the panel still lists this task locally. * Used only for adopt/retry UX after hard reconnect — never for soft * pause/resume (those are process-global via TransferRuntime / store). */ ownsTask?: (taskId: string) => boolean; /** Pull lifecycle fields from the store after unified soft-control. */ syncOwnedTasks?: () => void; canAdopt?: (task: TransferTask) => boolean; canPrepareAdoption?: boolean; adopt?: (task: TransferTask) => void | Promise; resolveConflict?: (taskId: string, action: FileConflictAction, applyToAll?: boolean) => void | Promise; } export interface SftpTransferCenterSnapshot { tasks: readonly TransferTask[]; activeCount: number; queuedCount: number; attentionCount: number; } export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{ success: boolean; error?: string; needsAttention?: boolean; resetCheckpoint?: boolean; }>; export interface SftpTransferCenterStore { subscribe(listener: Listener): () => void; /** * Progress-only fanout. Pure byte ticks must not wake lifecycle subscribers * (owner foreign-id sync, badge, closed popover getters). */ subscribeProgress(listener: Listener): () => void; getSnapshot(): SftpTransferCenterSnapshot; /** Stable identity when badge counts are unchanged (avoids TopTabs thrash). */ getBadgeSnapshot(): { count: number; hasAttention: boolean }; /** Single task row — same object identity until that row is patched. */ getTask(taskId: string): TransferTask | undefined; /** Observe exact task settlement before completed child rows are compacted. */ observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void }; /** Publish an explicit dispatch identity without flushing large child-history batches. */ admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict"; getOwnerTasks(ownerId: string): TransferTask[]; publishOwner(ownerId: string, tasks: readonly TransferTask[]): void; registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void; setDedicatedResumeHandler(handler: DedicatedTransferResumeHandler | null): void; patchTask(taskId: string, updates: Partial): void; /** Insert or merge tasks by id (used by dedicated directory resume for children). */ upsertTasks(incoming: readonly TransferTask[]): void; canControl(taskId: string): boolean; subscribeResume(listener: Listener): () => void; isResuming(taskId: string): boolean; pause(taskId: string): Promise; resume(taskId: string): Promise; cancel(taskId: string): Promise; retry(taskId: string): Promise; prioritize(taskId: string): Promise; dismiss(taskId: string): void; clearTerminal(status?: TransferTask["status"]): void; markReconnectRequired(taskId: string, error?: string): void; reportResumePreparationFailure(taskId: string, error: string): void; ingestBackgroundEvent(event: { type: "queued" | "started" | "progress" | "pausing" | "paused" | "resumed" | "cancelled" | "completed" | "failed"; transferId: string; direction?: TransferTask["direction"]; fileName?: string; sourcePath?: string; targetPath?: string; startedAt?: number; endedAt?: number; error?: string; transferred?: number; totalBytes?: number; speed?: number; checkpointBytes?: number; resumeStage?: TransferTask["resumeStage"]; downloadCheckpointBytes?: number; uploadCheckpointBytes?: number; sourceFingerprint?: string; sessionId?: string; sourceHostId?: string; targetHostId?: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string; isDirectory?: boolean; controlKind?: TransferTask["controlKind"]; phase?: TransferTask["phase"]; lifecycleEpoch?: number; lifecycleState?: "queued" | "pausing" | "paused" | "transferring"; resumable?: boolean; pauseUnavailableReason?: string; }): void; resolveConflict(taskId: string, action: FileConflictAction, applyToAll?: boolean): Promise; } type BackgroundTransferEvent = Parameters[0]; interface StorePersistence { read(): string | null; write(value: string): void; } const EMPTY_SNAPSHOT: SftpTransferCenterSnapshot = { tasks: [], activeCount: 0, queuedCount: 0, attentionCount: 0, }; function buildSnapshot(tasks: readonly TransferTask[]): SftpTransferCenterSnapshot { const topLevelTasks = tasks.filter((task) => !task.parentTaskId); return { tasks, activeCount: topLevelTasks.filter((task) => task.status === "transferring" || task.status === "pausing").length, queuedCount: topLevelTasks.filter((task) => task.status === "pending" || task.status === "queued").length, attentionCount: topLevelTasks.filter((task) => task.status === "attention" || task.status === "failed").length, }; } function buildBadgeSnapshot(tasks: readonly TransferTask[]): { count: number; hasAttention: boolean } { const topLevelTasks = tasks.filter((task) => !task.parentTaskId); return { count: topLevelTasks.filter((task) => ["pending", "queued", "transferring", "pausing", "paused", "interrupted"].includes(task.status) ).length, hasAttention: topLevelTasks.some((task) => task.status === "attention" || task.status === "failed" || task.status === "interrupted" || task.reconnectRequired === true ), }; } const TERMINAL_OWNER_STATUSES = new Set([ "completed", "cancelled", "failed", ]); /** * Panel snapshots and main-process progress IPC both write the same rows. * Never let a stale panel paint roll back bytes that background events already * advanced (tab-close / hide dual-writer race freezes the global center bar). * Also never resurrect completed/cancelled store rows from a late transferring * panel snapshot (ingest already guards terminal; publishOwner must too). */ export function mergeOwnerPublishedTask( existing: TransferTask, incoming: TransferTask, ownerId: string, parentStatus?: TransferTask["status"], ): TransferTask { // Explicit restart / re-queue may reset progress to 0 — allow that. const incomingReset = ( (incoming.status === "pending" || incoming.status === "queued") && (incoming.transferredBytes ?? 0) === 0 && (incoming.checkpointBytes ?? 0) === 0 ); const incomingTerminal = TERMINAL_OWNER_STATUSES.has(incoming.status); // completed/cancelled stick on the store. A late panel "transferring" paint // must not un-complete after ingestBackgroundEvent(completed|cancelled). // failed is intentionally not sticky: same-id checkpoint resume re-opens it. if ( (existing.status === "completed" || existing.status === "cancelled") && !incomingTerminal && !incomingReset ) { return { ...existing, ownerId, updatedAt: existing.updatedAt ?? Date.now(), }; } // Prefer cancelled over a late completed when both sides are terminal. if ( existing.status === "cancelled" && incoming.status === "completed" ) { return { ...existing, ownerId, updatedAt: existing.updatedAt ?? Date.now(), }; } const merged: TransferTask = { ...incoming, ownerId, updatedAt: incoming.updatedAt ?? Date.now(), }; if (incomingReset || incomingTerminal) { return merged; } // Keep higher water marks while either side still treats the row as live work. const existingLive = !TERMINAL_OWNER_STATUSES.has(existing.status); const incomingLive = !TERMINAL_OWNER_STATUSES.has(incoming.status); if (existingLive && incomingLive) { const existingBytes = existing.transferredBytes ?? 0; const incomingBytes = incoming.transferredBytes ?? 0; if (existingBytes > incomingBytes) { merged.transferredBytes = existingBytes; // Prefer the fresher non-zero speed from either side when bytes stall. if (!(Number.isFinite(merged.speed) && merged.speed > 0) && existing.speed > 0) { merged.speed = existing.speed; } } const existingCheckpoint = existing.checkpointBytes ?? 0; const incomingCheckpoint = incoming.checkpointBytes ?? 0; if (existingCheckpoint > incomingCheckpoint) { merged.checkpointBytes = existingCheckpoint; } if ((existing.totalBytes ?? 0) > (incoming.totalBytes ?? 0)) { merged.totalBytes = existing.totalBytes; } const existingLifecycleEpoch = Number.isFinite(existing.lifecycleEpoch) ? (existing.lifecycleEpoch as number) : -1; const incomingLifecycleEpoch = Number.isFinite(incoming.lifecycleEpoch) ? (incoming.lifecycleEpoch as number) : -1; if (existingLifecycleEpoch > incomingLifecycleEpoch) { merged.lifecycleEpoch = existing.lifecycleEpoch; merged.status = existing.status; if (existing.status === "paused" || existing.status === "pausing") { merged.transferredBytes = existing.transferredBytes; merged.checkpointBytes = existing.checkpointBytes; merged.speed = 0; } } // Background progress re-opens transferring; don't let a frozen panel // snapshot paint a non-terminal lower status over a live transferring row // when bytes moved forward on the store. if ( existing.status === "transferring" && (incoming.status === "pending" || incoming.status === "queued") && (merged.transferredBytes ?? 0) > 0 ) { merged.status = "transferring"; } // Intentional pause from the panel must always win over soft-drain water // marks. Freezing transferredBytes on pause used to leave store bytes ahead // of the panel snapshot; the old "higher bytes keep transferring" rule then // rejected pause and the global transfer center looked stuck on 传输中. // Only a strictly newer lifecycle epoch may re-open transferring (resume). const incomingPaused = incoming.status === "paused" || incoming.status === "pausing"; const existingPaused = existing.status === "paused" || existing.status === "pausing"; if (incomingPaused && incomingLifecycleEpoch >= existingLifecycleEpoch) { merged.status = incoming.status; merged.speed = 0; // Pin the visible bar while paused/latched. Soft-drain must not keep // raising transferredBytes after the user paused (checkpoint may still rise). const latched = isTransferOrRootPauseLatched( existing.parentTaskId ?? existing.id, existing.id, ) || isTransferPauseLatched(existing.id); if (latched || existingPaused) { merged.transferredBytes = existingBytes; } else { merged.transferredBytes = Math.max(existingBytes, incomingBytes); } if (existingCheckpoint > 0 || incomingCheckpoint > 0) { merged.checkpointBytes = Math.max(existingCheckpoint, incomingCheckpoint); } } else if ( existingPaused && (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued") // Only a strictly older epoch is a stale snapshot. Equal/missing epochs must // allow intentional Resume (publishOwner after unlatch) to re-open the row. && incomingLifecycleEpoch < existingLifecycleEpoch ) { // Soft-drain / late progress publish must not un-pause without resume epoch. merged.status = existing.status; merged.speed = 0; merged.transferredBytes = existing.transferredBytes; merged.checkpointBytes = existing.checkpointBytes ?? merged.checkpointBytes; } // Folder parent already paused/pausing: never re-open a child as live from a // late panel paint. That made the collapsed "current file" row blink in/out. const parentHoldsPause = parentStatus === "paused" || parentStatus === "pausing"; if ( parentHoldsPause && !["completed", "cancelled", "failed"].includes(merged.status) && (merged.status === "transferring" || merged.status === "pending" || merged.status === "queued") ) { merged.status = parentStatus === "pausing" ? "pausing" : "paused"; merged.speed = 0; } } if ( existing.directoryResumeCheckpoint && ( !merged.directoryResumeCheckpoint || merged.directoryResumeCheckpoint.coveredEntries < existing.directoryResumeCheckpoint.coveredEntries ) ) { merged.directoryResumeCheckpoint = existing.directoryResumeCheckpoint; merged.transferredBytes = Math.max( merged.transferredBytes ?? 0, existing.directoryResumeCheckpoint.completedEntries, ); } return merged; } function areTransferTasksEquivalent(left: TransferTask, right: TransferTask): boolean { const leftRecord = left as unknown as Record; const rightRecord = right as unknown as Record; const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); for (const key of keys) { // updatedAt is bookkeeping, not a visible or durable transfer change. if (key === "updatedAt") continue; if (!Object.is(leftRecord[key], rightRecord[key])) return false; } return true; } export function createSftpTransferCenterStore(persistence?: StorePersistence): SftpTransferCenterStore { const persistedRaw = persistence?.read() ?? null; const shouldRestoreCooperatively = ( typeof persistedRaw === "string" && persistedRaw.length >= ASYNC_TRANSFER_HISTORY_RESTORE_THRESHOLD_BYTES ); const restored = shouldRestoreCooperatively ? { tasks: [] } : deserializeSftpTransferCenter(persistedRaw); let tasks = pruneSftpTransferHistory(restored.tasks); let restoreMigrationPending = shouldRestoreCooperatively; let replayingRestoreEvents = false; let deferRestoreReplayEmits = false; let restoreEventSequence = 0; const backgroundEventsDuringRestore = new Map>(); let snapshot = tasks.length > 0 ? buildSnapshot(tasks) : EMPTY_SNAPSHOT; let badgeSnapshot = buildBadgeSnapshot(tasks); let snapshotDirty = false; const listeners = new Set(); const progressListeners = new Set(); const settlementObservers = new Map>(); const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => ( expected.id === candidate.id && expected.sourcePath === candidate.sourcePath && expected.targetPath === candidate.targetPath && expected.parentTaskId === candidate.parentTaskId && expected.directoryEntryIndex === candidate.directoryEntryIndex && expected.directoryEntryIdentity === candidate.directoryEntryIdentity ); const refreshBadgeSnapshot = () => { const next = buildBadgeSnapshot(tasks); if ( next.count === badgeSnapshot.count && next.hasAttention === badgeSnapshot.hasAttention ) { return; } badgeSnapshot = next; }; const controllers = new Map(); const lastPublishedByOwner = new Map>(); const PERSIST_INTERVAL_MS = 250; // Progress ticks must not touch localStorage — see emitProgress. Pause/complete // and other lifecycle emits still persist immediately via emit()/persist(true). let lastPersistedAt = 0; let persistenceDirty = false; let persistenceTimer: ReturnType | null = null; const resumeInvocations = new Map>(); // Ephemeral control intent: never serialize this or change bridge lifecycle epochs. const resumeRequests = new Map(); const resumeListeners = new Set(); const notifyResume = () => resumeListeners.forEach((listener) => listener()); const clearResumeRequest = (taskId: string) => { if (resumeRequests.delete(taskId)) notifyResume(); }; const resumePreparationFailures = new Map(); let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null; const dedicatedResumeWaiters = new Set<{ taskId: string; resolve: () => void; timer: ReturnType; }>(); const notifyDedicatedResumeWaiters = () => { for (const waiter of dedicatedResumeWaiters) { const current = tasks.find((task) => task.id === waiter.taskId); if ( !dedicatedResumeHandler && current && current.status !== "cancelled" && current.status !== "completed" ) continue; clearTimeout(waiter.timer); dedicatedResumeWaiters.delete(waiter); waiter.resolve(); } }; const waitForDedicatedResumeHandler = (taskId: string) => new Promise((resolve) => { if (dedicatedResumeHandler) { resolve(); return; } const waiter = { taskId, resolve, // Vault unlock + first host load can exceed a few seconds after force-quit. timer: setTimeout(() => { dedicatedResumeWaiters.delete(waiter); resolve(); }, 15000), }; dedicatedResumeWaiters.add(waiter); }); const writePersistence = () => { if (!persistence || !persistenceDirty || restoreMigrationPending) return; persistenceDirty = false; try { persistence.write(serializeSftpTransferCenter(tasks)); } catch (error) { // Transfer history is recoverability metadata. Storage exhaustion or a // transient browser storage failure must never take down the renderer. console.warn("[SFTP] Could not persist transfer history", error); } finally { // Measure the interval from completion, since stringify/setItem itself can // be expensive for a large directory snapshot. lastPersistedAt = Date.now(); } }; const persist = (immediate = false) => { if (!persistence) return; persistenceDirty = true; // Never replace a valid legacy snapshot with the initially empty store. // Mutations that happen during migration are merged before the final write. if (restoreMigrationPending) return; const elapsed = Date.now() - lastPersistedAt; if (immediate || lastPersistedAt === 0 || elapsed >= PERSIST_INTERVAL_MS) { if (persistenceTimer) { clearTimeout(persistenceTimer); persistenceTimer = null; } writePersistence(); return; } if (persistenceTimer) return; persistenceTimer = setTimeout(() => { persistenceTimer = null; writePersistence(); }, PERSIST_INTERVAL_MS - elapsed); }; const notifyOwnersOfPrunedTasks = (removed: readonly TransferTask[]) => { if (removed.length === 0) return; const byOwner = new Map(); for (const task of removed) { if (!task.ownerId) continue; const ownerTasks = byOwner.get(task.ownerId) ?? []; ownerTasks.push(task); byOwner.set(task.ownerId, ownerTasks); } for (const [ownerId, ownerTasks] of byOwner) { const controller = controllers.get(ownerId); if (!controller) continue; if (controller.dismissMany) { controller.dismissMany(ownerTasks); } else { for (const task of ownerTasks) controller.dismiss(task.id, task); } } }; const hasCriticalPersistenceChange = ( previous: readonly TransferTask[], next: readonly TransferTask[], ) => { const previousById = new Map(previous.map((task) => [task.id, task])); for (const task of next) { const before = previousById.get(task.id); if (task.sourceFingerprint !== before?.sourceFingerprint) return true; if (task.directoryResumeCheckpoint !== before?.directoryResumeCheckpoint) return true; if (task.status === "paused" || task.status === "pausing") { if ( task.status !== before?.status || task.checkpointBytes !== before?.checkpointBytes || task.resumeStage !== before?.resumeStage || task.downloadCheckpointBytes !== before?.downloadCheckpointBytes || task.uploadCheckpointBytes !== before?.uploadCheckpointBytes ) return true; } if ( !task.parentTaskId && task.status !== before?.status && (task.status === "completed" || task.status === "failed" || task.status === "cancelled") ) return true; } return false; }; // Progress ticks used to call every listener synchronously (React transfer // center + any owner sync). Coalesce to one paint per animation frame so we // do not add another 200–500ms of lag on top of the main-process throttle // (stacked delays made the bar jump and feel frozen). let progressNotifyHandle: ReturnType | number | null = null; const cancelScheduledProgressNotify = () => { if (progressNotifyHandle == null) return; if (typeof globalThis.cancelAnimationFrame === "function" && typeof progressNotifyHandle === "number") { globalThis.cancelAnimationFrame(progressNotifyHandle); } else { clearTimeout(progressNotifyHandle as ReturnType); } progressNotifyHandle = null; }; const scheduleProgressNotify = () => { if (progressNotifyHandle != null) return; const run = () => { progressNotifyHandle = null; for (const listener of progressListeners) listener(); }; if (typeof globalThis.requestAnimationFrame === "function") { progressNotifyHandle = globalThis.requestAnimationFrame(run); } else { progressNotifyHandle = setTimeout(run, 16); } }; const ensureSnapshot = () => { if (!snapshotDirty) return snapshot; snapshot = buildSnapshot(tasks); snapshotDirty = false; return snapshot; }; const settleFinishedTransferControlState = ( candidates: readonly TransferTask[], allTasks: readonly TransferTask[] = tasks, ) => { const allTaskIds = new Set(allTasks.map((task) => task.id)); const childIdsByRoot = new Map(); for (const task of allTasks) { if (!task.parentTaskId) continue; const childIds = childIdsByRoot.get(task.parentTaskId) ?? []; childIds.push(task.id); childIdsByRoot.set(task.parentTaskId, childIds); } for (const task of candidates) { if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue; if (task.parentTaskId && allTaskIds.has(task.parentTaskId)) continue; if (isTransferWalkInFlight(task.id)) continue; const childIds = childIdsByRoot.get(task.id) ?? []; const relatedChildIds = settleTransferCancelTree(task.id, childIds); settleTransferControlEpochTree(task.id, relatedChildIds); } }; const emit = (persistImmediately = false) => { if (deferRestoreReplayEmits) { snapshotDirty = true; persistenceDirty = persistenceDirty || persistImmediately; return; } // Lifecycle / structural changes must paint immediately. Drop any pending // progress coalesce so we do not notify twice with a stale intermediate snapshot. cancelScheduledProgressNotify(); const shouldPersistImmediately = persistImmediately || hasCriticalPersistenceChange(snapshot.tasks, tasks); // Store terminal state is authoritative for background/detached work. A // live renderer walk keeps its controls until TransferRuntime.runWalk // settles; everything else can release its whole tree here. settleFinishedTransferControlState(tasks, tasks); if (settlementObservers.size > 0) { for (const task of tasks) { if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue; for (const observer of settlementObservers.get(task.id) ?? []) { if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task; } } } const beforePrune = tasks; tasks = pruneSftpTransferHistory(tasks); const retainedIds = new Set(tasks.map((task) => task.id)); const removed = beforePrune.filter((task) => !retainedIds.has(task.id)); if (removed.length > 0) { for (const [ownerId, published] of lastPublishedByOwner) { if (published.size === 0) continue; const retained = new Map( [...published].filter(([taskId]) => retainedIds.has(taskId)), ); if (retained.size === 0) lastPublishedByOwner.delete(ownerId); else if (retained.size !== published.size) lastPublishedByOwner.set(ownerId, retained); } } snapshotDirty = false; snapshot = buildSnapshot(tasks); refreshBadgeSnapshot(); persist(shouldPersistImmediately); notifyDedicatedResumeWaiters(); for (const listener of listeners) listener(); for (const listener of progressListeners) listener(); // Notify only after the store snapshot is final. Owners must remove the // whole group atomically; per-row callbacks republish the remaining stale // rows and recursively re-enter pruning for large completed epochs. notifyOwnersOfPrunedTasks(removed); }; /** Progress-only: mutate tasks, defer snapshot rebuild, wake progress UI only. */ const emitProgress = (persistImmediately = false) => { if (deferRestoreReplayEmits) { snapshotDirty = true; persistenceDirty = persistenceDirty || persistImmediately; return; } snapshotDirty = true; // Never JSON.stringify/localStorage on the pure-progress path. Sync storage // on large directory histories freezes CSS spinners every few seconds. // Pause / complete / fingerprint / lifecycle still force persist below. if (persistImmediately) { snapshotDirty = false; snapshot = buildSnapshot(tasks); refreshBadgeSnapshot(); persist(true); notifyDedicatedResumeWaiters(); cancelScheduledProgressNotify(); for (const listener of listeners) listener(); for (const listener of progressListeners) listener(); return; } notifyDedicatedResumeWaiters(); scheduleProgressNotify(); }; const findOwner = (taskId: string) => tasks.find((task) => task.id === taskId)?.ownerId; const isAlreadyCompactedCompletedChild = (candidate: TransferTask) => { if (candidate.status !== "completed" || !candidate.parentTaskId) return false; if (!Number.isSafeInteger(candidate.directoryEntryIndex)) return false; const parent = tasks.find((task) => task.id === candidate.parentTaskId); const covered = parent?.directoryResumeCheckpoint?.coveredEntries ?? 0; if ((candidate.directoryEntryIndex ?? covered) >= covered) return false; return !tasks.some((task) => ( task.parentTaskId === candidate.parentTaskId && task.directoryEntryIndex === candidate.directoryEntryIndex )); }; const findAdopter = (task: TransferTask) => [...controllers.entries()].find(([, controls]) => ( controls.adopt && controls.canAdopt?.(task) )); const prepareAdopter = async (task: TransferTask) => { let adopter = findAdopter(task); let preparationError: string | undefined; let cancelled = false; if (!adopter && typeof globalThis.window !== "undefined") { // Open the SFTP panel on the active terminal tab first so a preparer can // register, then ask it to reconnect the required hosts. globalThis.window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-target", { detail: { task, forResume: true }, })); // ~45s is enough for MFA/password prompts; longer felt like a hang. const maxAttempts = 90; let prepareDispatched = false; for (let attempt = 0; attempt < maxAttempts && !adopter && !preparationError; attempt += 1) { const currentTask = tasks.find((candidate) => candidate.id === task.id); if (!currentTask || ["cancelled", "completed"].includes(currentTask.status)) { cancelled = true; break; } preparationError = resumePreparationFailures.get(task.id); if (preparationError) break; const preparer = [...controllers.entries()].find(([, controls]) => controls.canPrepareAdoption); if (preparer && !prepareDispatched) { prepareDispatched = true; globalThis.window.dispatchEvent(new CustomEvent("netcatty:prepare-sftp-transfer-resume", { detail: { task, targetOwnerId: preparer[0], reportFailure: (error: string) => { preparationError = error; resumePreparationFailures.set(task.id, error); }, }, })); } else if (!preparer && attempt === 10) { // Re-request panel open if nothing registered after a few seconds. globalThis.window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-target", { detail: { task, forResume: true }, })); prepareDispatched = false; } await new Promise((resolve) => setTimeout(resolve, 500)); adopter = findAdopter(task); } if (!adopter && !preparationError && !cancelled) { preparationError = resumePreparationFailures.get(task.id) ?? "Could not reconnect in time. Open an SFTP panel and try again."; } } resumePreparationFailures.delete(task.id); return { adopter, error: preparationError, cancelled }; }; const resolveLiveController = (taskId: string) => { const ownerId = findOwner(taskId); const candidate = ownerId ? controllers.get(ownerId) : undefined; if (!candidate) return undefined; // Stale owner after tab close: registered but no longer tracking the task. if (typeof candidate.ownsTask === "function" && !candidate.ownsTask(taskId)) { return undefined; } return candidate; }; const invoke = async (taskId: string, requestedAction: "pause" | "resume" | "cancel" | "retry" | "prioritize") => { let action = requestedAction; let controller = resolveLiveController(taskId); let task = tasks.find((candidate) => candidate.id === taskId); if (action === "cancel") { // External folder walks outlive their originating panel. Stop their // process-level controller from the same global cancel entry used by all // transfer UIs, whether or not the terminal tab still exists. await cancelExternalUploadRuntime(taskId); } // Intentional resume/retry must clear a pre-start cancel latch left by an // earlier cancel that never hit startTransferNow (same transferId). if (action === "resume" || action === "retry") { try { await netcattyBridge.get()?.clearPendingTransferCancel?.(taskId); } catch { // best-effort } } // Compressed soft-control is process-global: do not gate on a live panel owner. const liveCompressedJob = task?.controlKind === "compressed-upload" && task.reconnectRequired !== true && task.status !== "interrupted" && task.status !== "attention"; if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) { const bridge = netcattyBridge.get(); if (action === "pause") { // Control epoch supersedes races; bridge lifecycleEpoch is stamped on success. const pauseControlEpoch = bumpTransferControlEpoch(taskId); tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "pausing" as const, pauseUnavailableReason: undefined, speed: 0, } : candidate); emit(); const result = await (bridge?.pauseCompressedUpload?.(taskId) ?? { success: false, deferred: false, reason: "Pause unavailable" }); const latest = tasks.find((candidate) => candidate.id === taskId); if (!latest || latest.status === "cancelled") return; // Drop stale paint if a newer control resume already superseded this pause. if (!isTransferControlEpochCurrent(taskId, pauseControlEpoch) && (latest.status === "transferring" || latest.status === "paused")) { return; } const bridgeEpoch = Number.isFinite(result.lifecycleEpoch) ? (result.lifecycleEpoch as number) : undefined; tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: result.success ? (result.deferred ? "pausing" as const : "paused" as const) : "transferring" as const, pauseUnavailableReason: result.success ? undefined : result.reason, speed: result.success ? 0 : candidate.speed, ...(bridgeEpoch !== undefined ? { lifecycleEpoch: bridgeEpoch } : null), } : candidate); emit(); if (!result.success && result.reason) notify.warning(result.reason, "SFTP"); return; } if (action === "resume") { bumpTransferControlEpoch(taskId); const result = await (bridge?.resumeCompressedUpload?.(taskId) ?? { success: false, reason: "Resume unavailable" }); const latest = tasks.find((candidate) => candidate.id === taskId); if (!latest || latest.status === "cancelled") return; const bridgeEpoch = Number.isFinite(result.lifecycleEpoch) ? (result.lifecycleEpoch as number) : undefined; tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: result.success ? "transferring" as const : candidate.status, error: result.success ? undefined : (result.reason ?? candidate.error), pauseUnavailableReason: result.success ? undefined : candidate.pauseUnavailableReason, speed: 0, // Bridge epoch or clear so fanout progress is not stale-dropped. lifecycleEpoch: result.success ? bridgeEpoch : candidate.lifecycleEpoch, } : candidate); emit(); if (!result.success && result.reason) notify.warning(result.reason, "SFTP"); return; } const result = await (bridge?.cancelCompressedUpload?.(taskId) ?? { success: false }); if (!result.success) { const reason = "Could not cancel the compressed upload."; tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "attention" as const, error: reason, speed: 0, } : candidate); emit(); notify.warning(reason, "SFTP"); return; } markTransferCancelledTree(taskId); releaseTransferPauseTree(taskId); tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "cancelled" as const, error: undefined, endTime: Date.now(), speed: 0, phase: undefined, } : candidate); emit(); return; } // After app restart (or any reconnectRequired task), a retained panel owner // often cannot resume (missing panes / dead sftpId). Prefer a dedicated // transfer session instead of failing with "Reconnect the source and target". // Never dedicated-resume rows waiting on conflict resolution — that would // stream with replace semantics and skip Replace/Skip/Duplicate UI. const needsDedicatedReconnect = action === "resume" && !!task && !task.conflict && ( task.reconnectRequired === true || task.status === "interrupted" || (task.status === "attention" && !task.conflict) || task.ownerId === "background-agent" ); if (needsDedicatedReconnect && controller && !controller.canAdopt?.(task)) { controller = undefined; } // Prefer the live owner controller for pause/resume of still-active work. // Do NOT drop it just because canAdopt is false when the transfer is still // live in the backend — downloads often have only a remote pane open. // // Only paint "Reconnecting…" when we truly need a dedicated/session reopen. // Soft-paused live streams after tab close must soft-resume without this // flash (otherwise Resume feels laggy / "broken"). if (action === "resume" && task && needsDedicatedReconnect) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "pending", error: undefined, speed: 0, phase: undefined, reconnectRequired: true, lifecycleEpoch: undefined, } : candidate); emit(); } // The transfer center can render before vault startup has installed the // dedicated reconnect handler. Only wait when nothing else can take the // job yet (no owner controller and no panel adopter). if ( needsDedicatedReconnect && !dedicatedResumeHandler && !controller && task && !findAdopter(task) ) { await waitForDedicatedResumeHandler(taskId); task = tasks.find((candidate) => candidate.id === taskId); if (!task || task.status === "cancelled" || task.status === "completed") return; // A panel may have registered while we waited for vault startup. controller = resolveLiveController(taskId); } // Unified soft pause/resume — process-global, not tied to panel/terminal // lifecycle. Controllers only sync local React lists after the store paints. if (action === "pause" || action === "resume") { const controlHost: TransferControlHost = { getTasks: () => tasks, setTasks: (next) => { tasks = next; emit(); }, getBridge: defaultTransferControlBridge, }; const notifyOwners = () => { for (const controls of controllers.values()) { try { controls.syncOwnedTasks?.(); } catch { /* best-effort */ } } }; if (action === "pause") { await softPauseTransfer(controlHost, taskId); notifyOwners(); return; } // Soft-resume whenever a walk may still be alive, even if reconnectRequired // was set spuriously. Dedicated only when the live stream is truly gone. const preferSoft = !needsDedicatedReconnect || isTransferWalkInFlight(taskId); if (preferSoft) { const soft = await softResumeTransfer(controlHost, taskId); if (soft.handled) { notifyOwners(); return; } if (action === "resume") { const reason = soft.reason || "Transfer session is no longer active"; // Transient / verification soft-misses must NOT hard-reconnect: that // rehomes ownerId to dedicated-resume (panel queue disappears) and can // race a still-live paused stream → "reconnecting" then stuck paused. // "Resume unavailable" / not found = no live bridge stream (restart or // tests without a bridge) — those must still hard-reconnect / adopt. const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i .test(reason); if (!streamGone) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "paused" as const, reconnectRequired: false, speed: 0, phase: undefined, error: reason, pauseUnavailableReason: undefined, } : candidate); emit(); notifyOwners(); return; } // Soft could not rejoin a dead stream. Demote so the hard reconnect / // adopt path below can run even when a stale panel owner remains. tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "interrupted" as const, reconnectRequired: true, speed: 0, phase: undefined, error: candidate.error ?? `${reason}. Resume will reconnect.`, } : candidate); emit(); task = tasks.find((candidate) => candidate.id === taskId); controller = undefined; notifyOwners(); } } } // Preferred path after app restart / closed server: open a dedicated SFTP // session from vault credentials and continue from the checkpoint. Does not // require any UI panel. Single files resume the stream; directories re-walk // the tree, skip completed children, and resume partial files. const softFailedNeedsHard = action === "resume" && !!task && task.reconnectRequired === true && task.status === "interrupted"; if ( (!controller || needsDedicatedReconnect || softFailedNeedsHard) && action === "resume" && task && !task.conflict && dedicatedResumeHandler ) { // Same source+target already writing elsewhere — do not open a second // stream into the same destination (.part / rename race). const pathConflict = findActivePathConflict(tasks, task); if (pathConflict) { const conflictError = pathConflictMessage(pathConflict); tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "attention" as const, error: conflictError, reconnectRequired: true, speed: 0, phase: undefined, } : candidate); emit(); // Surface exclusivity failures immediately — silent attention rows look // like a dead Resume click after path races. notify.warning(conflictError, "SFTP"); return; } const previousOwnerId = task.ownerId; // Detach parent + directory children so publishOwner cannot clobber // in-flight dedicated progress with a stale interrupted/paused snapshot. tasks = tasks.map((candidate) => { if (candidate.id !== taskId && candidate.parentTaskId !== taskId) return candidate; if (candidate.id === taskId) { return { ...candidate, ownerId: "dedicated-resume", status: "pending" as const, error: undefined, reconnectRequired: true, speed: 0, phase: undefined, updatedAt: Date.now(), lifecycleEpoch: undefined, }; } // Re-home children (keep completed status for skip-on-resume). return { ...candidate, ownerId: "dedicated-resume", updatedAt: Date.now(), lifecycleEpoch: undefined, }; }); emit(); const latest = tasks.find((candidate) => candidate.id === taskId) ?? task; const result = await dedicatedResumeHandler({ ...latest, ownerId: "dedicated-resume", reconnectRequired: true, }); // Cancel/pause may finish while dedicated resume was still reconnecting. const afterDedicated = tasks.find((candidate) => candidate.id === taskId); if (!afterDedicated || afterDedicated.status === "cancelled") { const childIds = tasks .filter((candidate) => candidate.parentTaskId === taskId) .map((candidate) => candidate.id); try { await netcattyBridge.get()?.cancelTransfer?.(taskId); } catch { /* best-effort */ } for (const childId of childIds) { try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ } } const cancelIds = new Set([taskId, ...childIds]); tasks = tasks.map((candidate) => cancelIds.has(candidate.id) && candidate.status !== "completed" ? { ...candidate, status: "cancelled", error: undefined, endTime: candidate.endTime ?? Date.now(), speed: 0, } : candidate); emit(); return; } if (result.success) { // Stream / directory finished successfully. Even if the user hit pause // during reconnect (status demoted to interrupted), work is done — // promote to completed rather than leaving a false interrupted row. tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, ownerId: "dedicated-resume", status: "completed", transferredBytes: Math.max( candidate.transferredBytes, candidate.totalBytes || candidate.transferredBytes, ), speed: 0, endTime: Date.now(), error: undefined, reconnectRequired: false, phase: undefined, } : candidate); emit(); return; } if (afterDedicated.status === "paused" || afterDedicated.status === "interrupted") { // Keep interrupted/paused without calling cancelTransfer — that would // poison pendingCancelTransferIds and break a later same-id resume. // Finalize any children still marked transferring after abort wind-down. const childIds = tasks .filter((candidate) => candidate.parentTaskId === taskId && ["transferring", "pausing", "pending", "queued"].includes(candidate.status)) .map((candidate) => candidate.id); if (childIds.length > 0) { for (const childId of childIds) { try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ } } tasks = tasks.map((candidate) => childIds.includes(candidate.id) ? { ...candidate, status: "interrupted", speed: 0, reconnectRequired: true, phase: undefined, } : candidate); emit(); } return; } // Abort throws "Transfer cancelled" when shouldAbort (pause/interrupt). // Do not force-cancel a row the user already re-activated (transferring/ // pending after a quick Resume during wind-down). const cancelLike = /cancelled|canceled/i.test(result.error || ""); if (cancelLike) { const liveAfter = tasks.find((candidate) => candidate.id === taskId); // Soft-unpause may have painted transferring while the held dedicated // walk was already dying. If no invocation remains, demote so Resume works. if ( liveAfter && ["transferring", "pending", "queued", "paused", "pausing"].includes(liveAfter.status) ) { if (!resumeInvocations.has(taskId) && liveAfter.status === "transferring") { tasks = tasks.map((candidate) => candidate.id === taskId || candidate.parentTaskId === taskId ? { ...candidate, status: candidate.status === "completed" || candidate.status === "cancelled" ? candidate.status : "interrupted" as const, speed: 0, reconnectRequired: true, phase: undefined, } : candidate); emit(); } return; } const cancelIds = new Set([ taskId, ...tasks.filter((candidate) => candidate.parentTaskId === taskId).map((c) => c.id), ]); tasks = tasks.map((candidate) => cancelIds.has(candidate.id) && candidate.status !== "completed" ? { ...candidate, status: "cancelled", error: undefined, endTime: candidate.endTime ?? Date.now(), speed: 0, reconnectRequired: false, phase: undefined, } : candidate); emit(); return; } // Source changed / partial directory attention — keep progress, show retry UI. if (result.needsAttention) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, ownerId: "dedicated-resume", status: "attention", error: result.error, reconnectRequired: false, speed: 0, phase: undefined, retryable: true, ...(result.resetCheckpoint ? { checkpointBytes: 0, transferredBytes: 0 } : null), } : candidate); emit(); return; } // Soft failure for server-to-server (needs panel) — restore prior owner // so a live controller can still resume, or fall through to adoption. if (result.error && /SFTP panel|both hosts/i.test(result.error)) { const restoreOwner = previousOwnerId && previousOwnerId !== "dedicated-resume" ? previousOwnerId : undefined; const restoredTask = { ...(tasks.find((candidate) => candidate.id === taskId) ?? task), ownerId: restoreOwner || "dedicated-resume", status: "attention" as const, error: result.error, reconnectRequired: true, speed: 0, phase: undefined, }; if (restoreOwner) { tasks = tasks.map((candidate) => candidate.id === taskId ? restoredTask : candidate); emit(); controller = controllers.get(restoreOwner); // Re-home via adopt (not resume) — the panel dropped the row while // ownership was dedicated-resume, so resume would no-op. if (controller?.canAdopt?.(restoredTask) && controller.adopt) { await controller.adopt({ ...restoredTask, ownerId: restoreOwner, reconnectRequired: true }); return; } // Live owner cannot adopt (missing host panes) — clear so prepareAdopter runs. controller = undefined; } } else if (result.error) { // A failed directory may have more exception children than the history // cap can retain. Keeping its compact skip checkpoint would make an // evicted failed child indistinguishable from a compacted completion. // Fall back to an explicit fresh Retry: bounded history, no silent skip. tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: candidate.isDirectory ? "failed" : "attention", error: result.error, reconnectRequired: candidate.isDirectory ? false : true, speed: 0, phase: undefined, ...(candidate.isDirectory ? { checkpointBytes: 0, directoryResumeCheckpoint: undefined, endTime: Date.now(), } : null), } : candidate); emit(); return; } } if (!controller && action === "resume") { const prepared = task ? await prepareAdopter(task) : undefined; const adopter = prepared?.adopter; const currentTask = tasks.find((candidate) => candidate.id === taskId); if (prepared?.cancelled || !currentTask || ["cancelled", "completed"].includes(currentTask.status)) return; if (task && adopter) { const [adopterId, adopterControls] = adopter; // Rehome parent + directory children together so completed child // checkpoints survive publishOwner / foreign-owner stripping. tasks = tasks.map((candidate) => ( candidate.id === taskId || candidate.parentTaskId === taskId ? { ...candidate, ownerId: adopterId } : candidate )); emit(); await adopterControls.adopt?.({ ...task, ownerId: adopterId, reconnectRequired: true }); return; } if (task) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "attention", error: prepared?.error ?? "Could not reconnect. Check the host credentials and try again.", reconnectRequired: true, } : candidate); emit(); } } if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) { const childIds = tasks .filter((candidate) => candidate.parentTaskId === taskId) .map((candidate) => candidate.id); // Stop surviving processTransfer walks immediately (panel-local // cancelledTasksRef is gone after unmount). markTransferCancelledTree(taskId, childIds); releaseTransferPauseTree(taskId, childIds); try { globalSftpTransferScheduler.cancel(taskId); for (const childId of childIds) globalSftpTransferScheduler.cancel(childId); } catch { // best-effort } try { await netcattyBridge.get()?.cancelTransfer?.(taskId); for (const childId of childIds) { try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ } } } catch { // Best-effort backend cancel when the owning panel is gone / no window. } try { await netcattyBridge.get()?.cleanupTransferArtifacts?.({ transferId: taskId, sourcePath: task.sourcePath, targetPath: task.targetPath, stagedTargetPath: task.stagedTargetPath, }); } catch { // best-effort temp/.part cleanup } const cancelIds = new Set([taskId, ...childIds]); tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? { ...candidate, status: "cancelled", error: undefined, endTime: Date.now(), speed: 0, conflict: undefined, } : candidate); emit(); return; } // pause/resume always handled above (unified soft-control or dedicated). // Controllers remain for cancel/retry/prioritize when a live owner exists. if (action === "pause" || action === "resume") return; controller = resolveLiveController(taskId); if (!controller) return; await controller[action](taskId); }; const store: SftpTransferCenterStore = { subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, subscribeProgress(listener) { progressListeners.add(listener); return () => progressListeners.delete(listener); }, getSnapshot: () => ensureSnapshot(), getBadgeSnapshot: () => badgeSnapshot, admitTaskRun(incoming, pausedAtResume) { const rootId = incoming.parentTaskId ?? incoming.id; if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled"; const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined; if (parent?.status === "cancelled") return "cancelled"; const index = tasks.findIndex((task) => task.id === incoming.id); const existing = index < 0 ? undefined : tasks[index]; if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath || existing.parentTaskId !== incoming.parentTaskId)) return "conflict"; if (existing?.status === "cancelled") return "cancelled"; if (existing?.status === "completed") { return matchesObservedTask(incoming, existing) ? "completed" : "conflict"; } const resumesUnchangedPause = existing === pausedAtResume && existing?.status === "paused" && (parent?.status === "pending" || parent?.status === "transferring"); if (isTransferOrRootPauseLatched(rootId, incoming.id) || parent?.status === "paused" || parent?.status === "pausing" || (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused"; if (parent?.status === "completed") return "conflict"; if (!existing) return "ready"; if (existing.status === "transferring" && existing.directoryEntryIndex === incoming.directoryEntryIndex && existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready"; // Explicit retry may leave failed/interrupted and replace its manifest identity. // Do not overwrite byte checkpoints with a delayed renderer snapshot. const next = tasks.slice(); next[index] = { ...existing, directoryEntryIndex: incoming.directoryEntryIndex, directoryEntryIdentity: incoming.directoryEntryIdentity, ...(existing.status === "transferring" ? {} : { status: "transferring" as const, lifecycleEpoch: undefined, error: undefined, endTime: undefined, reconnectRequired: false, }), }; tasks = next; emitProgress(false); return "ready"; }, observeTaskSettlement(expected) { const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } }; const observers = settlementObservers.get(expected.id) ?? new Set(); observers.add(observer); settlementObservers.set(expected.id, observers); let disposed = false; return { read() { if (disposed) return undefined; if (observer.settled) return observer.settled; const current = tasks.find((task) => task.id === expected.id); return current && matchesObservedTask(observer.expected, current) ? current : undefined; }, dispose() { disposed = true; observer.settled = undefined; observers.delete(observer); if (observers.size === 0) settlementObservers.delete(expected.id); }, }; }, getTask(taskId) { return tasks.find((task) => task.id === taskId); }, getOwnerTasks(ownerId) { return tasks.filter((task) => task.ownerId === ownerId).map((task) => ({ ...task })); }, publishOwner(ownerId, ownerTasks) { const previousTasks = tasks; let changed = false; let persistImmediately = false; const incoming = new Map(ownerTasks.map((task) => [task.id, task])); const previousPublished = lastPublishedByOwner.get(ownerId); lastPublishedByOwner.set(ownerId, incoming); const existingIds = new Set(tasks.map((task) => task.id)); // Resolve parent status from the store first when runtime owns the walk // (panel is view-only for live lifecycle), else prefer the panel snapshot. const resolveParentStatus = (parentTaskId: string | undefined): TransferTask["status"] | undefined => { if (!parentTaskId) return undefined; const storeParent = tasks.find((candidate) => candidate.id === parentTaskId); if ( storeParent && (isTransferWalkInFlight(parentTaskId) || isTransferPauseLatched(parentTaskId) || storeParent.status === "paused" || storeParent.status === "pausing") ) { return storeParent.status; } return incoming.get(parentTaskId)?.status ?? storeParent?.status; }; tasks = tasks.flatMap((task) => { // Tasks reassigned to dedicated-resume (or other owners) are not // clobbered by this panel's local snapshot. if (task.ownerId !== ownerId) return [task]; const replacement = incoming.get(task.id); if (!replacement) { // Runtime-owned live walks keep their store row even when the panel // unmounts and stops publishing (no orphan drop of in-flight work). if ( isTransferWalkInFlight(task.id) || (task.parentTaskId && isTransferWalkInFlight(task.parentTaskId)) || isTransferPauseLatched(task.id) || (task.parentTaskId && isTransferPauseLatched(task.parentTaskId)) || task.status === "transferring" || task.status === "pausing" || task.status === "paused" || task.status === "queued" || task.status === "pending" ) { return [task]; } changed = true; persistImmediately = true; return []; } // setTransfers keeps unchanged rows by reference. If this exact owner // row was already published, the store may only be newer (for example // from global progress), so leave it untouched without comparing every // field of thousands of directory children. if (previousPublished?.get(task.id) === replacement) return [task]; let merged = mergeOwnerPublishedTask( task, replacement, ownerId, resolveParentStatus(replacement.parentTaskId ?? task.parentTaskId), ); // Runtime writer is authority for live walks: panel cannot roll lifecycle // back past the process-global control epoch (soft pause/resume). const runtimeOwned = isTransferWalkInFlight(task.id) || (task.parentTaskId ? isTransferWalkInFlight(task.parentTaskId) : false) || isTransferOrRootPauseLatched(task.parentTaskId ?? task.id, task.id); if (runtimeOwned) { const storeEpoch = Number.isFinite(task.lifecycleEpoch) ? (task.lifecycleEpoch as number) : getTransferControlEpoch(task.parentTaskId ?? task.id); const panelEpoch = Number.isFinite(replacement.lifecycleEpoch) ? (replacement.lifecycleEpoch as number) : -1; if (storeEpoch > panelEpoch) { merged = { ...merged, status: task.status, lifecycleEpoch: task.lifecycleEpoch ?? merged.lifecycleEpoch, speed: (task.status === "paused" || task.status === "pausing") ? 0 : merged.speed, transferredBytes: Math.max(task.transferredBytes ?? 0, merged.transferredBytes ?? 0), checkpointBytes: Math.max(task.checkpointBytes ?? 0, merged.checkpointBytes ?? 0), }; } } if (areTransferTasksEquivalent(task, merged)) return [task]; if ( merged.sourceFingerprint !== task.sourceFingerprint || ( merged.status !== task.status && (merged.status === "paused" || merged.status === "pausing") ) || ( merged.status !== task.status && !merged.parentTaskId && (merged.status === "completed" || merged.status === "failed" || merged.status === "cancelled") ) ) { persistImmediately = true; } changed = true; return [merged]; }); for (const task of ownerTasks) { // Never re-introduce a panel row that already exists under another owner // (e.g. completed via dedicated resume while the panel still holds interrupted). if (!existingIds.has(task.id)) { if (isAlreadyCompactedCompletedChild(task)) continue; const parentStatus = resolveParentStatus(task.parentTaskId); const parentHoldsPause = parentStatus === "paused" || parentStatus === "pausing"; const seeded = { ...task, ownerId, updatedAt: task.updatedAt ?? Date.now() }; if ( parentHoldsPause && !["completed", "cancelled", "failed", "paused", "pausing"].includes(seeded.status) ) { seeded.status = parentStatus === "pausing" ? "pausing" : "paused"; seeded.speed = 0; } tasks.push(seeded); if ( seeded.sourceFingerprint !== undefined || seeded.status === "paused" || seeded.status === "pausing" || (!seeded.parentTaskId && ( seeded.status === "completed" || seeded.status === "failed" || seeded.status === "cancelled" )) ) { persistImmediately = true; } changed = true; } } if (!changed) { tasks = previousTasks; return; } emit(persistImmediately); }, registerOwner(ownerId, controls) { controllers.set(ownerId, controls); return () => { if (controllers.get(ownerId) === controls) { controllers.delete(ownerId); lastPublishedByOwner.delete(ownerId); settleFinishedTransferControlState( tasks.filter((task) => task.ownerId === ownerId), tasks, ); } }; }, setDedicatedResumeHandler(handler) { dedicatedResumeHandler = handler; notifyDedicatedResumeWaiters(); }, patchTask(taskId, updates) { const index = tasks.findIndex((task) => task.id === taskId); if (index < 0) return; const task = tasks[index]; let changed = false; const applyOne = () => { // Never resurrect a user-stopped row via dedicated-resume progress. if (task.status === "cancelled") return task; // Dedicated-owned rows may move pending → transferring while a panel // still holds a local interrupted copy that is no longer authoritative. // Force-quit continue rehomes to dedicated-resume first; allow that owner // (and explicit ownerId updates in the patch) to leave interrupted. const nextOwner = updates.ownerId ?? task.ownerId; const currentEpoch = Number.isFinite(task.lifecycleEpoch) ? (task.lifecycleEpoch as number) : -1; const incomingEpoch = Number.isFinite(updates.lifecycleEpoch) ? (updates.lifecycleEpoch as number) : -1; const explicitResume = updates.status === "transferring" && (incomingEpoch > currentEpoch || (incomingEpoch >= currentEpoch && updates.lifecycleEpoch !== undefined) || nextOwner === "dedicated-resume" || task.ownerId === "dedicated-resume"); if ( nextOwner !== "dedicated-resume" && task.ownerId !== "dedicated-resume" && (task.status === "paused" || task.status === "interrupted") && (updates.status === "transferring" || updates.status === "pending" || updates.status === "completed") && !explicitResume ) { return task; } changed = true; const merged = { ...task, ...updates, updatedAt: Date.now() }; // Soft-drain / walk progress must not move the visible bar after Pause. // Latch or paused/pausing freezes transferredBytes/speed unless this patch // is an explicit resume (transferring + epoch, or dedicated-resume). const pauseHeld = isTransferOrRootPauseLatched(task.parentTaskId ?? task.id, task.id) || isTransferPauseLatched(task.id) || task.status === "paused" || task.status === "pausing" || (!!task.parentTaskId && ( isTransferPauseLatched(task.parentTaskId) || (() => { const parent = tasks.find((candidate) => candidate.id === task.parentTaskId); return parent?.status === "paused" || parent?.status === "pausing"; })() )); if (pauseHeld && !explicitResume) { // Keep lifecycle at paused/pausing; allow checkpoint metadata only. if (task.status === "pausing" || task.status === "paused") { merged.status = task.status; } else { merged.status = "paused"; } merged.transferredBytes = task.transferredBytes; merged.speed = 0; if (updates.checkpointBytes !== undefined) { merged.checkpointBytes = Math.max( task.checkpointBytes ?? 0, Number(updates.checkpointBytes) || 0, ); } else { merged.checkpointBytes = task.checkpointBytes; } if (updates.resumeStage !== undefined) merged.resumeStage = updates.resumeStage; if (updates.downloadCheckpointBytes !== undefined) { merged.downloadCheckpointBytes = updates.downloadCheckpointBytes; } if (updates.uploadCheckpointBytes !== undefined) { merged.uploadCheckpointBytes = updates.uploadCheckpointBytes; } if (updates.sourceFingerprint !== undefined) { merged.sourceFingerprint = updates.sourceFingerprint; } return merged; } // Dedicated-resume / dual-writer progress must never roll the bar back // while the row is still live (force-quit continue freezes at checkpoint // if a late lower sample wins). if ( updates.transferredBytes !== undefined && !["completed", "cancelled", "failed"].includes(merged.status) && (updates.status === "transferring" || merged.status === "transferring" || merged.status === "pausing") ) { merged.transferredBytes = Math.max(task.transferredBytes ?? 0, Number(updates.transferredBytes) || 0); } if ( updates.checkpointBytes !== undefined && !["completed", "cancelled", "failed"].includes(merged.status) ) { merged.checkpointBytes = Math.max(task.checkpointBytes ?? 0, Number(updates.checkpointBytes) || 0); } return merged; }; const nextTask = applyOne(); if (!changed || nextTask === task) return; const next = tasks.slice(); next[index] = nextTask; tasks = next; if (changed) { const lifecycleChanged = ( (updates.status !== undefined && updates.status !== task.status) || (updates.error !== undefined && updates.error !== task.error) || (updates.endTime !== undefined && updates.endTime !== task.endTime) || (updates.conflict !== undefined && updates.conflict !== task.conflict) || ( updates.reconnectRequired !== undefined && updates.reconnectRequired !== task.reconnectRequired ) ); // Only a *new* fingerprint must flush + persist immediately. Progress // IPC always echoes the current fingerprint once computed; treating // "key present" as critical disabled emitProgress coalesce and re-pegged // the renderer (~1 notify per tick during resumable uploads). const fingerprintChanged = updates.sourceFingerprint !== undefined && updates.sourceFingerprint !== task.sourceFingerprint; const directoryCheckpointChanged = Object.prototype.hasOwnProperty.call( updates, "directoryResumeCheckpoint", ) && updates.directoryResumeCheckpoint !== task.directoryResumeCheckpoint; if (fingerprintChanged) emit(true); else if (directoryCheckpointChanged) emit(true); else if (lifecycleChanged) emit(); else emitProgress(false); } }, upsertTasks(incoming) { if (incoming.length === 0) return; const byId = new Map(incoming.map((task) => [task.id, task])); const seen = new Set(); const parentTerminal = new Map(); for (const task of tasks) { if (!task.parentTaskId) parentTerminal.set(task.id, task.status); } tasks = tasks.map((task) => { const replacement = byId.get(task.id); if (!replacement) return task; seen.add(task.id); if (task.status === "cancelled" && replacement.status !== "cancelled") return task; return { ...task, ...replacement, updatedAt: Date.now() }; }); for (const task of incoming) { if (seen.has(task.id)) continue; if (isAlreadyCompactedCompletedChild(task)) continue; // Do not resurrect work under a cancelled/completed directory parent. if (task.parentTaskId) { const parentStatus = parentTerminal.get(task.parentTaskId) ?? tasks.find((candidate) => candidate.id === task.parentTaskId)?.status; if (parentStatus === "cancelled" || parentStatus === "completed") continue; } tasks.push({ ...task, updatedAt: task.updatedAt ?? Date.now() }); } emit(); }, canControl(taskId) { const ownerId = findOwner(taskId); const task = tasks.find((candidate) => candidate.id === taskId); if (!task) return false; const hasLiveOwner = !!ownerId && controllers.has(ownerId); if (hasLiveOwner) return true; // After restart (or panel unmount) unfinished tasks stay in the store with // no owner controller. The global center must still be able to resume, // cancel, or dismiss them — otherwise they become dead rows. const terminal = task.status === "completed" || task.status === "cancelled"; // Failed rows stay controllable so orphan Retry/Resume can still run after restart. if (!terminal && task.status !== "failed") return true; if (task.status === "failed") return true; return !!(task && [...controllers.values()].some((controls) => ( controls.adopt && controls.canAdopt?.(task) ))); }, subscribeResume(listener) { resumeListeners.add(listener); return () => { resumeListeners.delete(listener); }; }, isResuming(taskId) { if (!resumeRequests.has(taskId)) return false; const task = tasks.find((candidate) => candidate.id === taskId); return !!task && !task.error && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status); }, pause(taskId) { clearResumeRequest(taskId); return invoke(taskId, "pause"); }, async resume(taskId) { const request = Symbol("resume"); resumeRequests.set(taskId, request); notifyResume(); const runResume = async () => { const startFresh = () => { const running = invoke(taskId, "resume").finally(() => { if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId); }); resumeInvocations.set(taskId, running); return running; }; const existing = resumeInvocations.get(taskId); if (existing) { // Dedicated resume holds the invocation for the full stream lifetime. const task = tasks.find((candidate) => candidate.id === taskId); if (task?.status === "cancelled") return existing; // Soft-unpause live backend streams when still paused under a held run. // Dedicated *directory* walks treat status===paused as shouldAbort between // files, so soft-rejoin is unsafe (dying promise + false transferring). // Wind down soft-paused children then startFresh from checkpoints. // Single-file dedicated (and non-directory live streams) soft-unpause. if (task && (task.status === "paused" || task.status === "pausing")) { const childIds = tasks .filter((candidate) => candidate.parentTaskId === taskId && (candidate.status === "paused" || candidate.status === "pausing" || candidate.status === "transferring")) .map((candidate) => candidate.id); // Always clear process-global latches + control epoch so walks wake and // late soft-drain / pauseWatch cannot re-pause streams. Do not stamp // control epoch as task.lifecycleEpoch (bridge-aligned only). let resumeEpoch = bumpTransferControlEpoch(taskId); releaseTransferPauseTree(taskId, childIds); if (task.ownerId === "dedicated-resume" && task.isDirectory) { const bridge = netcattyBridge.get(); // Cancel soft-paused child streams so the held walk settles. When a // child is not in activeTransfers, cancelTransfer leaves a sticky // pendingCancel latch — clear it before startFresh reuses the same // child transferIds (otherwise startStreamTransfer aborts immediately). for (const id of childIds) { try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ } try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ } } try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ } try { await existing; } catch { /* previous aborted / cancelled */ } // Clear again after wind-down in case cancel raced during await. for (const id of childIds) { try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ } } try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ } if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing; return resumeInvocations.get(taskId) ?? startFresh(); } try { // Reuse the live control path: held dedicated transfers must obey // the same ordering and per-child lifecycle rules as panel jobs. const softOperation = softResumeTransfer({ getTasks: () => tasks, setTasks: (next) => { tasks = next; emit(); }, getBridge: defaultTransferControlBridge, }, taskId); resumeEpoch = getTransferControlEpoch(taskId); const soft = await softOperation; if (soft.handled) return existing; const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i .test(soft.reason ?? ""); if (!streamGone) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "paused" as const, speed: 0, phase: undefined, error: soft.reason, } : candidate); emit(); return existing; } } catch { // Fall through to await + restart. } if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing; try { await existing; } catch { /* previous aborted */ } if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing; return resumeInvocations.get(taskId) ?? startFresh(); } // After demotion to interrupted/attention/failed while work unwinds: // wait then re-invoke (do not rejoin a dying canceling promise). if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) { const resumeEpoch = getTransferControlEpoch(taskId); try { await existing; } catch { /* previous aborted */ } if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing; return resumeInvocations.get(taskId) ?? startFresh(); } return existing; } return startFresh(); }; try { return await runResume(); } finally { if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId); } }, cancel(taskId) { clearResumeRequest(taskId); return invoke(taskId, "cancel"); }, async retry(taskId) { const ownerId = findOwner(taskId); const task = tasks.find((candidate) => candidate.id === taskId); // The background-agent controller can only reopen an SFTP channel on the // original terminal session. Failed rows survive an app restart but that // session id does not, so Retry must take the same fresh dedicated path // as any other restored task. Pause/resume of a still-live backend stream // remains handled by invoke() and the process-global bridge controls. const controller = ownerId === "background-agent" ? undefined : controllers.get(ownerId ?? ""); if (controller) { await controller.retry(taskId); return; } // Orphaned after restart: clear checkpoint so Retry truly restarts, then // resume (dedicated or adopt) from byte 0. if (task) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "interrupted", error: undefined, checkpointBytes: 0, transferredBytes: 0, downloadCheckpointBytes: undefined, uploadCheckpointBytes: undefined, resumeStage: undefined, sourceFingerprint: undefined, reconnectRequired: true, speed: 0, endTime: undefined, lifecycleEpoch: undefined, } : candidate); emit(); try { await netcattyBridge.get()?.cleanupTransferArtifacts?.({ transferId: taskId, sourcePath: task.sourcePath, targetPath: task.targetPath, stagedTargetPath: task.stagedTargetPath, }); } catch { // best-effort } } await this.resume(taskId); }, async prioritize(taskId) { const ownerId = findOwner(taskId); const controller = controllers.get(ownerId ?? ""); if (controller) { void controller.prioritize(taskId); return; } // Orphan: still bump store priority and ask backend / renderer scheduler. tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, priority: Date.now(), updatedAt: Date.now() } : candidate); emit(); try { void netcattyBridge.get()?.prioritizeTransfer?.(taskId); } catch { // best-effort } try { globalSftpTransferScheduler.prioritize(taskId); } catch { // Scheduler may be empty in pure node tests. } }, async resolveConflict(taskId, action, applyToAll) { let ownerId = findOwner(taskId); let controller = controllers.get(ownerId ?? ""); const task = tasks.find((candidate) => candidate.id === taskId); if (!controller && task) { const prepared = await prepareAdopter(task); if (prepared.cancelled) return; const adopter = prepared.adopter; if (!adopter) { tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, status: "attention", error: prepared.error ?? "Could not open an SFTP panel to resolve this conflict.", reconnectRequired: true, } : candidate); emit(); return; } const [adopterId, adopterControls] = adopter; const current = tasks.find((candidate) => candidate.id === taskId); if (!current || current.status === "cancelled" || current.status === "completed") return; ownerId = adopterId; controller = adopterControls; tasks = tasks.map((candidate) => candidate.id === taskId ? { ...candidate, ownerId: adopterId } : candidate); emit(); await adopterControls.adopt?.({ ...current, ownerId: adopterId }); const afterAdopt = tasks.find((candidate) => candidate.id === taskId); if (!afterAdopt || afterAdopt.status === "cancelled") return; } await controller?.resolveConflict?.(taskId, action, applyToAll); }, dismiss(taskId) { const task = tasks.find((candidate) => candidate.id === taskId); const ownerId = task?.ownerId; const controller = ownerId ? controllers.get(ownerId) : undefined; if (controller) { controller.dismiss(taskId, task); } const removing = tasks.filter((candidate) => ( candidate.id === taskId || candidate.parentTaskId === taskId )); settleFinishedTransferControlState(removing, removing); tasks = tasks.filter((task) => task.id !== taskId && task.parentTaskId !== taskId); emit(true); }, clearTerminal(status) { const terminal = new Set(["completed", "failed", "cancelled"]); const unfinishedParents = new Set( tasks.filter((task) => !terminal.has(task.status)).map((task) => task.id), ); // Do not wipe completed children of an unfinished directory parent — // those rows are resume checkpoints, not disposable history. const removing = tasks.filter((task) => terminal.has(task.status) && (status === undefined || task.status === status) && !(task.parentTaskId && unfinishedParents.has(task.parentTaskId)), ); const removingIds = new Set(removing.map((task) => task.id)); settleFinishedTransferControlState(removing, removing); tasks = tasks.filter((task) => !removingIds.has(task.id) && !removingIds.has(task.parentTaskId ?? "")); emit(true); notifyOwnersOfPrunedTasks(removing); }, markReconnectRequired(taskId, error) { tasks = tasks.map((task) => task.id === taskId ? { ...task, status: "attention", error: error ?? "The original server connection is unavailable", reconnectRequired: true, speed: 0, lifecycleEpoch: undefined, } : task); emit(); }, reportResumePreparationFailure(taskId, error) { resumePreparationFailures.set(taskId, error); }, ingestBackgroundEvent(event) { if (restoreMigrationPending && !replayingRestoreEvents) { const pending = backgroundEventsDuringRestore.get(event.transferId) ?? []; pending.push({ sequence: restoreEventSequence, event: { ...event } }); restoreEventSequence += 1; backgroundEventsDuringRestore.set(event.transferId, pending); return; } let existing = tasks.find((task) => task.id === event.transferId); const eventParent = event.parentTaskId ? tasks.find((task) => task.id === event.parentTaskId) : undefined; // Hot reload / mixed-version delivery can leave an already-created child // looking like a top-level background row. The first hierarchy-aware // event repairs it in place instead of leaving a duplicate control row. if (existing && eventParent && existing.parentTaskId !== event.parentTaskId) { tasks = tasks.map((task) => task.id === event.transferId ? { ...task, ownerId: eventParent.ownerId ?? task.ownerId, parentTaskId: event.parentTaskId, directoryEntryIndex: event.directoryEntryIndex ?? task.directoryEntryIndex, directoryEntryIdentity: event.directoryEntryIdentity ?? task.directoryEntryIdentity, background: false, } : task); existing = tasks.find((task) => task.id === event.transferId); } // A delayed child progress event can arrive after the renderer compacted // that completed child into its parent checkpoint. Never resurrect it as // a live row, especially after the parent itself has finished. if (!existing && eventParent) { if (["completed", "failed", "cancelled"].includes(eventParent.status)) return; const covered = eventParent.directoryResumeCheckpoint?.coveredEntries ?? 0; if ( Number.isSafeInteger(event.directoryEntryIndex) && (event.directoryEntryIndex ?? covered) < covered ) return; } const persistImmediately = event.type === "paused" || event.type === "pausing" || ( !existing?.parentTaskId && (event.type === "completed" || event.type === "failed" || event.type === "cancelled") ) || (event.sourceFingerprint !== undefined && event.sourceFingerprint !== existing?.sourceFingerprint); const terminal = existing && (existing.status === "cancelled" || existing.status === "completed" || existing.status === "failed"); // Never resurrect a finished/cancelled agent row with late queued/progress. if (terminal && event.type !== "cancelled") { // Allow a late explicit cancel to stick; ignore everything else. if (event.type === "completed" || event.type === "failed") { // Keep existing terminal state (prefer cancelled over late completed). return; } if (event.type === "queued" || event.type === "started" || event.type === "progress" || event.type === "pausing" || event.type === "resumed" || event.type === "paused") { return; } } if (existing && event.type === "progress") { const parent = existing.parentTaskId ? tasks.find((task) => task.id === existing.parentTaskId) : undefined; const pauseHeld = existing.status === "paused" || existing.status === "pausing" || parent?.status === "paused" || parent?.status === "pausing" || isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id) || isTransferPauseLatched(existing.id); const lifecycleStatus = event.lifecycleState === "paused" ? "paused" : event.lifecycleState === "pausing" ? "pausing" : event.lifecycleState === "queued" ? "queued" : "transferring"; const sameLifecycle = event.lifecycleState === undefined ? existing.status === "transferring" : existing.status === lifecycleStatus; const sameOptional = (incoming: T | undefined, current: T | undefined) => ( incoming === undefined || incoming === current ); if ( !pauseHeld && sameLifecycle && existing.error === undefined && existing.endTime === undefined && sameOptional(event.transferred, existing.transferredBytes) && sameOptional(event.totalBytes, existing.totalBytes) && sameOptional(event.speed, existing.speed) && sameOptional(event.checkpointBytes, existing.checkpointBytes) && sameOptional(event.resumeStage, existing.resumeStage) && sameOptional(event.downloadCheckpointBytes, existing.downloadCheckpointBytes) && sameOptional(event.uploadCheckpointBytes, existing.uploadCheckpointBytes) && sameOptional(event.sourceFingerprint, existing.sourceFingerprint) && sameOptional(event.phase, existing.phase) && sameOptional(event.lifecycleEpoch, existing.lifecycleEpoch) && sameOptional(event.resumable, existing.resumable) && sameOptional(event.pauseUnavailableReason, existing.pauseUnavailableReason) ) { return; } } if ((event.type === "queued" || event.type === "started" || event.type === "progress") && !existing) { const sourcePath = event.sourcePath ?? ""; const targetPath = event.targetPath ?? ""; tasks.push({ id: event.transferId, ownerId: eventParent?.ownerId ?? (event.controlKind === "compressed-upload" ? "background-transfer" : "background-agent"), fileName: event.fileName ?? ( targetPath.split(/[\\/]/).pop() || sourcePath.split(/[\\/]/).pop() || event.transferId ), sourcePath, targetPath, sourceConnectionId: event.direction === "upload" ? "local" : (event.sessionId ?? "agent"), targetConnectionId: event.direction === "download" ? "local" : (event.sessionId ?? "agent"), sourceHostId: event.sourceHostId, targetHostId: event.targetHostId, parentTaskId: event.parentTaskId, directoryEntryIndex: event.directoryEntryIndex, directoryEntryIdentity: event.directoryEntryIdentity, direction: event.direction ?? "upload", status: event.type === "queued" ? "queued" : "transferring", totalBytes: event.totalBytes ?? 0, transferredBytes: event.transferred ?? 0, speed: event.speed ?? 0, startTime: event.startedAt ?? Date.now(), isDirectory: event.isDirectory ?? false, origin: "agent", background: !event.parentTaskId, resumable: event.resumable ?? true, pauseUnavailableReason: event.pauseUnavailableReason, controlKind: event.controlKind, phase: event.phase, lifecycleEpoch: event.lifecycleEpoch, }); } else if (existing && (event.type === "queued" || event.type === "started")) { tasks = tasks.map((task) => { if (task.id !== event.transferId) return task; const currentEpoch = Number.isFinite(task.lifecycleEpoch) ? (task.lifecycleEpoch as number) : -1; const incomingEpoch = Number.isFinite(event.lifecycleEpoch) ? (event.lifecycleEpoch as number) : -1; const acceptsLifecycle = task.lifecycleEpoch === undefined ? true : event.lifecycleEpoch !== undefined && incomingEpoch >= currentEpoch; return { ...task, status: acceptsLifecycle ? (event.type === "queued" ? "queued" : "transferring") : task.status, error: acceptsLifecycle ? undefined : task.error, endTime: acceptsLifecycle ? undefined : task.endTime, fileName: event.fileName ?? task.fileName, totalBytes: event.totalBytes ?? task.totalBytes, isDirectory: event.isDirectory ?? task.isDirectory, controlKind: event.controlKind ?? task.controlKind, parentTaskId: event.parentTaskId ?? task.parentTaskId, directoryEntryIndex: event.directoryEntryIndex ?? task.directoryEntryIndex, directoryEntryIdentity: event.directoryEntryIdentity ?? task.directoryEntryIdentity, background: event.parentTaskId ? false : task.background, phase: event.phase ?? task.phase, resumable: event.resumable ?? task.resumable, pauseUnavailableReason: event.pauseUnavailableReason ?? task.pauseUnavailableReason, lifecycleEpoch: acceptsLifecycle && event.lifecycleEpoch !== undefined ? event.lifecycleEpoch : task.lifecycleEpoch, }; }); } else if (existing && event.type === "progress") { tasks = tasks.map((task) => { if (task.id !== event.transferId) return task; // Main-process progress must re-open a live bar even if the panel is // hidden and no longer painting "transferring" via React callbacks. const nextTransferred = event.transferred === undefined ? task.transferredBytes : Math.max(task.transferredBytes ?? 0, Number(event.transferred) || 0); const currentEpoch = Number.isFinite(task.lifecycleEpoch) ? (task.lifecycleEpoch as number) : -1; const incomingEpoch = Number.isFinite(event.lifecycleEpoch) ? (event.lifecycleEpoch as number) : -1; const staleLifecycle = task.lifecycleEpoch !== undefined && (event.lifecycleEpoch === undefined || incomingEpoch < currentEpoch); if (staleLifecycle) return task; const acceptsLifecycle = incomingEpoch >= currentEpoch; const lifecycleStatus = event.lifecycleState === "paused" ? "paused" : event.lifecycleState === "pausing" ? "pausing" : event.lifecycleState === "queued" ? "queued" : "transferring"; // Soft-drain continues writing after Pause, but the bar must freeze as // soon as the user asked to pause. Only an explicit resume (higher // epoch + transferring) may move bytes again. // Also freeze children when the folder parent is paused/latched — otherwise // soft-drain progress re-opens them as "transferring" and the collapsed // current-file row blinks in and out under an already-paused parent. const parentRow = task.parentTaskId ? tasks.find((candidate) => candidate.id === task.parentTaskId) : undefined; const parentHoldsPause = !!parentRow && ( parentRow.status === "paused" || parentRow.status === "pausing" || isTransferPauseLatched(parentRow.id) ); const selfLatched = isTransferOrRootPauseLatched( task.parentTaskId ?? task.id, task.id, ) || isTransferPauseLatched(task.id); const uiPauseLatched = task.status === "paused" || task.status === "pausing" || parentHoldsPause || selfLatched; const explicitResume = lifecycleStatus === "transferring" && event.lifecycleEpoch !== undefined && incomingEpoch > currentEpoch; if (uiPauseLatched && !explicitResume) { const nextCheckpoint = Math.max( task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0, ); const holdStatus = event.lifecycleState === "paused" && acceptsLifecycle ? "paused" as const : (task.status === "paused" || task.status === "pausing" ? task.status : parentHoldsPause ? (parentRow!.status === "pausing" ? "pausing" as const : "paused" as const) : "pausing" as const); return { ...task, // Keep pausing until backend confirms paused; never flip back to // transferring from soft-drain progress. status: holdStatus, speed: 0, checkpointBytes: nextCheckpoint || task.checkpointBytes, resumeStage: event.resumeStage ?? task.resumeStage, downloadCheckpointBytes: event.downloadCheckpointBytes ?? task.downloadCheckpointBytes, uploadCheckpointBytes: event.uploadCheckpointBytes ?? task.uploadCheckpointBytes, sourceFingerprint: event.sourceFingerprint ?? task.sourceFingerprint, resumable: event.resumable ?? task.resumable, pauseUnavailableReason: event.pauseUnavailableReason ?? task.pauseUnavailableReason, updatedAt: Date.now(), lifecycleEpoch: acceptsLifecycle && event.lifecycleEpoch !== undefined ? event.lifecycleEpoch : task.lifecycleEpoch, }; } const nextStatus = event.lifecycleEpoch !== undefined && acceptsLifecycle ? lifecycleStatus : task.lifecycleEpoch === undefined ? ((task.status === "paused" || task.status === "pausing") ? task.status : "transferring") : task.status; const keepPaused = nextStatus === "paused" || nextStatus === "pausing"; return { ...task, status: nextStatus, transferredBytes: keepPaused ? task.transferredBytes : nextTransferred, totalBytes: event.totalBytes === undefined ? task.totalBytes : Math.max(task.totalBytes ?? 0, Number(event.totalBytes) || 0), speed: keepPaused ? 0 : (event.speed ?? task.speed), checkpointBytes: event.checkpointBytes === undefined ? task.checkpointBytes : Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0), resumeStage: event.resumeStage ?? task.resumeStage, downloadCheckpointBytes: event.downloadCheckpointBytes ?? task.downloadCheckpointBytes, uploadCheckpointBytes: event.uploadCheckpointBytes ?? task.uploadCheckpointBytes, sourceFingerprint: event.sourceFingerprint ?? task.sourceFingerprint, phase: event.phase ?? task.phase, resumable: event.resumable ?? task.resumable, pauseUnavailableReason: event.pauseUnavailableReason ?? task.pauseUnavailableReason, error: keepPaused ? task.error : undefined, endTime: undefined, // A single-file dedicated resume reports bytes through the global // main-process event stream, not the dedicated renderer callback. // The first accepted live progress proves reconnect is over; keep // no stale reconnect flag that would hide the Pause action forever. reconnectRequired: task.ownerId === "dedicated-resume" && lifecycleStatus === "transferring" ? false : task.reconnectRequired, updatedAt: Date.now(), lifecycleEpoch: acceptsLifecycle && event.lifecycleEpoch !== undefined ? event.lifecycleEpoch : task.lifecycleEpoch, }; }); if (event.resumable === false && existing.parentTaskId) { tasks = tasks.map((task) => task.id === existing.parentTaskId ? { ...task, resumable: false, pauseUnavailableReason: event.pauseUnavailableReason ?? task.pauseUnavailableReason, } : task); } } else if (existing && (event.type === "pausing" || event.type === "paused" || event.type === "resumed")) { tasks = tasks.map((task) => { if (task.id !== event.transferId) return task; const currentEpoch = Number.isFinite(task.lifecycleEpoch) ? (task.lifecycleEpoch as number) : -1; const incomingEpoch = Number.isFinite(event.lifecycleEpoch) ? (event.lifecycleEpoch as number) : -1; const acceptsLifecycle = task.lifecycleEpoch === undefined ? true : event.lifecycleEpoch !== undefined && incomingEpoch >= currentEpoch; return { ...task, status: acceptsLifecycle ? (event.type === "pausing" ? "pausing" : event.type === "paused" ? "paused" : "transferring") : task.status, speed: acceptsLifecycle && event.type !== "resumed" ? 0 : task.speed, checkpointBytes: event.checkpointBytes ?? task.checkpointBytes, resumeStage: event.resumeStage ?? task.resumeStage, downloadCheckpointBytes: event.downloadCheckpointBytes ?? task.downloadCheckpointBytes, uploadCheckpointBytes: event.uploadCheckpointBytes ?? task.uploadCheckpointBytes, sourceFingerprint: event.sourceFingerprint ?? task.sourceFingerprint, phase: event.phase ?? task.phase, lifecycleEpoch: acceptsLifecycle && event.lifecycleEpoch !== undefined ? event.lifecycleEpoch : task.lifecycleEpoch, }; }); } else if (existing && event.type !== "started") { tasks = tasks.map((task) => task.id === event.transferId ? { ...task, status: event.type === "completed" ? "completed" : event.type === "cancelled" ? "cancelled" : "failed", transferredBytes: event.transferred === undefined ? task.transferredBytes : Math.max(task.transferredBytes ?? 0, Number(event.transferred) || 0), totalBytes: event.totalBytes === undefined ? task.totalBytes : Math.max(task.totalBytes ?? 0, Number(event.totalBytes) || 0), checkpointBytes: event.checkpointBytes === undefined ? task.checkpointBytes : Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0), error: event.error, endTime: event.endedAt ?? Date.now(), speed: 0, reconnectRequired: event.type === "failed" ? task.reconnectRequired : false, phase: undefined, } : task); } // Progress-only ticks skip prune + coalesce listener paints; lifecycle // events still take the full emit path so UI never lags on pause/cancel. if (event.type === "progress" && !persistImmediately) { emitProgress(false); } else { emit(persistImmediately); } }, }; if (shouldRestoreCooperatively && persistedRaw) { void restoreSftpTransferHistoryCooperatively(persistedRaw).then((migrated) => { const replayBufferedBackgroundEvents = () => { const buffered = [...backgroundEventsDuringRestore.values()] .flat() .sort((left, right) => left.sequence - right.sequence); backgroundEventsDuringRestore.clear(); if (buffered.length === 0) return; replayingRestoreEvents = true; deferRestoreReplayEmits = true; try { for (const { event } of buffered) store.ingestBackgroundEvent(event); } finally { deferRestoreReplayEmits = false; replayingRestoreEvents = false; } }; if (!migrated.valid) { replayBufferedBackgroundEvents(); restoreMigrationPending = false; // Preserve invalid/unsupported source data unless a live mutation must // be made durable. This mirrors the old best-effort restore behavior. if (snapshotDirty || persistenceDirty) emit(true); else writePersistence(); return; } // New transfers may have arrived while the old snapshot was migrating. // Keep the live row for an ID collision, then run the canonical bounded // prune once more over the now-small merged result. const liveIds = new Set(tasks.map((task) => task.id)); tasks = pruneSftpTransferHistory([ ...migrated.tasks.filter((task) => !liveIds.has(task.id)), ...tasks, ]); replayBufferedBackgroundEvents(); restoreMigrationPending = false; emit(true); }).catch((error) => { const buffered = [...backgroundEventsDuringRestore.values()] .flat() .sort((left, right) => left.sequence - right.sequence); backgroundEventsDuringRestore.clear(); replayingRestoreEvents = true; deferRestoreReplayEmits = true; try { for (const { event } of buffered) store.ingestBackgroundEvent(event); } finally { deferRestoreReplayEmits = false; replayingRestoreEvents = false; } restoreMigrationPending = false; console.warn("[SFTP] Could not migrate legacy transfer history", error); // Do not erase the legacy value on a migration failure. Only flush if a // live task mutation was already waiting for durability. if (snapshotDirty || persistenceDirty) emit(true); else writePersistence(); }); } return store; } const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined" ? undefined : { read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER), write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); }, }; export const sftpTransferCenterStore = createSftpTransferCenterStore(browserPersistence); export function useSftpTransferCenter(): SftpTransferCenterSnapshot { return useSyncExternalStore( sftpTransferCenterStore.subscribe, sftpTransferCenterStore.getSnapshot, sftpTransferCenterStore.getSnapshot, ); } /** Badge only — stable when counts are unchanged so TopTabs does not re-render every progress byte. */ export function useSftpTransferCenterBadge(): { count: number; hasAttention: boolean } { return useSyncExternalStore( sftpTransferCenterStore.subscribe, sftpTransferCenterStore.getBadgeSnapshot, sftpTransferCenterStore.getBadgeSnapshot, ); } /** * Live row from the transfer center store. Progress ticks patch the store without * re-rendering the whole SFTP panel via setTransfersState — queue rows subscribe * here so bars/spinners still move. */ export function useSftpTransferTask(taskId: string, fallback: TransferTask): TransferTask { const fallbackRef = useRef(fallback); fallbackRef.current = fallback; return useSyncExternalStore( sftpTransferCenterStore.subscribeProgress, () => sftpTransferCenterStore.getTask(taskId) ?? fallbackRef.current, () => fallbackRef.current, ); } /** Shared control feedback for every transfer surface, including newly mounted rows. */ export function useSftpTransferResuming(taskId: string): boolean { return useSyncExternalStore( (listener) => { const unsubscribeTask = sftpTransferCenterStore.subscribe(listener); const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener); return () => { unsubscribeTask(); unsubscribeResume(); }; }, () => sftpTransferCenterStore.isResuming(taskId), () => false, ); }