diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilePreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilePreviewPane.tsx index 9144200adb1..a588768b131 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilePreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilePreviewPane.tsx @@ -11,6 +11,8 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useBucketTrashDeleteMutation, useBucketTrashRestoreMutation, + useTrashVersionDeleteMutation, + useTrashVersionRestoreMutation, } from '@/data/storage/protection/bucket-trash-query' import { type TrashObject } from '@/data/storage/protection/protection-mocks' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -18,9 +20,30 @@ import { BASE_PATH } from '@/lib/constants' import { formatBytes } from '@/lib/helpers' import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' -import { useDeletedFilesContext } from './DeletedFilesContext' +import { useDeletedFilesContext, type SelectedDeletedVersion } from './DeletedFilesContext' export const DeletedFilePreviewPane = () => { + const { + selectedDeletedFile, + setSelectedDeletedFile, + selectedDeletedVersion, + setSelectedDeletedVersion, + } = useDeletedFilesContext() + + if (selectedDeletedVersion) { + return + } + + if (selectedDeletedFile) { + return + } + + return null +} + +// ── Object-level preview (delete-marker) ────────────────────────────── + +const ObjectPreviewPane = () => { const { selectedDeletedFile: file, setSelectedDeletedFile } = useDeletedFilesContext() const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot() const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') @@ -36,7 +59,7 @@ export const DeletedFilePreviewPane = () => { const { mutate: deleteObjects, isPending: isDeleting } = useBucketTrashDeleteMutation({ onSuccess: () => { - toast.success('Version permanently deleted') + toast.success('File permanently deleted') setShowDeleteConfirm(false) setSelectedDeletedFile(undefined) }, @@ -72,7 +95,7 @@ export const DeletedFilePreviewPane = () => { /> - +
@@ -87,9 +110,162 @@ export const DeletedFilePreviewPane = () => { /> + {file.noncurrentVersions && file.noncurrentVersions.length > 0 && ( + + )} +
+
+ +
+ } + loading={isRestoring} + disabled={!canUpdateFiles} + onClick={handleRestore} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateFiles + ? 'You need additional permissions to restore versions' + : undefined, + }, + }} + > + Restore + + } + disabled={!canUpdateFiles} + onClick={() => setShowDeleteConfirm(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateFiles + ? 'You need additional permissions to delete versions' + : undefined, + }, + }} + > + Delete permanently + +
+ + + setShowDeleteConfirm(false)} + onConfirm={handleDeletePermanently} + > +

+ {file.name} will be permanently deleted and can no longer be restored. This action cannot + be undone. +

+
+ + ) +} + +// ── Version-level preview ───────────────────────────────────────────── + +const VersionPreviewPane = () => { + const { selectedDeletedVersion, setSelectedDeletedVersion } = useDeletedFilesContext() + const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot() + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const { mutate: restoreVersion, isPending: isRestoring } = useTrashVersionRestoreMutation({ + onSuccess: () => { + toast.success(`Version restored`) + setSelectedDeletedVersion(undefined) + }, + }) + + const { mutate: deleteVersion, isPending: isDeleting } = useTrashVersionDeleteMutation({ + onSuccess: () => { + toast.success('Version permanently deleted') + setShowDeleteConfirm(false) + setSelectedDeletedVersion(undefined) + }, + }) + + if (!selectedDeletedVersion) return null + + const { parentObject, version } = selectedDeletedVersion + const shortId = version.versionId.slice(0, 8) + + const handleRestore = () => { + if (!projectRef || !selectedBucket?.id) return + restoreVersion({ + projectRef, + bucketId: selectedBucket.id, + objectId: parentObject.id, + versionId: version.versionId, + }) + } + + const handleDeletePermanently = () => { + if (!projectRef || !selectedBucket?.id) return + deleteVersion({ + projectRef, + bucketId: selectedBucket.id, + objectId: parentObject.id, + versionId: version.versionId, + }) + } + + const width = 450 + + return ( + <> +
+
+
+ setSelectedDeletedVersion(undefined)} + /> +
+ +
+

No preview available for this version

+
+ +
+
+
{parentObject.name}
+

+ Noncurrent version ·{' '} + {shortId} +

+
+ + + + + +
@@ -142,51 +318,28 @@ export const DeletedFilePreviewPane = () => { onConfirm={handleDeletePermanently} >

- {file.name} will be permanently deleted and can no longer be restored. This action cannot - be undone. + Version {shortId} of{' '} + {parentObject.name} will be permanently deleted. This action cannot be undone.

) } -const DeletedFilePreview = ({ file }: { file: TrashObject }) => { - const mimeType = file.name.match(/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i) - ? 'image' - : file.name.match(/\.(mp4|webm|ogg|mov)$/i) - ? 'video' - : file.name.match(/\.(mp3|wav|ogg|aac|flac)$/i) - ? 'audio' - : null +// ── Shared helpers ──────────────────────────────────────────────────── - if (!mimeType) { - return ( -
-
- - code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"') - } - /> -
-
- ) - } - - return ( -
-
- - code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"') - } - /> -
+const FilePreviewPlaceholder = () => ( +
+
+ + code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"') + } + />
- ) -} +
+) const DeletedFileDetail = ({ label, value }: { label: string; value: string }) => (
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesContext.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesContext.tsx index 680565b9353..d89baa5c377 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesContext.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesContext.tsx @@ -1,7 +1,15 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' -import { type TrashObject } from '@/data/storage/protection/protection-mocks' +import { + type DeletedObjectVersion, + type TrashObject, +} from '@/data/storage/protection/protection-mocks' + +export interface SelectedDeletedVersion { + parentObject: TrashObject + version: DeletedObjectVersion +} interface DeletedFilesContextValue { isEnabled: boolean @@ -9,6 +17,8 @@ interface DeletedFilesContextValue { setIsShowingDeleted: (value: boolean) => void selectedDeletedFile: TrashObject | undefined setSelectedDeletedFile: (file: TrashObject | undefined) => void + selectedDeletedVersion: SelectedDeletedVersion | undefined + setSelectedDeletedVersion: (version: SelectedDeletedVersion | undefined) => void selectedDeletedIds: string[] setSelectedDeletedIds: (ids: string[]) => void lastToggledId: string | null @@ -22,6 +32,8 @@ const DeletedFilesContext = createContext({ setIsShowingDeleted: () => {}, selectedDeletedFile: undefined, setSelectedDeletedFile: () => {}, + selectedDeletedVersion: undefined, + setSelectedDeletedVersion: () => {}, selectedDeletedIds: [], setSelectedDeletedIds: () => {}, lastToggledId: null, @@ -42,6 +54,9 @@ export const DeletedFilesProvider = ({ enabled, children }: DeletedFilesProvider parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) ) const [selectedDeletedFile, setSelectedDeletedFile] = useState() + const [selectedDeletedVersion, setSelectedDeletedVersion] = useState< + SelectedDeletedVersion | undefined + >() const [selectedDeletedIds, setSelectedDeletedIds] = useState([]) const [lastToggledId, setLastToggledId] = useState(null) @@ -58,6 +73,8 @@ export const DeletedFilesProvider = ({ enabled, children }: DeletedFilesProvider setIsShowingDeleted, selectedDeletedFile, setSelectedDeletedFile, + selectedDeletedVersion, + setSelectedDeletedVersion, selectedDeletedIds, setSelectedDeletedIds, lastToggledId, diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesList.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesList.tsx index bdf1d7ccf13..ab6af8f7dde 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesList.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/DeletedFilesList.tsx @@ -1,7 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import dayjs from 'dayjs' -import { ChevronDown, ChevronRight, CornerDownRight, RotateCcw, Trash2 } from 'lucide-react' +import { + ChevronDown, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, + CornerDownRight, + RotateCcw, + Trash2, +} from 'lucide-react' import { Fragment, useState } from 'react' import { toast } from 'sonner' import { @@ -27,6 +35,8 @@ import { useBucketTrashDeleteMutation, useBucketTrashQuery, useBucketTrashRestoreMutation, + useTrashVersionDeleteMutation, + useTrashVersionRestoreMutation, } from '@/data/storage/protection/bucket-trash-query' import { type DeletedObjectVersion, @@ -38,6 +48,9 @@ import { formatBytes } from '@/lib/helpers' import { toggleSelectAll, toggleSelection } from '../Trash/Trash.utils' import { useDeletedFilesContext } from './DeletedFilesContext' +/** Composite key for a version row — unambiguous vs. top-level object ids. */ +const versionKey = (objectId: string, versionId: string) => `${objectId}::${versionId}` + interface DeletedFilesListProps { bucketId: string searchString: string @@ -48,6 +61,8 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro const { selectedDeletedFile, setSelectedDeletedFile, + selectedDeletedVersion, + setSelectedDeletedVersion, selectedDeletedIds, setSelectedDeletedIds, lastToggledId, @@ -56,6 +71,10 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const [fileToDelete, setFileToDelete] = useState() + const [versionToDelete, setVersionToDelete] = useState<{ + parentObject: TrashObject + version: DeletedObjectVersion + }>() const [expandedIds, setExpandedIds] = useState>(new Set()) const { @@ -66,6 +85,8 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro isSuccess, } = useBucketTrashQuery({ projectRef: ref, bucketId }) + // ── Object-level mutations ────────────────────────────────────────── + const { mutate: restoreObjects, isPending: isRestoring } = useBucketTrashRestoreMutation({ onSuccess: (_data, variables) => { const count = variables.objectIds.length @@ -89,6 +110,36 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro }, }) + // ── Version-level mutations ───────────────────────────────────────── + + const { mutate: restoreVersion, isPending: isRestoringVersion } = useTrashVersionRestoreMutation({ + onSuccess: (_data, variables) => { + toast.success(`Version ${variables.versionId.slice(0, 8)} restored`) + // Clear version preview if we just restored the previewed version + if (selectedDeletedVersion?.version.versionId === variables.versionId) { + setSelectedDeletedVersion(undefined) + } + // Remove from selection + const key = versionKey(variables.objectId, variables.versionId) + setSelectedDeletedIds(selectedDeletedIds.filter((id) => id !== key)) + }, + }) + + const { mutate: deleteVersionPermanently, isPending: isDeletingVersion } = + useTrashVersionDeleteMutation({ + onSuccess: (_data, variables) => { + toast.success(`Version ${variables.versionId.slice(0, 8)} permanently deleted`) + setVersionToDelete(undefined) + if (selectedDeletedVersion?.version.versionId === variables.versionId) { + setSelectedDeletedVersion(undefined) + } + const key = versionKey(variables.objectId, variables.versionId) + setSelectedDeletedIds(selectedDeletedIds.filter((id) => id !== key)) + }, + }) + + // ── Loading / error states ────────────────────────────────────────── + if (isPending) { return (
@@ -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 locationDeletedSize - 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 - )} -