Fix SFTP upload context menu handling

This commit is contained in:
bincxz
2026-05-09 17:47:45 +08:00
parent a01ee1da61
commit 6c6a051c0c
19 changed files with 824 additions and 150 deletions

View File

@@ -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',

View File

@@ -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': '拖拽文件到这里',

View File

@@ -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,

View File

@@ -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 },
]);
});

View File

@@ -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(),

View 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");
});

View File

@@ -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[]>;
} }

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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,
}; };

View File

@@ -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),
}), }),

View 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";

View File

@@ -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,

View File

@@ -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 });
}
});

View File

@@ -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
View File

@@ -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 }>;

View File

@@ -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;
} }
} }

View File

@@ -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) {