Fix SFTP upload context menu handling
This commit is contained in:
@@ -775,9 +775,9 @@ const en: Messages = {
|
||||
'sftp.context.delete': 'Delete',
|
||||
'sftp.context.refresh': 'Refresh',
|
||||
'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.uploadFolderToFolder': 'Upload Folder to "{name}"...',
|
||||
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
|
||||
'sftp.context.downloadSelected': 'Download selected ({count})',
|
||||
'sftp.context.deleteSelected': 'Delete selected ({count})',
|
||||
'sftp.dropFilesHere': 'Drop files here',
|
||||
|
||||
@@ -559,9 +559,9 @@ const zhCN: Messages = {
|
||||
'sftp.context.delete': '删除',
|
||||
'sftp.context.refresh': '刷新',
|
||||
'sftp.context.uploadFiles': '上传文件...',
|
||||
'sftp.context.uploadFilesToFolder': '上传文件到 "{name}"...',
|
||||
'sftp.context.uploadFilesHere': '上传文件到这里...',
|
||||
'sftp.context.uploadFolder': '上传文件夹...',
|
||||
'sftp.context.uploadFolderToFolder': '上传文件夹到 "{name}"...',
|
||||
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
|
||||
'sftp.context.downloadSelected': '下载选中项({count})',
|
||||
'sftp.context.deleteSelected': '删除选中项({count})',
|
||||
'sftp.dropFilesHere': '拖拽文件到这里',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
startUploadScanningTask,
|
||||
} from "../../../lib/uploadService";
|
||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
@@ -57,11 +58,16 @@ interface SftpExternalOperationsResult {
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFolder: (
|
||||
uploadExternalFileList: (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFolderPath: (
|
||||
side: "left" | "right",
|
||||
folderPath: string,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
@@ -724,10 +730,9 @@ export const useSftpExternalOperations = (
|
||||
],
|
||||
);
|
||||
|
||||
// Upload from a FileList (e.g. <input type="file" webkitdirectory>). Routes
|
||||
// through uploadFromFileList so folder structure is preserved via
|
||||
// webkitRelativePath; mirrors uploadExternalFiles otherwise.
|
||||
const uploadExternalFolder = useCallback(
|
||||
// Upload from a FileList. This keeps the original File objects from the file
|
||||
// picker so Electron can resolve local file paths for stream uploads.
|
||||
const uploadExternalFileList = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
@@ -787,7 +792,7 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Folder upload failed:", error);
|
||||
logger.error("[SFTP] File picker upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
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(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -923,7 +1057,8 @@ export const useSftpExternalOperations = (
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFolder,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import test from "node:test";
|
||||
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 {
|
||||
return {
|
||||
@@ -10,6 +16,37 @@ function createDataTransfer(files: File[]): 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 () => {
|
||||
const events: string[] = [];
|
||||
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"]);
|
||||
});
|
||||
|
||||
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,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFolder,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
@@ -382,7 +383,8 @@ export const useSftpState = (
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFolder,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
@@ -438,7 +440,8 @@ export const useSftpState = (
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFolder,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
@@ -504,8 +507,10 @@ export const useSftpState = (
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalFolder: (...args: Parameters<typeof uploadExternalFolder>) =>
|
||||
methodsRef.current.uploadExternalFolder(...args),
|
||||
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
|
||||
methodsRef.current.uploadExternalFileList(...args),
|
||||
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
|
||||
methodsRef.current.uploadExternalFolderPath(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
methodsRef.current.uploadExternalEntries(...args),
|
||||
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
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
// External folder upload from <input webkitdirectory> picker (FileList).
|
||||
// Routes through uploadFromFileList so webkitRelativePath is preserved.
|
||||
onUploadExternalFolder?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||
// External file upload from <input type="file" multiple> picker (FileList).
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||
// External folder upload from native directory picker.
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOr
|
||||
import { isNavigableDirectory } from "./index";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./index";
|
||||
import {
|
||||
getSftpListUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
getSftpUploadFolderLabelKey,
|
||||
shouldShowSftpUploadFolderMenu,
|
||||
shouldShowSftpUploadFilesMenu,
|
||||
} from "./sftpUploadMenu";
|
||||
|
||||
interface SftpPaneFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -60,8 +67,8 @@ interface SftpPaneFileListProps {
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFolder?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFileList?: (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
|
||||
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
|
||||
isLocal?: boolean;
|
||||
@@ -151,7 +158,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onEditPermissions,
|
||||
onUploadExternalFiles,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
isLocal = false,
|
||||
openRenameDialog,
|
||||
@@ -200,37 +207,29 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onClearSelection();
|
||||
}, [onClearSelection, pane.selectedFiles.size]);
|
||||
|
||||
// Hidden file inputs backing the "Upload File(s)" / "Upload Folder" context
|
||||
// menu items. The file input wraps the picked FileList into a DataTransfer
|
||||
// and reuses the drag-and-drop upload pipeline. The folder input uses the
|
||||
// non-standard `webkitdirectory` attribute and routes the FileList through
|
||||
// uploadFromFileList so folder structure is preserved via webkitRelativePath.
|
||||
// Both entrypoints are suppressed for local panes — there is no "upload"
|
||||
// semantic when the active pane is the local filesystem.
|
||||
const uploadEnabled = !isLocal && (!!onUploadExternalFiles || !!onUploadExternalFolder);
|
||||
// Hidden file input backing the "Upload File(s)" context menu item. It sends
|
||||
// the original FileList through uploadFromFileList so Electron can still
|
||||
// resolve local paths for stream uploads.
|
||||
const uploadEnabled = shouldShowSftpUploadFilesMenu({
|
||||
isLocal,
|
||||
hasFileListUpload: !!onUploadExternalFileList,
|
||||
});
|
||||
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
|
||||
isLocal,
|
||||
hasFolderUpload: !!onUploadExternalFolder,
|
||||
});
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetPathRef = useRef<string | undefined>(undefined);
|
||||
const folderTargetPathRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const triggerUploadPicker = useCallback((targetPath?: string) => {
|
||||
if (isLocal || !onUploadExternalFiles) return;
|
||||
if (isLocal || !onUploadExternalFileList) return;
|
||||
const input = uploadInputRef.current;
|
||||
if (!input) return;
|
||||
uploadTargetPathRef.current = targetPath;
|
||||
// Reset value so selecting the same files twice still fires onChange.
|
||||
input.value = "";
|
||||
input.click();
|
||||
}, [isLocal, onUploadExternalFiles]);
|
||||
|
||||
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]);
|
||||
}, [isLocal, onUploadExternalFileList]);
|
||||
|
||||
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
@@ -238,35 +237,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
uploadTargetPathRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (!onUploadExternalFiles) {
|
||||
if (!onUploadExternalFileList) {
|
||||
uploadTargetPathRef.current = undefined;
|
||||
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;
|
||||
uploadTargetPathRef.current = undefined;
|
||||
void onUploadExternalFiles(dt, targetPath);
|
||||
}, [onUploadExternalFiles]);
|
||||
|
||||
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]);
|
||||
void onUploadExternalFileList(files, targetPath);
|
||||
}, [onUploadExternalFileList]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
@@ -425,37 +403,26 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
||||
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
{uploadEnabled && onUploadExternalFiles && (
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const target = isNavigableDirectory(entry) && entry.name !== ".."
|
||||
? joinPath(pane.connection?.currentPath ?? "", entry.name)
|
||||
: undefined;
|
||||
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||
triggerUploadPicker(target);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="mr-2" />{" "}
|
||||
{isNavigableDirectory(entry) && entry.name !== ".."
|
||||
? t("sftp.context.uploadFilesToFolder", { name: entry.name })
|
||||
: t("sftp.context.uploadFiles")}
|
||||
{t(getSftpUploadFilesLabelKey(entry))}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{uploadEnabled && onUploadExternalFolder && (
|
||||
{folderUploadEnabled && onUploadExternalFolder && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
// For a directory row the picked folder should be uploaded
|
||||
// INTO that directory (matching drag-and-drop semantics):
|
||||
// picked `myproj` becomes <dirRow>/myproj.
|
||||
const target = isNavigableDirectory(entry) && entry.name !== ".."
|
||||
? joinPath(pane.connection?.currentPath ?? "", entry.name)
|
||||
: undefined;
|
||||
triggerFolderPicker(target);
|
||||
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||
void onUploadExternalFolder(target);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="mr-2" />{" "}
|
||||
{isNavigableDirectory(entry) && entry.name !== ".."
|
||||
? t("sftp.context.uploadFolderToFolder", { name: entry.name })
|
||||
: t("sftp.context.uploadFolder")}
|
||||
{t(getSftpUploadFolderLabelKey(entry))}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
@@ -483,9 +450,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onNavigateTo,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
onUploadExternalFiles,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
uploadEnabled,
|
||||
folderUploadEnabled,
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.connection,
|
||||
@@ -494,7 +462,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
triggerUploadPicker,
|
||||
triggerFolderPicker,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -666,23 +633,21 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
}}>
|
||||
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
{uploadEnabled && onUploadExternalFiles && (
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
|
||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{uploadEnabled && onUploadExternalFolder && (
|
||||
<ContextMenuItem onClick={() => triggerFolderPicker(undefined)}>
|
||||
{folderUploadEnabled && onUploadExternalFolder && (
|
||||
<ContextMenuItem onClick={() => void onUploadExternalFolder(undefined)}>
|
||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Hidden file inputs backing the "Upload File(s)" / "Upload Folder"
|
||||
context menu items. Suppressed for local panes since upload only makes
|
||||
sense when the pane is a remote SFTP filesystem. */}
|
||||
{uploadEnabled && onUploadExternalFiles && (
|
||||
{/* Hidden file input backing the "Upload File(s)" context menu item. */}
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
@@ -691,19 +656,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
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 */}
|
||||
<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,
|
||||
Shield,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -47,6 +48,13 @@ import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSf
|
||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
import {
|
||||
getSftpTreeUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
getSftpUploadFolderLabelKey,
|
||||
shouldShowSftpUploadFolderMenu,
|
||||
shouldShowSftpUploadFilesMenu,
|
||||
} from './sftpUploadMenu';
|
||||
|
||||
type NodeDescriptor =
|
||||
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
|
||||
@@ -76,6 +84,8 @@ interface SftpPaneTreeViewProps {
|
||||
openNewFolderDialog: (targetPath: string) => void;
|
||||
openNewFileDialog: (targetPath: string) => void;
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||
columnWidths: ColumnWidths;
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
||||
@@ -281,6 +291,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
openNewFolderDialog,
|
||||
openNewFileDialog,
|
||||
onUploadExternalFiles,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
@@ -297,6 +309,38 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
const [dragOverNodePath, setDragOverNodePath] = useState<string | null>(null);
|
||||
const onUploadExternalFilesRef = useRef(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 ──────────────────────────────────────
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1303,6 +1347,24 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
<ContextMenuItem onClick={() => openNewFileDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
|
||||
<FilePlus size={14} className="mr-2" />{tRef.current('sftp.newFile')}
|
||||
</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>
|
||||
);
|
||||
}, [
|
||||
@@ -1315,6 +1377,10 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
getActionPaths,
|
||||
toTransferSources,
|
||||
executeMoveAction,
|
||||
triggerUploadPicker,
|
||||
uploadEnabled,
|
||||
folderUploadEnabled,
|
||||
onUploadExternalFolder,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -1412,6 +1478,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
|
||||
{uploadEnabled && (
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUploadInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -279,8 +279,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||
|
||||
const handleUploadExternalFolder = useCallback(async (fileList: FileList, targetPath?: string) => {
|
||||
await callbacks.onUploadExternalFolder?.(fileList, targetPath);
|
||||
const handleUploadExternalFileList = useCallback(async (fileList: FileList, targetPath?: string) => {
|
||||
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;
|
||||
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
||||
requestTreeReload([affectedPath]);
|
||||
@@ -532,6 +540,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||
openNewFileDialog={openNewFileDialogAtPath}
|
||||
onUploadExternalFiles={handleUploadExternalFiles}
|
||||
onUploadExternalFileList={handleUploadExternalFileList}
|
||||
onUploadExternalFolder={handleUploadExternalFolder}
|
||||
columnWidths={columnWidths}
|
||||
handleSort={handleSortWithTransition}
|
||||
handleResizeStart={handleResizeStart}
|
||||
@@ -582,7 +592,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
onUploadExternalFiles={handleUploadExternalFiles}
|
||||
onUploadExternalFileList={handleUploadExternalFileList}
|
||||
onUploadExternalFolder={handleUploadExternalFolder}
|
||||
isLocal={!!pane.connection?.isLocal}
|
||||
openRenameDialog={openRenameDialog}
|
||||
|
||||
@@ -106,8 +106,10 @@ interface UseSftpViewFileOpsResult {
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
onUploadExternalFolderLeft: (fileList: FileList, targetPath?: string) => void;
|
||||
onUploadExternalFolderRight: (fileList: FileList, targetPath?: string) => void;
|
||||
onUploadExternalFileListLeft: (fileList: FileList, targetPath?: string) => void;
|
||||
onUploadExternalFileListRight: (fileList: FileList, targetPath?: string) => void;
|
||||
onUploadExternalFolderLeft: (targetPath?: string) => Promise<void>;
|
||||
onUploadExternalFolderRight: (targetPath?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
@@ -420,10 +422,10 @@ export const useSftpViewFileOps = ({
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFolderForSide = useCallback(
|
||||
const handleUploadExternalFileListForSide = useCallback(
|
||||
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
|
||||
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)) {
|
||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||
@@ -450,7 +452,7 @@ export const useSftpViewFileOps = ({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload external folder:", error);
|
||||
logger.error("[SftpView] Failed to upload picked files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
@@ -460,13 +462,67 @@ export const useSftpViewFileOps = ({
|
||||
[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(
|
||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFolderForSide("left", fileList, targetPath),
|
||||
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
|
||||
[handleUploadExternalFolderForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFolderRight = useCallback(
|
||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFolderForSide("right", fileList, targetPath),
|
||||
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
|
||||
[handleUploadExternalFolderForSide],
|
||||
);
|
||||
|
||||
@@ -937,6 +993,8 @@ export const useSftpViewFileOps = ({
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
onUploadExternalFilesRight,
|
||||
onUploadExternalFileListLeft,
|
||||
onUploadExternalFileListRight,
|
||||
onUploadExternalFolderLeft,
|
||||
onUploadExternalFolderRight,
|
||||
};
|
||||
|
||||
@@ -171,6 +171,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
||||
}),
|
||||
@@ -210,6 +211,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||
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
|
||||
*/
|
||||
@@ -311,6 +359,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:rename", renameLocalFile);
|
||||
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
|
||||
ipcMain.handle("netcatty:local:stat", statLocal);
|
||||
ipcMain.handle("netcatty:local:tree", listLocalTree);
|
||||
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
||||
ipcMain.handle("netcatty:system:info", getSystemInfo);
|
||||
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
|
||||
@@ -325,6 +374,8 @@ module.exports = {
|
||||
renameLocalFile,
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
collectLocalTreeEntries,
|
||||
listLocalTree,
|
||||
getHomeDir,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const test = require("node:test");
|
||||
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", () => {
|
||||
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)}`,
|
||||
);
|
||||
});
|
||||
|
||||
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) => {
|
||||
return ipcRenderer.invoke("netcatty:local:stat", { path });
|
||||
},
|
||||
listLocalTree: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:tree", { path });
|
||||
},
|
||||
getHomeDir: async () => {
|
||||
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>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
listLocalTree?(path: string): Promise<Array<{
|
||||
localPath: string;
|
||||
relativePath: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
lastModified: number;
|
||||
}>>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
|
||||
@@ -437,6 +437,23 @@ export interface DropEntry {
|
||||
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
|
||||
*/
|
||||
@@ -598,6 +615,9 @@ export async function extractDropEntries(
|
||||
|
||||
// Process entries iteratively (non-recursive) to avoid stack overflow
|
||||
const results = await processEntriesIteratively(entries);
|
||||
if (results.length === 0) {
|
||||
return createDropEntriesFromFiles(dataTransfer.files);
|
||||
}
|
||||
|
||||
// Restore the 'path' property for all files
|
||||
// Try to get the path directly from webUtils.getPathForFile for each file
|
||||
@@ -635,16 +655,6 @@ export async function extractDropEntries(
|
||||
} else {
|
||||
// Fallback: use regular FileList (no folder support)
|
||||
// Files from FileList in Electron already have the 'path' property
|
||||
const results: DropEntry[] = [];
|
||||
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;
|
||||
return createDropEntriesFromFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,39 @@ export interface UploadConfig {
|
||||
// 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
|
||||
*/
|
||||
@@ -338,24 +371,17 @@ export async function uploadFromDataTransfer(
|
||||
}
|
||||
|
||||
// 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 scanningTask = startUploadScanningTask(callbacks);
|
||||
let entries: DropEntry[];
|
||||
try {
|
||||
entries = await extractDropEntries(dataTransfer);
|
||||
} catch (error) {
|
||||
endScanning();
|
||||
scanningTask.complete();
|
||||
throw error;
|
||||
}
|
||||
endScanning();
|
||||
scanningTask.complete();
|
||||
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -516,6 +542,13 @@ async function uploadEntries(
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = [];
|
||||
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) => {
|
||||
try {
|
||||
@@ -564,8 +597,10 @@ async function uploadEntries(
|
||||
return entry;
|
||||
};
|
||||
|
||||
const ensureDirectory = async (dirPath: string) => {
|
||||
if (createdDirs.has(dirPath)) return;
|
||||
const ensureDirectory = async (dirPath: string): Promise<string | null> => {
|
||||
if (createdDirs.has(dirPath)) return null;
|
||||
const previousFailure = failedDirs.get(dirPath);
|
||||
if (previousFailure) return previousFailure;
|
||||
|
||||
try {
|
||||
if (isLocal) {
|
||||
@@ -576,8 +611,11 @@ async function uploadEntries(
|
||||
await bridge.mkdirSftp(sftpId, dirPath);
|
||||
}
|
||||
createdDirs.add(dirPath);
|
||||
} catch {
|
||||
createdDirs.add(dirPath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = formatUploadError(error);
|
||||
failedDirs.set(dirPath, errorMessage);
|
||||
return errorMessage;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -654,6 +692,7 @@ async function uploadEntries(
|
||||
|
||||
const resolvedRootFolders = detectRootFolders(resolvedEntries);
|
||||
const sortedEntries = sortEntries(resolvedEntries);
|
||||
const explicitDirectoryPaths = new Map<string, string>();
|
||||
|
||||
// Pre-create all needed directories in batch before file transfers
|
||||
const uploadT0 = performance.now();
|
||||
@@ -661,7 +700,9 @@ async function uploadEntries(
|
||||
const allDirPaths = new Set<string>();
|
||||
for (const entry of sortedEntries) {
|
||||
if (entry.isDirectory) {
|
||||
allDirPaths.add(joinPath(targetPath, entry.relativePath));
|
||||
const dirPath = joinPath(targetPath, entry.relativePath);
|
||||
allDirPaths.add(dirPath);
|
||||
explicitDirectoryPaths.set(dirPath, entry.relativePath);
|
||||
} else {
|
||||
const pathParts = entry.relativePath.split('/');
|
||||
if (pathParts.length > 1) {
|
||||
@@ -686,12 +727,24 @@ async function uploadEntries(
|
||||
const sortedDepths = Array.from(dirsByDepth.keys()).sort((a, b) => a - b);
|
||||
for (const depth of sortedDepths) {
|
||||
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`);
|
||||
|
||||
let wasCancelled = false;
|
||||
|
||||
// Track bundled task progress
|
||||
const bundleProgress = new Map<string, {
|
||||
totalBytes: number;
|
||||
@@ -1020,7 +1073,7 @@ async function uploadEntries(
|
||||
break;
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatUploadError(error);
|
||||
results.push({ fileName: entry.relativePath, success: false, error: errorMessage });
|
||||
|
||||
if (bundleTaskId) {
|
||||
@@ -1084,6 +1137,10 @@ export async function uploadEntriesDirect(
|
||||
): Promise<UploadResult[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
||||
|
||||
if (controller?.isCancelled()) {
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
controller.reset();
|
||||
controller.setBridge(bridge);
|
||||
@@ -1335,7 +1392,7 @@ async function uploadFoldersCompressed(
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatUploadError(error);
|
||||
|
||||
// Remove compression ID from controller on error
|
||||
if (taskId) {
|
||||
|
||||
Reference in New Issue
Block a user