fix(sftp): Ctrl+V 粘贴文件夹不再变成空文件 (#1266) (#1312)

* fix(sftp): paste folders as directories instead of empty files (#1266)

Prefer Electron readClipboardFiles and extractDropEntries over clipboardData.files,
upload pasted directories via uploadExternalFolderPath, and add macOS NSFilenames
clipboard support.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): harden clipboard paste after review findings

Start extractDropEntries synchronously during paste, restore path-backed
file snapshot fallback, handle per-folder upload failures, and add
public.file-url clipboard test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈大猫
2026-06-08 19:42:45 +08:00
committed by GitHub
parent b1f930a995
commit f2c2501fa5
5 changed files with 170 additions and 55 deletions
+2 -4
View File
@@ -108,15 +108,13 @@ test("clipboard files become path-backed upload entries", () => {
]);
});
test("clipboard upload ignores directories until recursive paste is supported", () => {
test("clipboard upload keeps directories for recursive folder paste", () => {
const files: ClipboardLocalFile[] = [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
];
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
]);
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
});
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
+1 -1
View File
@@ -75,7 +75,7 @@ export function createDropEntriesFromClipboardFiles(files: ClipboardLocalFile[])
}
export function getSupportedClipboardUploadFiles(files: ClipboardLocalFile[]): ClipboardLocalFile[] {
return files.filter((file) => !file.isDirectory);
return files;
}
export function shouldLetNativePasteEventHandleSftpPaste(
+110 -50
View File
@@ -19,6 +19,7 @@ import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { toast } from "../../ui/toast";
import {
createDropEntriesFromClipboardFiles,
@@ -214,10 +215,6 @@ export const useSftpKeyboardShortcuts = ({
) => {
const sftp = sftpRef.current;
const uploadFiles = getSupportedClipboardUploadFiles(files);
const skippedDirectoryCount = files.length - uploadFiles.length;
if (skippedDirectoryCount > 0) {
toast.info("Folder paste is not supported yet. Only files will be uploaded.", "SFTP");
}
if (uploadFiles.length === 0) return;
const entries = createDropEntriesFromClipboardFiles(uploadFiles);
@@ -229,7 +226,39 @@ export const useSftpKeyboardShortcuts = ({
files: uploadFiles,
onConfirm: async () => {
try {
const results = await sftp.uploadExternalEntries(focusedSide, entries, { targetPath });
const results: Awaited<ReturnType<SftpStateApi["uploadExternalEntries"]>> = [];
const fileEntries: DropEntry[] = [];
for (const file of uploadFiles) {
if (file.isDirectory) {
try {
const folderResults = await sftp.uploadExternalFolderPath(focusedSide, file.path, targetPath);
results.push(...folderResults);
} catch (error) {
results.push({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else {
fileEntries.push(
entries.find((entry) => entry.localPath === file.path) ?? {
file: null,
localPath: file.path,
relativePath: file.name,
isDirectory: false,
size: file.size,
},
);
}
}
if (fileEntries.length > 0) {
const fileResults = await sftp.uploadExternalEntries(focusedSide, fileEntries, { targetPath });
results.push(...fileResults);
}
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
@@ -238,28 +267,36 @@ export const useSftpKeyboardShortcuts = ({
});
}, [dialogActionScopeId, showUploadResults, sftpRef]);
const triggerFileListClipboardUpload = useCallback((
files: File[],
const triggerDropEntriesClipboardUpload = useCallback((
entries: DropEntry[],
focusedSide: "left" | "right",
targetPath: string,
) => {
const sftp = sftpRef.current;
const bridge = netcattyBridge.get();
const dialogFiles: ClipboardLocalFile[] = files.map((file) => ({
path: bridge?.getPathForFile?.(file) || file.name,
name: file.name,
isDirectory: false,
size: file.size,
}));
if (entries.length === 0) return;
const rootNames = new Set<string>();
const previewFiles: ClipboardLocalFile[] = [];
for (const entry of entries) {
const rootName = entry.relativePath.split("/")[0];
if (rootNames.has(rootName)) continue;
rootNames.add(rootName);
previewFiles.push({
path: entry.localPath ?? entry.relativePath,
name: rootName,
isDirectory: entry.isDirectory,
size: entry.size,
});
}
sftpClipboardUploadStore.trigger({
scopeId: dialogActionScopeId,
side: focusedSide,
targetPath,
files: dialogFiles,
files: previewFiles,
onConfirm: async () => {
try {
const results = await sftp.uploadExternalFileList(focusedSide, files, targetPath);
const results = await sftp.uploadExternalEntries(focusedSide, entries, { targetPath });
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
@@ -369,51 +406,74 @@ export const useSftpKeyboardShortcuts = ({
const target = e.target as HTMLElement;
if (isEditableShortcutTarget(target) || hasOpenDialog()) return;
const hasInternalClipboardFiles = sftpClipboardStore.hasFiles();
const hasInternalClipboardFiles = sftpClipboardStore.hasFiles();
const { focusedSide, pane } = getFocusedPane();
if (!pane?.connection) return;
const targetPath = getClipboardUploadTarget(pane);
const pendingClipboardWrite = pendingSftpSystemClipboardWrite;
if (pendingClipboardWrite && hasInternalClipboardFiles) {
e.preventDefault();
e.stopPropagation();
void pendingClipboardWrite.finally(() => {
void pasteInternalSftpClipboard(focusedSide, pane);
});
return;
}
const pastedFiles = Array.from(e.clipboardData?.files ?? []).filter((file) => file.name);
if (pastedFiles.length > 0) {
e.preventDefault();
e.stopPropagation();
triggerFileListClipboardUpload(pastedFiles, focusedSide, targetPath);
return;
}
const bridge = netcattyBridge.get();
const clipboardFilesPromise = bridge?.readClipboardFiles?.();
if (!clipboardFilesPromise) {
if (!hasInternalClipboardFiles) return;
e.preventDefault();
e.stopPropagation();
void pasteInternalSftpClipboard(focusedSide, pane);
const dataTransfer = e.clipboardData;
const hasClipboardItems = (dataTransfer?.items?.length ?? 0) > 0;
// webkitGetAsEntry must be invoked synchronously during the paste event.
const dropEntriesPromise = dataTransfer?.items?.length
? extractDropEntries(dataTransfer)
: null;
const pastedFileSnapshot = dataTransfer?.files?.length
? Array.from(dataTransfer.files).filter((file) => file.name)
: [];
if (!hasInternalClipboardFiles && !hasClipboardItems && !bridge?.readClipboardFiles) {
return;
}
const runPaste = async () => {
if (pendingClipboardWrite && hasInternalClipboardFiles) {
await pendingClipboardWrite;
await pasteInternalSftpClipboard(focusedSide, pane);
return;
}
if (bridge?.readClipboardFiles) {
const clipboardFiles = await bridge.readClipboardFiles();
if (clipboardFiles.length > 0) {
triggerPathBackedClipboardUpload(clipboardFiles, focusedSide, targetPath);
return;
}
}
if (dropEntriesPromise) {
const entries = await dropEntriesPromise;
if (entries.length > 0) {
triggerDropEntriesClipboardUpload(entries, focusedSide, targetPath);
return;
}
}
if (pastedFileSnapshot.length > 0) {
const pathBackedFiles: ClipboardLocalFile[] = pastedFileSnapshot
.map((file) => ({
path: bridge?.getPathForFile?.(file) || file.name,
name: file.name,
isDirectory: false,
size: file.size,
}))
.filter((file) => file.path.includes("/") || file.path.includes("\\"));
if (pathBackedFiles.length > 0) {
triggerPathBackedClipboardUpload(pathBackedFiles, focusedSide, targetPath);
return;
}
}
if (hasInternalClipboardFiles) {
await pasteInternalSftpClipboard(focusedSide, pane);
}
};
e.preventDefault();
e.stopPropagation();
void clipboardFilesPromise.then((files) => {
if (files.length === 0) {
if (hasInternalClipboardFiles) {
void pasteInternalSftpClipboard(focusedSide, pane);
}
return;
}
triggerPathBackedClipboardUpload(files, focusedSide, targetPath);
});
void runPaste();
},
[
getClipboardUploadTarget,
@@ -422,7 +482,7 @@ export const useSftpKeyboardShortcuts = ({
isActive,
keyBindings,
pasteInternalSftpClipboard,
triggerFileListClipboardUpload,
triggerDropEntriesClipboardUpload,
triggerPathBackedClipboardUpload,
],
);
+17
View File
@@ -127,6 +127,23 @@ function readClipboardFiles({
) {
return parseClipboardTextFilePaths(clipboard.readText(), options);
}
if (typeof clipboard.read === "function") {
for (const format of ["NSFilenamesPboardType", "public.file-url"]) {
if (!formats.includes(format)) continue;
const raw = clipboard.read(format);
if (typeof raw !== "string" || raw.trim().length === 0) continue;
if (raw.includes("file://")) {
const files = parseClipboardTextFilePaths(raw, options);
if (files.length > 0) return files;
}
const files = collectExistingFiles(
raw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
options,
);
if (files.length > 0) return files;
}
}
} catch {
return [];
}
+40
View File
@@ -81,6 +81,46 @@ test("ignores plain text paths without file uri formats", () => {
assert.deepEqual(parseClipboardTextFilePaths("/Users/me/a.txt", { fsImpl, pathImpl: require("node:path") }), []);
});
test("reads macOS public.file-url clipboard paths", () => {
const fsImpl = createFs({
"/Users/me/folder": "directory",
});
const clipboard = {
availableFormats: () => ["public.file-url"],
read: (format) => {
if (format === "public.file-url") {
return "file:///Users/me/folder";
}
return "";
},
};
assert.deepEqual(readClipboardFiles({ clipboard, fsImpl, pathImpl: require("node:path") }), [
{ path: "/Users/me/folder", name: "folder", isDirectory: true, size: 0 },
]);
});
test("reads macOS NSFilenamesPboardType clipboard paths", () => {
const fsImpl = createFs({
"/Users/me/folder": "directory",
"/Users/me/a.txt": "file",
});
const clipboard = {
availableFormats: () => ["NSFilenamesPboardType"],
read: (format) => {
if (format === "NSFilenamesPboardType") {
return "/Users/me/folder\n/Users/me/a.txt";
}
return "";
},
};
assert.deepEqual(readClipboardFiles({ clipboard, fsImpl, pathImpl: require("node:path") }), [
{ path: "/Users/me/folder", name: "folder", isDirectory: true, size: 0 },
{ path: "/Users/me/a.txt", name: "a.txt", isDirectory: false, size: 42 },
]);
});
test("reads CF_HDROP before falling back to FileNameW", () => {
const filesOffset = 20;
const header = Buffer.alloc(filesOffset);