Skip to content

Commit 5c52c5d

Browse files
G4brymclaude
andauthored
feat: add file and folder duplicate/copy operation (G4brym#152)
Add a "Duplicate" context menu option that copies files and folders within the same bucket, appending " (copy)" to the name while preserving all metadata. - New CopyObject backend endpoint (POST /api/buckets/:bucket/copy) - Frontend duplicate logic with progress tracking for folder duplication - Backend integration tests (6 tests) and E2E test for file duplication Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 67ea55c commit 5c52c5d

File tree

9 files changed

+343
-2
lines changed

9 files changed

+343
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"r2-explorer": minor
3+
---
4+
5+
Add file and folder duplicate/copy operation via context menu. Right-clicking a file or folder and selecting "Duplicate" creates a copy with a " (copy)" suffix in the same directory, preserving all metadata.

packages/dashboard/e2e/file-operations.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,33 @@ test.describe("File operations", () => {
109109
});
110110
});
111111

112+
test.describe("Duplicate file", () => {
113+
test.beforeEach(async ({ request }) => {
114+
await uploadFile(request, "e2e-copy-source.txt", "duplicate me");
115+
});
116+
117+
test.afterEach(async ({ request }) => {
118+
await deleteObject(request, "e2e-copy-source.txt");
119+
await deleteObject(request, "e2e-copy-source (copy).txt");
120+
});
121+
122+
test("duplicates a file via context menu", async ({ page }) => {
123+
await page.goto(`/${BUCKET}/files`);
124+
await expect(page.locator("text=e2e-copy-source.txt")).toBeVisible({
125+
timeout: 10_000,
126+
});
127+
128+
await page.locator("text=e2e-copy-source.txt").click({ button: "right" });
129+
await page.locator(".q-menu").getByText("Duplicate").click();
130+
131+
// Copy should appear in listing, original should still be there
132+
await expect(page.locator("text=e2e-copy-source (copy).txt")).toBeVisible({
133+
timeout: 5_000,
134+
});
135+
await expect(page.locator("text=e2e-copy-source.txt").first()).toBeVisible();
136+
});
137+
});
138+
112139
test.describe("Delete folder", () => {
113140
test.beforeEach(async ({ request }) => {
114141
await createFolder(request, "e2e-delete-folder");

packages/dashboard/src/appUtils.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export const apiHandler = {
171171
newKey: encode(newKey),
172172
});
173173
},
174+
copyObject: (bucket, sourceKey, destinationKey) => {
175+
return api.post(`/buckets/${bucket}/copy`, {
176+
sourceKey: encode(sourceKey),
177+
destinationKey: encode(destinationKey),
178+
});
179+
},
174180
updateMetadata: async (bucket, key, customMetadata, httpMetadata = {}) => {
175181
let prefix = "";
176182
if (key.includes("/")) {

packages/dashboard/src/components/files/FileOptions.vue

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,74 @@ export default defineComponent({
128128
);
129129
}
130130
},
131+
generateCopyName: (key, isFolder) => {
132+
if (isFolder) {
133+
const base = key.replace(/\/$/, "");
134+
return `${base} (copy)/`;
135+
}
136+
const lastDot = key.lastIndexOf(".");
137+
const lastSlash = key.lastIndexOf("/");
138+
if (lastDot > lastSlash + 1) {
139+
return `${key.substring(0, lastDot)} (copy)${key.substring(lastDot)}`;
140+
}
141+
return `${key} (copy)`;
142+
},
143+
duplicateObject: async function (row) {
144+
if (row.type === "folder") {
145+
const folderContents = await apiHandler.fetchFile(
146+
this.selectedBucket,
147+
row.key,
148+
"",
149+
);
150+
151+
const sourcePrefix = row.key;
152+
const destPrefix = this.generateCopyName(sourcePrefix, true);
153+
154+
const notif = this.q.notify({
155+
group: false,
156+
spinner: true,
157+
message: "Duplicating folder...",
158+
caption: "0%",
159+
timeout: 0,
160+
});
161+
162+
await apiHandler.createFolder(destPrefix, this.selectedBucket);
163+
164+
for (const [i, innerFile] of folderContents.entries()) {
165+
if (innerFile.key && !innerFile.key.endsWith("/")) {
166+
const newKey = innerFile.key.replace(sourcePrefix, destPrefix);
167+
await apiHandler.copyObject(
168+
this.selectedBucket,
169+
innerFile.key,
170+
newKey,
171+
);
172+
}
173+
notif({
174+
caption: `${Number.parseInt((i * 100) / folderContents.length)}%`,
175+
});
176+
}
177+
178+
notif({
179+
icon: "done",
180+
spinner: false,
181+
caption: "100%",
182+
message: "Folder duplicated!",
183+
timeout: 2500,
184+
});
185+
} else {
186+
const destKey = this.generateCopyName(row.key, false);
187+
await apiHandler.copyObject(this.selectedBucket, row.key, destKey);
188+
this.q.notify({
189+
group: false,
190+
icon: "done",
191+
spinner: false,
192+
message: "File duplicated!",
193+
timeout: 2500,
194+
});
195+
}
196+
197+
this.$bus.emit("fetchFiles");
198+
},
131199
renameConfirm: async function () {
132200
if (this.renameInput.length === 0) {
133201
return;

packages/dashboard/src/pages/files/FileContextMenu.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
<q-item clickable v-close-popup @click="renameObject" v-if="prop.row.type === 'file'">
1010
<q-item-section>Rename</q-item-section>
1111
</q-item>
12+
<q-item clickable v-close-popup @click="duplicateObject">
13+
<q-item-section>Duplicate</q-item-section>
14+
</q-item>
1215
<q-item clickable v-close-popup @click="updateMetadataObject" v-if="prop.row.type === 'file'">
1316
<q-item-section>Update Metadata</q-item-section>
1417
</q-item>
@@ -71,6 +74,9 @@ export default {
7174
renameObject: function () {
7275
this.$emit("renameObject", this.prop.row);
7376
},
77+
duplicateObject: function () {
78+
this.$emit("duplicateObject", this.prop.row);
79+
},
7480
updateMetadataObject: function () {
7581
this.$emit("updateMetadataObject", this.prop.row);
7682
},

packages/dashboard/src/pages/files/FilesFolderPage.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,15 @@
8181
touch-position
8282
context-menu
8383
>
84-
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
84+
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @duplicateObject="$refs.options.duplicateObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
8585
</q-menu>
8686
</template>
8787

8888
<template v-slot:body-cell-options="prop">
8989
<td class="text-right">
9090
<q-btn round flat icon="more_vert" size="sm">
9191
<q-menu>
92-
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
92+
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @duplicateObject="$refs.options.duplicateObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
9393
</q-menu>
9494
</q-btn>
9595
</td>

packages/worker/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { cors } from "hono/cors";
1010
import { z } from "zod";
1111
import { readOnlyMiddleware } from "./foundation/middlewares/readonly";
1212
import { settings } from "./foundation/settings";
13+
import { CopyObject } from "./modules/buckets/copyObject";
1314
import { CreateFolder } from "./modules/buckets/createFolder";
1415
import { CreateShareLink } from "./modules/buckets/createShareLink";
1516
import { DeleteObject } from "./modules/buckets/deleteObject";
@@ -121,6 +122,7 @@ export function R2Explorer(config?: R2ExplorerConfig) {
121122

122123
openapi.get("/api/buckets/:bucket", ListObjects);
123124
openapi.post("/api/buckets/:bucket/move", MoveObject);
125+
openapi.post("/api/buckets/:bucket/copy", CopyObject);
124126
openapi.post("/api/buckets/:bucket/folder", CreateFolder);
125127
openapi.post("/api/buckets/:bucket/upload", PutObject);
126128
openapi.post("/api/buckets/:bucket/multipart/create", CreateUpload);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { OpenAPIRoute } from "chanfana";
2+
import { HTTPException } from "hono/http-exception";
3+
import { z } from "zod";
4+
import type { AppContext } from "../../types";
5+
6+
export class CopyObject extends OpenAPIRoute {
7+
schema = {
8+
operationId: "post-bucket-copy-object",
9+
tags: ["Buckets"],
10+
summary: "Copy object",
11+
request: {
12+
params: z.object({
13+
bucket: z.string(),
14+
}),
15+
body: {
16+
content: {
17+
"application/json": {
18+
schema: z.object({
19+
sourceKey: z.string().describe("base64 encoded source file key"),
20+
destinationKey: z
21+
.string()
22+
.describe("base64 encoded destination file key"),
23+
}),
24+
},
25+
},
26+
},
27+
},
28+
};
29+
30+
async handle(c: AppContext) {
31+
const data = await this.getValidatedData<typeof this.schema>();
32+
33+
const bucketName = data.params.bucket;
34+
const bucket = c.env[bucketName] as R2Bucket | undefined;
35+
36+
if (!bucket) {
37+
throw new HTTPException(500, {
38+
message: `Bucket binding not found: ${bucketName}`,
39+
});
40+
}
41+
42+
const sourceKey = decodeURIComponent(escape(atob(data.body.sourceKey)));
43+
const destinationKey = decodeURIComponent(
44+
escape(atob(data.body.destinationKey)),
45+
);
46+
47+
const object = await bucket.get(sourceKey);
48+
49+
if (object === null) {
50+
throw new HTTPException(404, {
51+
message: `Source object not found: ${sourceKey}`,
52+
});
53+
}
54+
55+
const resp = await bucket.put(destinationKey, object.body, {
56+
customMetadata: object.customMetadata,
57+
httpMetadata: object.httpMetadata,
58+
});
59+
60+
return resp;
61+
}
62+
}

0 commit comments

Comments
 (0)