mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
* 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:
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user