Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1907 lines
70 KiB
TypeScript
1907 lines
70 KiB
TypeScript
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,
|
|
};
|
|
};
|