From f2c2501fa5c93f7aa1089dbea0a08c19476a6458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:42:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(sftp):=20Ctrl+V=20=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E4=B8=8D=E5=86=8D=E5=8F=98=E6=88=90?= =?UTF-8?q?=E7=A9=BA=E6=96=87=E4=BB=B6=20(#1266)=20(#1312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Cursor --- components/SftpClipboardUpload.test.ts | 6 +- components/sftp/clipboardUpload.ts | 2 +- .../sftp/hooks/useSftpKeyboardShortcuts.ts | 160 ++++++++++++------ electron/bridges/clipboardFiles.cjs | 17 ++ electron/bridges/clipboardFiles.test.cjs | 40 +++++ 5 files changed, 170 insertions(+), 55 deletions(-) diff --git a/components/SftpClipboardUpload.test.ts b/components/SftpClipboardUpload.test.ts index 0af62c077..aa5ea5d95 100644 --- a/components/SftpClipboardUpload.test.ts +++ b/components/SftpClipboardUpload.test.ts @@ -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", () => { diff --git a/components/sftp/clipboardUpload.ts b/components/sftp/clipboardUpload.ts index 6e51fbf4c..1245c8f9c 100644 --- a/components/sftp/clipboardUpload.ts +++ b/components/sftp/clipboardUpload.ts @@ -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( diff --git a/components/sftp/hooks/useSftpKeyboardShortcuts.ts b/components/sftp/hooks/useSftpKeyboardShortcuts.ts index ffbee9ae0..b556ee3b1 100644 --- a/components/sftp/hooks/useSftpKeyboardShortcuts.ts +++ b/components/sftp/hooks/useSftpKeyboardShortcuts.ts @@ -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> = []; + 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(); + 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, ], ); diff --git a/electron/bridges/clipboardFiles.cjs b/electron/bridges/clipboardFiles.cjs index 3235cf43d..bf9b46782 100644 --- a/electron/bridges/clipboardFiles.cjs +++ b/electron/bridges/clipboardFiles.cjs @@ -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 []; } diff --git a/electron/bridges/clipboardFiles.test.cjs b/electron/bridges/clipboardFiles.test.cjs index ec5c36ded..652699188 100644 --- a/electron/bridges/clipboardFiles.test.cjs +++ b/electron/bridges/clipboardFiles.test.cjs @@ -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);