Files
NetMesh/application/state/sftp/useSftpExternalOperations.ts

1907 lines
70 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from "react";
import { FileConflict, FileConflictAction, Host, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
import { joinPath } from "./utils";
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
import {
UploadController,
uploadFromFileList,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
startUploadScanningTask,
} from "../../../lib/uploadService";
import { uploadLocalFoldersProgressively } from "../../../lib/progressiveFolderUpload";
import {
captureDropPayload,
formatDropScanLabel,
isDropScanCancelledError,
localTreeToDropEntries,
materializeDropEntries,
type DropEntry,
type LocalTreeListEntry,
} from "../../../lib/sftpFileUtils";
function isUploadScanCancelled(controller: UploadController, error?: unknown): boolean {
return controller.isCancelled() || isDropScanCancelledError(error);
}
// Re-export UploadResult for external usage
export type { UploadResult };
type LocalTreeScanOptions = {
onProgress?: (progress: { fileCount: number; directoryCount: number; entryCount: number }) => void;
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
};
function createDropScanCancelledError(): Error {
const error = new Error("Drop scan cancelled");
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
return error;
}
async function listLocalTreeWithAbort(
bridge: NetcattyBridge,
localPath: string,
options: LocalTreeScanOptions = {},
): Promise<LocalTreeListEntry[]> {
const scanId = `drop-scan-${crypto.randomUUID()}`;
const abortSignal = options.abortSignal;
const requestCancel = () => {
void bridge.cancelLocalTreeScan?.(scanId);
};
if (abortSignal?.aborted) {
throw createDropScanCancelledError();
}
abortSignal?.addEventListener("abort", requestCancel, { once: true });
const { abortSignal: _abortSignal, ...bridgeOptions } = options;
try {
return await bridge.listLocalTree!(localPath, { ...bridgeOptions, scanId });
} catch (error) {
if (abortSignal?.aborted || isDropScanCancelledError(error)) {
throw createDropScanCancelledError();
}
throw error;
} finally {
abortSignal?.removeEventListener("abort", requestCancel);
}
}
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { editorTabStore } from "../editorTabStore";
import {
resolveUploadStreamTargetSftpId,
} from "../../../domain/sftpDedicatedStreamPolicy";
import { isSessionError } from "./errors";
import { runWithCompressedUploadSession } from "./compressedUploadSession";
import {
assertUploadEndpointUnchanged,
captureUploadEndpoint,
resolveUploadTargetPane,
type UploadEndpointPin,
} from "./uploadTargetPin";
import {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
registerExternalUploadController,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
import {
isTransferPauseLatched,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
const readSftpFileTransferConcurrency = (): number =>
resolveSftpTransferConcurrency(
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
);
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
};
export function drainUploadConflictResolvers(
resolvers: Map<string, UploadConflictResolver>,
owners: Map<string, UploadController>,
controller?: UploadController,
): string[] {
const canceledIds: string[] = [];
for (const [conflictId, resolver] of [...resolvers]) {
if (controller && owners.get(conflictId) !== controller) continue;
canceledIds.push(conflictId);
resolvers.delete(conflictId);
owners.delete(conflictId);
resolver.resolve("stop");
}
return canceledIds;
}
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const {
ownerId,
getActivePane,
getPaneByConnectionId,
getPaneByTabId,
getTabByConnectionId,
getSideByTabId,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
ensureRemoteSftpId,
resolveConnectedHost,
acquireTransferSession,
clearDirCacheEntry,
useCompressedUpload = false,
isTransferCancelled,
} = params;
/** Connect-time Host for a tab (session overrides), when available. */
const resolveUploadConnectHost = useCallback(
(tabId: string, isLocal: boolean): Host | undefined => {
if (isLocal || !resolveConnectedHost) return undefined;
const host = resolveConnectedHost(tabId);
if (!host || host === "local") return undefined;
return host;
},
[resolveConnectedHost],
);
/**
* Resolve an SFTP id for upload prep (mkdir/stat/conflict checks).
* File bytes use dedicated pool connections inside startStreamTransfer
* so concurrent files can multiplex up to 2 sessions per host.
*/
const resolveRemoteSftpId = useCallback(async (
side: "left" | "right",
options?: { forceReconnect?: boolean; connectionId?: string; tabId?: string },
): Promise<{ sftpId: string | null; release: () => void }> => {
const pane = resolveUploadTargetPane({
side,
tabId: options?.tabId,
connectionId: options?.connectionId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
if (pane.connection.isLocal) return { sftpId: null, release: () => {} };
const connectionId = pane.connection.id;
const pinTabId = options?.tabId ?? pane.id;
// Tab may have moved sides while probing/reconnecting — follow live side.
const reconnectSide = getSideByTabId?.(pinTabId) ?? side;
if (ensureRemoteSftpId) {
const sftpId = await ensureRemoteSftpId(reconnectSide, {
forceReconnect: options?.forceReconnect,
connectionId,
tabId: pinTabId,
});
return { sftpId, release: () => {} };
}
const sftpId = sftpSessionsRef.current.get(connectionId);
if (!sftpId) throw new Error("SFTP session not found");
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
unregisterExternalUploadController(controller);
}, []);
const bindUploadControllerCallbacks = useCallback((
controller: UploadController,
callbacks: UploadCallbacks,
): UploadCallbacks => ({
...callbacks,
onScanningStart: (taskId, info) => {
registerUploadController(taskId, controller);
callbacks.onScanningStart?.(taskId, info);
},
onScanningProgress: (taskId, progress) => {
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller);
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
callbacks.onTaskCreated?.(task);
},
}), [registerUploadController]);
// Temp files downloaded for external editors (even without auto-sync watches).
// Parking / disconnect / reconnect call closeSftp, which deletes these paths —
// forget matching retainers so hasActiveWork does not stick after cleanup.
const externalEditTempsRef = useRef(createExternalEditTempRetention());
const [activeExternalEditCount, setActiveExternalEditCount] = useState(0);
const rememberExternalEditTemp = useCallback((sftpId: string, localPath: string) => {
if (!externalEditTempsRef.current.remember(sftpId, localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTemp = useCallback((localPath: string) => {
if (!externalEditTempsRef.current.forgetPath(localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTempsForSftp = useCallback((sftpId: string) => {
if (!externalEditTempsRef.current.forgetSftp(sftpId)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
// Track every renderer-owned watch id so duplicate opens stay deduplicated
// and panel/window teardown releases the worker-side polling resources.
const stopExternalFileWatch = useCallback(async (watchId: string, cleanupTempFile: boolean) => {
await netcattyBridge.get()?.stopFileWatch?.(watchId, cleanupTempFile);
}, []);
const subscribeExternalFileWatchStopped = useCallback((
callback: (payload: { watchId: string; localPath?: string }) => void,
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
const {
activeCountRef: activeFileWatchCountRef,
captureGeneration: captureExternalFileWatchGeneration,
remember: rememberExternalFileWatch,
releaseAll: releaseExternalFileWatchesBase,
} = useExternalFileWatchLifecycle(
stopExternalFileWatch,
subscribeExternalFileWatchStopped,
// Force-stop (temp deleted / session cleanup) includes localPath — drop retainer.
forgetExternalEditTemp,
);
const releaseExternalFileWatches = useCallback(async (cleanupTempFiles = false) => {
await releaseExternalFileWatchesBase(cleanupTempFiles);
if (externalEditTempsRef.current.clear()) {
setActiveExternalEditCount(0);
}
}, [releaseExternalFileWatchesBase]);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef<Map<string, UploadConflictResolver>>(new Map());
/** Maps conflict id → owning UploadController so cancel A never stops B's prompts. */
const uploadConflictOwnersRef = useRef<Map<string, UploadController>>(new Map());
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const readBinaryFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
return await bridge.readLocalFile(filePath);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) {
throw new Error("Binary file reading not supported");
}
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const writeTextFile = useCallback(
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return;
}
throw new Error("Local file writing not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const writeTextFileByConnection = useCallback(
async (
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
sftpTabId?: string,
): Promise<string> => {
let tabRef = getTabByConnectionId?.(connectionId) ?? null;
let pane = tabRef?.pane ?? getPaneByConnectionId(connectionId);
if (!pane?.connection && sftpTabId) {
const pinned = getPaneByTabId(sftpTabId);
if (pinned?.connection) {
pane = pinned;
const side = getSideByTabId?.(sftpTabId);
if (side) tabRef = { side, tabId: sftpTabId, pane: pinned };
}
}
if (!pane?.connection) {
throw new Error("SFTP connection is no longer available");
}
if (pane.connection.hostId !== expectedHostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) throw new Error("Local file writing not supported");
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return pane.connection.id;
}
const tabId = tabRef?.tabId ?? pane.id;
const side = tabRef?.side ?? getSideByTabId?.(tabId) ?? null;
let sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId && ensureRemoteSftpId && side) {
sftpId = await ensureRemoteSftpId(side, {
connectionId: pane.connection.id,
tabId,
});
}
// Reconnect replaces the pane's connection id — resolve again before write/return.
const refreshedPane = (() => {
if (sftpTabId) {
const pinned = getPaneByTabId(sftpTabId);
if (pinned?.connection) return pinned;
}
return getTabByConnectionId?.(connectionId)?.pane
?? getPaneByConnectionId(connectionId)
?? pane;
})();
if (!refreshedPane?.connection) {
throw new Error("SFTP connection is no longer available");
}
if (refreshedPane.connection.hostId !== expectedHostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
const liveConnectionId = refreshedPane.connection.id;
if (!sftpId) {
sftpId = sftpSessionsRef.current.get(liveConnectionId);
}
if (!sftpId) throw new Error("SFTP session not found");
if (liveConnectionId !== connectionId) {
editorTabStore.remapSessionId(connectionId, liveConnectionId);
}
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
await bridge.writeSftp(
sftpId,
filePath,
content,
filenameEncoding ?? refreshedPane.filenameEncoding,
);
return liveConnectionId;
},
[
ensureRemoteSftpId,
getPaneByConnectionId,
getPaneByTabId,
getSideByTabId,
getTabByConnectionId,
sftpSessionsRef,
],
);
const downloadToTemp = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
): Promise<{ localTempPath: string; sftpId: string; externalTransferId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTempWithProgress) {
throw new Error("SFTP temp download not supported");
}
if (pane.connection.isLocal) {
throw new Error("Temp download is only available for remote files");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
let localTempPath: string;
let wasCancelled = false;
let externalTransferId: string | undefined;
const isLocalTempDownloadCancelled = () =>
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
const cleanupTempDownload = async (filePath: string) => {
if (!bridge.deleteTempFile) return;
try {
await bridge.deleteTempFile(filePath);
} catch (err) {
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
}
};
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
sftpTransferCenterStore.upsertTasks([{
id: externalTransferId,
ownerId,
fileName,
sourcePath: remotePath,
targetPath: "(temp)",
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
retryable: false,
origin: "editor-sync",
background: true,
resumable: true,
phase: "transferring",
}]);
try {
const result = await bridge.downloadSftpToTempWithProgress(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
externalTransferId,
);
wasCancelled = result.cancelled;
localTempPath = result.localPath;
} catch (err) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
throw err;
}
if (wasCancelled) {
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "", sftpId, externalTransferId };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "", sftpId, externalTransferId };
}
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "", sftpId, externalTransferId };
}
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
rememberExternalEditTemp(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
}
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const { localTempPath, sftpId, externalTransferId } = await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) {
return { localTempPath: "" };
}
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
await cleanupFailedExternalOpenTemp(bridge, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
}
throw err;
}
let watchId: string | undefined;
if (options?.enableWatch && bridge.startFileWatch) {
const watchGeneration = captureExternalFileWatchGeneration();
try {
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
watchId = result.watchId;
rememberExternalFileWatch(watchId, watchGeneration);
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
}
return { localTempPath, watchId };
},
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
const openWithSystemDefault = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
options?: { enableWatch?: boolean }
): Promise<void> => {
try {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithSystemDefault) {
throw new Error("System default opening not supported");
}
const bridgeMethods = bridge;
const { localTempPath, sftpId, externalTransferId } = pane.connection.isLocal
? { localTempPath: remotePath, sftpId: "", externalTransferId: undefined }
: await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) return;
let result;
try {
result = await bridgeMethods.openWithSystemDefault(localTempPath);
} catch (error) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
throw error;
}
if (!result.success) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: result.error || "Failed to open file",
speed: 0,
});
}
throw new Error(result.error || "Failed to open file");
}
// Start file watch for remote SFTP auto-sync (mirrors downloadToTempAndOpen behavior)
if (options?.enableWatch && !pane.connection.isLocal && bridgeMethods.startFileWatch) {
const watchGeneration = captureExternalFileWatchGeneration();
try {
const result = await bridgeMethods.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
rememberExternalFileWatch(result.watchId, watchGeneration);
} catch (err) {
console.warn("[SFTP] Failed to start file watch for default app open:", err);
}
}
} catch (err) {
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
targetHostLabel?: string,
): UploadCallbacks => createUploadTaskCallbacks({
ownerId,
connectionId,
targetPath,
targetHostId,
targetHostLabel,
targetConnectionKey,
}), [ownerId]);
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
const resolver = uploadConflictResolversRef.current.get(conflictId);
if (!resolver) return;
uploadConflictResolversRef.current.delete(conflictId);
uploadConflictOwnersRef.current.delete(conflictId);
if (conflict && applyToAll) {
resolver.setDefault(action);
}
resolver.resolve(action);
}, [uploadConflicts]);
const cancelPendingUploadConflicts = useCallback((controller?: UploadController) => {
if (uploadConflictResolversRef.current.size === 0) return;
const canceledIds = drainUploadConflictResolvers(
uploadConflictResolversRef.current,
uploadConflictOwnersRef.current,
controller,
);
if (canceledIds.length === 0) return;
setUploadConflicts((prev) => prev.filter((item) => !canceledIds.includes(item.transferId)));
}, []);
useEffect(() => () => {
drainUploadConflictResolvers(
uploadConflictResolversRef.current,
uploadConflictOwnersRef.current,
);
// Upload controllers are process-level. Their upload finally blocks remove
// them from externalUploadRuntime; panel unmount must not cancel them.
}, []);
const createUploadConflictResolver = useCallback((controller: UploadController) => {
const conflictDefaults = new Map<string, FileConflictAction>();
return async (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
const fileConflict: FileConflict = {
transferId: conflictId,
fileName: conflict.fileName,
sourcePath: "local",
targetPath: conflict.targetPath,
isDirectory: conflict.isDirectory,
existingType: conflict.existingType,
applyToAllCount: conflict.applyToAllCount,
existingSize: conflict.existingSize,
newSize: conflict.newSize,
existingModified: conflict.existingModified,
newModified: conflict.newModified,
};
setUploadConflicts((prev) => [...prev, fileConflict]);
return new Promise<FileConflictAction>((resolve) => {
uploadConflictOwnersRef.current.set(conflictId, controller);
uploadConflictResolversRef.current.set(conflictId, {
resolve,
setDefault: (action) => {
conflictDefaults.set(conflictType, action);
},
});
controller.addCancelListener(() => {
cancelPendingUploadConflicts(controller);
});
});
};
}, [cancelPendingUploadConflicts]);
// Create upload bridge that wraps netcattyBridge.
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
// (session hostname/port/user overrides), not the vault entry by hostId alone.
const createUploadBridge = useCallback((
connectHost?: Host,
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
): UploadBridge => {
const bridge = netcattyBridge.get();
return {
managesTransferLifecycle: Boolean(
bridge?.startStreamTransfer,
),
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
// No-follow stats so Replace unlinks a same-named symlink instead of
// writing through it (uploadService prefers lstat* over followed stat*).
lstatLocal: bridge?.lstatLocal,
deleteLocalFile: bridge?.deleteLocalFile,
stageUploadFile: bridge?.stageUploadFile,
cancelStagedUploadFile: bridge?.cancelStagedUploadFile,
deleteTempFile: bridge?.deleteTempFile,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
statSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
// Only wire when present so uploadService can fall back via `lstat ?? stat`.
lstatSftp: bridge?.lstatSftp
? async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.lstatSftp) return null;
return b.lstatSftp(sftpId, path);
}
: undefined,
deleteSftp: async (sftpId: string, path: string, expectedType) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path, undefined, expectedType);
}
},
// Stream transfer for large files (avoids loading into memory).
// FileZilla-style: each concurrent file acquires a dedicated transfer
// session (max 2/host) so the browse connection stays free.
startStreamTransfer: bridge?.startStreamTransfer
? async (options) => {
const b = netcattyBridge.get();
if (!b?.startStreamTransfer) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
const wantPool =
!!acquireTransferSession
&& options.targetType === "sftp"
&& !!options.targetHostId;
// Acquire pool lease *inside* admission so queued uploads do not
// pin dedicated connections while waiting for a scheduler slot.
try {
return await globalSftpTransferScheduler.run(
ownerId,
options.transferId,
getSftpTransferResourceKeys(options),
readTransferConcurrency,
async () => {
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
try {
if (wantPool && acquireTransferSession && options.targetHostId) {
// Never fall back to the browse/prep session for bulk
// streams — that path dies when the SFTP/terminal tab closes.
// Pass connectHost so session-time hostname/port/user
// overrides open the pinned endpoint, not vault-only hostId.
lease = await acquireTransferSession(
options.targetHostId,
options.transferId,
connectHost,
);
}
const resolvedTarget = resolveUploadStreamTargetSftpId({
requirePool: wantPool,
poolSftpId: lease?.sftpId,
prepSftpId: options.targetSftpId,
});
if (resolvedTarget.error) {
throw new Error(resolvedTarget.error);
}
const transferOptions = {
...options,
targetSftpId: resolvedTarget.sftpId,
// Already admitted by globalSftpTransferScheduler.
skipAdmission: true as const,
};
const result = await b.startStreamTransfer!(transferOptions);
// Dead session → drop from pool so the next file opens fresh.
if (result?.error && isSessionError(new Error(result.error))) {
lease?.discard();
lease = null;
}
return result;
} catch (error) {
if (isSessionError(error)) {
lease?.discard();
lease = null;
}
throw error;
} finally {
lease?.release();
}
},
);
} catch (error) {
return {
transferId: options.transferId,
error: error instanceof Error ? error.message : String(error),
};
}
}
: undefined,
cancelTransfer: bridge?.cancelTransfer,
};
}, [acquireTransferSession, ownerId]);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
// DataTransfer is only valid during the drop event. Capture roots BEFORE
// any await (session reconnect can take seconds while a transfer is busy).
// Native tree expansion (listLocalTree) happens after the scanning UI is up.
const dropPayload = captureDropPayload(dataTransfer);
if (dropPayload.roots.length === 0 && dropPayload.filesFallback.length === 0) {
return [];
}
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
pane.connection.isLocal ? undefined : pane.connection.hostLabel,
),
);
const pathBackedFolderRoots = dropPayload.roots
.filter((root) => root.isDirectory && !!root.localPath)
.map((root) => ({ name: root.name, localPath: root.localPath! }));
// Progressive only when *every* root is a path-backed folder. Mixed drops
// (folder + files, path-less dirs) must fall through to materialize so
// sibling items are not silently dropped.
const canProgressiveUpload = pathBackedFolderRoots.length > 0
&& pathBackedFolderRoots.length === dropPayload.roots.length
&& !!bridge.listLocalTree
&& !useCompressedUpload;
const needsDeepScan = dropPayload.roots.some((root) => root.isDirectory);
const scanLabel = formatDropScanLabel(dropPayload.roots);
// Instant feedback: scanning / parent row appears before work begins.
const scanningTask = needsDeepScan
? startUploadScanningTask(callbacks, crypto.randomUUID(), { label: scanLabel })
: null;
if (scanningTask && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask?.isOpen()) scanningTask.cancel();
});
// Path-backed folders (no compressed upload): edge-scan + edge-transfer.
// Open the SFTP session first, then stream discovery batches into upload
// workers so the first files start while the rest of the tree is still walking.
if (canProgressiveUpload) {
const runProgressive = async (forceReconnect = false): Promise<UploadResult[]> => {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(
connectHost,
readSftpFileTransferConcurrency,
);
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const parentTaskIds = new Map<string, string>();
if (scanningTask && pathBackedFolderRoots.length === 1) {
parentTaskIds.set(pathBackedFolderRoots[0].name, scanningTask.taskId);
// Keep the scanning row as the folder parent (do not dismiss it).
scanningTask.complete = () => {
/* no-op: progressive path settles via onTaskCompleted/Cancelled */
};
} else if (scanningTask?.isOpen()) {
scanningTask.complete();
}
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const results = await uploadLocalFoldersProgressively(
pathBackedFolderRoots,
{
targetPath: liveTargetPath,
sftpId,
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: liveCallbacks,
parentTaskIds,
abortSignal: scanAbort.signal,
// Soft-pause must freeze discovery enqueue + child UI rows, not
// only the open streams (otherwise Pause still floods the queue).
waitWhilePaused: (parentTaskId) => waitWhileTransferOrRootPaused(parentTaskId),
isPaused: (parentTaskId) => isTransferPauseLatched(parentTaskId),
resolveConflict: createUploadConflictResolver(controller),
listLocalTree: (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
}),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} finally {
release();
}
};
try {
return await runProgressive(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Progressive upload session lost; reconnecting once", error);
try {
return await runProgressive(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Progressive folder upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
}
let capturedEntries: DropEntry[] = [];
try {
const scanT0 = performance.now();
capturedEntries = await materializeDropEntries(dropPayload, {
abortSignal: scanAbort.signal,
isCancelled: () => controller.isCancelled(),
listLocalTree: bridge.listLocalTree
? (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
})
: undefined,
onProgress: scanningTask
? (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: progress.label || scanLabel,
});
}
: undefined,
});
logger.debug(
`[SFTP:perf] materializeDropEntries — ${capturedEntries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`,
);
} catch (error) {
if (scanningTask?.isOpen()) {
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
} else {
scanningTask.fail(error);
}
}
detachScanCancel();
unregisterUploadController(controller);
if (isUploadScanCancelled(controller, error)) {
return [{ fileName: "", success: false, cancelled: true }];
}
logger.error("[SFTP] Failed to read dropped files:", error);
throw error;
}
if (controller.isCancelled()) {
scanningTask?.cancel();
detachScanCancel();
unregisterUploadController(controller);
return [{ fileName: "", success: false, cancelled: true }];
}
if (capturedEntries.length === 0) {
scanningTask?.complete();
detachScanCancel();
unregisterUploadController(controller);
return [];
}
// Keep scanning visible through session open so the UI never goes blank
// between "N files found" and the real transfer rows.
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
// Retarget callbacks if the pane path/id changed after reconnect.
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = scanningTask
? {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
}
: liveCallbacks;
let transferSucceeded = false;
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const hasDirectory = capturedEntries.some((entry) => (
entry.isDirectory || entry.relativePath.replace(/\\/g, "/").includes("/")
));
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
hasDirectory,
isLocal: livePane.connection.isLocal,
hostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
capturedEntries,
{
targetPath: liveTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
controller,
),
});
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
transferSucceeded = true;
return results;
} finally {
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else if (transferSucceeded) scanningTask.complete();
}
release();
}
};
try {
return await run(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Upload session lost; reconnecting and retrying once", error);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
} else if (scanningTask?.isOpen()) {
scanningTask.fail(retryError);
}
throw retryError;
}
}
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.fail(error);
}
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
},
[
acquireTransferSession,
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadBridge,
createUploadCallbacks,
bindUploadControllerCallbacks,
unregisterUploadController,
createUploadConflictResolver,
ensureRemoteSftpId,
getActivePane,
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
useCompressedUpload,
],
);
// Upload from a FileList. This keeps the original File objects from the file
// picker so Electron can resolve local file paths for stream uploads.
const uploadExternalFileList = useCallback(
async (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string,
): Promise<UploadResult[]> => {
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) throw new Error("No active connection");
if (!netcattyBridge.get()) throw new Error("Bridge not available");
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) throw new Error("No active connection");
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
try {
const files = Array.from(fileList);
const hasDirectory = files.some((file) => (
!!file.webkitRelativePath && file.webkitRelativePath.replace(/\\/g, "/").includes("/")
));
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
hasDirectory,
isLocal: livePane.connection.isLocal,
hostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadFromFileList(
fileList,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
controller,
),
});
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} finally {
release();
unregisterUploadController(controller);
}
};
try {
return await run(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] File picker upload session lost; reconnecting and retrying once", error);
return await run(true);
}
logger.error("[SFTP] File picker upload failed:", error);
throw error;
}
},
[
acquireTransferSession,
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadBridge,
createUploadCallbacks,
bindUploadControllerCallbacks,
unregisterUploadController,
createUploadConflictResolver,
ensureRemoteSftpId,
getActivePane,
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
useCompressedUpload,
],
);
const uploadExternalFolderPath = useCallback(
async (
side: "left" | "right",
folderPath: string,
targetPath?: string,
options?: { connectionId?: string; tabId?: string; endpointPin?: UploadEndpointPin },
): Promise<UploadResult[]> => {
// Pin before any await so tab switches cannot retarget multi-folder pastes.
const originatingPane = resolveUploadTargetPane({
side,
tabId: options?.tabId,
connectionId: options?.connectionId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
const originatingTabId = originatingPane.id;
// Prefer the pin captured when the paste dialog opened so multi-folder
// uploads keep the original endpoint even if later calls re-resolve a
// retargeted tab.
const originatingEndpoint = options?.endpointPin ?? captureUploadEndpoint(
originatingPane.connection,
connectionCacheKeyMapRef.current,
);
assertUploadEndpointUnchanged(
originatingPane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const initialUploadTargetPath = targetPath || originatingPane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
originatingPane.connection.id,
initialUploadTargetPath,
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostId,
originatingPane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(originatingPane.connection.id),
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostLabel,
),
);
const folderName = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop()
|| folderPath;
const scanningTask = startUploadScanningTask(callbacks, crypto.randomUUID(), {
label: folderName,
});
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask.isOpen()) scanningTask.cancel();
});
let capturedEntries: DropEntry[] | null = null;
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
let release = () => {};
try {
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
const resolved = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
const sftpId = resolved.sftpId;
release = resolved.release;
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
// Pin connect-time Host before listLocalTree: a slow folder scan can
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
// otherwise open the pooled stream bridge on the newly selected endpoint.
const uploadConnectHost = resolveUploadConnectHost(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const liveCallbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
};
if (capturedEntries === null) {
const localEntries = await listLocalTreeWithAbort(bridge, folderPath, {
abortSignal: scanAbort.signal,
onProgress: (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: folderName,
});
},
});
capturedEntries = localTreeToDropEntries(localEntries);
}
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
hasDirectory: true,
isLocal: livePane.connection.isLocal,
hostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
capturedEntries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
controller,
),
});
if (clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === livePane.connection.currentPath) {
const refreshSide = getSideByTabId?.(uploadPaneId) ?? side;
await refresh(refreshSide, { tabId: uploadPaneId });
}
return results;
} finally {
release();
}
};
try {
return await run(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Folder upload session lost; reconnecting and retrying once", error);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
}
finally {
detachScanCancel();
unregisterUploadController(controller);
if (scanningTask.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.complete();
}
}
},
[
acquireTransferSession,
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadBridge,
createUploadCallbacks,
bindUploadControllerCallbacks,
unregisterUploadController,
createUploadConflictResolver,
ensureRemoteSftpId,
getActivePane,
getPaneByConnectionId,
getPaneByTabId,
getSideByTabId,
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
useCompressedUpload,
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
entries: DropEntry[],
options?: {
targetPath?: string;
connectionId?: string;
tabId?: string;
endpointPin?: UploadEndpointPin;
strictConnectionPin?: boolean;
},
): Promise<UploadResult[]> => {
// Pin before any await so tab switches cannot retarget the upload.
const originatingPane = resolveUploadTargetPane({
side,
tabId: options?.tabId,
connectionId: options?.connectionId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
const originatingTabId = originatingPane.id;
const originatingEndpoint = options?.endpointPin ?? captureUploadEndpoint(
originatingPane.connection,
connectionCacheKeyMapRef.current,
{ pinConnectionId: options?.strictConnectionPin },
);
assertUploadEndpointUnchanged(
originatingPane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
if (!netcattyBridge.get()) throw new Error("Bridge not available");
const { sftpId, release } = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
// Capture the pane ID now so we can refresh the correct tab after
// upload, even if focus switches during the transfer.
const uploadPaneId = livePane.id;
const controller = new UploadController();
const uploadTargetPath = options?.targetPath || livePane.connection.currentPath;
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const directUploadBridge = createUploadBridge(connectHost);
try {
const hasDirectory = entries.some((entry) => (
entry.isDirectory || entry.relativePath.replace(/\\/g, "/").includes("/")
));
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
hasDirectory,
isLocal: livePane.connection.isLocal,
hostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: directUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
controller,
),
});
// Refresh the specific tab that initiated the upload (not whichever
// tab is active now — focus may have switched during the transfer).
// Also invalidate the upload target's cache entry so returning to
// that path triggers a fresh listing.
if (clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === livePane.connection.currentPath) {
const refreshSide = getSideByTabId?.(uploadPaneId) ?? side;
await refresh(refreshSide, { tabId: uploadPaneId });
}
return results;
} finally {
release();
unregisterUploadController(controller);
}
};
try {
return await run(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Entry upload session lost; reconnecting and retrying once", error);
return await run(true);
}
logger.error("[SFTP] Upload failed:", error);
throw error;
}
},
[
acquireTransferSession,
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadBridge,
createUploadCallbacks,
bindUploadControllerCallbacks,
unregisterUploadController,
createUploadConflictResolver,
ensureRemoteSftpId,
getActivePane,
getPaneByConnectionId,
getPaneByTabId,
getSideByTabId,
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async (taskId?: string) => {
if (taskId) {
const controller = getExternalUploadController(taskId);
if (controller) {
logger.info("[SFTP] Cancelling external upload", { taskId });
cancelPendingUploadConflicts(controller);
}
} else {
logger.info("[SFTP] Cancelling all external uploads");
cancelPendingUploadConflicts();
}
await cancelExternalUploadRuntime(taskId);
}, [cancelPendingUploadConflicts]);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return null;
}
return await bridge.selectApplication();
},
[],
);
return {
readTextFile,
readBinaryFile,
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
};
};