Fix SFTP upload context menu handling
This commit is contained in:
@@ -775,9 +775,9 @@ const en: Messages = {
|
|||||||
'sftp.context.delete': 'Delete',
|
'sftp.context.delete': 'Delete',
|
||||||
'sftp.context.refresh': 'Refresh',
|
'sftp.context.refresh': 'Refresh',
|
||||||
'sftp.context.uploadFiles': 'Upload File(s)...',
|
'sftp.context.uploadFiles': 'Upload File(s)...',
|
||||||
'sftp.context.uploadFilesToFolder': 'Upload File(s) to "{name}"...',
|
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
|
||||||
'sftp.context.uploadFolder': 'Upload Folder...',
|
'sftp.context.uploadFolder': 'Upload Folder...',
|
||||||
'sftp.context.uploadFolderToFolder': 'Upload Folder to "{name}"...',
|
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
|
||||||
'sftp.context.downloadSelected': 'Download selected ({count})',
|
'sftp.context.downloadSelected': 'Download selected ({count})',
|
||||||
'sftp.context.deleteSelected': 'Delete selected ({count})',
|
'sftp.context.deleteSelected': 'Delete selected ({count})',
|
||||||
'sftp.dropFilesHere': 'Drop files here',
|
'sftp.dropFilesHere': 'Drop files here',
|
||||||
|
|||||||
@@ -559,9 +559,9 @@ const zhCN: Messages = {
|
|||||||
'sftp.context.delete': '删除',
|
'sftp.context.delete': '删除',
|
||||||
'sftp.context.refresh': '刷新',
|
'sftp.context.refresh': '刷新',
|
||||||
'sftp.context.uploadFiles': '上传文件...',
|
'sftp.context.uploadFiles': '上传文件...',
|
||||||
'sftp.context.uploadFilesToFolder': '上传文件到 "{name}"...',
|
'sftp.context.uploadFilesHere': '上传文件到这里...',
|
||||||
'sftp.context.uploadFolder': '上传文件夹...',
|
'sftp.context.uploadFolder': '上传文件夹...',
|
||||||
'sftp.context.uploadFolderToFolder': '上传文件夹到 "{name}"...',
|
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
|
||||||
'sftp.context.downloadSelected': '下载选中项({count})',
|
'sftp.context.downloadSelected': '下载选中项({count})',
|
||||||
'sftp.context.deleteSelected': '删除选中项({count})',
|
'sftp.context.deleteSelected': '删除选中项({count})',
|
||||||
'sftp.dropFilesHere': '拖拽文件到这里',
|
'sftp.dropFilesHere': '拖拽文件到这里',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
UploadCallbacks,
|
UploadCallbacks,
|
||||||
UploadResult,
|
UploadResult,
|
||||||
UploadTaskInfo,
|
UploadTaskInfo,
|
||||||
|
startUploadScanningTask,
|
||||||
} from "../../../lib/uploadService";
|
} from "../../../lib/uploadService";
|
||||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||||
|
|
||||||
@@ -57,11 +58,16 @@ interface SftpExternalOperationsResult {
|
|||||||
dataTransfer: DataTransfer,
|
dataTransfer: DataTransfer,
|
||||||
targetPath?: string
|
targetPath?: string
|
||||||
) => Promise<UploadResult[]>;
|
) => Promise<UploadResult[]>;
|
||||||
uploadExternalFolder: (
|
uploadExternalFileList: (
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
fileList: FileList | File[],
|
fileList: FileList | File[],
|
||||||
targetPath?: string
|
targetPath?: string
|
||||||
) => Promise<UploadResult[]>;
|
) => Promise<UploadResult[]>;
|
||||||
|
uploadExternalFolderPath: (
|
||||||
|
side: "left" | "right",
|
||||||
|
folderPath: string,
|
||||||
|
targetPath?: string
|
||||||
|
) => Promise<UploadResult[]>;
|
||||||
uploadExternalEntries: (
|
uploadExternalEntries: (
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
entries: DropEntry[],
|
entries: DropEntry[],
|
||||||
@@ -724,10 +730,9 @@ export const useSftpExternalOperations = (
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Upload from a FileList (e.g. <input type="file" webkitdirectory>). Routes
|
// Upload from a FileList. This keeps the original File objects from the file
|
||||||
// through uploadFromFileList so folder structure is preserved via
|
// picker so Electron can resolve local file paths for stream uploads.
|
||||||
// webkitRelativePath; mirrors uploadExternalFiles otherwise.
|
const uploadExternalFileList = useCallback(
|
||||||
const uploadExternalFolder = useCallback(
|
|
||||||
async (
|
async (
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
fileList: FileList | File[],
|
fileList: FileList | File[],
|
||||||
@@ -787,7 +792,7 @@ export const useSftpExternalOperations = (
|
|||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[SFTP] Folder upload failed:", error);
|
logger.error("[SFTP] File picker upload failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
uploadControllerRef.current = null;
|
uploadControllerRef.current = null;
|
||||||
@@ -806,6 +811,135 @@ export const useSftpExternalOperations = (
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const uploadExternalFolderPath = useCallback(
|
||||||
|
async (
|
||||||
|
side: "left" | "right",
|
||||||
|
folderPath: string,
|
||||||
|
targetPath?: string,
|
||||||
|
): Promise<UploadResult[]> => {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
if (!bridge.listLocalTree) {
|
||||||
|
throw new Error("Folder upload not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sftpId = pane.connection.isLocal
|
||||||
|
? null
|
||||||
|
: sftpSessionsRef.current.get(pane.connection.id) || null;
|
||||||
|
|
||||||
|
if (!pane.connection.isLocal && !sftpId) {
|
||||||
|
throw new Error("SFTP session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadPaneId = pane.id;
|
||||||
|
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||||
|
const controller = new UploadController();
|
||||||
|
uploadControllerRef.current = controller;
|
||||||
|
|
||||||
|
const callbacks = createUploadCallbacks(
|
||||||
|
pane.connection.id,
|
||||||
|
uploadTargetPath,
|
||||||
|
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||||
|
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanningTask = startUploadScanningTask(callbacks);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localEntries = await bridge.listLocalTree(folderPath);
|
||||||
|
if (controller.isCancelled()) {
|
||||||
|
scanningTask.cancel();
|
||||||
|
return [{ fileName: "", success: false, cancelled: true }];
|
||||||
|
}
|
||||||
|
scanningTask.complete();
|
||||||
|
|
||||||
|
const entries: DropEntry[] = localEntries.map((entry) => {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
relativePath: entry.relativePath,
|
||||||
|
isDirectory: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = {
|
||||||
|
name: entry.relativePath.split("/").pop() || entry.relativePath,
|
||||||
|
size: entry.size,
|
||||||
|
lastModified: entry.lastModified,
|
||||||
|
type: "",
|
||||||
|
path: entry.localPath,
|
||||||
|
arrayBuffer: async () => {
|
||||||
|
const currentBridge = netcattyBridge.get();
|
||||||
|
if (!currentBridge?.readLocalFile) {
|
||||||
|
throw new Error("Local file reading not supported");
|
||||||
|
}
|
||||||
|
return currentBridge.readLocalFile(entry.localPath);
|
||||||
|
},
|
||||||
|
} as File & { path?: string };
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
relativePath: entry.relativePath,
|
||||||
|
isDirectory: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await uploadEntriesDirect(
|
||||||
|
entries,
|
||||||
|
{
|
||||||
|
targetPath: uploadTargetPath,
|
||||||
|
sftpId,
|
||||||
|
isLocal: pane.connection.isLocal,
|
||||||
|
bridge: createUploadBridge,
|
||||||
|
joinPath,
|
||||||
|
callbacks,
|
||||||
|
useCompressedUpload,
|
||||||
|
resolveConflict: createUploadConflictResolver(),
|
||||||
|
},
|
||||||
|
controller,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearDirCacheEntry) {
|
||||||
|
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||||
|
}
|
||||||
|
if (uploadTargetPath === pane.connection.currentPath) {
|
||||||
|
await refresh(side, { tabId: uploadPaneId });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.isCancelled()) {
|
||||||
|
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 {
|
||||||
|
uploadControllerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearDirCacheEntry,
|
||||||
|
connectionCacheKeyMapRef,
|
||||||
|
createUploadCallbacks,
|
||||||
|
createUploadBridge,
|
||||||
|
createUploadConflictResolver,
|
||||||
|
getActivePane,
|
||||||
|
refresh,
|
||||||
|
sftpSessionsRef,
|
||||||
|
useCompressedUpload,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const uploadExternalEntries = useCallback(
|
const uploadExternalEntries = useCallback(
|
||||||
async (
|
async (
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
@@ -923,7 +1057,8 @@ export const useSftpExternalOperations = (
|
|||||||
writeTextFileByConnection,
|
writeTextFileByConnection,
|
||||||
downloadToTempAndOpen,
|
downloadToTempAndOpen,
|
||||||
uploadExternalFiles,
|
uploadExternalFiles,
|
||||||
uploadExternalFolder,
|
uploadExternalFileList,
|
||||||
|
uploadExternalFolderPath,
|
||||||
uploadExternalEntries,
|
uploadExternalEntries,
|
||||||
cancelExternalUpload,
|
cancelExternalUpload,
|
||||||
selectApplication,
|
selectApplication,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
|
import {
|
||||||
|
UploadController,
|
||||||
|
startUploadScanningTask,
|
||||||
|
uploadEntriesDirect,
|
||||||
|
uploadFromDataTransfer,
|
||||||
|
uploadFromFileList,
|
||||||
|
} from "../../lib/uploadService.ts";
|
||||||
|
|
||||||
function createDataTransfer(files: File[]): DataTransfer {
|
function createDataTransfer(files: File[]): DataTransfer {
|
||||||
return {
|
return {
|
||||||
@@ -10,6 +16,37 @@ function createDataTransfer(files: File[]): DataTransfer {
|
|||||||
} as unknown as DataTransfer;
|
} as unknown as DataTransfer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
|
||||||
|
const items = files.map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
getAsFile: () => file,
|
||||||
|
webkitGetAsEntry: () => null,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
files,
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("upload scanning task can be shown and cancelled before transfers start", () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const scanningTask = startUploadScanningTask(
|
||||||
|
{
|
||||||
|
onScanningStart: (taskId) => events.push(`start:${taskId}`),
|
||||||
|
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
|
||||||
|
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
|
||||||
|
},
|
||||||
|
"scan-folder-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(scanningTask.isOpen(), true);
|
||||||
|
scanningTask.cancel();
|
||||||
|
scanningTask.complete();
|
||||||
|
|
||||||
|
assert.equal(scanningTask.isOpen(), false);
|
||||||
|
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
||||||
@@ -42,3 +79,119 @@ test("clears the scanning placeholder when every dropped file is skipped by conf
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
|
||||||
|
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
|
||||||
|
const uploadedPaths: string[] = [];
|
||||||
|
|
||||||
|
const results = await uploadFromDataTransfer(
|
||||||
|
createDataTransferWithNullEntries([file]),
|
||||||
|
{
|
||||||
|
targetPath: "/target",
|
||||||
|
sftpId: "sftp-1",
|
||||||
|
isLocal: false,
|
||||||
|
bridge: {
|
||||||
|
mkdirSftp: async () => {},
|
||||||
|
writeSftpBinary: async (_sftpId, path) => {
|
||||||
|
uploadedPaths.push(path);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
joinPath: (base, name) => `${base}/${name}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{ fileName: "picked.txt", success: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uploads picked folder files with their relative directory structure", async () => {
|
||||||
|
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
||||||
|
Object.defineProperty(file, "webkitRelativePath", {
|
||||||
|
value: "folder/sub/file.txt",
|
||||||
|
});
|
||||||
|
const madeDirs: string[] = [];
|
||||||
|
const uploadedPaths: string[] = [];
|
||||||
|
|
||||||
|
const results = await uploadFromFileList(
|
||||||
|
[file],
|
||||||
|
{
|
||||||
|
targetPath: "/target",
|
||||||
|
sftpId: "sftp-1",
|
||||||
|
isLocal: false,
|
||||||
|
bridge: {
|
||||||
|
mkdirSftp: async (_sftpId, path) => {
|
||||||
|
madeDirs.push(path);
|
||||||
|
},
|
||||||
|
writeSftpBinary: async (_sftpId, path) => {
|
||||||
|
uploadedPaths.push(path);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
joinPath: (base, name) => `${base}/${name}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
|
||||||
|
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{ fileName: "folder/sub/file.txt", success: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports empty directory creation failures", async () => {
|
||||||
|
const madeDirs: string[] = [];
|
||||||
|
|
||||||
|
const results = await uploadEntriesDirect(
|
||||||
|
[
|
||||||
|
{ file: null, relativePath: "folder", isDirectory: true },
|
||||||
|
{ file: null, relativePath: "folder/empty", isDirectory: true },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
targetPath: "/target",
|
||||||
|
sftpId: "sftp-1",
|
||||||
|
isLocal: false,
|
||||||
|
bridge: {
|
||||||
|
mkdirSftp: async (_sftpId, path) => {
|
||||||
|
madeDirs.push(path);
|
||||||
|
if (path.endsWith("/empty")) {
|
||||||
|
throw new Error("permission denied");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
joinPath: (base, name) => `${base}/${name}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{ fileName: "folder/empty", success: false, error: "permission denied" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not restart a direct upload that was already cancelled", async () => {
|
||||||
|
const controller = new UploadController();
|
||||||
|
await controller.cancel();
|
||||||
|
let mkdirCalled = false;
|
||||||
|
|
||||||
|
const results = await uploadEntriesDirect(
|
||||||
|
[{ file: null, relativePath: "folder", isDirectory: true }],
|
||||||
|
{
|
||||||
|
targetPath: "/target",
|
||||||
|
sftpId: "sftp-1",
|
||||||
|
isLocal: false,
|
||||||
|
bridge: {
|
||||||
|
mkdirSftp: async () => {
|
||||||
|
mkdirCalled = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
joinPath: (base, name) => `${base}/${name}`,
|
||||||
|
},
|
||||||
|
controller,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(mkdirCalled, false);
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{ fileName: "", success: false, cancelled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -304,7 +304,8 @@ export const useSftpState = (
|
|||||||
writeTextFileByConnection,
|
writeTextFileByConnection,
|
||||||
downloadToTempAndOpen,
|
downloadToTempAndOpen,
|
||||||
uploadExternalFiles,
|
uploadExternalFiles,
|
||||||
uploadExternalFolder,
|
uploadExternalFileList,
|
||||||
|
uploadExternalFolderPath,
|
||||||
uploadExternalEntries,
|
uploadExternalEntries,
|
||||||
cancelExternalUpload,
|
cancelExternalUpload,
|
||||||
selectApplication,
|
selectApplication,
|
||||||
@@ -382,7 +383,8 @@ export const useSftpState = (
|
|||||||
writeTextFileByConnection,
|
writeTextFileByConnection,
|
||||||
downloadToTempAndOpen,
|
downloadToTempAndOpen,
|
||||||
uploadExternalFiles,
|
uploadExternalFiles,
|
||||||
uploadExternalFolder,
|
uploadExternalFileList,
|
||||||
|
uploadExternalFolderPath,
|
||||||
uploadExternalEntries,
|
uploadExternalEntries,
|
||||||
cancelExternalUpload,
|
cancelExternalUpload,
|
||||||
selectApplication,
|
selectApplication,
|
||||||
@@ -438,7 +440,8 @@ export const useSftpState = (
|
|||||||
writeTextFileByConnection,
|
writeTextFileByConnection,
|
||||||
downloadToTempAndOpen,
|
downloadToTempAndOpen,
|
||||||
uploadExternalFiles,
|
uploadExternalFiles,
|
||||||
uploadExternalFolder,
|
uploadExternalFileList,
|
||||||
|
uploadExternalFolderPath,
|
||||||
uploadExternalEntries,
|
uploadExternalEntries,
|
||||||
cancelExternalUpload,
|
cancelExternalUpload,
|
||||||
selectApplication,
|
selectApplication,
|
||||||
@@ -504,8 +507,10 @@ export const useSftpState = (
|
|||||||
methodsRef.current.writeTextFileByConnection(...args),
|
methodsRef.current.writeTextFileByConnection(...args),
|
||||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||||
uploadExternalFolder: (...args: Parameters<typeof uploadExternalFolder>) =>
|
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
|
||||||
methodsRef.current.uploadExternalFolder(...args),
|
methodsRef.current.uploadExternalFileList(...args),
|
||||||
|
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
|
||||||
|
methodsRef.current.uploadExternalFolderPath(...args),
|
||||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||||
methodsRef.current.uploadExternalEntries(...args),
|
methodsRef.current.uploadExternalEntries(...args),
|
||||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||||
|
|||||||
72
components/SftpPaneFileList.test.tsx
Normal file
72
components/SftpPaneFileList.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import type { SftpFileEntry } from "../types.ts";
|
||||||
|
import {
|
||||||
|
getSftpListUploadFilesTargetPath,
|
||||||
|
getSftpTreeUploadFilesTargetPath,
|
||||||
|
getSftpUploadFilesLabelKey,
|
||||||
|
getSftpUploadFolderLabelKey,
|
||||||
|
shouldShowSftpUploadFolderMenu,
|
||||||
|
shouldShowSftpUploadFilesMenu,
|
||||||
|
} from "./sftp/sftpUploadMenu.ts";
|
||||||
|
|
||||||
|
const baseEntry: SftpFileEntry = {
|
||||||
|
name: "notes.txt",
|
||||||
|
type: "file",
|
||||||
|
size: 1,
|
||||||
|
sizeFormatted: "1 B",
|
||||||
|
lastModified: 1,
|
||||||
|
lastModifiedFormatted: "now",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("upload file menu is shown only for remote panes with a picker upload handler", () => {
|
||||||
|
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: true }), true);
|
||||||
|
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: true, hasFileListUpload: true }), false);
|
||||||
|
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: false }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upload folder menu is shown only for remote panes with a folder upload handler", () => {
|
||||||
|
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: true }), true);
|
||||||
|
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: true, hasFolderUpload: true }), false);
|
||||||
|
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: false }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("directory row upload targets that directory without using its name in the label", () => {
|
||||||
|
const directoryEntry: SftpFileEntry = {
|
||||||
|
...baseEntry,
|
||||||
|
name: "a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||||
|
type: "directory",
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getSftpListUploadFilesTargetPath(directoryEntry, "/home/app"),
|
||||||
|
"/home/app/a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||||
|
);
|
||||||
|
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||||
|
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("file row upload targets the current directory", () => {
|
||||||
|
assert.equal(getSftpListUploadFilesTargetPath(baseEntry, "/home/app"), undefined);
|
||||||
|
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||||
|
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tree directory row upload targets that directory", () => {
|
||||||
|
const directoryEntry: SftpFileEntry = {
|
||||||
|
...baseEntry,
|
||||||
|
name: "logs",
|
||||||
|
type: "directory",
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getSftpTreeUploadFilesTargetPath(directoryEntry, "/var/logs"), "/var/logs");
|
||||||
|
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||||
|
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tree file row upload targets the file parent directory", () => {
|
||||||
|
assert.equal(getSftpTreeUploadFilesTargetPath(baseEntry, "/var/logs/app.log"), "/var/logs");
|
||||||
|
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||||
|
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||||
|
});
|
||||||
@@ -55,9 +55,10 @@ export interface SftpPaneCallbacks {
|
|||||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||||
// External file upload (supports folders via DataTransfer)
|
// External file upload (supports folders via DataTransfer)
|
||||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||||
// External folder upload from <input webkitdirectory> picker (FileList).
|
// External file upload from <input type="file" multiple> picker (FileList).
|
||||||
// Routes through uploadFromFileList so webkitRelativePath is preserved.
|
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||||
onUploadExternalFolder?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
// External folder upload from native directory picker.
|
||||||
|
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOr
|
|||||||
import { isNavigableDirectory } from "./index";
|
import { isNavigableDirectory } from "./index";
|
||||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||||
import { SftpFileRow } from "./index";
|
import { SftpFileRow } from "./index";
|
||||||
|
import {
|
||||||
|
getSftpListUploadFilesTargetPath,
|
||||||
|
getSftpUploadFilesLabelKey,
|
||||||
|
getSftpUploadFolderLabelKey,
|
||||||
|
shouldShowSftpUploadFolderMenu,
|
||||||
|
shouldShowSftpUploadFilesMenu,
|
||||||
|
} from "./sftpUploadMenu";
|
||||||
|
|
||||||
interface SftpPaneFileListProps {
|
interface SftpPaneFileListProps {
|
||||||
t: (key: string, params?: Record<string, unknown>) => string;
|
t: (key: string, params?: Record<string, unknown>) => string;
|
||||||
@@ -60,8 +67,8 @@ interface SftpPaneFileListProps {
|
|||||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void> | void;
|
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||||
onUploadExternalFolder?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||||
// Whether this pane is rendering a local filesystem. Upload menu items only
|
// Whether this pane is rendering a local filesystem. Upload menu items only
|
||||||
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
|
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
|
||||||
isLocal?: boolean;
|
isLocal?: boolean;
|
||||||
@@ -151,7 +158,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onDownloadFiles,
|
onDownloadFiles,
|
||||||
onEditPermissions,
|
onEditPermissions,
|
||||||
onUploadExternalFiles,
|
onUploadExternalFileList,
|
||||||
onUploadExternalFolder,
|
onUploadExternalFolder,
|
||||||
isLocal = false,
|
isLocal = false,
|
||||||
openRenameDialog,
|
openRenameDialog,
|
||||||
@@ -200,37 +207,29 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
onClearSelection();
|
onClearSelection();
|
||||||
}, [onClearSelection, pane.selectedFiles.size]);
|
}, [onClearSelection, pane.selectedFiles.size]);
|
||||||
|
|
||||||
// Hidden file inputs backing the "Upload File(s)" / "Upload Folder" context
|
// Hidden file input backing the "Upload File(s)" context menu item. It sends
|
||||||
// menu items. The file input wraps the picked FileList into a DataTransfer
|
// the original FileList through uploadFromFileList so Electron can still
|
||||||
// and reuses the drag-and-drop upload pipeline. The folder input uses the
|
// resolve local paths for stream uploads.
|
||||||
// non-standard `webkitdirectory` attribute and routes the FileList through
|
const uploadEnabled = shouldShowSftpUploadFilesMenu({
|
||||||
// uploadFromFileList so folder structure is preserved via webkitRelativePath.
|
isLocal,
|
||||||
// Both entrypoints are suppressed for local panes — there is no "upload"
|
hasFileListUpload: !!onUploadExternalFileList,
|
||||||
// semantic when the active pane is the local filesystem.
|
});
|
||||||
const uploadEnabled = !isLocal && (!!onUploadExternalFiles || !!onUploadExternalFolder);
|
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
|
||||||
|
isLocal,
|
||||||
|
hasFolderUpload: !!onUploadExternalFolder,
|
||||||
|
});
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const uploadTargetPathRef = useRef<string | undefined>(undefined);
|
const uploadTargetPathRef = useRef<string | undefined>(undefined);
|
||||||
const folderTargetPathRef = useRef<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const triggerUploadPicker = useCallback((targetPath?: string) => {
|
const triggerUploadPicker = useCallback((targetPath?: string) => {
|
||||||
if (isLocal || !onUploadExternalFiles) return;
|
if (isLocal || !onUploadExternalFileList) return;
|
||||||
const input = uploadInputRef.current;
|
const input = uploadInputRef.current;
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
uploadTargetPathRef.current = targetPath;
|
uploadTargetPathRef.current = targetPath;
|
||||||
// Reset value so selecting the same files twice still fires onChange.
|
// Reset value so selecting the same files twice still fires onChange.
|
||||||
input.value = "";
|
input.value = "";
|
||||||
input.click();
|
input.click();
|
||||||
}, [isLocal, onUploadExternalFiles]);
|
}, [isLocal, onUploadExternalFileList]);
|
||||||
|
|
||||||
const triggerFolderPicker = useCallback((targetPath?: string) => {
|
|
||||||
if (isLocal || !onUploadExternalFolder) return;
|
|
||||||
const input = folderInputRef.current;
|
|
||||||
if (!input) return;
|
|
||||||
folderTargetPathRef.current = targetPath;
|
|
||||||
input.value = "";
|
|
||||||
input.click();
|
|
||||||
}, [isLocal, onUploadExternalFolder]);
|
|
||||||
|
|
||||||
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
@@ -238,35 +237,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
uploadTargetPathRef.current = undefined;
|
uploadTargetPathRef.current = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!onUploadExternalFiles) {
|
if (!onUploadExternalFileList) {
|
||||||
uploadTargetPathRef.current = undefined;
|
uploadTargetPathRef.current = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Wrap the FileList in a DataTransfer so it flows through the existing
|
|
||||||
// drag-and-drop upload pipeline (extractDropEntries -> uploadFromDataTransfer).
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
dt.items.add(files[i]);
|
|
||||||
}
|
|
||||||
const targetPath = uploadTargetPathRef.current;
|
const targetPath = uploadTargetPathRef.current;
|
||||||
uploadTargetPathRef.current = undefined;
|
uploadTargetPathRef.current = undefined;
|
||||||
void onUploadExternalFiles(dt, targetPath);
|
void onUploadExternalFileList(files, targetPath);
|
||||||
}, [onUploadExternalFiles]);
|
}, [onUploadExternalFileList]);
|
||||||
|
|
||||||
const handleFolderInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
folderTargetPathRef.current = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!onUploadExternalFolder) {
|
|
||||||
folderTargetPathRef.current = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetPath = folderTargetPathRef.current;
|
|
||||||
folderTargetPathRef.current = undefined;
|
|
||||||
void onUploadExternalFolder(files, targetPath);
|
|
||||||
}, [onUploadExternalFolder]);
|
|
||||||
|
|
||||||
const renderRow = useCallback(
|
const renderRow = useCallback(
|
||||||
(entry: SftpFileEntry, index: number) => (
|
(entry: SftpFileEntry, index: number) => (
|
||||||
@@ -425,37 +403,26 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
||||||
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{uploadEnabled && onUploadExternalFiles && (
|
{uploadEnabled && onUploadExternalFileList && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const target = isNavigableDirectory(entry) && entry.name !== ".."
|
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||||
? joinPath(pane.connection?.currentPath ?? "", entry.name)
|
|
||||||
: undefined;
|
|
||||||
triggerUploadPicker(target);
|
triggerUploadPicker(target);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload size={14} className="mr-2" />{" "}
|
<Upload size={14} className="mr-2" />{" "}
|
||||||
{isNavigableDirectory(entry) && entry.name !== ".."
|
{t(getSftpUploadFilesLabelKey(entry))}
|
||||||
? t("sftp.context.uploadFilesToFolder", { name: entry.name })
|
|
||||||
: t("sftp.context.uploadFiles")}
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{uploadEnabled && onUploadExternalFolder && (
|
{folderUploadEnabled && onUploadExternalFolder && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// For a directory row the picked folder should be uploaded
|
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||||
// INTO that directory (matching drag-and-drop semantics):
|
void onUploadExternalFolder(target);
|
||||||
// picked `myproj` becomes <dirRow>/myproj.
|
|
||||||
const target = isNavigableDirectory(entry) && entry.name !== ".."
|
|
||||||
? joinPath(pane.connection?.currentPath ?? "", entry.name)
|
|
||||||
: undefined;
|
|
||||||
triggerFolderPicker(target);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload size={14} className="mr-2" />{" "}
|
<Upload size={14} className="mr-2" />{" "}
|
||||||
{isNavigableDirectory(entry) && entry.name !== ".."
|
{t(getSftpUploadFolderLabelKey(entry))}
|
||||||
? t("sftp.context.uploadFolderToFolder", { name: entry.name })
|
|
||||||
: t("sftp.context.uploadFolder")}
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
@@ -483,9 +450,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
onNavigateTo,
|
onNavigateTo,
|
||||||
onOpenFileWith,
|
onOpenFileWith,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onUploadExternalFiles,
|
onUploadExternalFileList,
|
||||||
onUploadExternalFolder,
|
onUploadExternalFolder,
|
||||||
uploadEnabled,
|
uploadEnabled,
|
||||||
|
folderUploadEnabled,
|
||||||
openDeleteConfirm,
|
openDeleteConfirm,
|
||||||
openRenameDialog,
|
openRenameDialog,
|
||||||
pane.connection,
|
pane.connection,
|
||||||
@@ -494,7 +462,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
setShowNewFileDialog,
|
setShowNewFileDialog,
|
||||||
t,
|
t,
|
||||||
triggerUploadPicker,
|
triggerUploadPicker,
|
||||||
triggerFolderPicker,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -666,23 +633,21 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
}}>
|
}}>
|
||||||
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{uploadEnabled && onUploadExternalFiles && (
|
{uploadEnabled && onUploadExternalFileList && (
|
||||||
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
|
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
|
||||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
|
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{uploadEnabled && onUploadExternalFolder && (
|
{folderUploadEnabled && onUploadExternalFolder && (
|
||||||
<ContextMenuItem onClick={() => triggerFolderPicker(undefined)}>
|
<ContextMenuItem onClick={() => void onUploadExternalFolder(undefined)}>
|
||||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
|
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
{/* Hidden file inputs backing the "Upload File(s)" / "Upload Folder"
|
{/* Hidden file input backing the "Upload File(s)" context menu item. */}
|
||||||
context menu items. Suppressed for local panes since upload only makes
|
{uploadEnabled && onUploadExternalFileList && (
|
||||||
sense when the pane is a remote SFTP filesystem. */}
|
|
||||||
{uploadEnabled && onUploadExternalFiles && (
|
|
||||||
<input
|
<input
|
||||||
ref={uploadInputRef}
|
ref={uploadInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -691,19 +656,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
onChange={handleUploadInputChange}
|
onChange={handleUploadInputChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{uploadEnabled && onUploadExternalFolder && (
|
|
||||||
<input
|
|
||||||
ref={folderInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFolderInputChange}
|
|
||||||
// webkitdirectory is non-standard but is the standard way to expose a
|
|
||||||
// directory picker in Chromium (and Electron is Chromium). React's
|
|
||||||
// intrinsic input typings already include this attribute.
|
|
||||||
webkitdirectory=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,13 @@ import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSf
|
|||||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||||
|
import {
|
||||||
|
getSftpTreeUploadFilesTargetPath,
|
||||||
|
getSftpUploadFilesLabelKey,
|
||||||
|
getSftpUploadFolderLabelKey,
|
||||||
|
shouldShowSftpUploadFolderMenu,
|
||||||
|
shouldShowSftpUploadFilesMenu,
|
||||||
|
} from './sftpUploadMenu';
|
||||||
|
|
||||||
type NodeDescriptor =
|
type NodeDescriptor =
|
||||||
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
|
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
|
||||||
@@ -76,6 +84,8 @@ interface SftpPaneTreeViewProps {
|
|||||||
openNewFolderDialog: (targetPath: string) => void;
|
openNewFolderDialog: (targetPath: string) => void;
|
||||||
openNewFileDialog: (targetPath: string) => void;
|
openNewFileDialog: (targetPath: string) => void;
|
||||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||||
|
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||||
|
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||||
columnWidths: ColumnWidths;
|
columnWidths: ColumnWidths;
|
||||||
handleSort: (field: SortField) => void;
|
handleSort: (field: SortField) => void;
|
||||||
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
||||||
@@ -281,6 +291,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
openNewFolderDialog,
|
openNewFolderDialog,
|
||||||
openNewFileDialog,
|
openNewFileDialog,
|
||||||
onUploadExternalFiles,
|
onUploadExternalFiles,
|
||||||
|
onUploadExternalFileList,
|
||||||
|
onUploadExternalFolder,
|
||||||
columnWidths,
|
columnWidths,
|
||||||
handleSort,
|
handleSort,
|
||||||
handleResizeStart,
|
handleResizeStart,
|
||||||
@@ -297,6 +309,38 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
const [dragOverNodePath, setDragOverNodePath] = useState<string | null>(null);
|
const [dragOverNodePath, setDragOverNodePath] = useState<string | null>(null);
|
||||||
const onUploadExternalFilesRef = useRef(onUploadExternalFiles);
|
const onUploadExternalFilesRef = useRef(onUploadExternalFiles);
|
||||||
onUploadExternalFilesRef.current = onUploadExternalFiles;
|
onUploadExternalFilesRef.current = onUploadExternalFiles;
|
||||||
|
const onUploadExternalFileListRef = useRef(onUploadExternalFileList);
|
||||||
|
onUploadExternalFileListRef.current = onUploadExternalFileList;
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const uploadTargetPathRef = useRef<string | undefined>(undefined);
|
||||||
|
const uploadEnabled = shouldShowSftpUploadFilesMenu({
|
||||||
|
isLocal: !!pane.connection?.isLocal,
|
||||||
|
hasFileListUpload: !!onUploadExternalFileList,
|
||||||
|
});
|
||||||
|
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
|
||||||
|
isLocal: !!pane.connection?.isLocal,
|
||||||
|
hasFolderUpload: !!onUploadExternalFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerUploadPicker = useCallback((targetPath?: string) => {
|
||||||
|
if (!uploadEnabled) return;
|
||||||
|
const input = uploadInputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
uploadTargetPathRef.current = targetPath;
|
||||||
|
input.value = '';
|
||||||
|
input.click();
|
||||||
|
}, [uploadEnabled]);
|
||||||
|
|
||||||
|
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
uploadTargetPathRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetPath = uploadTargetPathRef.current;
|
||||||
|
uploadTargetPathRef.current = undefined;
|
||||||
|
void onUploadExternalFileListRef.current?.(files, targetPath);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Virtual scrolling state ──────────────────────────────────────
|
// ── Virtual scrolling state ──────────────────────────────────────
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -1303,6 +1347,24 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
<ContextMenuItem onClick={() => openNewFileDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
|
<ContextMenuItem onClick={() => openNewFileDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
|
||||||
<FilePlus size={14} className="mr-2" />{tRef.current('sftp.newFile')}
|
<FilePlus size={14} className="mr-2" />{tRef.current('sftp.newFile')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
{uploadEnabled && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
triggerUploadPicker(getSftpTreeUploadFilesTargetPath(entry, entryPath));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFilesLabelKey(entry))}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{folderUploadEnabled && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void onUploadExternalFolder?.(getSftpTreeUploadFilesTargetPath(entry, entryPath));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFolderLabelKey(entry))}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -1315,6 +1377,10 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
getActionPaths,
|
getActionPaths,
|
||||||
toTransferSources,
|
toTransferSources,
|
||||||
executeMoveAction,
|
executeMoveAction,
|
||||||
|
triggerUploadPicker,
|
||||||
|
uploadEnabled,
|
||||||
|
folderUploadEnabled,
|
||||||
|
onUploadExternalFolder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1412,6 +1478,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
{contextMenuContent}
|
{contextMenuContent}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
|
{uploadEnabled && (
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUploadInputChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{pane.loading && !pane.reconnecting && (
|
{pane.loading && !pane.reconnecting && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10 pointer-events-none">
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10 pointer-events-none">
|
||||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -279,8 +279,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||||
|
|
||||||
const handleUploadExternalFolder = useCallback(async (fileList: FileList, targetPath?: string) => {
|
const handleUploadExternalFileList = useCallback(async (fileList: FileList, targetPath?: string) => {
|
||||||
await callbacks.onUploadExternalFolder?.(fileList, targetPath);
|
await callbacks.onUploadExternalFileList?.(fileList, targetPath);
|
||||||
|
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
||||||
|
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
||||||
|
requestTreeReload([affectedPath]);
|
||||||
|
}
|
||||||
|
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||||
|
|
||||||
|
const handleUploadExternalFolder = useCallback(async (targetPath?: string) => {
|
||||||
|
await callbacks.onUploadExternalFolder?.(targetPath);
|
||||||
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
||||||
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
||||||
requestTreeReload([affectedPath]);
|
requestTreeReload([affectedPath]);
|
||||||
@@ -532,6 +540,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||||
openNewFileDialog={openNewFileDialogAtPath}
|
openNewFileDialog={openNewFileDialogAtPath}
|
||||||
onUploadExternalFiles={handleUploadExternalFiles}
|
onUploadExternalFiles={handleUploadExternalFiles}
|
||||||
|
onUploadExternalFileList={handleUploadExternalFileList}
|
||||||
|
onUploadExternalFolder={handleUploadExternalFolder}
|
||||||
columnWidths={columnWidths}
|
columnWidths={columnWidths}
|
||||||
handleSort={handleSortWithTransition}
|
handleSort={handleSortWithTransition}
|
||||||
handleResizeStart={handleResizeStart}
|
handleResizeStart={handleResizeStart}
|
||||||
@@ -582,7 +592,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
onDownloadFile={callbacks.onDownloadFile}
|
onDownloadFile={callbacks.onDownloadFile}
|
||||||
onDownloadFiles={callbacks.onDownloadFiles}
|
onDownloadFiles={callbacks.onDownloadFiles}
|
||||||
onEditPermissions={callbacks.onEditPermissions}
|
onEditPermissions={callbacks.onEditPermissions}
|
||||||
onUploadExternalFiles={handleUploadExternalFiles}
|
onUploadExternalFileList={handleUploadExternalFileList}
|
||||||
onUploadExternalFolder={handleUploadExternalFolder}
|
onUploadExternalFolder={handleUploadExternalFolder}
|
||||||
isLocal={!!pane.connection?.isLocal}
|
isLocal={!!pane.connection?.isLocal}
|
||||||
openRenameDialog={openRenameDialog}
|
openRenameDialog={openRenameDialog}
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ interface UseSftpViewFileOpsResult {
|
|||||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||||
onUploadExternalFolderLeft: (fileList: FileList, targetPath?: string) => void;
|
onUploadExternalFileListLeft: (fileList: FileList, targetPath?: string) => void;
|
||||||
onUploadExternalFolderRight: (fileList: FileList, targetPath?: string) => void;
|
onUploadExternalFileListRight: (fileList: FileList, targetPath?: string) => void;
|
||||||
|
onUploadExternalFolderLeft: (targetPath?: string) => Promise<void>;
|
||||||
|
onUploadExternalFolderRight: (targetPath?: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSftpViewFileOps = ({
|
export const useSftpViewFileOps = ({
|
||||||
@@ -420,10 +422,10 @@ export const useSftpViewFileOps = ({
|
|||||||
[handleUploadExternalFilesForSide],
|
[handleUploadExternalFilesForSide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUploadExternalFolderForSide = useCallback(
|
const handleUploadExternalFileListForSide = useCallback(
|
||||||
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
|
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
|
||||||
try {
|
try {
|
||||||
const results = await sftpRef.current.uploadExternalFolder(side, fileList, targetPath);
|
const results = await sftpRef.current.uploadExternalFileList(side, fileList, targetPath);
|
||||||
|
|
||||||
if (results.some((r) => r.cancelled)) {
|
if (results.some((r) => r.cancelled)) {
|
||||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||||
@@ -450,7 +452,7 @@ export const useSftpViewFileOps = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[SftpView] Failed to upload external folder:", error);
|
logger.error("[SftpView] Failed to upload picked files:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||||
"SFTP",
|
"SFTP",
|
||||||
@@ -460,13 +462,67 @@ export const useSftpViewFileOps = ({
|
|||||||
[sftpRef, t],
|
[sftpRef, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onUploadExternalFileListLeft = useCallback(
|
||||||
|
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("left", fileList, targetPath),
|
||||||
|
[handleUploadExternalFileListForSide],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUploadExternalFileListRight = useCallback(
|
||||||
|
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("right", fileList, targetPath),
|
||||||
|
[handleUploadExternalFileListForSide],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUploadExternalFolderForSide = useCallback(
|
||||||
|
async (side: "left" | "right", targetPath?: string) => {
|
||||||
|
if (!selectDirectory) {
|
||||||
|
toast.error(t("sftp.error.uploadFailed"), "SFTP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDirectory = await selectDirectory(t("sftp.context.uploadFolder"));
|
||||||
|
if (!selectedDirectory) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await sftpRef.current.uploadExternalFolderPath(side, selectedDirectory, targetPath);
|
||||||
|
|
||||||
|
if (results.some((r) => r.cancelled)) {
|
||||||
|
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
|
||||||
|
if (failCount === 0) {
|
||||||
|
const folderName = selectedDirectory.split(/[/\\]/).filter(Boolean).pop() || selectedDirectory;
|
||||||
|
toast.success(`${t("sftp.uploadFolder")}: ${folderName}`, "SFTP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
|
||||||
|
failedFiles.forEach((failed) => {
|
||||||
|
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
||||||
|
toast.error(
|
||||||
|
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
|
||||||
|
"SFTP",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[SftpView] Failed to upload picked folder:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||||
|
"SFTP",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectDirectory, sftpRef, t],
|
||||||
|
);
|
||||||
|
|
||||||
const onUploadExternalFolderLeft = useCallback(
|
const onUploadExternalFolderLeft = useCallback(
|
||||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFolderForSide("left", fileList, targetPath),
|
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
|
||||||
[handleUploadExternalFolderForSide],
|
[handleUploadExternalFolderForSide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onUploadExternalFolderRight = useCallback(
|
const onUploadExternalFolderRight = useCallback(
|
||||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFolderForSide("right", fileList, targetPath),
|
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
|
||||||
[handleUploadExternalFolderForSide],
|
[handleUploadExternalFolderForSide],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -937,6 +993,8 @@ export const useSftpViewFileOps = ({
|
|||||||
onDownloadFilesRight,
|
onDownloadFilesRight,
|
||||||
onUploadExternalFilesLeft,
|
onUploadExternalFilesLeft,
|
||||||
onUploadExternalFilesRight,
|
onUploadExternalFilesRight,
|
||||||
|
onUploadExternalFileListLeft,
|
||||||
|
onUploadExternalFileListRight,
|
||||||
onUploadExternalFolderLeft,
|
onUploadExternalFolderLeft,
|
||||||
onUploadExternalFolderRight,
|
onUploadExternalFolderRight,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||||
|
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||||
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
||||||
}),
|
}),
|
||||||
@@ -210,6 +211,7 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onDownloadFile: fileOps.onDownloadFileRight,
|
onDownloadFile: fileOps.onDownloadFileRight,
|
||||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||||
|
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||||
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
|
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
|
||||||
}),
|
}),
|
||||||
|
|||||||
49
components/sftp/sftpUploadMenu.ts
Normal file
49
components/sftp/sftpUploadMenu.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SftpFileEntry } from "../../types";
|
||||||
|
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
|
||||||
|
import { isNavigableDirectory } from "./utils";
|
||||||
|
|
||||||
|
export const shouldShowSftpUploadFilesMenu = ({
|
||||||
|
isLocal,
|
||||||
|
hasFileListUpload,
|
||||||
|
}: {
|
||||||
|
isLocal: boolean;
|
||||||
|
hasFileListUpload: boolean;
|
||||||
|
}) => !isLocal && hasFileListUpload;
|
||||||
|
|
||||||
|
export const shouldShowSftpUploadFolderMenu = ({
|
||||||
|
isLocal,
|
||||||
|
hasFolderUpload,
|
||||||
|
}: {
|
||||||
|
isLocal: boolean;
|
||||||
|
hasFolderUpload: boolean;
|
||||||
|
}) => !isLocal && hasFolderUpload;
|
||||||
|
|
||||||
|
export const getSftpListUploadFilesTargetPath = (
|
||||||
|
entry: SftpFileEntry,
|
||||||
|
currentPath: string,
|
||||||
|
): string | undefined => {
|
||||||
|
if (!isNavigableDirectory(entry) || entry.name === "..") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return joinPath(currentPath, entry.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSftpTreeUploadFilesTargetPath = (
|
||||||
|
entry: SftpFileEntry,
|
||||||
|
entryPath: string,
|
||||||
|
): string | undefined => {
|
||||||
|
if (entry.name === "..") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return isNavigableDirectory(entry) ? entryPath : getParentPath(entryPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSftpUploadFilesLabelKey = (entry: SftpFileEntry): string =>
|
||||||
|
isNavigableDirectory(entry) && entry.name !== ".."
|
||||||
|
? "sftp.context.uploadFilesHere"
|
||||||
|
: "sftp.context.uploadFiles";
|
||||||
|
|
||||||
|
export const getSftpUploadFolderLabelKey = (entry: SftpFileEntry): string =>
|
||||||
|
isNavigableDirectory(entry) && entry.name !== ".."
|
||||||
|
? "sftp.context.uploadFolderHere"
|
||||||
|
: "sftp.context.uploadFolder";
|
||||||
@@ -247,6 +247,54 @@ async function statLocal(event, payload) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectLocalTreeEntries(rootPath) {
|
||||||
|
const rootStat = await fs.promises.stat(rootPath);
|
||||||
|
if (!rootStat.isDirectory()) {
|
||||||
|
throw new Error("Selected path is not a directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootName = path.basename(rootPath);
|
||||||
|
const entries = [{
|
||||||
|
localPath: rootPath,
|
||||||
|
relativePath: rootName,
|
||||||
|
type: "directory",
|
||||||
|
size: rootStat.size,
|
||||||
|
lastModified: rootStat.mtime.getTime(),
|
||||||
|
}];
|
||||||
|
const queue = [{ localPath: rootPath, relativePath: rootName }];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
const children = await fs.promises.readdir(current.localPath, { withFileTypes: true });
|
||||||
|
children.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
const childPath = path.join(current.localPath, child.name);
|
||||||
|
const childRelativePath = `${current.relativePath}/${child.name}`;
|
||||||
|
const stat = await fs.promises.stat(childPath);
|
||||||
|
const isDirectory = stat.isDirectory();
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
localPath: childPath,
|
||||||
|
relativePath: childRelativePath,
|
||||||
|
type: isDirectory ? "directory" : "file",
|
||||||
|
size: stat.size,
|
||||||
|
lastModified: stat.mtime.getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
queue.push({ localPath: childPath, relativePath: childRelativePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLocalTree(event, payload) {
|
||||||
|
return collectLocalTreeEntries(payload.path);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the home directory
|
* Get the home directory
|
||||||
*/
|
*/
|
||||||
@@ -311,6 +359,7 @@ function registerHandlers(ipcMain) {
|
|||||||
ipcMain.handle("netcatty:local:rename", renameLocalFile);
|
ipcMain.handle("netcatty:local:rename", renameLocalFile);
|
||||||
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
|
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
|
||||||
ipcMain.handle("netcatty:local:stat", statLocal);
|
ipcMain.handle("netcatty:local:stat", statLocal);
|
||||||
|
ipcMain.handle("netcatty:local:tree", listLocalTree);
|
||||||
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
||||||
ipcMain.handle("netcatty:system:info", getSystemInfo);
|
ipcMain.handle("netcatty:system:info", getSystemInfo);
|
||||||
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
|
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
|
||||||
@@ -325,6 +374,8 @@ module.exports = {
|
|||||||
renameLocalFile,
|
renameLocalFile,
|
||||||
mkdirLocal,
|
mkdirLocal,
|
||||||
statLocal,
|
statLocal,
|
||||||
|
collectLocalTreeEntries,
|
||||||
|
listLocalTree,
|
||||||
getHomeDir,
|
getHomeDir,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
readKnownHosts,
|
readKnownHosts,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
const { parseAttribOutput, listWindowsHiddenBasenames } = require("./localFsBridge.cjs");
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
collectLocalTreeEntries,
|
||||||
|
parseAttribOutput,
|
||||||
|
listWindowsHiddenBasenames,
|
||||||
|
} = require("./localFsBridge.cjs");
|
||||||
|
|
||||||
test("parseAttribOutput returns an empty set for empty input", () => {
|
test("parseAttribOutput returns an empty set for empty input", () => {
|
||||||
assert.equal(parseAttribOutput("").size, 0);
|
assert.equal(parseAttribOutput("").size, 0);
|
||||||
@@ -137,3 +145,28 @@ test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directorie
|
|||||||
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
|
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("collectLocalTreeEntries preserves empty directories in selected folders", async () => {
|
||||||
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-"));
|
||||||
|
const selected = path.join(root, "project");
|
||||||
|
await fs.promises.mkdir(path.join(selected, "empty"), { recursive: true });
|
||||||
|
await fs.promises.mkdir(path.join(selected, "src"), { recursive: true });
|
||||||
|
await fs.promises.writeFile(path.join(selected, "src", "main.txt"), "hello");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await collectLocalTreeEntries(selected);
|
||||||
|
const summary = entries.map((entry) => ({
|
||||||
|
relativePath: entry.relativePath,
|
||||||
|
type: entry.type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(summary, [
|
||||||
|
{ relativePath: "project", type: "directory" },
|
||||||
|
{ relativePath: "project/empty", type: "directory" },
|
||||||
|
{ relativePath: "project/src", type: "directory" },
|
||||||
|
{ relativePath: "project/src/main.txt", type: "file" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -796,6 +796,9 @@ const api = {
|
|||||||
statLocal: async (path) => {
|
statLocal: async (path) => {
|
||||||
return ipcRenderer.invoke("netcatty:local:stat", { path });
|
return ipcRenderer.invoke("netcatty:local:stat", { path });
|
||||||
},
|
},
|
||||||
|
listLocalTree: async (path) => {
|
||||||
|
return ipcRenderer.invoke("netcatty:local:tree", { path });
|
||||||
|
},
|
||||||
getHomeDir: async () => {
|
getHomeDir: async () => {
|
||||||
return ipcRenderer.invoke("netcatty:local:homedir");
|
return ipcRenderer.invoke("netcatty:local:homedir");
|
||||||
},
|
},
|
||||||
|
|||||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -468,6 +468,13 @@ declare global {
|
|||||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||||
mkdirLocal?(path: string): Promise<void>;
|
mkdirLocal?(path: string): Promise<void>;
|
||||||
statLocal?(path: string): Promise<SftpStatResult>;
|
statLocal?(path: string): Promise<SftpStatResult>;
|
||||||
|
listLocalTree?(path: string): Promise<Array<{
|
||||||
|
localPath: string;
|
||||||
|
relativePath: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size: number;
|
||||||
|
lastModified: number;
|
||||||
|
}>>;
|
||||||
getHomeDir?(): Promise<string>;
|
getHomeDir?(): Promise<string>;
|
||||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||||
|
|
||||||
|
|||||||
@@ -437,6 +437,23 @@ export interface DropEntry {
|
|||||||
isDirectory: boolean;
|
isDirectory: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createDropEntriesFromFiles = (files: FileList | File[]): DropEntry[] => {
|
||||||
|
const results: DropEntry[] = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const path = getPathForFile(file);
|
||||||
|
if (path) {
|
||||||
|
(file as File & { path?: string }).path = path;
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
file,
|
||||||
|
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||||
|
isDirectory: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a FileSystemEntry to a File
|
* Convert a FileSystemEntry to a File
|
||||||
*/
|
*/
|
||||||
@@ -598,6 +615,9 @@ export async function extractDropEntries(
|
|||||||
|
|
||||||
// Process entries iteratively (non-recursive) to avoid stack overflow
|
// Process entries iteratively (non-recursive) to avoid stack overflow
|
||||||
const results = await processEntriesIteratively(entries);
|
const results = await processEntriesIteratively(entries);
|
||||||
|
if (results.length === 0) {
|
||||||
|
return createDropEntriesFromFiles(dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore the 'path' property for all files
|
// Restore the 'path' property for all files
|
||||||
// Try to get the path directly from webUtils.getPathForFile for each file
|
// Try to get the path directly from webUtils.getPathForFile for each file
|
||||||
@@ -635,16 +655,6 @@ export async function extractDropEntries(
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: use regular FileList (no folder support)
|
// Fallback: use regular FileList (no folder support)
|
||||||
// Files from FileList in Electron already have the 'path' property
|
// Files from FileList in Electron already have the 'path' property
|
||||||
const results: DropEntry[] = [];
|
return createDropEntriesFromFiles(dataTransfer.files);
|
||||||
const files = dataTransfer.files;
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
results.push({
|
|
||||||
file,
|
|
||||||
relativePath: file.name,
|
|
||||||
isDirectory: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,39 @@ export interface UploadConfig {
|
|||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatUploadError = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
export interface UploadScanningTask {
|
||||||
|
taskId: string;
|
||||||
|
complete: () => void;
|
||||||
|
fail: (error: unknown) => void;
|
||||||
|
cancel: () => void;
|
||||||
|
isOpen: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startUploadScanningTask(
|
||||||
|
callbacks?: UploadCallbacks,
|
||||||
|
taskId = crypto.randomUUID(),
|
||||||
|
): UploadScanningTask {
|
||||||
|
let open = true;
|
||||||
|
callbacks?.onScanningStart?.(taskId);
|
||||||
|
|
||||||
|
const close = (settle: () => void) => {
|
||||||
|
if (!open) return;
|
||||||
|
open = false;
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
complete: () => close(() => callbacks?.onScanningEnd?.(taskId)),
|
||||||
|
fail: (error) => close(() => callbacks?.onTaskFailed?.(taskId, formatUploadError(error))),
|
||||||
|
cancel: () => close(() => callbacks?.onTaskCancelled?.(taskId)),
|
||||||
|
isOpen: () => open,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect root folders from drop entries for bundled task creation
|
* Detect root folders from drop entries for bundled task creation
|
||||||
*/
|
*/
|
||||||
@@ -338,24 +371,17 @@ export async function uploadFromDataTransfer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create scanning placeholder
|
// Create scanning placeholder
|
||||||
const scanningTaskId = crypto.randomUUID();
|
|
||||||
let scanningEnded = false;
|
|
||||||
const endScanning = () => {
|
|
||||||
if (scanningEnded) return;
|
|
||||||
scanningEnded = true;
|
|
||||||
callbacks?.onScanningEnd?.(scanningTaskId);
|
|
||||||
};
|
|
||||||
callbacks?.onScanningStart?.(scanningTaskId);
|
|
||||||
|
|
||||||
const scanT0 = performance.now();
|
const scanT0 = performance.now();
|
||||||
|
const scanningTask = startUploadScanningTask(callbacks);
|
||||||
let entries: DropEntry[];
|
let entries: DropEntry[];
|
||||||
try {
|
try {
|
||||||
entries = await extractDropEntries(dataTransfer);
|
entries = await extractDropEntries(dataTransfer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
endScanning();
|
scanningTask.complete();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
endScanning();
|
scanningTask.complete();
|
||||||
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
|
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -516,6 +542,13 @@ async function uploadEntries(
|
|||||||
): Promise<UploadResult[]> {
|
): Promise<UploadResult[]> {
|
||||||
const results: UploadResult[] = [];
|
const results: UploadResult[] = [];
|
||||||
const createdDirs = new Set<string>();
|
const createdDirs = new Set<string>();
|
||||||
|
const failedDirs = new Map<string, string>();
|
||||||
|
const reportedDirectoryFailures = new Set<string>();
|
||||||
|
let wasCancelled = false;
|
||||||
|
|
||||||
|
if (controller?.isCancelled()) {
|
||||||
|
return [{ fileName: "", success: false, cancelled: true }];
|
||||||
|
}
|
||||||
|
|
||||||
const statTarget = async (path: string) => {
|
const statTarget = async (path: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -564,8 +597,10 @@ async function uploadEntries(
|
|||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureDirectory = async (dirPath: string) => {
|
const ensureDirectory = async (dirPath: string): Promise<string | null> => {
|
||||||
if (createdDirs.has(dirPath)) return;
|
if (createdDirs.has(dirPath)) return null;
|
||||||
|
const previousFailure = failedDirs.get(dirPath);
|
||||||
|
if (previousFailure) return previousFailure;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
@@ -576,8 +611,11 @@ async function uploadEntries(
|
|||||||
await bridge.mkdirSftp(sftpId, dirPath);
|
await bridge.mkdirSftp(sftpId, dirPath);
|
||||||
}
|
}
|
||||||
createdDirs.add(dirPath);
|
createdDirs.add(dirPath);
|
||||||
} catch {
|
return null;
|
||||||
createdDirs.add(dirPath);
|
} catch (error) {
|
||||||
|
const errorMessage = formatUploadError(error);
|
||||||
|
failedDirs.set(dirPath, errorMessage);
|
||||||
|
return errorMessage;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -654,6 +692,7 @@ async function uploadEntries(
|
|||||||
|
|
||||||
const resolvedRootFolders = detectRootFolders(resolvedEntries);
|
const resolvedRootFolders = detectRootFolders(resolvedEntries);
|
||||||
const sortedEntries = sortEntries(resolvedEntries);
|
const sortedEntries = sortEntries(resolvedEntries);
|
||||||
|
const explicitDirectoryPaths = new Map<string, string>();
|
||||||
|
|
||||||
// Pre-create all needed directories in batch before file transfers
|
// Pre-create all needed directories in batch before file transfers
|
||||||
const uploadT0 = performance.now();
|
const uploadT0 = performance.now();
|
||||||
@@ -661,7 +700,9 @@ async function uploadEntries(
|
|||||||
const allDirPaths = new Set<string>();
|
const allDirPaths = new Set<string>();
|
||||||
for (const entry of sortedEntries) {
|
for (const entry of sortedEntries) {
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
allDirPaths.add(joinPath(targetPath, entry.relativePath));
|
const dirPath = joinPath(targetPath, entry.relativePath);
|
||||||
|
allDirPaths.add(dirPath);
|
||||||
|
explicitDirectoryPaths.set(dirPath, entry.relativePath);
|
||||||
} else {
|
} else {
|
||||||
const pathParts = entry.relativePath.split('/');
|
const pathParts = entry.relativePath.split('/');
|
||||||
if (pathParts.length > 1) {
|
if (pathParts.length > 1) {
|
||||||
@@ -686,12 +727,24 @@ async function uploadEntries(
|
|||||||
const sortedDepths = Array.from(dirsByDepth.keys()).sort((a, b) => a - b);
|
const sortedDepths = Array.from(dirsByDepth.keys()).sort((a, b) => a - b);
|
||||||
for (const depth of sortedDepths) {
|
for (const depth of sortedDepths) {
|
||||||
const dirs = dirsByDepth.get(depth)!;
|
const dirs = dirsByDepth.get(depth)!;
|
||||||
await Promise.all(dirs.map(d => ensureDirectory(d)));
|
const directoryResults = await Promise.all(dirs.map(async (dirPath) => ({
|
||||||
|
dirPath,
|
||||||
|
error: await ensureDirectory(dirPath),
|
||||||
|
})));
|
||||||
|
for (const { dirPath, error } of directoryResults) {
|
||||||
|
if (!error) continue;
|
||||||
|
const relativePath = explicitDirectoryPaths.get(dirPath);
|
||||||
|
if (!relativePath || reportedDirectoryFailures.has(relativePath)) continue;
|
||||||
|
reportedDirectoryFailures.add(relativePath);
|
||||||
|
results.push({ fileName: relativePath, success: false, error });
|
||||||
|
}
|
||||||
|
if (controller?.isCancelled()) {
|
||||||
|
wasCancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.debug(`[SFTP:perf] batch mkdir done — ${allDirPaths.size} dirs — ${(performance.now() - uploadT0).toFixed(0)}ms`);
|
logger.debug(`[SFTP:perf] batch mkdir done — ${allDirPaths.size} dirs — ${(performance.now() - uploadT0).toFixed(0)}ms`);
|
||||||
|
|
||||||
let wasCancelled = false;
|
|
||||||
|
|
||||||
// Track bundled task progress
|
// Track bundled task progress
|
||||||
const bundleProgress = new Map<string, {
|
const bundleProgress = new Map<string, {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
@@ -1020,7 +1073,7 @@ async function uploadEntries(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = formatUploadError(error);
|
||||||
results.push({ fileName: entry.relativePath, success: false, error: errorMessage });
|
results.push({ fileName: entry.relativePath, success: false, error: errorMessage });
|
||||||
|
|
||||||
if (bundleTaskId) {
|
if (bundleTaskId) {
|
||||||
@@ -1084,6 +1137,10 @@ export async function uploadEntriesDirect(
|
|||||||
): Promise<UploadResult[]> {
|
): Promise<UploadResult[]> {
|
||||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
||||||
|
|
||||||
|
if (controller?.isCancelled()) {
|
||||||
|
return [{ fileName: "", success: false, cancelled: true }];
|
||||||
|
}
|
||||||
|
|
||||||
if (controller) {
|
if (controller) {
|
||||||
controller.reset();
|
controller.reset();
|
||||||
controller.setBridge(bridge);
|
controller.setBridge(bridge);
|
||||||
@@ -1335,7 +1392,7 @@ async function uploadFoldersCompressed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = formatUploadError(error);
|
||||||
|
|
||||||
// Remove compression ID from controller on error
|
// Remove compression ID from controller on error
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user