Compare commits

...
Author SHA1 Message Date
netcatty-bot 94fa2fe3a6 fix: address Codex review on PR #3687 2026-10-15 09:57:50 +00:00
netcatty-bot f6afecbc0a fix(#3057): automated AI fix 2026-10-15 09:28:57 +00:00
12 changed files with 277 additions and 11 deletions

No files matched your search

+1
View File
@@ -634,6 +634,7 @@ export const enVaultMessages: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.noFiles': 'No files to upload',
'sftp.upload.virtualFolderError': 'Folders dragged from an archive window cannot be uploaded. Extract the archive to a normal folder first, then upload the folder.',
'sftp.upload.partialSuccess': 'Uploaded {success} file(s); {failed} failed',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
+1
View File
@@ -634,6 +634,7 @@ export const esVaultMessages: Messages = {
'sftp.upload.currentFile': 'Actual: {fileName}',
'sftp.upload.cancelled': 'Subida cancelada',
'sftp.upload.noFiles': 'No hay archivos para subir',
'sftp.upload.virtualFolderError': 'No se pueden subir carpetas arrastradas desde una ventana de archivo comprimido. Extrae el archivo a una carpeta normal y vuelve a subirlo.',
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
'sftp.upload.cancel': 'Cancelar',
'sftp.upload.completedToPath': 'Subido a {path}',
+1
View File
@@ -676,6 +676,7 @@ export const ruVaultMessages: Messages = {
'sftp.upload.currentFile': 'Текущий: {fileName}',
'sftp.upload.cancelled': 'Загрузка отменена',
'sftp.upload.noFiles': 'Нет файлов для загрузки',
'sftp.upload.virtualFolderError': 'Нельзя загрузить папку, перетащенную из окна архива. Сначала распакуйте архив в обычную папку и загрузите её.',
'sftp.upload.partialSuccess': 'Загружено: {success}; ошибок: {failed}',
'sftp.upload.cancel': 'Отмена',
'sftp.upload.completedToPath': 'Загружено в {path}',
@@ -187,6 +187,7 @@ export const zhCNTerminalMessages: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.noFiles': '没有可上传的文件',
'sftp.upload.virtualFolderError': '无法上传从压缩包中直接拖出的文件夹,请先解压到普通文件夹后再上传。',
'sftp.upload.partialSuccess': '已上传 {success} 个文件,{failed} 个失败',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
@@ -187,6 +187,7 @@ export const zhTWTerminalMessages: Messages = {
'sftp.upload.currentFile': '目前: {fileName}',
'sftp.upload.cancelled': '上傳已取消',
'sftp.upload.noFiles': '沒有可上傳的檔案',
'sftp.upload.virtualFolderError': '無法上傳從壓縮檔中直接拖出的資料夾,請先解壓縮到一般資料夾後再上傳。',
'sftp.upload.partialSuccess': '已上傳 {success} 個檔案,{failed} 個失敗',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上傳至 {path}',
+84
View File
@@ -9,6 +9,7 @@ import {
uploadFromFileList,
} from "../../lib/uploadService.ts";
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
import { SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR } from "../../domain/sftpVirtualDrop.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
@@ -1196,3 +1197,86 @@ test("does not restart a direct upload that was already cancelled", async () =>
{ fileName: "", success: false, cancelled: true },
]);
});
test("archive-window folder drags (path-less empty pseudo-files) fail fast without a conflict dialog", async () => {
const dialogConflicts: unknown[] = [];
const uploadedPaths: string[] = [];
const stagedFiles: string[] = [];
// Chromium presents an archive folder as an extension-less, empty,
// path-less pseudo-file with no entry.
const pseudoFolder = new File([], "folder");
const results = await uploadEntriesDirect(
[{ file: pseudoFolder, relativePath: "folder", isDirectory: false }],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1000 }),
stageUploadFile: async (stagedFile) => {
stagedFiles.push(stagedFile.name);
return "/netcatty-temp/folder";
},
startStreamTransfer: async (payload) => {
uploadedPaths.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async (conflict) => {
dialogConflicts.push(conflict);
return "duplicate";
},
},
);
// Pseudo folders must never reach the conflict dialog or the transfer path.
assert.deepEqual(dialogConflicts, []);
assert.deepEqual(stagedFiles, []);
assert.deepEqual(uploadedPaths, []);
assert.equal(results.length, 1);
assert.equal(results[0].fileName, "folder");
assert.equal(results[0].success, false);
assert.equal(results[0].error, SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR);
});
test("mixed drops upload real files and only refuse the pseudo archive folder", async () => {
const uploadedPaths: string[] = [];
const pseudoFolder = new File([], "folder");
const realFile = new File(["payload"], "notes.txt");
const results = await uploadEntriesDirect(
[
{ file: pseudoFolder, relativePath: "folder", isDirectory: false },
{ file: realFile, relativePath: "notes.txt", isDirectory: false },
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async () => null,
stageUploadFile: async () => "/netcatty-temp/notes.txt",
deleteTempFile: async () => {},
startStreamTransfer: async (payload) => {
uploadedPaths.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(uploadedPaths, ["/netcatty-temp/notes.txt"]);
assert.deepEqual(
results.map((result) => ({ fileName: result.fileName, success: result.success })),
[
{ fileName: "folder", success: false },
{ fileName: "notes.txt", success: true },
],
);
assert.equal(results[0].error, SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR);
});
@@ -24,6 +24,8 @@ import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import { resolveSameConnectionPasteAction } from "../../../application/state/sftp/samePanePaste";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { resolveSftpUploadErrorText } from "../reportSftpUploadResults";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { toast } from "../../ui/toast";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
import {
@@ -183,6 +185,7 @@ export const useSftpKeyboardShortcuts = ({
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const { t } = useI18n();
const typeaheadRef = useRef<{ paneId: string; state: SftpTypeaheadState } | null>(null);
const getFocusedPane = useCallback(() => {
@@ -222,10 +225,13 @@ export const useSftpKeyboardShortcuts = ({
toast.success(`Uploaded ${successCount} item${successCount === 1 ? "" : "s"}.`, "SFTP");
}
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
// Virtual archive-folder drags store a sentinel; map it to the
// localized, actionable message instead of showing the raw marker.
const mappedError = resolveSftpUploadErrorText(failed.error, t);
const errorMsg = mappedError ? ` - ${mappedError}` : "";
toast.error(`Upload failed: ${failed.fileName}${errorMsg}`, "SFTP");
});
}, []);
}, [t]);
const triggerPathBackedClipboardUpload = useCallback((
files: ClipboardLocalFile[],
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { reportSftpUploadResults } from "./reportSftpUploadResults.ts";
import { reportSftpUploadResults, resolveSftpUploadErrorText } from "./reportSftpUploadResults.ts";
import { SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR } from "../../domain/sftpVirtualDrop";
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.upload.partialSuccess") {
@@ -117,3 +118,30 @@ test("partial terminal drop success names the exact remote destination", () => {
message: "partial 1/1 (/srv/releases)",
});
});
test("replaces the virtual archive-folder sentinel with a localized message", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [
{ fileName: "folder", success: false, error: SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR },
],
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.equal(calls[0]?.type, "error");
assert.equal(
calls[0]?.message,
"sftp.error.uploadFailed: folder - sftp.upload.virtualFolderError",
);
});
test("resolveSftpUploadErrorText maps the sentinel for non-reporting consumers", () => {
assert.equal(resolveSftpUploadErrorText(SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR, t), "sftp.upload.virtualFolderError");
assert.equal(resolveSftpUploadErrorText("stream error", t), "stream error");
assert.equal(resolveSftpUploadErrorText(undefined, t), undefined);
});
+14 -1
View File
@@ -1,5 +1,17 @@
import { isSftpVirtualFolderUploadError } from "../../domain/sftpVirtualDrop";
import type { UploadResult } from "../../lib/uploadService.types";
/**
* Maps a raw upload error for display. The virtual archive-folder sentinel is
* an internal marker, never user-facing text: every upload-result consumer
* must route errors through this helper before presenting them.
*/
export const resolveSftpUploadErrorText = (
error: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string,
): string | undefined =>
isSftpVirtualFolderUploadError(error) ? t("sftp.upload.virtualFolderError") : error;
export type ReportSftpUploadResultsOptions = {
results: readonly UploadResult[];
t: (key: string, params?: Record<string, string | number>) => string;
@@ -38,7 +50,8 @@ export function reportSftpUploadResults({
if (failed.length > 0) {
for (const item of failed) {
const errorMsg = item.error ? ` - ${item.error}` : "";
const rawError = resolveSftpUploadErrorText(item.error, t);
const errorMsg = rawError ? ` - ${rawError}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${item.fileName || t("sftp.upload")}${errorMsg}`,
"SFTP",
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
isSftpVirtualFolderUploadError,
isVirtualFolderPseudoFileDrop,
} from "./sftpVirtualDrop.ts";
const fileEntry = (overrides: Partial<Parameters<typeof isVirtualFolderPseudoFileDrop>[0]> = {}) => ({
relativePath: "folder",
isDirectory: false,
localPath: undefined,
size: 0,
file: { size: 0 },
...overrides,
});
test("detects path-less extension-less empty pseudo-files as archive folder drags", () => {
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry()), true);
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ size: undefined, file: null })), true);
});
test("keeps real directory entries on the folder upload flow", () => {
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ isDirectory: true })), false);
});
test("keeps path-backed drops on the normal flow", () => {
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ localPath: "\\Temp\\folder" })), false);
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ localPath: "/tmp/folder" })), false);
});
test("keeps nested enumerated children and dotted names on the normal flow", () => {
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "folder/notes" })), false);
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "backup 2.0" })), false);
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "" })), false);
});
test("keeps path-less virtual files that still carry content", () => {
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "Makefile", size: 120, file: { size: 120 } })), false);
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "logo", size: 4096, file: { size: 4096 } })), false);
});
test("sentinel helpers only match the exact marker", () => {
assert.equal(isSftpVirtualFolderUploadError(SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR), true);
assert.equal(isSftpVirtualFolderUploadError("stream error"), false);
assert.equal(isSftpVirtualFolderUploadError(undefined), false);
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Windows archive-window drags (WinRAR / 7-Zip / Explorer zip) hand the app
* "virtual" items: Chromium forwards the shell FILEDESCRIPTOR/FILECONTENTS
* data without a real disk path, so Electron's path resolution returns
* nothing. A folder dragged out of an archive arrives in the renderer as a
* single path-less, extension-less, empty pseudo-file — its contents cannot
* be enumerated, so it can neither be merged nor safely replaced, and
* streaming it only fabricates an empty file before failing with a confusing
* path error (#3057). Those items are rejected before the conflict dialog
* with an actionable message pointing the user to extract the archive first.
*/
export interface VirtualFolderDropCandidate {
relativePath: string;
isDirectory: boolean;
localPath?: string;
size?: number;
/** Renderer pseudo-file handle; null for directory entries. */
file?: { size?: number } | null;
}
/**
* Sentinel stored in `UploadResult.error`. The toast reporting layer replaces
* this with a localized message before it is presented to the user, so the
* sentinel must never be shown as raw text.
*/
export const SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR = "netcatty:virtual-folder-upload";
export const isSftpVirtualFolderUploadError = (error: string | undefined): boolean =>
error === SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR;
export const isVirtualFolderPseudoFileDrop = (
entry: VirtualFolderDropCandidate,
): boolean => {
// Real directory entries keep the normal folder flow (merge/replace dialog).
if (entry.isDirectory) return false;
// Normal Explorer/Finder drags always expose a real local path in Electron.
if (entry.localPath) return false;
// Only standalone roots matter. Enumerated children keep slash-nested
// relative paths and upload as real (streamed) files.
const relativePath = entry.relativePath || "";
if (!relativePath || relativePath.includes("/")) return false;
// Folder names can contain dots; only extension-less names are unambiguous
// enough to reject automatically.
if (relativePath.includes(".")) return false;
// Virtual folder descriptors carry no streamable content.
const size = entry.file?.size ?? entry.size;
return size === undefined || size === 0;
};
+40 -7
View File
@@ -17,6 +17,10 @@ import {
} from "../domain/sftpConflict";
import { isMissingStatError } from "../domain/sftpStatError";
import { resolveSftpTransferConcurrency } from "../domain/sftpTransferConcurrency";
import {
SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
isVirtualFolderPseudoFileDrop,
} from "../domain/sftpVirtualDrop";
// ============================================================================
// Types
@@ -257,8 +261,37 @@ async function uploadEntriesWithOptionalCompression(
resolveConflict,
} = config;
// Windows archive-window drags hand over "virtual" items without a real
// disk path. A folder dragged out of an archive arrives as one path-less,
// extension-less, empty pseudo-file whose content cannot be enumerated, so
// uploading would fabricate an empty file and its same-name conflict cannot
// honestly offer merge/replace (#3057). Fail those items fast with an
// actionable error; everything else keeps the regular flow.
const virtualFolderResults: UploadResult[] = [];
const uploadableEntries: DropEntry[] = [];
for (const entry of entries) {
if (isVirtualFolderPseudoFileDrop(entry)) {
virtualFolderResults.push({
fileName: entry.relativePath,
success: false,
error: SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
});
} else {
uploadableEntries.push(entry);
}
}
if (uploadableEntries.length === 0) {
return virtualFolderResults;
}
entries = uploadableEntries;
const withVirtualFolderResults = (results: UploadResult[]): UploadResult[] => [
...virtualFolderResults,
...results,
];
if (!useCompressedUpload || isLocal || !sftpId) {
return uploadEntries(
return withVirtualFolderResults(await uploadEntries(
entries,
targetPath,
sftpId,
@@ -270,12 +303,12 @@ async function uploadEntriesWithOptionalCompression(
resolveConflict,
config.targetHostId,
config.fileTransferConcurrency,
);
));
}
const rootGroups = Array.from(detectRootFolders(entries).entries());
if (!rootGroups.some(([key]) => !key.startsWith("__file__"))) {
return uploadEntries(
return withVirtualFolderResults(await uploadEntries(
entries,
targetPath,
sftpId,
@@ -287,7 +320,7 @@ async function uploadEntriesWithOptionalCompression(
resolveConflict,
config.targetHostId,
config.fileTransferConcurrency,
);
));
}
const statTarget = async (path: string) => {
@@ -350,7 +383,7 @@ async function uploadEntriesWithOptionalCompression(
if (action === "stop") {
await controller?.cancel();
return [{ fileName: info.rootName, success: false, cancelled: true }];
return withVirtualFolderResults([{ fileName: info.rootName, success: false, cancelled: true }]);
}
if (action === "skip") {
resolvedResults.push({ fileName: info.rootName, success: false, cancelled: true });
@@ -397,7 +430,7 @@ async function uploadEntriesWithOptionalCompression(
result.success || result.error !== compressionFallbackError
));
if (regularEntries.length === 0) {
return [...resolvedResults, ...terminalCompressedResults];
return withVirtualFolderResults([...resolvedResults, ...terminalCompressedResults]);
}
const replayResolvedConflict: UploadConfig["resolveConflict"] = resolveConflict
@@ -419,7 +452,7 @@ async function uploadEntriesWithOptionalCompression(
config.targetHostId,
config.fileTransferConcurrency,
);
return [...resolvedResults, ...terminalCompressedResults, ...regularResults];
return withVirtualFolderResults([...resolvedResults, ...terminalCompressedResults, ...regularResults]);
}
/**