feat(storage): replace a file from the preview panel

Overwrites an object in place, which on a versioned bucket is what creates a new
version. Not `uploadFiles`, which renames rather than overwrites on a name
clash, and with upsert set so storage does not reject the path as a conflict.

The panel's own preview URL is cached for a week against the path, so the
replace invalidates it alongside the version list; without that the panel keeps
rendering the bytes from before. A rejected write reports why where storage
answers with a bare status and no message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
This commit is contained in:
Claude committed 2026-10-05 16:01:01 +00:00
1 parent 71406d7b3a
commit dc37cb39cc
9 files changed
+337 -11

No files matched your search

@@ -1,7 +1,7 @@
import { toast } from 'sonner'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
import { getStorageItemPath } from './StorageExplorer.utils'
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useObjectPurgeMutation } from '@/data/storage/versioning/object-purge-mutation'
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
@@ -33,11 +33,7 @@ export const ConfirmPurgeModal = () => {
if (!projectRef || !selectedBucket?.id || itemToPurge === undefined) return
// The endpoint addresses an object by full path; a row only knows its leaf name.
const folderPath = getPathAlongOpenedFolders(
{ openedFolders: openedFolders.slice(0, itemToPurge.columnIndex), selectedBucket },
false
)
const path = [folderPath, itemToPurge.name].filter(Boolean).join('/')
const path = getStorageItemPath({ openedFolders }, itemToPurge)
purgeObject({ projectRef, bucketId: selectedBucket.id, path })
}
@@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FilePreview } from '@/components/interfaces/Storage/StorageExplorer/FilePreview'
import { fileUrlKey } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
import { customRender as render } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
@@ -65,6 +66,36 @@ describe('FilePreview', () => {
)
})
it('keys each version separately, so a new one cannot reuse the cached URL', () => {
const current = fileUrlKey({
projectRef: 'abcdef',
isBucketPublic: false,
bucketId: 'my-bucket',
path: 'photo.png',
})
expect(
fileUrlKey({
projectRef: 'abcdef',
isBucketPublic: false,
bucketId: 'my-bucket',
path: 'photo.png',
versionId: 'v-new',
})
).not.toEqual(current)
// The version-less key is a prefix of every version's, so one invalidation clears all.
expect(
fileUrlKey({
projectRef: 'abcdef',
isBucketPublic: false,
bucketId: 'my-bucket',
path: 'photo.png',
versionId: 'v-new',
}).slice(0, current.length)
).toEqual(current)
})
it('skips the request for a file too large to preview', async () => {
const bodies = mockSignEndpoint()
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQuery } from '@tanstack/react-query'
import { AlertCircle, Archive, ChevronDown, Copy, Download, Trash2, X } from 'lucide-react'
import { AlertCircle, Archive, ChevronDown, Copy, Download, Trash2, Upload, X } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
@@ -18,6 +18,7 @@ import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { StorageItem } from '../Storage.types'
import { getBucketVersioningState } from '../StorageVersioning.constants'
import { FilePreview } from './FilePreview'
import { pickFile } from './pickFile'
import { PreviewSection } from './PreviewSection'
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
@@ -65,10 +66,13 @@ interface CurrentFilePreviewProps {
size: string | null
isPublicBucket: boolean
isVersionedBucket: boolean
/** Pins the preview to the current version, so a new one busts the cached URL. */
currentVersionId?: string
hasCurrentVersion: boolean
canUpdateFiles: boolean
onCopyUrl: (path: string, expiry?: number) => void
onDownload: () => void
onReplace: () => void
onCustomExpiry: () => void
onDelete: () => void
onPurge: () => void
@@ -81,10 +85,12 @@ const CurrentFilePreview = ({
size,
isPublicBucket,
isVersionedBucket,
currentVersionId,
hasCurrentVersion,
canUpdateFiles,
onCopyUrl,
onDownload,
onReplace,
onCustomExpiry,
onDelete,
onPurge,
@@ -94,7 +100,12 @@ const CurrentFilePreview = ({
className="flex items-center justify-center overflow-hidden rounded-md border border-overlay"
style={{ height: 'clamp(120px, calc((100vh - 144px) * 0.4), 180px)' }}
>
<FilePreview path={path} mimeType={mimeType} size={file.metadata?.size} />
<FilePreview
path={path}
mimeType={mimeType}
size={file.metadata?.size}
versionId={currentVersionId}
/>
</div>
<div className="mt-2 flex flex-col">
@@ -129,6 +140,21 @@ const CurrentFilePreview = ({
tooltip={{ content: { side: 'top', text: 'Download current' } }}
/>
{canUpdateFiles && (
<ButtonTooltip
variant="outline"
className="px-2"
icon={<Upload size={14} />}
onClick={onReplace}
tooltip={{
content: {
side: 'top',
text: isVersionedBucket ? 'Upload new version' : 'Replace file',
},
}}
/>
)}
{isPublicBucket ? (
<Button
variant="outline"
@@ -233,6 +259,7 @@ export const PreviewPane = () => {
setSelectedItemsToDelete,
setItemToPurge,
refetchAllOpenedFolders,
replaceFile,
setSelectedFileCustomExpiry,
downloadFile,
} = useStorageExplorerStateSnapshot()
@@ -305,6 +332,11 @@ export const PreviewPane = () => {
})
}
const onReplaceFile = async () => {
const newFile = await pickFile()
if (newFile !== undefined) await replaceFile({ file: newFile, item: file })
}
// The compare widget replaces the top of the panel, so scroll up to show it.
const handlePreviewVersion = (version: ObjectVersion) => {
setPreviewedVersion(version)
@@ -348,10 +380,12 @@ export const PreviewPane = () => {
size={size}
isPublicBucket={!!selectedBucket?.public}
isVersionedBucket={isVersionedBucket}
currentVersionId={currentVersion?.versionId}
hasCurrentVersion={currentVersion !== undefined}
canUpdateFiles={canUpdateFiles}
onCopyUrl={onCopyUrl}
onDownload={() => downloadFile(file)}
onReplace={onReplaceFile}
onCustomExpiry={() => setSelectedFileCustomExpiry(file)}
onDelete={() => setSelectedItemsToDelete([file])}
onPurge={() => setItemToPurge(file)}
@@ -10,9 +10,11 @@ import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
import {
copyStorageExplorerUrl,
copyStoragePath,
describeUploadFailure,
getPathAlongFoldersToIndex,
getPathAlongOpenedFolders,
getStorageExplorerUrlForItem,
getStorageItemPath,
getStoragePathForItem,
parseStoragePath,
sanitizeNameForDuplicateInColumn,
@@ -452,3 +454,57 @@ describe('clipboard helpers', () => {
expect(toast.success).toHaveBeenCalledWith('Copied URL for "photo.png"')
})
})
describe('getStorageItemPath', () => {
const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')]
it('returns the bare name for an item at the bucket root', () => {
expect(getStorageItemPath({ openedFolders }, { name: 'avatar.png', columnIndex: 0 })).toBe(
'avatar.png'
)
})
it('prefixes the folders opened above the item', () => {
expect(getStorageItemPath({ openedFolders }, { name: 'avatar.png', columnIndex: 2 })).toBe(
'images/2024/avatar.png'
)
})
it('ignores folders opened below the item', () => {
// The user drilled into january, but the item sits in the 2024 column.
expect(getStorageItemPath({ openedFolders }, { name: 'notes.txt', columnIndex: 1 })).toBe(
'images/notes.txt'
)
})
})
describe('describeUploadFailure', () => {
it('names the allowed types when the bucket rejects the mime type', () => {
expect(
describeUploadFailure({
status: 415,
fallback: 'tus: unexpected response',
allowedMimeTypes: ['image/png', 'image/jpeg'],
})
).toBe(
'that file type is not allowed in this bucket. Allowed MIME types: image/png, image/jpeg.'
)
})
it('omits the list when the bucket allows everything', () => {
expect(describeUploadFailure({ status: 415, fallback: 'tus: unexpected response' })).toBe(
'that file type is not allowed in this bucket.'
)
})
it('explains a size rejection', () => {
expect(describeUploadFailure({ status: 413, fallback: 'tus: unexpected response' })).toBe(
'the file exceeds the bucket file size limit.'
)
})
it('falls back to the raw message for anything else', () => {
expect(describeUploadFailure({ status: 500, fallback: 'network down' })).toBe('network down')
expect(describeUploadFailure({ fallback: 'network down' })).toBe('network down')
})
})
@@ -38,6 +38,15 @@ export function getPathAlongOpenedFolders(
* Returns the path to the folder at the given index in the openedFolders array,
* joining all folders from the root up to (but not including) the given index.
*/
/** The object's full path within the bucket; a row only knows its leaf name and column. */
export function getStorageItemPath(
state: Pick<StorageExplorerState, 'openedFolders'>,
item: { name: string; columnIndex: number }
): string {
const folderPath = getPathAlongFoldersToIndex(state, item.columnIndex)
return folderPath.length > 0 ? `${folderPath}/${item.name}` : item.name
}
export function getPathAlongFoldersToIndex(
state: Pick<StorageExplorerState, 'openedFolders'>,
index: number
@@ -316,3 +325,26 @@ const readEntriesPromise = async (directoryReader: FileSystemDirectoryReader) =>
console.error('readEntriesPromise error:', err)
}
}
/**
* Storage rejects a write that breaks the bucket's own rules with a bare status and no
* usable message, so turn the ones a replace can hit into something actionable.
*/
export const describeUploadFailure = ({
status,
fallback,
allowedMimeTypes,
}: {
status?: number
fallback: string
allowedMimeTypes?: string[] | null
}) => {
if (status === 415) {
const allowed = allowedMimeTypes?.length
? ` Allowed MIME types: ${allowedMimeTypes.join(', ')}.`
: ''
return `that file type is not allowed in this bucket.${allowed}`
}
if (status === 413) return 'the file exceeds the bucket file size limit.'
return fallback
}
@@ -62,8 +62,8 @@ const VersionFateLabel = ({ fate }: { fate: VersionFate }) => {
Queued to expire
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-64">
Matches the lifecycle policy and will be permanently deleted, which can take up to 24
hours.
Matches the lifecycle policy and will be permanently deleted. This action can take up to
24 hours.
</TooltipContent>
</Tooltip>
)
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import { pickFile } from './pickFile'
/** Captures the input `pickFile` creates, and whether `click()` happened synchronously. */
const interceptInput = () => {
const created = { input: undefined as HTMLInputElement | undefined, clickedSync: false }
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
const element = realCreateElement(tagName)
if (tagName === 'input') {
created.input = element as HTMLInputElement
element.click = () => {
created.clickedSync = true
}
}
return element
})
return created
}
const setFiles = (input: HTMLInputElement, files: File[]) => {
Object.defineProperty(input, 'files', { value: files, configurable: true })
}
describe('pickFile', () => {
it('opens the picker synchronously, so the user gesture is not lost', () => {
const created = interceptInput()
void pickFile()
expect(created.clickedSync).toBe(true)
vi.restoreAllMocks()
})
it('resolves with the chosen file', async () => {
const created = interceptInput()
const pending = pickFile()
const file = new File(['hello'], 'avatar.png', { type: 'image/png' })
setFiles(created.input!, [file])
created.input!.dispatchEvent(new Event('change'))
await expect(pending).resolves.toBe(file)
vi.restoreAllMocks()
})
it('resolves undefined when the picker is dismissed', async () => {
const created = interceptInput()
const pending = pickFile()
created.input!.dispatchEvent(new Event('cancel'))
await expect(pending).resolves.toBeUndefined()
vi.restoreAllMocks()
})
it('resolves undefined when the picker closes with no selection', async () => {
const created = interceptInput()
const pending = pickFile()
setFiles(created.input!, [])
created.input!.dispatchEvent(new Event('change'))
await expect(pending).resolves.toBeUndefined()
vi.restoreAllMocks()
})
it('passes an accept filter through to the input', () => {
const created = interceptInput()
void pickFile({ accept: 'image/*' })
expect(created.input!.accept).toBe('image/*')
vi.restoreAllMocks()
})
})
@@ -0,0 +1,16 @@
/**
* Created and clicked synchronously to stay inside the user gesture, which Safari
* requires — so no hidden input driven from an effect.
*/
export const pickFile = ({ accept }: { accept?: string } = {}): Promise<File | undefined> =>
new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
if (accept) input.accept = accept
input.addEventListener('change', () => resolve(input.files?.[0]), { once: true })
// Not supported everywhere, so this is a cleanup path rather than the one resolve needs.
input.addEventListener('cancel', () => resolve(undefined), { once: true })
input.click()
})
+83 -1
View File
@@ -32,15 +32,20 @@ import {
} from '@/components/interfaces/Storage/Storage.types'
import {
calculateTotalRemainingTime,
describeUploadFailure,
EMPTY_FOLDER_PLACEHOLDER_FILE_NAME,
formatFolderItems,
formatTime,
getFilesDataTransferItems,
getPathAlongFoldersToIndex,
getStorageItemPath,
sanitizeNameForDuplicateInColumn,
validateFolderName,
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
import { fetchFileUrl } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
import {
fetchFileUrl,
fileUrlKey,
} from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
import { getStoragePreference } from '@/components/interfaces/Storage/StorageExplorer/useStoragePreference'
import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from '@/components/ui/InlineLink'
@@ -54,6 +59,7 @@ import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation'
import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation'
import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation'
import type { Bucket } from '@/data/storage/buckets-query'
import { storageKeys } from '@/data/storage/keys'
import { moveStorageObject } from '@/data/storage/object-move-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
@@ -1503,6 +1509,82 @@ export function createStorageExplorerState({
state.setSelectedItemsToMove([])
},
/**
* Overwrites an object in place, which on a versioned bucket is what creates a new
* version. Not `uploadFiles`: that renames rather than overwrites on a name clash.
*/
replaceFile: async ({ file, item }: { file: File; item: StorageItemWithColumn }) => {
const path = getStorageItemPath(state, item)
const toastId = toast.loading(`Uploading a new version of ${item.name}...`)
state.updateRowStatus({
name: item.name,
status: STORAGE_ROW_STATUS.LOADING,
columnIndex: item.columnIndex,
})
try {
await new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: state.resumableUploadUrl,
retryDelays: [0, 200, 500, 1500, 3000, 5000],
// Without upsert the write is rejected as a conflict: the path exists.
headers: { 'x-source': 'supabase-dashboard', 'x-upsert': 'true' },
uploadDataDuringCreation: true,
removeFingerprintOnSuccess: true,
metadata: {
bucketName: state.selectedBucket.name,
objectName: path,
contentType: file.type || 'application/octet-stream',
cacheControl: '3600',
},
onBeforeRequest: async (req) => {
const { apiKey } = await getOrRefreshTemporaryApiKey(state.projectRef)
req.setHeader('apikey', apiKey)
if (!IS_PLATFORM) req.setHeader('Authorization', `Bearer ${apiKey}`)
},
onError: (error) => reject(error),
onSuccess: () => resolve(),
})
// No `findPreviousUploads`: a resumed upload would write the wrong file's bytes.
upload.start()
})
toast.success(`Uploaded a new version of ${item.name}`, { id: toastId })
await state.refetchAllOpenedFolders()
await Promise.all([
getQueryClient().invalidateQueries({
queryKey: storageKeys.objectVersions(state.projectRef, state.selectedBucket.id, path),
}),
// The preview URL is cached for a week against the path, so without this the
// panel keeps rendering the bytes from before the replace.
getQueryClient().invalidateQueries({
queryKey: fileUrlKey({
projectRef: state.projectRef,
isBucketPublic: state.selectedBucket.public,
bucketId: state.selectedBucket.id,
path,
}),
}),
])
} catch (error) {
const status =
error instanceof tus.DetailedError ? error.originalResponse?.getStatus() : undefined
const reason = describeUploadFailure({
status,
fallback: error instanceof Error ? error.message : 'the upload failed.',
allowedMimeTypes: state.selectedBucket.allowed_mime_types,
})
toast.error(`Failed to upload a new version of ${item.name}: ${reason}`, { id: toastId })
state.updateRowStatus({
name: item.name,
status: STORAGE_ROW_STATUS.READY,
columnIndex: item.columnIndex,
})
}
},
deleteFiles: async ({
files,
isDeleteFolder = false,