mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675d7d7489 | ||
|
|
c72fde439e | ||
|
|
6c40232556 | ||
|
|
c98bd62dae | ||
|
|
269a79683a | ||
|
|
cda594263b | ||
|
|
5b56871450 | ||
|
|
2df507fda1 |
No files matched your search
@@ -133,6 +133,11 @@ export type OpenTransferSftpSessionOptions = {
|
||||
* credentials. When false, try sourceSessionId first (shared transport).
|
||||
*/
|
||||
dedicated?: boolean;
|
||||
/**
|
||||
* Connect-time jump-host snapshots. When set, this session dials through the
|
||||
* pinned chain the pane connected with, not the live (possibly edited) chain.
|
||||
*/
|
||||
jumpHosts?: Host[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -159,6 +164,7 @@ export async function openTransferSftpSession(
|
||||
identities: [...deps.identities],
|
||||
knownHosts: deps.knownHosts ? [...deps.knownHosts] : undefined,
|
||||
terminalSettings: deps.terminalSettings,
|
||||
jumpHostsOverride: options?.jumpHosts,
|
||||
});
|
||||
// Shared transport path: open an SFTP channel on the already-authenticated
|
||||
// terminal connection. The transport registry lease keeps the conn alive
|
||||
|
||||
@@ -137,11 +137,26 @@ async function tryStatTransferTarget(
|
||||
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
|
||||
}
|
||||
|
||||
export interface TransferConnectHosts {
|
||||
source?: Host;
|
||||
target?: Host;
|
||||
/**
|
||||
* Connect-time jump-host chain snapshots. Pinned together with the top-level
|
||||
* host so leases opened after a bastion edit still dial through the chain the
|
||||
* pane's session connected with (private hostnames can resolve differently
|
||||
* per bastion). Present only when the matching endpoint is pinned.
|
||||
*/
|
||||
sourceChain?: Host[];
|
||||
targetChain?: Host[];
|
||||
}
|
||||
|
||||
export type AcquireTransferSessionFn = (
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
/** Connect-time host (session hostname/port/user overrides). Prefer over vault. */
|
||||
connectHost?: Host,
|
||||
/** Connect-time jump-host snapshots; undefined re-resolves the live chain. */
|
||||
jumpHosts?: Host[],
|
||||
) => Promise<TransferConnectionLease>;
|
||||
|
||||
interface UseSftpDirectoryTransferOpsParams {
|
||||
@@ -268,6 +283,7 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
connectHosts?: TransferConnectHosts,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -315,10 +331,14 @@ export function useSftpDirectoryTransferOps({
|
||||
// Dedicated pool only for remote ends — panel/browse sessions die when
|
||||
// the SFTP tab is closed, which freezes global transfer center rows.
|
||||
if (acquireTransferSession && !sourceIsLocal && task.sourceHostId) {
|
||||
sourceLease = await acquireTransferSession(task.sourceHostId, task.id);
|
||||
sourceLease = await acquireTransferSession(
|
||||
task.sourceHostId, task.id, connectHosts?.source, connectHosts?.sourceChain,
|
||||
);
|
||||
}
|
||||
if (acquireTransferSession && !targetIsLocal && task.targetHostId) {
|
||||
targetLease = await acquireTransferSession(task.targetHostId, task.id);
|
||||
targetLease = await acquireTransferSession(
|
||||
task.targetHostId, task.id, connectHosts?.target, connectHosts?.targetChain,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -541,6 +561,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
connectHosts?: TransferConnectHosts,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -698,6 +719,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks,
|
||||
progress,
|
||||
traversal,
|
||||
connectHosts,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -925,6 +947,7 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
connectHosts,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
|
||||
@@ -42,6 +42,12 @@ interface UseSftpConnectionsParams {
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when each tab connected, keyed by tab
|
||||
* id. Same lifecycle as connectedHostByTabIdRef; used to keep transfer
|
||||
* session opens on the pane's connect-time bastion chain after chain edits.
|
||||
*/
|
||||
connectedHostChainByTabIdRef?: MutableRefObject<Map<string, Host[]>>;
|
||||
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
|
||||
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
@@ -415,12 +421,15 @@ export async function closeSftpTabLifecycle(params: {
|
||||
connectRequests: Map<string, symbol>;
|
||||
connectInFlight: Map<string, Promise<void>>;
|
||||
connectedHosts: { delete: (tabId: string) => boolean };
|
||||
/** Connect-time jump-host chain snapshots, same tab-id lifecycle. */
|
||||
connectedHostChains?: { delete: (tabId: string) => boolean };
|
||||
closeTab: (side: "left" | "right") => void;
|
||||
releaseConnection: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
|
||||
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
|
||||
params.connectedHosts.delete(params.tabId);
|
||||
params.connectedHostChains?.delete(params.tabId);
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: params.requestedSide,
|
||||
tabId: params.tabId,
|
||||
@@ -581,6 +590,7 @@ export const useSftpConnections = ({
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
connectedHostChainByTabIdRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
@@ -863,6 +873,24 @@ export const useSftpConnections = ({
|
||||
// Always remember the full connect-time Host for this tab so background
|
||||
// upload reconnects can restore session-time overrides (not just vault hostId).
|
||||
connectedHostByTabIdRef.current.set(activeTabId, host);
|
||||
// Snapshot the resolved jump-host chain so retained transfer leases keep
|
||||
// dialing the chain this tab connected through, even after one of the
|
||||
// bastion hosts is edited (private hostnames can resolve differently
|
||||
// per bastion — the live chain must not silently replace this one).
|
||||
if (connectedHostChainByTabIdRef) {
|
||||
if (host !== "local" && host.hostChain?.hostIds?.length) {
|
||||
const chain = host.hostChain.hostIds
|
||||
.map((chainId) => hosts.find((candidate) => candidate.id === chainId))
|
||||
.filter((candidate): candidate is Host => !!candidate);
|
||||
if (chain.length === host.hostChain.hostIds.length) {
|
||||
connectedHostChainByTabIdRef.current.set(activeTabId, chain);
|
||||
} else {
|
||||
connectedHostChainByTabIdRef.current.delete(activeTabId);
|
||||
}
|
||||
} else {
|
||||
connectedHostChainByTabIdRef.current.delete(activeTabId);
|
||||
}
|
||||
}
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
// by connectionId instead of relying on the per-side lastConnectedHostRef.
|
||||
if (host !== "local") {
|
||||
@@ -1426,6 +1454,7 @@ export const useSftpConnections = ({
|
||||
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
connectedHostChainByTabIdRef?.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal && sftpId) {
|
||||
try {
|
||||
|
||||
@@ -148,6 +148,7 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef,
|
||||
ensureRemoteSftpId,
|
||||
resolveConnectedHost,
|
||||
resolveConnectedChain,
|
||||
acquireTransferSession,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload = false,
|
||||
@@ -165,6 +166,15 @@ export const useSftpExternalOperations = (
|
||||
[resolveConnectedHost],
|
||||
);
|
||||
|
||||
/** Jump-host chain the tab dialed at connect time, when still meaningful. */
|
||||
const resolveUploadConnectChain = useCallback(
|
||||
(tabId: string, isLocal: boolean): Host[] | undefined => {
|
||||
if (isLocal || !resolveConnectedChain) return undefined;
|
||||
return resolveConnectedChain(tabId) ?? undefined;
|
||||
},
|
||||
[resolveConnectedChain],
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve an SFTP id for upload prep (mkdir/stat/conflict checks).
|
||||
* File bytes use dedicated pool connections inside startStreamTransfer
|
||||
@@ -843,6 +853,7 @@ export const useSftpExternalOperations = (
|
||||
// (session hostname/port/user overrides), not the vault entry by hostId alone.
|
||||
const createUploadBridge = useCallback((
|
||||
connectHost?: Host,
|
||||
connectChain?: Host[],
|
||||
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -920,6 +931,7 @@ export const useSftpExternalOperations = (
|
||||
options.targetHostId,
|
||||
options.transferId,
|
||||
connectHost,
|
||||
connectChain,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1042,8 +1054,10 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(
|
||||
connectHost,
|
||||
connectChain,
|
||||
readSftpFileTransferConcurrency,
|
||||
);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
@@ -1209,7 +1223,8 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
|
||||
// Retarget callbacks if the pane path/id changed after reconnect.
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
@@ -1252,7 +1267,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1331,6 +1346,7 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1367,7 +1383,8 @@ export const useSftpExternalOperations = (
|
||||
),
|
||||
);
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
|
||||
try {
|
||||
const files = Array.from(fileList);
|
||||
@@ -1382,7 +1399,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadFromFileList(
|
||||
@@ -1441,6 +1458,7 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1557,7 +1575,11 @@ export const useSftpExternalOperations = (
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost);
|
||||
const uploadConnectChain = resolveUploadConnectChain(
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost, uploadConnectChain);
|
||||
const liveCallbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
@@ -1603,7 +1625,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost, uploadConnectChain)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1687,6 +1709,7 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1775,7 +1798,8 @@ export const useSftpExternalOperations = (
|
||||
),
|
||||
);
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const directUploadBridge = createUploadBridge(connectHost);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const directUploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
|
||||
try {
|
||||
const hasDirectory = entries.some((entry) => (
|
||||
@@ -1789,7 +1813,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1856,6 +1880,7 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface UseSftpExternalOperationsParams {
|
||||
* Used so pooled stream uploads open the pinned browse endpoint.
|
||||
*/
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when the tab connected, keyed by tab id.
|
||||
* Lets pooled uploads keep dialing the pane's connect-time bastion chain
|
||||
* after a chain host is edited.
|
||||
*/
|
||||
resolveConnectedChain?: (tabId: string) => Host[] | null | undefined;
|
||||
/**
|
||||
* FileZilla-style dedicated transfer sessions for bulk uploads.
|
||||
* When set, remote stream uploads prefer pool connections (1–2/host)
|
||||
@@ -41,6 +47,7 @@ export interface UseSftpExternalOperationsParams {
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
connectHost?: Host,
|
||||
connectChain?: Host[],
|
||||
) => Promise<{ sftpId: string; release: () => void; discard: () => void }>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
|
||||
@@ -707,3 +707,30 @@ test("imported IdentityFile paths remain usable when agent login is disabled", (
|
||||
assert.equal(credentials.useSshAgent, false);
|
||||
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials pins the captured jump chain over edited vault hosts", () => {
|
||||
const oldBastion = host({ id: "jump-1", hostname: "bastion-internal.old" });
|
||||
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [editedBastion],
|
||||
keys: [],
|
||||
identities: [],
|
||||
jumpHostsOverride: [oldBastion],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.old");
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostId, "jump-1");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials re-resolves the live chain without a pinned snapshot", () => {
|
||||
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [editedBastion],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.edited");
|
||||
});
|
||||
@@ -38,7 +38,17 @@ export const buildSftpHostCredentials = ({
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
jumpHostsOverride,
|
||||
}: UseSftpHostCredentialsParams & {
|
||||
host: Host;
|
||||
/**
|
||||
* Connect-time jump-host snapshots. When set, the chain resolves from these
|
||||
* pinned Host objects instead of the live `hosts` array, so leases opened
|
||||
* after a bastion edit keep connecting through the chain the pane's session
|
||||
* dialed — private hostnames can resolve differently per bastion.
|
||||
*/
|
||||
jumpHostsOverride?: Host[];
|
||||
}): NetcattySSHOptions => {
|
||||
const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) };
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
@@ -57,12 +67,21 @@ export const buildSftpHostCredentials = ({
|
||||
? resolveProxyConfigAuth(host.proxyConfig, identities)
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds.map((hostId) => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
const resolveChainHost = (hostId: string): Host => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
return jumpHost;
|
||||
};
|
||||
// A non-empty pinned chain snapshot wins over live hostChain resolution.
|
||||
const chainHosts: Host[] | null = jumpHostsOverride && jumpHostsOverride.length > 0
|
||||
? jumpHostsOverride
|
||||
: host.hostChain?.hostIds && host.hostChain.hostIds.length > 0
|
||||
? host.hostChain.hostIds.map(resolveChainHost)
|
||||
: null;
|
||||
if (chainHosts) {
|
||||
jumpHosts = chainHosts.map((jumpHost, index) => {
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
@@ -72,8 +91,6 @@ export const buildSftpHostCredentials = ({
|
||||
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
@@ -83,6 +84,160 @@ function installGlobals(netty: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
for (const pinned of [false, true]) {
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`${pinned ? "existing" : "new"} dual-pane ${direction} preserves its endpoint after host edits`, async () => {
|
||||
const acquired: string[] = [];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => direction === "download"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
statLocal: async () => direction === "upload"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
// These pane callbacks stay stable in the app while the saved hosts change.
|
||||
const getActivePane = (side: "left" | "right") => makePane(side);
|
||||
const getPaneByConnectionId = () => null;
|
||||
const getTabByConnectionId = () => null;
|
||||
const updateTab = () => undefined;
|
||||
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
|
||||
const pinnedHost = { id: "host-1", hostname: "old-endpoint" } as Host;
|
||||
const pinnedChain = [{ id: "jump-1", hostname: "old-bastion" } as Host];
|
||||
const acquiredChains: string[] = [];
|
||||
const acquire = (savedEndpoint: string) => async (
|
||||
_hostId: string, _jobId: string, connectHost?: Host, connectChain?: Host[],
|
||||
) => {
|
||||
const endpoint = connectHost?.hostname ?? savedEndpoint;
|
||||
acquired.push(endpoint);
|
||||
acquiredChains.push(connectChain?.map((hop) => hop.hostname).join(" -> ") ?? "unpinned");
|
||||
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
|
||||
};
|
||||
const original = acquire("old-endpoint");
|
||||
const refreshed = acquire("new-endpoint");
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe({ acquireSession }: { acquireSession: typeof original }) {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `refresh-host-${direction}`,
|
||||
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
|
||||
refresh: async () => undefined, clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined, sftpSessionsRef,
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
acquireTransferSession: acquireSession,
|
||||
resolveConnectedHost: () => pinned ? pinnedHost : null,
|
||||
resolveConnectedChain: pinned ? () => pinnedChain : undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, { acquireSession: original })); });
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { acquireSession: refreshed })); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
|
||||
});
|
||||
assert.equal(acquired.length, 2, "both the work lease and file lease must use the pane endpoint");
|
||||
assert.ok(acquired.every((endpoint) => endpoint === (pinned ? "old-endpoint" : "new-endpoint")), JSON.stringify(acquired));
|
||||
if (pinned) {
|
||||
assert.ok(
|
||||
acquiredChains.every((chain) => chain === "old-bastion"),
|
||||
`the pinned jump chain must ride along with the pinned endpoint: ${JSON.stringify(acquiredChains)}`,
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
acquiredChains.every((chain) => chain === "unpinned"),
|
||||
`no chain may attach when the connect host is unresolved: ${JSON.stringify(acquiredChains)}`,
|
||||
);
|
||||
}
|
||||
assert.equal(started.length, 1);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`dual-pane ${direction} started during a reconnect falls back to the pane endpoint, not the incoming tab host`, async () => {
|
||||
const acquired: string[] = [];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => direction === "download"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
statLocal: async () => direction === "upload"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const getActivePane = (side: "left" | "right") => makePane(side);
|
||||
const getPaneByConnectionId = () => null;
|
||||
const getTabByConnectionId = () => null;
|
||||
const updateTab = () => undefined;
|
||||
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
|
||||
// connect() has already recorded the incoming endpoint for this tab id
|
||||
// while the pane still shows the old connection (reconnect in flight).
|
||||
const incomingHost = { id: "host-2", hostname: "reconnect-endpoint" } as Host;
|
||||
const acquire = (savedEndpoint: string) => async (_hostId: string, _jobId: string, connectHost?: Host) => {
|
||||
const endpoint = connectHost?.hostname ?? savedEndpoint;
|
||||
acquired.push(endpoint);
|
||||
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
|
||||
};
|
||||
const acquireSession = acquire("saved-endpoint");
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `reconnect-host-${direction}`,
|
||||
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
|
||||
refresh: async () => undefined, clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined, sftpSessionsRef,
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
acquireTransferSession: acquireSession,
|
||||
resolveConnectedHost: () => incomingHost,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
|
||||
});
|
||||
assert.equal(acquired.length, 2, "both the work lease and file lease must be acquired");
|
||||
assert.ok(
|
||||
acquired.every((endpoint) => endpoint === "saved-endpoint"),
|
||||
`the incoming endpoint must not capture the transfer: ${JSON.stringify(acquired)}`,
|
||||
);
|
||||
assert.equal(started.length, 1);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction
|
||||
import {
|
||||
FileConflict,
|
||||
FileConflictAction,
|
||||
Host,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
@@ -25,7 +26,10 @@ import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import {
|
||||
useSftpDirectoryTransferOps,
|
||||
type TransferConnectHosts,
|
||||
} from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -78,6 +82,78 @@ const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
// A "local" or missing resolved host means the dedicated pool should fall
|
||||
// back to the saved vault host; only a concrete Host pins the endpoint.
|
||||
const toConnectHost = (host: Host | "local" | null | undefined): Host | undefined =>
|
||||
host && host !== "local" ? host : undefined;
|
||||
|
||||
// Stable identity of a pinned proxy transport. Two endpoints sharing
|
||||
// id/hostname/port/username can still dial through different proxies after an
|
||||
// edit; remote DNS behind each proxy may resolve the same hostname to a
|
||||
// different server, so a same-host `cp` pinned to the source route could write
|
||||
// on the wrong one.
|
||||
const proxyIdentity = (p: Host["proxyConfig"]): string =>
|
||||
p
|
||||
? JSON.stringify([p.type, p.host, p.port, p.command ?? "", p.identityId ?? "", p.username ?? "", p.password ?? ""])
|
||||
: "";
|
||||
|
||||
// Stable identity of a pinned jump chain. buildCacheKey omits the chain, so
|
||||
// two panes on the same saved host can share a cache key while pinning
|
||||
// different bastion routes (e.g. one connected before a jump-host edit, one
|
||||
// after) — those routes may reach different servers. Compare identities, not
|
||||
// just cache keys, before allowing any same-host optimization. proxyConfig
|
||||
// must be part of the identity: buildSftpHostCredentials forwards it as the
|
||||
// hop's proxy transport, so two hops sharing id/hostname/port/username can
|
||||
// still dial through different proxies after an edit.
|
||||
const chainIdentity = (chain: Host[] | undefined): string =>
|
||||
(chain ?? []).map(h => `${h.id}@${h.hostname}:${h.port ?? ""}:${h.username}:${proxyIdentity(h.proxyConfig)}`).join("→");
|
||||
|
||||
// Only pin a concrete endpoint while the pane's live connection still belongs
|
||||
// to it. connect() records the incoming tab host before it awaits closure of
|
||||
// the old session and replaces the pane's connection, so during that window a
|
||||
// transfer must not capture endpoint B while its pane snapshot, task paths,
|
||||
// and plan still belong to endpoint A; the pool then falls back to the saved
|
||||
// vault host for the pane's own hostId (mirrors resolveSftpReconnectHost's
|
||||
// tab-host-vs-connection guard).
|
||||
const toConnectHostForPane = (
|
||||
pane: SftpPane,
|
||||
host: Host | "local" | null | undefined,
|
||||
): Host | undefined => {
|
||||
const connection = pane.connection;
|
||||
if (!connection || connection.isLocal) return undefined;
|
||||
return host && host !== "local" && host.id === connection.hostId ? host : undefined;
|
||||
};
|
||||
|
||||
// Snapshot the panes' connect-time endpoints. Called before any preflight
|
||||
// await: tab ids survive reconnect, so resolving this map later can return a
|
||||
// different endpoint than the one the pane snapshot, task paths, and transfer
|
||||
// plan belong to.
|
||||
const captureConnectHosts = (
|
||||
resolveConnectedHost: UseSftpTransfersParams["resolveConnectedHost"],
|
||||
resolveConnectedChain: UseSftpTransfersParams["resolveConnectedChain"],
|
||||
...panes: SftpPane[]
|
||||
): TransferConnectHosts => {
|
||||
const captureEndpoint = (
|
||||
pane: SftpPane | undefined,
|
||||
): { host?: Host; chain?: Host[] } => {
|
||||
if (!pane) return {};
|
||||
const host = toConnectHostForPane(pane, resolveConnectedHost?.(pane.id));
|
||||
if (!host) return {};
|
||||
// Pin only the chain captured with this tab's connection; a later edit of
|
||||
// its bastion hosts must not redirect retained transfers (private
|
||||
// hostnames can resolve differently behind each bastion).
|
||||
return { host, chain: resolveConnectedChain?.(pane.id) ?? undefined };
|
||||
};
|
||||
const source = captureEndpoint(panes[0]);
|
||||
const target = captureEndpoint(panes[1]);
|
||||
return {
|
||||
source: source.host,
|
||||
sourceChain: source.chain,
|
||||
target: target.host,
|
||||
targetChain: target.chain,
|
||||
};
|
||||
};
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -187,6 +263,8 @@ export const useSftpTransfers = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
acquireTransferSession,
|
||||
resolveConnectedHost,
|
||||
resolveConnectedChain,
|
||||
}: UseSftpTransfersParams): UseSftpTransfersResult => {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
@@ -476,6 +554,8 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
/** Connect-time endpoint snapshot captured before any preflight await. */
|
||||
connectHostsOverride?: TransferConnectHosts,
|
||||
): Promise<TransferStatus> => {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
return "cancelled";
|
||||
@@ -534,7 +614,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
let walkStatus: TransferStatus = "transferring";
|
||||
await transferRuntime.runWalk(task.id, async () => {
|
||||
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask);
|
||||
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask, connectHostsOverride);
|
||||
});
|
||||
return walkStatus;
|
||||
});
|
||||
@@ -546,6 +626,8 @@ export const useSftpTransfers = ({
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
updateTask: (updates: Partial<TransferTask>) => void,
|
||||
/** Connect-time endpoint snapshot. Undefined re-resolves the live map. */
|
||||
connectHostsOverride?: TransferConnectHosts,
|
||||
): Promise<TransferStatus> => {
|
||||
|
||||
// Initialize encoding early to avoid temporal dead zone issues
|
||||
@@ -597,13 +679,29 @@ export const useSftpTransfers = ({
|
||||
&& sourceCacheKey === targetCacheKey
|
||||
);
|
||||
|
||||
// Keep the pane's connect-time endpoint even when its saved host is edited.
|
||||
// startTransfer snapshots both hosts before its preflight await so a
|
||||
// reconnect (same tab id, same host id, different endpoint) pending when
|
||||
// processing starts cannot redirect the transfer away from the endpoint
|
||||
// its panes, paths, and plan were captured against.
|
||||
const resolvedConnectHosts = connectHostsOverride
|
||||
?? captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
|
||||
const connectHosts: TransferConnectHosts = {
|
||||
source: toConnectHost(resolvedConnectHosts.source),
|
||||
sourceChain: resolvedConnectHosts.sourceChain,
|
||||
target: toConnectHost(resolvedConnectHosts.target),
|
||||
targetChain: resolvedConnectHosts.targetChain,
|
||||
};
|
||||
|
||||
try {
|
||||
// Prefer dedicated work leases for remote ends so folder walks/listings
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
sourceHostId, `${task.id}:work-source`, connectHosts.source, connectHosts.sourceChain,
|
||||
);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -616,7 +714,9 @@ export const useSftpTransfers = ({
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
targetWorkLease = await acquireTransferSession(
|
||||
targetHostId, `${task.id}:work-target`, connectHosts.target, connectHosts.targetChain,
|
||||
);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -625,7 +725,20 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
// The cache-key equality above omits the jump chain. When the two pinned
|
||||
// chains differ (a bastion edit between the panes' connect times), the
|
||||
// "same" saved host may resolve to different servers behind each bastion;
|
||||
// a same-host `cp` would then write on the source-side server. Treat the
|
||||
// transfer as cross-host unless the pinned routes match (both undefined =
|
||||
// both direct, which is equal).
|
||||
const sameHostPinnedChains = chainIdentity(connectHosts.sourceChain) === chainIdentity(connectHosts.targetChain);
|
||||
// buildCacheKey also omits the endpoint's own top-level proxyConfig, and a
|
||||
// proxy edit between the panes' connect times changes the transport (and
|
||||
// possibly the resolved server) without moving the cache key. Compare the
|
||||
// pinned top-level proxy identities too; undefined on both sides means
|
||||
// both dial directly, which is equal.
|
||||
const sameHostPinnedProxies = proxyIdentity(connectHosts.source?.proxyConfig) === proxyIdentity(connectHosts.target?.proxyConfig);
|
||||
const sameHost = sameHostEndpoints && sameHostPinnedChains && sameHostPinnedProxies && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
@@ -896,7 +1009,7 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide, connectHostsOverride);
|
||||
}
|
||||
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
@@ -978,6 +1091,7 @@ export const useSftpTransfers = ({
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
0, false, undefined, undefined, connectHosts,
|
||||
);
|
||||
|
||||
if (dirErrors > 0) {
|
||||
@@ -1025,6 +1139,7 @@ export const useSftpTransfers = ({
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
connectHosts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1281,12 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
// Capture the connect-time endpoints before the preflight stat await
|
||||
// below: reconnecting either pane to a different endpoint under the same
|
||||
// host id while stats are pending must not redirect the transfer — the
|
||||
// pane snapshot, task paths, and plan all belong to this endpoint.
|
||||
const connectHosts = captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
|
||||
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
@@ -1393,7 +1514,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const results = await Promise.all(runnableTasks.map(async (task): Promise<TransferResult> => {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide, connectHosts);
|
||||
return {
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
@@ -1404,8 +1525,10 @@ export const useSftpTransfers = ({
|
||||
|
||||
return [...skippedResults, ...results];
|
||||
},
|
||||
// Saved host edits replace acquisition; new transfers must not retain the old endpoint.
|
||||
// Already-running attempts keep the leases they acquired.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab],
|
||||
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab, acquireTransferSession, resolveConnectedHost],
|
||||
);
|
||||
|
||||
const cancelTransfer = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { Host, FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { AcquireTransferSessionFn } from "./transferDirectoryOps";
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface UseSftpTransfersParams {
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
/** FileZilla-style dedicated transfer sessions (1–2 per host). */
|
||||
acquireTransferSession?: AcquireTransferSessionFn;
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when the tab connected. Lets retained
|
||||
* transfers keep dialing the connect-time bastion chain after a chain host
|
||||
* is edited (private hostnames can resolve differently per bastion);
|
||||
* undefined = chain must be re-resolved from the live vault.
|
||||
*/
|
||||
resolveConnectedChain?: (tabId: string) => Host[] | null;
|
||||
}
|
||||
|
||||
export interface UseSftpTransfersResult {
|
||||
|
||||
@@ -191,6 +191,10 @@ export const useSftpState = (
|
||||
// Full Host used when each tab connected (includes session-time overrides).
|
||||
// Tab ids are stable across reconnect; connection ids are not.
|
||||
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
|
||||
// Resolved jump-host chain captured at connect time, keyed by tab id.
|
||||
// Retained transfers dial through this pinned chain even when one of the
|
||||
// chain's bastion hosts is edited later.
|
||||
const connectedHostChainByTabIdRef = useRef<Map<string, Host[]>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
@@ -227,6 +231,7 @@ export const useSftpState = (
|
||||
connectedHostByTabIdRef.current.set(resolvedTabId, host);
|
||||
} else {
|
||||
connectedHostByTabIdRef.current.delete(resolvedTabId);
|
||||
connectedHostChainByTabIdRef.current.delete(resolvedTabId);
|
||||
}
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
@@ -240,6 +245,7 @@ export const useSftpState = (
|
||||
connectRequests: connectRequestByTabIdRef.current,
|
||||
connectInFlight: connectInFlightRef.current,
|
||||
connectedHosts: connectedHostByTabIdRef.current,
|
||||
connectedHostChains: connectedHostChainByTabIdRef.current,
|
||||
closeTab: (liveSide) => closeTab(liveSide, tabId),
|
||||
releaseConnection: async () => {
|
||||
if (!pane?.connection) return;
|
||||
@@ -318,7 +324,7 @@ export const useSftpState = (
|
||||
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
|
||||
|
||||
const openPoolSftpSession = useCallback(
|
||||
async (host: Host) => {
|
||||
async (host: Host, jumpHosts?: Host[]) => {
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
@@ -337,7 +343,7 @@ export const useSftpState = (
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
},
|
||||
{ dedicated: false, sourceSessionId },
|
||||
{ dedicated: false, sourceSessionId, jumpHosts },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
@@ -361,14 +367,14 @@ export const useSftpState = (
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
},
|
||||
{ dedicated: false },
|
||||
{ dedicated: false, jumpHosts },
|
||||
);
|
||||
},
|
||||
[hosts, identities, keys, resolveTransferSourceSessionId, transferKnownHosts, transferTerminalSettings],
|
||||
);
|
||||
|
||||
const acquireTransferSession = useCallback(
|
||||
async (hostId: string, transferId: string, connectHost?: Host) => {
|
||||
async (hostId: string, transferId: string, connectHost?: Host, jumpHosts?: Host[]) => {
|
||||
// Prefer the connect-time Host (terminal session overrides) so pooled
|
||||
// uploads open the same endpoint as the browse tab, not the vault entry.
|
||||
const host = connectHost && connectHost.id === hostId
|
||||
@@ -382,7 +388,11 @@ export const useSftpState = (
|
||||
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
|
||||
);
|
||||
}
|
||||
const poolKey = await transferPoolKeyCache.get(host, () => ({
|
||||
// Chain-pinned acquisitions key the pool-key cache by the chain snapshot
|
||||
// (stable array captured at connect) so they cannot collide with the
|
||||
// vault-resolved entry for the same host object in the WeakMap.
|
||||
const cacheKey = jumpHosts?.length ? jumpHosts : host;
|
||||
const poolKey = await transferPoolKeyCache.get(cacheKey, () => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
@@ -396,9 +406,10 @@ export const useSftpState = (
|
||||
identities,
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
jumpHostsOverride: jumpHosts,
|
||||
}),
|
||||
}));
|
||||
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host));
|
||||
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host, jumpHosts));
|
||||
},
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
@@ -443,6 +454,7 @@ export const useSftpState = (
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
connectedHostChainByTabIdRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
@@ -557,6 +569,8 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
acquireTransferSession,
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -621,6 +635,7 @@ export const useSftpState = (
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
|
||||
acquireTransferSession,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
|
||||
Reference in new issue
Block a user