Skip to content
Open
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
27 changes: 27 additions & 0 deletions .changeset/cute-geckos-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"r2-explorer": minor
---

Trash / Recycle Bin

Deleted files and folders are now moved to a recoverable trash area inside the same R2 bucket instead of being permanently deleted. A new **Trash** page in the dashboard lets you restore items or delete them forever, and an **Undo** action appears right after deleting a file.

Example configuration:

```ts
export default R2Explorer({
readonly: false,
trash: {
retentionDays: 30,
},
});
```

To automatically purge expired trash, add a cron trigger to your `wrangler.toml`:

```toml
[triggers]
crons = ["0 0 * * *"]
```

Set `trash: false` to keep the previous hard-delete behavior.
121 changes: 121 additions & 0 deletions packages/dashboard/e2e/trash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { test, expect } from "@playwright/test";
import { uploadFile, deleteObject, BUCKET } from "./helpers";

test.describe("Trash", () => {
test.describe("Delete to trash and restore", () => {
test.beforeEach(async ({ request }) => {
await uploadFile(request, "e2e-trash-me.txt", "trash this");
});

test.afterEach(async ({ request }) => {
await deleteObject(request, "e2e-trash-me.txt");
// Ensure no restored copy remains
await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, {
data: { key: btoa("e2e-trash-me.txt"), permanent: true },
});
});

test("moves a deleted file to trash and restores it", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-trash-me.txt").click({ button: "right" });
await page.locator(".q-menu").getByText("Delete").click();
await page.locator(".q-dialog").getByRole("button", { name: "Delete" }).click();

await expect(page.locator(".q-dialog")).not.toBeVisible({ timeout: 5_000 });
await expect(
page.locator(".q-table").locator("text=e2e-trash-me.txt"),
).not.toBeVisible({ timeout: 5_000 });

// Navigate to trash
await page.getByRole("button", { name: "Trash" }).click();
await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({
timeout: 10_000,
});

// Restore the file
const row = page.locator("tr", { hasText: "e2e-trash-me.txt" });
await row.locator("button[aria-label='Restore']").click();

await expect(
page.locator(".q-table").locator("text=e2e-trash-me.txt"),
).not.toBeVisible({ timeout: 5_000 });

await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({
timeout: 10_000,
});
});
});

test.describe("Empty trash", () => {
test.beforeEach(async ({ request }) => {
await uploadFile(request, "e2e-trash-empty-1.txt", "one");
await uploadFile(request, "e2e-trash-empty-2.txt", "two");
await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, {
data: { key: btoa("e2e-trash-empty-1.txt") },
});
await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, {
data: { key: btoa("e2e-trash-empty-2.txt") },
});
});

test("empties the trash via the header button", async ({ page }) => {
await page.goto(`/${BUCKET}/trash`);
await expect(page.locator("text=e2e-trash-empty-1.txt")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator("text=e2e-trash-empty-2.txt")).toBeVisible({
timeout: 10_000,
});

await page.getByRole("button", { name: "Empty trash" }).click();
await page.locator(".q-dialog").getByRole("button", { name: "OK" }).click();

await expect(
page.locator("text=e2e-trash-empty-1.txt"),
).not.toBeVisible({ timeout: 5_000 });
await expect(
page.locator("text=e2e-trash-empty-2.txt"),
).not.toBeVisible({ timeout: 5_000 });
});
});

test.describe("Undo notification", () => {
test.beforeEach(async ({ request }) => {
await uploadFile(request, "e2e-trash-undo.txt", "undo me");
});

test.afterEach(async ({ request }) => {
await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, {
data: { key: btoa("e2e-trash-undo.txt"), permanent: true },
});
});

test("shows an undo action after deleting a file", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-trash-undo.txt")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-trash-undo.txt").click({ button: "right" });
await page.locator(".q-menu").getByText("Delete").click();
await page.locator(".q-dialog").getByRole("button", { name: "Delete" }).click();

await expect(page.locator("text=Moved to trash")).toBeVisible({
timeout: 5_000,
});
await page.locator("text=Undo").click();

await expect(page.locator("text=File restored!")).toBeVisible({
timeout: 5_000,
});
await expect(page.locator("text=e2e-trash-undo.txt")).toBeVisible({
timeout: 5_000,
});
});
});
});
46 changes: 35 additions & 11 deletions packages/dashboard/src/appUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,27 @@ export const apiHandler = {
key: encode(key),
});
},
deleteObject: (key, bucket) => {
deleteObject: (key, bucket, permanent = false) => {
return api.post(`/buckets/${bucket}/delete`, {
key: encode(key),
permanent: permanent,
});
},
listTrash: async (bucket, cursor = null) => {
return await api.get(`/buckets/${bucket}/trash`, {
params: {
cursor: cursor,
},
});
},
restoreTrash: (bucket, trashKey) => {
return api.post(`/buckets/${bucket}/trash/restore`, {
trashKey: encode(trashKey),
});
},
purgeTrash: (bucket, trashKey) => {
return api.post(`/buckets/${bucket}/trash/purge`, {
trashKey: trashKey ? encode(trashKey) : undefined,
});
},
downloadFile: (
Expand Down Expand Up @@ -288,9 +306,10 @@ export const apiHandler = {
})
.map((obj) => mapFile(obj, namePrefix))
.filter((obj) => {
// Remove hidden files
// Remove hidden files and internal explorer paths
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
(mainStore.showHiddenFiles !== true && obj.name.startsWith(".")) ||
obj.key.startsWith(".r2-explorer/")
);
});

Expand All @@ -303,7 +322,7 @@ export const apiHandler = {
const folders = response.data.delimitedPrefixes
.map((obj) => ({
name: obj.replace(namePrefix, ""),
hash: encode(obj.key),
hash: encode(obj),
key: obj,
lastModified: "--",
timestamp: 0,
Expand All @@ -314,9 +333,10 @@ export const apiHandler = {
color: "orange",
}))
.filter((obj) => {
// Remove hidden files
// Remove hidden files and internal explorer paths
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
(mainStore.showHiddenFiles !== true && obj.name.startsWith(".")) ||
obj.key.startsWith(".r2-explorer/")
);
});

Expand Down Expand Up @@ -358,9 +378,11 @@ export const apiHandler = {
})
.map((obj) => mapFile(obj, prefix))
.filter((obj) => {
// Remove hidden files
// Remove hidden files and internal explorer paths
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
(mainStore.showHiddenFiles !== true &&
obj.name.startsWith(".")) ||
obj.key.startsWith(".r2-explorer/")
);
});

Expand All @@ -373,7 +395,7 @@ export const apiHandler = {
const folders = response.data.delimitedPrefixes
.map((obj) => ({
name: obj.replace(prefix, ""),
hash: encode(obj.key),
hash: encode(obj),
key: obj,
lastModified: "--",
timestamp: 0,
Expand All @@ -384,9 +406,11 @@ export const apiHandler = {
color: "orange",
}))
.filter((obj) => {
// Remove hidden files
// Remove hidden files and internal explorer paths
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
(mainStore.showHiddenFiles !== true &&
obj.name.startsWith(".")) ||
obj.key.startsWith(".r2-explorer/")
);
});

Expand Down
82 changes: 67 additions & 15 deletions packages/dashboard/src/components/files/FileOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<code v-else><q-spinner color="primary"/></code>
files inside?</span>
<span v-else class="q-ml-sm">Are you sure you want to delete the file <code>{{row.name}}</code>?</span>
<q-checkbox v-if="trashEnabled" v-model="deletePermanent" label="Delete permanently (skip trash)" color="red" />
</q-card-section>

<q-card-actions align="right">
Expand Down Expand Up @@ -84,6 +85,7 @@ export default defineComponent({
deleteModal: false,
renameModal: false,
updateMetadataModal: false,
deletePermanent: false,
deleteFolderInnerFilesCount: null,
newFolderName: "",
renameInput: "",
Expand Down Expand Up @@ -243,6 +245,8 @@ export default defineComponent({
});
},
deleteConfirm: async function () {
const permanent = this.deletePermanent;

if (this.row.type === "folder") {
// When deleting folders, first must copy the objects, because the popup close forces a reset on properties
const originalFolder = { ...this.row };
Expand All @@ -254,39 +258,83 @@ export default defineComponent({
const notif = this.q.notify({
group: false,
spinner: true,
message: "Deleting files...",
message: permanent
? "Deleting files forever..."
: "Moving files to trash...",
caption: "0%",
timeout: 0,
});

for (const [i, innerFile] of folderContents.entries()) {
if (innerFile.key) {
await apiHandler.deleteObject(innerFile.key, this.selectedBucket);
await apiHandler.deleteObject(
innerFile.key,
this.selectedBucket,
permanent,
);
}
notif({
caption: `${Number.parseInt((i * 100) / (folderContentsCount + 1))}%`, // +1 because still needs to delete the folder
});
}

await apiHandler.deleteObject(originalFolder.key, this.selectedBucket);
await apiHandler.deleteObject(
originalFolder.key,
this.selectedBucket,
permanent,
);

notif({
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
icon: "done",
spinner: false,
caption: "100%",
message: "Folder deleted!",
timeout: 2500, // we will timeout it in 2.5s
message: permanent
? "Folder permanently deleted!"
: "Folder moved to trash!",
timeout: 2500,
});
} else {
this.deleteModal = false;
await apiHandler.deleteObject(this.row.key, this.selectedBucket);
this.q.notify({
group: false,
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: "File deleted!",
timeout: 2500, // we will timeout it in 2.5s
});
const resp = await apiHandler.deleteObject(
this.row.key,
this.selectedBucket,
permanent,
);

if (!permanent && this.trashEnabled) {
const trashKey = resp.data.trashKey;
this.q.notify({
group: false,
icon: "delete",
spinner: false,
message: "File moved to trash",
timeout: 5000,
actions: [
{
label: "Undo",
color: "white",
handler: async () => {
await apiHandler.restoreTrash(this.selectedBucket, trashKey);
this.$bus.emit("fetchFiles");
this.q.notify({
group: false,
icon: "restore_from_trash",
message: "File restored!",
timeout: 2500,
});
},
},
],
});
} else {
this.q.notify({
group: false,
icon: "done",
spinner: false,
message: "File deleted!",
timeout: 2500,
});
}
}

this.$bus.emit("fetchFiles");
Expand All @@ -297,6 +345,7 @@ export default defineComponent({
this.deleteModal = false;
this.renameModal = false;
this.updateMetadataModal = false;
this.deletePermanent = false;
this.renameInput = "";
this.updateCustomMetadata = [];
this.updateHttpMetadata = [];
Expand All @@ -320,6 +369,9 @@ export default defineComponent({
selectedBucket: function () {
return this.$route.params.bucket;
},
trashEnabled: function () {
return this.mainStore.config?.trash !== false;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
Expand Down
Loading
Loading