@@ -126,9 +177,21 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
)
}
- const orderedIds = filtered.map((o) => o.id)
+ // ── Flat ordered ID list (objects + expanded versions) for shift-select ──
+
+ const orderedIds = filtered.flatMap((o) => {
+ const ids = [o.id]
+ if (expandedIds.has(o.id) && o.noncurrentVersions) {
+ for (const v of o.noncurrentVersions) {
+ ids.push(versionKey(o.id, v.versionId))
+ }
+ }
+ return ids
+ })
+
+ const topLevelIds = filtered.map((o) => o.id)
const isAllSelected =
- orderedIds.length > 0 && orderedIds.every((id) => selectedDeletedIds.includes(id))
+ topLevelIds.length > 0 && topLevelIds.every((id) => selectedDeletedIds.includes(id))
const isSomeSelected = selectedDeletedIds.length > 0 && !isAllSelected
const handleToggle = (id: string, isShiftHeld: boolean) => {
@@ -144,7 +207,7 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
}
const handleToggleAll = () => {
- const newIds = toggleSelectAll(selectedDeletedIds, orderedIds)
+ const newIds = toggleSelectAll(selectedDeletedIds, topLevelIds)
setSelectedDeletedIds(newIds)
setLastToggledId(null)
}
@@ -154,6 +217,27 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
restoreObjects({ projectRef: ref, bucketId, objectIds: [object.id] })
}
+ const handleVersionRestore = (parent: TrashObject, version: DeletedObjectVersion) => {
+ if (!ref) return
+ restoreVersion({
+ projectRef: ref,
+ bucketId,
+ objectId: parent.id,
+ versionId: version.versionId,
+ })
+ }
+
+ const handleVersionDelete = (parent: TrashObject, version: DeletedObjectVersion) => {
+ setVersionToDelete({ parentObject: parent, version })
+ }
+
+ // ── Expand / collapse all ───────────────────────────────────────────
+
+ const expandableIds = filtered
+ .filter((o) => o.noncurrentVersions && o.noncurrentVersions.length > 0)
+ .map((o) => o.id)
+ const isAllExpanded = expandableIds.length > 0 && expandableIds.every((id) => expandedIds.has(id))
+
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
@@ -166,10 +250,25 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
})
}
+ const expandAll = () => setExpandedIds(new Set(expandableIds))
+ const collapseAll = () => setExpandedIds(new Set())
+
const totalVersionCount = (object: TrashObject) => {
return 1 + (object.noncurrentVersions?.length ?? 0)
}
+ // ── Preview handlers ────────────────────────────────────────────────
+
+ const handleSelectObject = (object: TrashObject) => {
+ setSelectedDeletedFile(object)
+ setSelectedDeletedVersion(undefined)
+ }
+
+ const handleSelectVersion = (parent: TrashObject, version: DeletedObjectVersion) => {
+ setSelectedDeletedVersion({ parentObject: parent, version })
+ setSelectedDeletedFile(undefined)
+ }
+
return (
<>
@@ -187,14 +286,34 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
Original location
Deleted
Size
- Expires
-
+
+ {expandableIds.length > 0 && (
+
+ :
+ }
+ onClick={isAllExpanded ? collapseAll : expandAll}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: isAllExpanded ? 'Collapse all versions' : 'Expand all versions',
+ },
+ }}
+ />
+
+ )}
+
{filtered.map((object) => {
const isChecked = selectedDeletedIds.includes(object.id)
- const isPreviewed = selectedDeletedFile?.id === object.id
+ const isPreviewed =
+ selectedDeletedFile?.id === object.id && selectedDeletedVersion === undefined
const hasVersions =
object.noncurrentVersions !== undefined && object.noncurrentVersions.length > 0
const isExpanded = expandedIds.has(object.id)
@@ -206,7 +325,7 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
'group cursor-pointer',
isPreviewed && 'bg-selection hover:bg-selection'
)}
- onClick={() => setSelectedDeletedFile(object)}
+ onClick={() => handleSelectObject(object)}
>
e.stopPropagation()}>
{formatBytes(object.size)}
-
- {object.expiresAt ? (
- {dayjs(object.expiresAt).fromNow()}
- ) : (
- Never
- )}
-
e.stopPropagation()}>
(
- {
- toast.success(
- `Restoring version ${version.versionId.slice(0, 8)} to current`
- )
- }}
- onDelete={() => {
- toast.success(
- `Permanently deleted version ${version.versionId.slice(0, 8)}`
- )
- }}
- />
- ))}
+ object.noncurrentVersions!.map((version) => {
+ const key = versionKey(object.id, version.versionId)
+ const isVersionChecked = selectedDeletedIds.includes(key)
+ const isVersionPreviewed =
+ selectedDeletedVersion?.version.versionId === version.versionId &&
+ selectedDeletedVersion?.parentObject.id === object.id
+
+ return (
+ handleToggle(key, isShiftHeld)}
+ onClick={() => handleSelectVersion(object, version)}
+ onRestore={() => handleVersionRestore(object, version)}
+ onDelete={() => handleVersionDelete(object, version)}
+ />
+ )
+ })}
)
})}
+ {/* Confirm delete for top-level object */}
+
+ {/* Confirm delete for individual version */}
+
setVersionToDelete(undefined)}
+ onConfirm={() => {
+ if (!ref || !versionToDelete) return
+ deleteVersionPermanently({
+ projectRef: ref,
+ bucketId,
+ objectId: versionToDelete.parentObject.id,
+ versionId: versionToDelete.version.versionId,
+ })
+ }}
+ >
+
+ Version{' '}
+
+ {versionToDelete?.version.versionId.slice(0, 8)}
+ {' '}
+ of {versionToDelete?.parentObject.name} will be permanently deleted. This action cannot be
+ undone.
+
+
>
)
}
@@ -354,7 +501,12 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
interface NoncurrentVersionRowProps {
version: DeletedObjectVersion
parentObject: TrashObject
+ isChecked: boolean
+ isPreviewed: boolean
canUpdateFiles: boolean
+ isRestoring: boolean
+ onToggleSelect: (isShiftHeld: boolean) => void
+ onClick: () => void
onRestore: () => void
onDelete: () => void
}
@@ -362,17 +514,37 @@ interface NoncurrentVersionRowProps {
const NoncurrentVersionRow = ({
version,
canUpdateFiles,
+ isChecked,
+ isPreviewed,
+ isRestoring,
+ onToggleSelect,
+ onClick,
onRestore,
onDelete,
}: NoncurrentVersionRowProps) => {
const shortId = `${version.versionId.slice(0, 8)}`
return (
-
-
+
+ e.stopPropagation()}>
+ onToggleSelect(event.nativeEvent.shiftKey)}
+ aria-label={`Select version ${shortId}`}
+ />
+
-
-
+
+
{shortId}
({version.action})
@@ -383,13 +555,13 @@ const NoncurrentVersionRow = ({
{formatBytes(version.size)}
-
e.stopPropagation()}>
}
+ loading={isRestoring}
disabled={!canUpdateFiles}
onClick={onRestore}
tooltip={{
diff --git a/apps/studio/components/interfaces/Storage/Trash/Trash.tsx b/apps/studio/components/interfaces/Storage/Trash/Trash.tsx
index 9b546297270..00ce2c0d7c3 100644
--- a/apps/studio/components/interfaces/Storage/Trash/Trash.tsx
+++ b/apps/studio/components/interfaces/Storage/Trash/Trash.tsx
@@ -15,8 +15,13 @@ import {
useBucketTrashDeleteMutation,
useBucketTrashQuery,
useBucketTrashRestoreMutation,
+ useTrashVersionDeleteMutation,
+ useTrashVersionRestoreMutation,
} from '@/data/storage/protection/bucket-trash-query'
-import { type TrashObject } from '@/data/storage/protection/protection-mocks'
+import {
+ type DeletedObjectVersion,
+ type TrashObject,
+} from '@/data/storage/protection/protection-mocks'
import { toggleSelectAll, toggleSelection } from './Trash.utils'
import { TrashList } from './TrashList'
@@ -32,6 +37,10 @@ export const Trash = ({ bucketId }: TrashProps) => {
const [selectedIds, setSelectedIds] = useState
([])
const [lastToggledId, setLastToggledId] = useState(null)
const [objectToDelete, setObjectToDelete] = useState()
+ const [versionToDelete, setVersionToDelete] = useState<{
+ parent: TrashObject
+ version: DeletedObjectVersion
+ }>()
const [showBulkDelete, setShowBulkDelete] = useState(false)
const [showDeleteAll, setShowDeleteAll] = useState(false)
@@ -61,6 +70,20 @@ export const Trash = ({ bucketId }: TrashProps) => {
},
})
+ const { mutate: restoreVersion } = useTrashVersionRestoreMutation({
+ onSuccess: (_data, variables) => {
+ toast.success(`Version ${variables.versionId.slice(0, 8)} restored`)
+ },
+ })
+
+ const { mutate: deleteVersionPermanently, isPending: isDeletingVersion } =
+ useTrashVersionDeleteMutation({
+ onSuccess: () => {
+ toast.success('Version permanently deleted')
+ setVersionToDelete(undefined)
+ },
+ })
+
const orderedIds = (objects ?? []).map((object) => object.id)
const handleToggleSelect = (id: string, isShiftHeld: boolean) => {
@@ -118,6 +141,18 @@ export const Trash = ({ bucketId }: TrashProps) => {
onToggleSelectAll={() => setSelectedIds(toggleSelectAll(selectedIds, orderedIds))}
onRestore={(object) => handleRestore([object.id])}
onDeleteForever={setObjectToDelete}
+ onRestoreVersion={(parent, version) => {
+ if (!ref) return
+ restoreVersion({
+ projectRef: ref,
+ bucketId,
+ objectId: parent.id,
+ versionId: version.versionId,
+ })
+ }}
+ onDeleteVersionForever={(parent, version) =>
+ setVersionToDelete({ parent, version })
+ }
/>
)}
@@ -167,6 +202,34 @@ export const Trash = ({ bucketId }: TrashProps) => {
+ setVersionToDelete(undefined)}
+ onConfirm={() => {
+ if (!ref || !versionToDelete) return
+ deleteVersionPermanently({
+ projectRef: ref,
+ bucketId,
+ objectId: versionToDelete.parent.id,
+ versionId: versionToDelete.version.versionId,
+ })
+ }}
+ >
+
+ Version{' '}
+
+ {versionToDelete?.version.versionId.slice(0, 8)}
+ {' '}
+ of {versionToDelete?.parent.name} will be permanently deleted. This action cannot be
+ undone.
+
+
+
void
onRestore: (object: TrashObject) => void
onDeleteForever: (object: TrashObject) => void
+ onRestoreVersion?: (parent: TrashObject, version: DeletedObjectVersion) => void
+ onDeleteVersionForever?: (parent: TrashObject, version: DeletedObjectVersion) => void
}
export const TrashList = ({
@@ -42,6 +51,8 @@ export const TrashList = ({
onToggleSelectAll,
onRestore,
onDeleteForever,
+ onRestoreVersion,
+ onDeleteVersionForever,
}: TrashListProps) => {
const isAllSelected = objects.length > 0 && objects.every((o) => selectedIds.includes(o.id))
const isSomeSelected = selectedIds.length > 0 && !isAllSelected
@@ -60,6 +71,14 @@ export const TrashList = ({
})
}
+ const expandableIds = objects
+ .filter((o) => o.noncurrentVersions && o.noncurrentVersions.length > 0)
+ .map((o) => o.id)
+ const isAllExpanded = expandableIds.length > 0 && expandableIds.every((id) => expandedIds.has(id))
+
+ const expandAll = () => setExpandedIds(new Set(expandableIds))
+ const collapseAll = () => setExpandedIds(new Set())
+
const totalVersionCount = (object: TrashObject) => {
return 1 + (object.noncurrentVersions?.length ?? 0)
}
@@ -80,8 +99,25 @@ export const TrashList = ({
Original location
Deleted
Size
- Expires
-
+
+ {expandableIds.length > 0 && (
+
+ : }
+ onClick={isAllExpanded ? collapseAll : expandAll}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: isAllExpanded ? 'Collapse all versions' : 'Expand all versions',
+ },
+ }}
+ />
+
+ )}
+
@@ -139,13 +175,6 @@ export const TrashList = ({
{formatBytes(object.size)}
-
- {object.expiresAt ? (
- {dayjs(object.expiresAt).fromNow()}
- ) : (
- Never
- )}
-
{
- toast.success(`Restoring version ${version.versionId.slice(0, 8)} to current`)
- }}
- onDelete={() => {
- toast.success(`Permanently deleted version ${version.versionId.slice(0, 8)}`)
- }}
+ onRestore={() => onRestoreVersion?.(object, version)}
+ onDelete={() => onDeleteVersionForever?.(object, version)}
/>
))}
@@ -197,8 +222,8 @@ const NoncurrentVersionRow = ({ version, onRestore, onDelete }: NoncurrentVersio
-
-
+
+
{shortId}
({version.action})
@@ -209,7 +234,6 @@ const NoncurrentVersionRow = ({ version, onRestore, onDelete }: NoncurrentVersio
{formatBytes(version.size)}
-
= {}) => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (variables) => {
+ restoreNoncurrentVersion(variables.objectId, variables.versionId)
+ await mockDelay(undefined, 400)
+ },
+ async onSuccess(data, variables, context) {
+ await queryClient.invalidateQueries({
+ queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
+ })
+ await onSuccess?.(data, variables, context)
+ },
+ onError(error, variables, context) {
+ if (onError === undefined) toast.error(`Failed to restore version: ${error.message}`)
+ else onError(error, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useTrashVersionDeleteMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: UseMutationOptions = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (variables) => {
+ deleteNoncurrentVersionPermanently(variables.objectId, variables.versionId)
+ await mockDelay(undefined, 400)
+ },
+ async onSuccess(data, variables, context) {
+ await queryClient.invalidateQueries({
+ queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
+ })
+ await onSuccess?.(data, variables, context)
+ },
+ onError(error, variables, context) {
+ if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`)
+ else onError(error, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/apps/studio/data/storage/protection/protection-mocks.ts b/apps/studio/data/storage/protection/protection-mocks.ts
index 9e50d78b38e..36ea0994b59 100644
--- a/apps/studio/data/storage/protection/protection-mocks.ts
+++ b/apps/studio/data/storage/protection/protection-mocks.ts
@@ -390,6 +390,30 @@ export const deleteMockTrashObjectsPermanently = (objectIds?: string[]): TrashOb
return [...trashObjects]
}
+/** Permanently deletes a single noncurrent version from a trash object. */
+export const deleteNoncurrentVersionPermanently = (
+ objectId: string,
+ versionId: string
+): TrashObject[] => {
+ trashObjects = trashObjects.map((obj) => {
+ if (obj.id !== objectId) return obj
+ return {
+ ...obj,
+ noncurrentVersions: (obj.noncurrentVersions ?? []).filter((v) => v.versionId !== versionId),
+ }
+ })
+ return [...trashObjects]
+}
+
+/**
+ * Restores a noncurrent version from trash. In a real implementation this
+ * would promote it to the current version of the live object. For the
+ * prototype we simply remove it from the trash entry.
+ */
+export const restoreNoncurrentVersion = (objectId: string, versionId: string): TrashObject[] => {
+ return deleteNoncurrentVersionPermanently(objectId, versionId)
+}
+
export interface BucketRetentionSummary {
bucket: string
live: number