fix: copy/move allow overwrite

This commit is contained in:
Henrique Dias 2026-06-06 08:28:31 +02:00
parent 5f7311d324
commit a1a514dcbb
No known key found for this signature in database
8 changed files with 70 additions and 13 deletions

View file

@ -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({

View file

@ -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({

View file

@ -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({

View file

@ -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({

View file

@ -53,6 +53,7 @@ interface ClipItem {
from: string;
name: string;
size?: number;
isDir?: boolean;
modified?: string;
}

View file

@ -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"));

View file

@ -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<ConflictingResource[]> {
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;

View file

@ -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({