mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 17:10:08 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c85d04e42e | ||
|
|
baf5e9ae3f | ||
|
|
6d5596377d | ||
|
|
2f5d76b5c9 | ||
|
|
cdc061f1fc | ||
|
|
e3ec831666 |
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
|
||||
|
||||
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
|
||||
name: "f",
|
||||
size: 50_000,
|
||||
type: "file",
|
||||
lastModified: 0,
|
||||
sizeKnown: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const listedSize of [25_000, 100_000]) {
|
||||
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async (_id, path, encoding) => {
|
||||
assert.equal(path, "/tmp/f");
|
||||
assert.equal(encoding, "auto");
|
||||
return stat();
|
||||
},
|
||||
"sftp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
|
||||
assert.notEqual(snapshot.size, listedSize);
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale file entry replaced by a directory uses directory routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, type: "directory" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
|
||||
test("a stale directory entry replaced by a file uses file routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 12, type: "file" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
|
||||
});
|
||||
|
||||
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
|
||||
const rejected = [
|
||||
undefined,
|
||||
async () => null as unknown as SftpStatResult,
|
||||
async () => { throw new Error("File not found"); },
|
||||
async () => stat({ type: "symlink" }),
|
||||
async () => stat({ size: Number.NaN }),
|
||||
async () => stat({ size: -1 }),
|
||||
];
|
||||
for (const candidate of rejected) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, sizeKnown: false }),
|
||||
"scp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
|
||||
});
|
||||
|
||||
test("SCP links retain their resolved file or directory routing without using link size", async () => {
|
||||
for (const target of ["file", "directory"] as const) {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink", size: 8 }),
|
||||
"scp-1", "/tmp/link", "auto",
|
||||
async (_id, parent, encoding) => {
|
||||
assert.equal(parent, "/tmp");
|
||||
assert.equal(encoding, "auto");
|
||||
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
|
||||
},
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
|
||||
}
|
||||
});
|
||||
|
||||
test("SCP link whose current target cannot be confirmed stops safely", async () => {
|
||||
for (const entries of [
|
||||
[],
|
||||
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
|
||||
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink" }),
|
||||
"scp-1", "/tmp/link", "auto", async () => entries,
|
||||
),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("directory routing does not require a file byte count", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
|
||||
"sftp-1", "/tmp/d", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
|
||||
import type { RemoteFile } from "../../../domain/models/workspace";
|
||||
import { getFileName, getParentPath } from "./utils";
|
||||
|
||||
export type DownloadSourceSnapshot = {
|
||||
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
|
||||
size: number | undefined;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type StatSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<SftpStatResult>;
|
||||
type ListSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<RemoteFile[]>;
|
||||
|
||||
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
|
||||
|
||||
/** A stale listing must never supply the size or type of a new download. */
|
||||
export const resolveDownloadSourceSnapshot = async (
|
||||
statSftp: StatSftp | undefined,
|
||||
sftpId: string,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
listSftp?: ListSftp,
|
||||
): Promise<DownloadSourceSnapshot> => {
|
||||
if (!statSftp) throw sourceUnavailable();
|
||||
|
||||
let stat: SftpStatResult | null;
|
||||
try {
|
||||
stat = await statSftp(sftpId, sourcePath, encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
|
||||
if (!stat) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
if (stat.type === "directory") return { size: 0, isDirectory: true };
|
||||
if (stat.type === "symlink") {
|
||||
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
|
||||
// parent to resolve the current target kind rather than trusting the pane.
|
||||
if (!listSftp) throw sourceUnavailable();
|
||||
let entries: RemoteFile[];
|
||||
try {
|
||||
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
|
||||
if (current?.type !== "symlink"
|
||||
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
// The link's own size is never a valid file plan; SCP's wire header is.
|
||||
return { size: undefined, isDirectory: current.linkTarget === "directory" };
|
||||
}
|
||||
if (stat.type !== "file") throw sourceUnavailable();
|
||||
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
|
||||
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
|
||||
return { size: stat.size, isDirectory: false };
|
||||
};
|
||||
@@ -347,6 +347,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
statSftp,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -1337,6 +1338,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
selectDirectory={selectDirectory}
|
||||
listLocalDir={listLocalDir}
|
||||
listDrives={listDrives}
|
||||
statSftp={statSftp}
|
||||
openPath={openPath}
|
||||
t={t}
|
||||
/>
|
||||
@@ -1389,6 +1391,7 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
|
||||
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
|
||||
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
|
||||
statSftp: ReturnType<typeof useSftpBackend>["statSftp"];
|
||||
openPath: ReturnType<typeof useSftpBackend>["openPath"];
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
};
|
||||
@@ -1431,6 +1434,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
selectDirectory,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
statSftp,
|
||||
openPath,
|
||||
t,
|
||||
}) => {
|
||||
@@ -1540,6 +1544,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
statSftp,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -424,6 +425,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "../../ui/toast";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { resolveDownloadSourceSnapshot } from "../../../application/state/sftp/downloadSourceSnapshot";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
@@ -36,6 +37,8 @@ export const useSftpViewFileOps = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
@@ -404,7 +407,7 @@ export const useSftpViewFileOps = ({
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
let isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
// For local files, use blob download.
|
||||
@@ -442,6 +445,18 @@ export const useSftpViewFileOps = ({
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// The listed entry can be stale when the file was deleted / recreated
|
||||
// in the terminal without refreshing the side panel. Re-stat so the
|
||||
// planned snapshot size and entry type match the real source.
|
||||
const sourceSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId,
|
||||
resolvedFullPath,
|
||||
pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
isDirectory = sourceSnapshot.isDirectory;
|
||||
|
||||
if (isDirectory) {
|
||||
if (!selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
@@ -451,6 +466,15 @@ export const useSftpViewFileOps = ({
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
// The picker may stay open while the remote entry changes again.
|
||||
const selectedSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId, resolvedFullPath, pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
if (!selectedSnapshot.isDirectory) {
|
||||
throw new Error("Remote source changed while choosing the download target");
|
||||
}
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
|
||||
try {
|
||||
@@ -481,14 +505,17 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
return;
|
||||
}
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
if (!targetPath) return;
|
||||
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
const selectedSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId, resolvedFullPath, pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
if (selectedSnapshot.isDirectory) {
|
||||
throw new Error("Remote source changed while choosing the download target");
|
||||
}
|
||||
// Route through downloadToLocal so FileZilla-style transfer pool
|
||||
// sessions are used (browse session stays free for listing).
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
@@ -501,7 +528,7 @@ export const useSftpViewFileOps = ({
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: false,
|
||||
totalBytes: fileSize,
|
||||
totalBytes: selectedSnapshot.size,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
@@ -523,6 +550,8 @@ export const useSftpViewFileOps = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -633,8 +662,16 @@ export const useSftpViewFileOps = ({
|
||||
try {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
// Re-stat each root: the listed entries can be stale after
|
||||
// terminal-side delete + recreate without a refresh.
|
||||
const sourceSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId,
|
||||
sourcePath,
|
||||
pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
const isDirectory = sourceSnapshot.isDirectory;
|
||||
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
@@ -646,7 +683,7 @@ export const useSftpViewFileOps = ({
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory,
|
||||
totalBytes: isDirectory ? undefined : fileSize,
|
||||
totalBytes: isDirectory ? undefined : sourceSnapshot.size,
|
||||
});
|
||||
results[index] = { status: "fulfilled", value: { file, status } };
|
||||
} catch (reason) {
|
||||
@@ -679,6 +716,8 @@ export const useSftpViewFileOps = ({
|
||||
t,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
handleDownloadFileForSide,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
@@ -21,6 +21,9 @@ export interface UseSftpViewFileOpsParams {
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
/** Application-layer fresh stat for download source snapshots (kept off the raw bridge). */
|
||||
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
|
||||
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
|
||||
}
|
||||
|
||||
export interface UseSftpViewFileOpsResult {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
|
||||
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<void>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
@@ -45,6 +46,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles,
|
||||
listDrives,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
@@ -59,6 +61,8 @@ export const useSftpViewPaneCallbacks = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
});
|
||||
|
||||
const listLocalFilesRef = useRef(listLocalFiles);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpStateApi } from "../../application/state/useSftpState";
|
||||
import { useSftpViewFileOps } from "./hooks/useSftpViewFileOps";
|
||||
|
||||
const file = (
|
||||
name: string, size: number, type: SftpFileEntry["type"] = "file",
|
||||
linkTarget?: SftpFileEntry["linkTarget"],
|
||||
): SftpFileEntry => ({
|
||||
name, size, type, linkTarget, lastModified: 0, sizeFormatted: "", lastModifiedFormatted: "",
|
||||
});
|
||||
|
||||
test("single and batch downloads route by current remote type and size", async () => {
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const current = new Map<string, SftpStatResult>([
|
||||
["/remote/smaller", { name: "smaller", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/larger", { name: "larger", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/became-dir", { name: "became-dir", size: 0, sizeKnown: true, type: "directory", lastModified: 0 }],
|
||||
["/remote/became-file", { name: "became-file", size: 12, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/changes-in-picker", { name: "changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/type-changes-in-picker", { name: "type-changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/statless", { name: "statless", size: 0, sizeKnown: false, type: "file", lastModified: 0 }],
|
||||
["/remote/file-link", { name: "file-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
|
||||
["/remote/dir-link", { name: "dir-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
|
||||
]);
|
||||
const downloads: Array<{ sourcePath: string; totalBytes?: number; isDirectory: boolean }> = [];
|
||||
let saveCalls = 0;
|
||||
let directoryCalls = 0;
|
||||
let onSave: (name: string) => void = () => undefined;
|
||||
const connection = { id: "conn-1", hostId: "host-1", hostLabel: "Host", currentPath: "/remote", isLocal: false };
|
||||
const pane = { connection, filenameEncoding: "auto" };
|
||||
const sftpRef = { current: {
|
||||
leftPane: pane, rightPane: pane,
|
||||
joinPath: (parent: string, name: string) => `${parent}/${name}`,
|
||||
downloadToLocal: async (params: { sourcePath: string; totalBytes?: number; isDirectory: boolean }) => {
|
||||
downloads.push(params);
|
||||
return "completed";
|
||||
},
|
||||
} as unknown as SftpStateApi };
|
||||
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpViewFileOps({
|
||||
sftpRef,
|
||||
behaviorRef: { current: "" },
|
||||
autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null },
|
||||
setOpenerForExtension: () => undefined,
|
||||
t: (key) => key,
|
||||
showSaveDialog: async (name) => { saveCalls++; onSave(name); return `/downloads/${name}`; },
|
||||
selectDirectory: async () => { directoryCalls++; return "/downloads"; },
|
||||
getSftpIdForConnection: () => "sftp-1",
|
||||
statSftp: async (_id, path) => current.get(path) ?? null as unknown as SftpStatResult,
|
||||
listSftp: async (_id, parent) => {
|
||||
assert.equal(parent, "/remote");
|
||||
return [
|
||||
{ name: "file-link", type: "symlink", linkTarget: "file", size: "9 bytes", lastModified: "" },
|
||||
{ name: "dir-link", type: "symlink", linkTarget: "directory", size: "9 bytes", lastModified: "" },
|
||||
];
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const single = ops!.onDownloadFileLeft as unknown as (entry: SftpFileEntry) => Promise<void>;
|
||||
const batch = ops!.onDownloadFilesLeft as unknown as (entries: SftpFileEntry[]) => Promise<void>;
|
||||
|
||||
await act(async () => { await single(file("smaller", 100_000)); });
|
||||
await act(async () => { await single(file("larger", 25_000)); });
|
||||
assert.deepEqual(downloads.map(({ totalBytes }) => totalBytes), [50_000, 50_000]);
|
||||
assert.equal(saveCalls, 2, "save dialog still opens for every single-file download");
|
||||
|
||||
onSave = (name) => {
|
||||
if (name === "changes-in-picker") current.set("/remote/changes-in-picker", {
|
||||
name, size: 30, sizeKnown: true, type: "file", lastModified: 0,
|
||||
});
|
||||
if (name === "type-changes-in-picker") current.set("/remote/type-changes-in-picker", {
|
||||
name, size: 0, sizeKnown: true, type: "directory", lastModified: 0,
|
||||
});
|
||||
};
|
||||
await act(async () => { await single(file("changes-in-picker", 100)); });
|
||||
assert.equal(downloads.at(-1)?.totalBytes, 30, "size is checked again after Save As");
|
||||
const countBeforeTypeChange = downloads.length;
|
||||
await act(async () => { await single(file("type-changes-in-picker", 100)); });
|
||||
assert.equal(downloads.length, countBeforeTypeChange, "type changes during Save As abort the transfer");
|
||||
const countBeforeMissing = saveCalls;
|
||||
await act(async () => { await single(file("missing", 100_000)); });
|
||||
assert.equal(saveCalls, countBeforeMissing, "missing source never opens Save As");
|
||||
|
||||
await act(async () => { await single(file("statless", 100_000)); });
|
||||
assert.equal(downloads.at(-1)?.totalBytes, undefined, "stat-less SCP does not use listed size");
|
||||
await act(async () => { await single(file("file-link", 9, "symlink", "directory")); });
|
||||
assert.equal(downloads.at(-1)?.isDirectory, false);
|
||||
assert.equal(downloads.at(-1)?.totalBytes, undefined, "link node size is not file size");
|
||||
|
||||
await act(async () => { await single(file("became-dir", 100_000)); });
|
||||
assert.equal(downloads.at(-1)?.isDirectory, true);
|
||||
assert.equal(directoryCalls, 1);
|
||||
|
||||
await act(async () => {
|
||||
await batch([
|
||||
file("became-file", 0, "directory"),
|
||||
file("missing", 100_000),
|
||||
file("dir-link", 9, "symlink", "file"),
|
||||
]);
|
||||
});
|
||||
assert.equal(directoryCalls, 2, "batch chooses one directory");
|
||||
const becameFile = downloads.find(({ sourcePath }) => sourcePath === "/remote/became-file");
|
||||
assert.equal(becameFile?.isDirectory, false);
|
||||
assert.equal(becameFile?.totalBytes, 12);
|
||||
assert.equal(downloads.find(({ sourcePath }) => sourcePath === "/remote/dir-link")?.isDirectory, true);
|
||||
assert.equal(downloads.some(({ sourcePath }) => sourcePath === "/remote/missing"), false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user