fix: storage copy url include the opened folder for files that are not in it (#43875)

Fixes #42357
Supersedes #42364

## Problem

When copying the URL of a file that is not in the currently opened
folder, the folder path is still included in that file URL.

## Solution

We actually always have the file path, use it directly instead of
rebuilding it from the storage explorer state

## How to test

1. Create a bucket and upload a file in it
2. Create a folder and upload a file in it

While the folder is selected, right click the root file uploaded in 1
and copy its URL. It should not contain the folder name. Same with the
dropdown menu that appears when hovering the file.
This commit is contained in:
Gildas Garcia
2026-03-17 18:12:28 +01:00
committed by GitHub
parent 8ca55ac1ff
commit c473cf0720
7 changed files with 109 additions and 29 deletions
@@ -1,8 +1,8 @@
import dayjs from 'dayjs'
import { Button, Form, Input, Listbox, Modal } from 'ui'
import { DATETIME_FORMAT } from 'lib/constants'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { Button, Form, Input, Listbox, Modal } from 'ui'
import { useCopyUrl } from './useCopyUrl'
const unitMap = {
@@ -36,7 +36,7 @@ export const CustomExpiryModal = () => {
onSubmit={async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
await onCopyUrl(
selectedFileCustomExpiry!.name,
selectedFileCustomExpiry!.path!,
values.expiresIn * unitMap[values.units as 'days' | 'weeks' | 'months' | 'years']
)
setSubmitting(false)
@@ -202,7 +202,9 @@ export const FileExplorerRow = ({
{
name: 'Get URL',
icon: <Copy size={12} className="text-foreground-light" />,
onClick: () => onCopyUrl(itemWithColumnIndex.name),
onClick: () => {
onCopyUrl(itemWithColumnIndex.path!)
},
},
]
: [
@@ -213,17 +215,17 @@ export const FileExplorerRow = ({
{
name: 'Expire in 1 week',
onClick: () =>
onCopyUrl(itemWithColumnIndex.name, URL_EXPIRY_DURATION.WEEK),
onCopyUrl(itemWithColumnIndex.path!, URL_EXPIRY_DURATION.WEEK),
},
{
name: 'Expire in 1 month',
onClick: () =>
onCopyUrl(itemWithColumnIndex.name, URL_EXPIRY_DURATION.MONTH),
onCopyUrl(itemWithColumnIndex.path!, URL_EXPIRY_DURATION.MONTH),
},
{
name: 'Expire in 1 year',
onClick: () =>
onCopyUrl(itemWithColumnIndex.name, URL_EXPIRY_DURATION.YEAR),
onCopyUrl(itemWithColumnIndex.path!, URL_EXPIRY_DURATION.YEAR),
},
{
name: 'Custom expiry',
@@ -404,6 +406,7 @@ export const FileExplorerRow = ({
<DropdownMenuTrigger>
<div className="storage-row-menu opacity-0">
<MoreVertical size={16} strokeWidth={2} />
<span className="sr-only">{item.name} actions</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
@@ -1,11 +1,13 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronRight, Copy, Download, Edit, Move, Trash2 } from 'lucide-react'
import { Item, Menu, Separator, Submenu } from 'react-contexify'
import 'react-contexify/dist/ReactContexify.css'
import { useParams } from 'common'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { StorageItemWithColumn } from '../Storage.types'
import { useCopyUrl } from './useCopyUrl'
@@ -34,7 +36,7 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => {
switch (event) {
case 'copy':
if (expiresIn !== undefined && expiresIn < 0) return setSelectedFileCustomExpiry(item)
else return onCopyUrl(item.name, expiresIn)
if (item.path) return onCopyUrl(item.path, expiresIn)
case 'rename':
return setSelectedItemToRename(item)
case 'move':
@@ -212,7 +212,7 @@ export const PreviewPane = () => {
<Button
type="outline"
icon={<Copy />}
onClick={() => onCopyUrl(file.name)}
onClick={() => onCopyUrl(file.path!)}
disabled={file.isCorrupted}
>
Get URL
@@ -232,19 +232,19 @@ export const PreviewPane = () => {
<DropdownMenuContent side="bottom" align="center">
<DropdownMenuItem
key="expires-one-week"
onClick={() => onCopyUrl(file.name, URL_EXPIRY_DURATION.WEEK)}
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)}
>
Expire in 1 week
</DropdownMenuItem>
<DropdownMenuItem
key="expires-one-month"
onClick={() => onCopyUrl(file.name, URL_EXPIRY_DURATION.MONTH)}
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)}
>
Expire in 1 month
</DropdownMenuItem>
<DropdownMenuItem
key="expires-one-year"
onClick={() => onCopyUrl(file.name, URL_EXPIRY_DURATION.YEAR)}
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)}
>
Expire in 1 year
</DropdownMenuItem>
@@ -4,42 +4,32 @@ import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { copyToClipboard } from 'ui'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
import { fetchFileUrl } from './useFetchFileUrlQuery'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
export const useCopyUrl = () => {
const { projectRef, selectedBucket, openedFolders } = useStorageExplorerStateSnapshot()
const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot()
const { hostEndpoint, customEndpoint } = useProjectApiUrl({ projectRef })
const isCustomDomainActive = !!customEndpoint
const getFileUrl = useCallback(
(fileName: string, expiresIn?: URL_EXPIRY_DURATION) => {
const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)
const formattedPathToFile = [pathToFile, fileName].join('/')
return fetchFileUrl(
formattedPathToFile,
projectRef,
selectedBucket.id,
selectedBucket.public,
expiresIn
)
(filePath: string, expiresIn?: URL_EXPIRY_DURATION) => {
return fetchFileUrl(filePath, projectRef, selectedBucket.id, selectedBucket.public, expiresIn)
},
[projectRef, selectedBucket, openedFolders]
[projectRef, selectedBucket]
)
const onCopyUrl = useCallback(
(name: string, expiresIn?: URL_EXPIRY_DURATION) => {
const formattedUrl = getFileUrl(name, expiresIn).then((url) => {
(filePath: string, expiresIn?: URL_EXPIRY_DURATION) => {
const formattedUrl = getFileUrl(filePath, expiresIn).then((url) => {
return isCustomDomainActive && hostEndpoint
? url.replace(hostEndpoint, customEndpoint)
: url
})
return copyToClipboard(formattedUrl, () => {
toast.success(`Copied URL for ${name} to clipboard.`)
toast.success(`Copied URL for ${filePath} to clipboard.`)
})
},
[customEndpoint, getFileUrl, hostEndpoint, isCustomDomainActive]
@@ -0,0 +1,3 @@
This is a test file for e2e storage testing.
It contains some sample content to verify file uploads work correctly.
+82
View File
@@ -217,12 +217,94 @@ test.describe('Storage', () => {
await createBucketViaApi(bucketName, false)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await createFolder(page, folderName)
// Rename the folder
await renameItem(page, folderName, newFolderName)
})
test('can copy a file url regardless of the opened folders', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_urls`
const folderName = 'test_folder'
const rootFileName = 'test-file.txt'
const rootFilePath = path.join(import.meta.dirname, 'files', rootFileName)
const folderFileName = 'test-file-2.txt'
const folderFilePath = path.join(import.meta.dirname, 'files', folderFileName)
// Create a bucket via API and navigate to it
await deleteBucketViaApi(bucketName)
await createBucketViaApi(bucketName, true)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await uploadFile(page, rootFilePath, rootFileName)
// Create a folder
await createFolder(page, folderName)
// Wait for the folder file input
await expect(page.getByText('Drop your files here')).toBeVisible()
await uploadFile(page, folderFilePath, folderFileName)
// Right-click on the folder file to open context menu
const folderFile = page.getByTitle(folderFileName)
await folderFile.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
)
}).toPass({ timeout: 2000 })
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Right-click on the root file to open context menu while the folder is still open
const rootFile = page.getByTitle(rootFileName)
await rootFile.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
}).toPass({ timeout: 2000 })
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the actions button on the folder file to open dropdown menu
await page.getByRole('button', { name: `${folderFileName} actions` }).click()
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
)
}).toPass({ timeout: 2000 })
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the actions button on the root file to open dropdown menu while the folder is still open
await page.getByRole('button', { name: `${rootFileName} actions` }).click()
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
}).toPass({ timeout: 2000 })
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the folder file to open its preview pane
await folderFile.click()
await page.getByRole('button', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
)
}).toPass({ timeout: 2000 })
// Click the root file to open its preview pane while folder is still open
await rootFile.click()
await page.getByRole('button', { name: 'Get URL' }).click()
await expect(async () => {
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
}).toPass({ timeout: 2000 })
})
test('resets folder name when renaming with empty string', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_reset_enter`
const folderName = 'folder_to_rename'