diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue index d6747f59..33b5a993 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -179,6 +179,7 @@ const drop = async (event: Event) => { to: props.url + encodeURIComponent(fileStore.req?.items[i].name), name: fileStore.req?.items[i].name, size: fileStore.req?.items[i].size, + isDir: fileStore.req?.items[i].isDir, modified: fileStore.req?.items[i].modified, overwrite: false, rename: false, @@ -204,7 +205,7 @@ const drop = async (event: Event) => { .catch($showError); }; - const conflict = await upload.checkConflict(items, path); + const conflict = await upload.checkConflict(items, path, true); if (conflict.length > 0) { layoutStore.showHover({ diff --git a/frontend/src/components/prompts/Copy.vue b/frontend/src/components/prompts/Copy.vue index 0073df8d..37139b9b 100644 --- a/frontend/src/components/prompts/Copy.vue +++ b/frontend/src/components/prompts/Copy.vue @@ -123,7 +123,7 @@ export default { }); }; - const conflict = await upload.checkConflict(items, this.dest); + const conflict = await upload.checkConflict(items, this.dest, true); if (conflict.length > 0) { this.showHover({ diff --git a/frontend/src/components/prompts/Move.vue b/frontend/src/components/prompts/Move.vue index 34a0d0fa..b8086a67 100644 --- a/frontend/src/components/prompts/Move.vue +++ b/frontend/src/components/prompts/Move.vue @@ -123,7 +123,7 @@ export default { }); }; - const conflict = await upload.checkConflict(items, this.dest); + const conflict = await upload.checkConflict(items, this.dest, true); if (conflict.length > 0) { this.showHover({ diff --git a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts index 8241be2e..cc138329 100644 --- a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts +++ b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts @@ -86,7 +86,8 @@ describe("copy and move conflict prompts", () => { isDir: false, }), ], - "/files/target/" + "/files/target/", + true ); expect(context.showHover).toHaveBeenCalledWith( expect.objectContaining({ @@ -109,7 +110,8 @@ describe("copy and move conflict prompts", () => { isDir: false, }), ], - "/files/target/" + "/files/target/", + true ); expect(context.showHover).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/frontend/src/types/file.d.ts b/frontend/src/types/file.d.ts index 7e6a9ebc..067598b0 100644 --- a/frontend/src/types/file.d.ts +++ b/frontend/src/types/file.d.ts @@ -53,6 +53,7 @@ interface ClipItem { from: string; name: string; size?: number; + isDir?: boolean; modified?: string; } diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index e73bd979..37a4e69b 100644 --- a/frontend/src/utils/__tests__/check-conflict.test.ts +++ b/frontend/src/utils/__tests__/check-conflict.test.ts @@ -167,6 +167,49 @@ describe("checkConflict", () => { expect(conflicts[0].name).toBe("/target/folder/deep/file.txt"); }); + // Copy/move stats the whole destination, so a same-named directory is a + // conflict in its own right (regression for the directory case of #5957). + it("reports a directory conflict for copy/move (includeDirectories)", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/folder", + name: "folder", + size: 0, + modified: "2026-06-04T00:00:00Z", + isDir: true, + }, + ]); + + const items = [{ ...moveItem("folder", "/files/target/", 0), isDir: true }]; + + const conflicts = await checkConflict(items, "/files/target/", true); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].name).toBe("/target/folder"); + }); + + // Uploads merge into an existing folder, so the directory itself must not be + // reported — only the files inside it can conflict. + it("ignores a directory conflict for uploads (default)", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/folder", + name: "folder", + size: 0, + modified: "2026-06-04T00:00:00Z", + isDir: true, + }, + ]); + + const files = [ + { name: "folder", size: 0, isDir: true, fullPath: "folder" }, + ]; + + const conflicts = await checkConflict(files, "/files/target/"); + + expect(conflicts).toHaveLength(0); + }); + it("returns no conflicts when the recursive listing fails", async () => { vi.mocked(api.fetchAll).mockRejectedValue(new Error("404")); diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index 994c95be..c4a9aab4 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -26,16 +26,24 @@ function conflictKey(item: UploadEntry): string { * server, so the caller can prompt the user to overwrite/rename/skip. * * The whole destination tree is fetched once and indexed by path relative to - * the destination, then every file is looked up directly — no need to mirror - * the upload's folder structure. Directories are never reported: an existing - * folder is merged on upload, and a copy/move onto one is rejected server-side. + * the destination, then every entry is looked up directly — no need to mirror + * the upload's folder structure. * - * @param files - flat upload list to check - * @param basePath - server destination path (e.g. "/files/uploads/") + * Directory handling differs by action, hence `includeDirectories`: + * - Upload (false): an existing folder is silently merged, so only the + * individual files inside it can conflict. + * - Copy/move (true): the server stats the destination and rejects it whole if + * a same-named entry exists, so the directory itself is a conflict. The list + * only holds the top-level items being moved, so each is reported once. + * + * @param files - flat upload list to check + * @param basePath - server destination path (e.g. "/files/uploads/") + * @param includeDirectories - report directories as conflicts (copy/move) */ export async function checkConflict( files: UploadList, - basePath: string + basePath: string, + includeDirectories = false ): Promise { if (files.length === 0) return []; @@ -62,7 +70,7 @@ export async function checkConflict( const conflicts: ConflictingResource[] = []; files.forEach((file, index) => { - if (file.isDir) return; // see directory note above + if (file.isDir && !includeDirectories) return; // see directory note above const server = serverMap.get(conflictKey(file)); if (!server) return; diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue index 06a010e4..9bf4c488 100644 --- a/frontend/src/views/files/FileListing.vue +++ b/frontend/src/views/files/FileListing.vue @@ -632,6 +632,7 @@ const copyCut = (event: Event | KeyboardEvent): void => { from: fileStore.req.items[i].url, name: fileStore.req.items[i].name, size: fileStore.req.items[i].size, + isDir: fileStore.req.items[i].isDir, modified: fileStore.req.items[i].modified, }); } @@ -661,6 +662,7 @@ const paste = async (event: Event) => { to, name: item.name, size: item.size, + isDir: item.isDir, modified: item.modified, overwrite: false, rename: clipboardStore.path == route.path, @@ -697,7 +699,7 @@ const paste = async (event: Event) => { } const path = route.path.endsWith("/") ? route.path : route.path + "/"; - const conflict = await upload.checkConflict(items, path); + const conflict = await upload.checkConflict(items, path, true); if (conflict.length > 0) { layoutStore.showHover({