Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-duplicate-copy-operation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"r2-explorer": minor
---

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.
27 changes: 27 additions & 0 deletions packages/dashboard/e2e/file-operations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,33 @@ test.describe("File operations", () => {
});
});

test.describe("Duplicate file", () => {
test.beforeEach(async ({ request }) => {
await uploadFile(request, "e2e-copy-source.txt", "duplicate me");
});

test.afterEach(async ({ request }) => {
await deleteObject(request, "e2e-copy-source.txt");
await deleteObject(request, "e2e-copy-source (copy).txt");
});

test("duplicates a file via context menu", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-copy-source.txt")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-copy-source.txt").click({ button: "right" });
await page.locator(".q-menu").getByText("Duplicate").click();

// Copy should appear in listing, original should still be there
await expect(page.locator("text=e2e-copy-source (copy).txt")).toBeVisible({
timeout: 5_000,
});
await expect(page.locator("text=e2e-copy-source.txt").first()).toBeVisible();
});
});

test.describe("Delete folder", () => {
test.beforeEach(async ({ request }) => {
await createFolder(request, "e2e-delete-folder");
Expand Down
6 changes: 6 additions & 0 deletions packages/dashboard/src/appUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ export const apiHandler = {
newKey: encode(newKey),
});
},
copyObject: (bucket, sourceKey, destinationKey) => {
return api.post(`/buckets/${bucket}/copy`, {
sourceKey: encode(sourceKey),
destinationKey: encode(destinationKey),
});
},
updateMetadata: async (bucket, key, customMetadata, httpMetadata = {}) => {
let prefix = "";
if (key.includes("/")) {
Expand Down
68 changes: 68 additions & 0 deletions packages/dashboard/src/components/files/FileOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,74 @@ export default defineComponent({
);
}
},
generateCopyName: (key, isFolder) => {
if (isFolder) {
const base = key.replace(/\/$/, "");
return `${base} (copy)/`;
}
const lastDot = key.lastIndexOf(".");
const lastSlash = key.lastIndexOf("/");
if (lastDot > lastSlash + 1) {
return `${key.substring(0, lastDot)} (copy)${key.substring(lastDot)}`;
}
return `${key} (copy)`;
},
duplicateObject: async function (row) {
if (row.type === "folder") {
const folderContents = await apiHandler.fetchFile(
this.selectedBucket,
row.key,
"",
);

const sourcePrefix = row.key;
const destPrefix = this.generateCopyName(sourcePrefix, true);

const notif = this.q.notify({
group: false,
spinner: true,
message: "Duplicating folder...",
caption: "0%",
timeout: 0,
});

await apiHandler.createFolder(destPrefix, this.selectedBucket);

for (const [i, innerFile] of folderContents.entries()) {
if (innerFile.key && !innerFile.key.endsWith("/")) {
const newKey = innerFile.key.replace(sourcePrefix, destPrefix);
await apiHandler.copyObject(
this.selectedBucket,
innerFile.key,
newKey,
);
}
notif({
caption: `${Number.parseInt((i * 100) / folderContents.length)}%`,
});
}

notif({
icon: "done",
spinner: false,
caption: "100%",
message: "Folder duplicated!",
timeout: 2500,
});
} else {
const destKey = this.generateCopyName(row.key, false);
await apiHandler.copyObject(this.selectedBucket, row.key, destKey);
this.q.notify({
group: false,
icon: "done",
spinner: false,
message: "File duplicated!",
timeout: 2500,
});
}

this.$bus.emit("fetchFiles");
},
renameConfirm: async function () {
if (this.renameInput.length === 0) {
return;
Expand Down
6 changes: 6 additions & 0 deletions packages/dashboard/src/pages/files/FileContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<q-item clickable v-close-popup @click="renameObject" v-if="prop.row.type === 'file'">
<q-item-section>Rename</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="duplicateObject">
<q-item-section>Duplicate</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="updateMetadataObject" v-if="prop.row.type === 'file'">
<q-item-section>Update Metadata</q-item-section>
</q-item>
Expand Down Expand Up @@ -71,6 +74,9 @@ export default {
renameObject: function () {
this.$emit("renameObject", this.prop.row);
},
duplicateObject: function () {
this.$emit("duplicateObject", this.prop.row);
},
updateMetadataObject: function () {
this.$emit("updateMetadataObject", this.prop.row);
},
Expand Down
4 changes: 2 additions & 2 deletions packages/dashboard/src/pages/files/FilesFolderPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@
touch-position
context-menu
>
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
<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" />
</q-menu>
</template>

<template v-slot:body-cell-options="prop">
<td class="text-right">
<q-btn round flat icon="more_vert" size="sm">
<q-menu>
<FileContextMenu :prop="prop" @openObject="openObject" @deleteObject="$refs.options.deleteObject" @renameObject="$refs.options.renameObject" @updateMetadataObject="$refs.options.updateMetadataObject" @createShareLink="$refs.shareFile.openCreateShare" />
<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" />
</q-menu>
</q-btn>
</td>
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { cors } from "hono/cors";
import { z } from "zod";
import { readOnlyMiddleware } from "./foundation/middlewares/readonly";
import { settings } from "./foundation/settings";
import { CopyObject } from "./modules/buckets/copyObject";
import { CreateFolder } from "./modules/buckets/createFolder";
import { CreateShareLink } from "./modules/buckets/createShareLink";
import { DeleteObject } from "./modules/buckets/deleteObject";
Expand Down Expand Up @@ -121,6 +122,7 @@ export function R2Explorer(config?: R2ExplorerConfig) {

openapi.get("/api/buckets/:bucket", ListObjects);
openapi.post("/api/buckets/:bucket/move", MoveObject);
openapi.post("/api/buckets/:bucket/copy", CopyObject);
openapi.post("/api/buckets/:bucket/folder", CreateFolder);
openapi.post("/api/buckets/:bucket/upload", PutObject);
openapi.post("/api/buckets/:bucket/multipart/create", CreateUpload);
Expand Down
62 changes: 62 additions & 0 deletions packages/worker/src/modules/buckets/copyObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { OpenAPIRoute } from "chanfana";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import type { AppContext } from "../../types";

export class CopyObject extends OpenAPIRoute {
schema = {
operationId: "post-bucket-copy-object",
tags: ["Buckets"],
summary: "Copy object",
request: {
params: z.object({
bucket: z.string(),
}),
body: {
content: {
"application/json": {
schema: z.object({
sourceKey: z.string().describe("base64 encoded source file key"),
destinationKey: z
.string()
.describe("base64 encoded destination file key"),
}),
},
},
},
},
};

async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();

const bucketName = data.params.bucket;
const bucket = c.env[bucketName] as R2Bucket | undefined;

if (!bucket) {
throw new HTTPException(500, {
message: `Bucket binding not found: ${bucketName}`,
});
}

const sourceKey = decodeURIComponent(escape(atob(data.body.sourceKey)));
const destinationKey = decodeURIComponent(
escape(atob(data.body.destinationKey)),
);

const object = await bucket.get(sourceKey);

if (object === null) {
throw new HTTPException(404, {
message: `Source object not found: ${sourceKey}`,
});
}

const resp = await bucket.put(destinationKey, object.body, {
customMetadata: object.customMetadata,
httpMetadata: object.httpMetadata,
});

return resp;
}
}
Loading
Loading