diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx
new file mode 100644
index 00000000000..891821c075d
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx
@@ -0,0 +1,93 @@
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ STORAGE_ROW_STATUS,
+ STORAGE_ROW_TYPES,
+ STORAGE_VIEWS,
+} from '@/components/interfaces/Storage/Storage.constants'
+import { FileExplorerRow } from '@/components/interfaces/Storage/StorageExplorer/FileExplorerRow'
+import { customRender as render } from '@/tests/lib/custom-render'
+
+vi.mock('@/state/storage-explorer', () => ({
+ useStorageExplorerStateSnapshot: () => ({
+ projectRef: 'abcdef',
+ selectedBucket: { id: 'my-bucket', name: 'my-bucket', public: false },
+ selectedFilePreview: undefined,
+ openedFolders: [],
+ setSelectedFileCustomExpiry: vi.fn(),
+ setSelectedItems: vi.fn(),
+ setSelectedItemsToDelete: vi.fn(),
+ downloadFile: vi.fn(),
+ setSelectedItemToRename: vi.fn(),
+ setSelectedItemsToMove: vi.fn(),
+ downloadFolder: vi.fn(),
+ selectRangeItems: vi.fn(),
+ }),
+}))
+vi.mock('@/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation', () => ({
+ useStorageExplorerNavigation: () => ({
+ openFolderAtIndex: vi.fn(),
+ truncateToColumn: vi.fn(),
+ setPreviewedFile: vi.fn(),
+ clearPreviewedFile: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/misc/useCheckPermissions', () => ({
+ useAsyncCheckPermissions: () => ({ can: true }),
+}))
+vi.mock('@/components/interfaces/Storage/StorageExplorer/useCopyUrl', () => ({
+ useCopyUrl: () => ({ onCopyUrl: vi.fn() }),
+}))
+
+const base = {
+ status: STORAGE_ROW_STATUS.READY,
+ metadata: { size: 10, mimetype: 'image/png' },
+ isCorrupted: false,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ path: 'photo.png',
+}
+
+describe('FileExplorerRow', () => {
+ it('offers both the relative path and the dashboard URL for a file', async () => {
+ render(
+
+ )
+ await userEvent.click(screen.getByRole('button', { name: 'photo.png actions' }))
+ expect(await screen.findByText('Copy relative path')).toBeInTheDocument()
+ expect(screen.getByText('Copy link')).toBeInTheDocument()
+ })
+
+ it('offers both the relative path and the dashboard URL for a folder', async () => {
+ render(
+
+ )
+ await userEvent.click(screen.getByRole('button', { name: 'avatars actions' }))
+ expect(await screen.findByText('Copy relative path')).toBeInTheDocument()
+ expect(screen.getByText('Copy link')).toBeInTheDocument()
+ expect(screen.queryByText('Copy path to folder')).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
index dbd9a5711ca..5865a4c8dd7 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
@@ -5,12 +5,13 @@ import {
Copy,
Download,
Edit,
+ Link2,
LoaderCircle,
MoreVertical,
Move,
Trash2,
} from 'lucide-react'
-import type { CSSProperties } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
import {
Checkbox,
cn,
@@ -38,7 +39,7 @@ import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
import { StorageRowIcon } from '../StorageRowIcon'
import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu'
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
-import { copyPathToFolder } from './StorageExplorer.utils'
+import { copyStorageExplorerUrl, copyStoragePath } from './StorageExplorer.utils'
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useCopyUrl } from './useCopyUrl'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -54,6 +55,13 @@ interface FileExplorerRowProps {
style?: CSSProperties
}
+type RowOption = {
+ name: string
+ icon?: ReactNode
+ onClick?: () => void
+ children?: { name: string; onClick: () => void }[]
+}
+
export const FileExplorerRow = ({
index: itemIndex,
item,
@@ -63,6 +71,7 @@ export const FileExplorerRow = ({
style,
}: FileExplorerRowProps) => {
const {
+ projectRef,
selectedBucket,
selectedFilePreview,
openedFolders,
@@ -103,7 +112,26 @@ export const FileExplorerRow = ({
clearPreviewedFile()
}
- const rowOptions =
+ const copyPathOptions: RowOption[] = [
+ {
+ name: 'Copy relative path',
+ icon: ,
+ onClick: () => copyStoragePath(openedFolders, itemWithColumnIndex),
+ },
+ {
+ name: 'Copy link',
+ icon: ,
+ onClick: () =>
+ copyStorageExplorerUrl({
+ openedFolders,
+ item: itemWithColumnIndex,
+ projectRef,
+ bucketId: selectedBucket.id,
+ }),
+ },
+ ]
+
+ const rowOptions: RowOption[] =
item.type === STORAGE_ROW_TYPES.FOLDER
? [
...(canUpdateFiles
@@ -120,11 +148,7 @@ export const FileExplorerRow = ({
icon: ,
onClick: () => downloadFolder(itemWithColumnIndex),
},
- {
- name: 'Copy path to folder',
- icon: ,
- onClick: () => copyPathToFolder(openedFolders, itemWithColumnIndex),
- },
+ ...copyPathOptions,
...(canUpdateFiles
? [
{ name: 'Separator', icon: undefined, onClick: undefined },
@@ -181,6 +205,7 @@ export const FileExplorerRow = ({
icon: ,
onClick: () => downloadFile(itemWithColumnIndex),
},
+ ...copyPathOptions,
...(canUpdateFiles
? [
{
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
index 6d4569da354..71876d4dfb2 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
@@ -1,4 +1,5 @@
import { toast } from 'sonner'
+import { copyToClipboard } from 'ui'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -7,8 +8,12 @@ import {
} from '@/components/interfaces/Storage/Storage.constants'
import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
import {
+ copyStorageExplorerUrl,
+ copyStoragePath,
getPathAlongFoldersToIndex,
getPathAlongOpenedFolders,
+ getStorageExplorerUrlForItem,
+ getStoragePathForItem,
parseStoragePath,
sanitizeNameForDuplicateInColumn,
serializeStoragePath,
@@ -159,7 +164,8 @@ describe('getPathAlongFoldersToIndex', () => {
})
})
-vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
+vi.mock('ui', () => ({ copyToClipboard: vi.fn() }))
describe('sanitizeNameForDuplicateInColumn', () => {
// Reset mock call counts between tests
@@ -318,3 +324,131 @@ describe('serializeStoragePath', () => {
expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments)
})
})
+
+function makeFile(name: string): StorageItem {
+ return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE }
+}
+
+describe('getStoragePathForItem', () => {
+ it('returns just the name at the bucket root', () => {
+ expect(getStoragePathForItem([], { ...makeFile('photo.png'), columnIndex: 0 })).toBe(
+ 'photo.png'
+ )
+ })
+
+ it('joins the opened folder chain above the item', () => {
+ const openedFolders = [makeFolder('avatars'), makeFolder('2024')]
+ expect(getStoragePathForItem(openedFolders, { ...makeFile('photo.png'), columnIndex: 2 })).toBe(
+ 'avatars/2024/photo.png'
+ )
+ })
+
+ it('uses the same shape for folders', () => {
+ const openedFolders = [makeFolder('avatars')]
+ expect(getStoragePathForItem(openedFolders, { ...makeFolder('2024'), columnIndex: 1 })).toBe(
+ 'avatars/2024'
+ )
+ })
+
+ it('omits the bucket name so the value works with storage.from(bucket)', () => {
+ const openedFolders = [makeFolder('avatars')]
+ const path = getStoragePathForItem(openedFolders, { ...makeFile('a.png'), columnIndex: 1 })
+ expect(path.startsWith('my-bucket')).toBe(false)
+ })
+})
+
+describe('getStorageExplorerUrlForItem', () => {
+ const projectRef = 'abcdef'
+ const bucketId = 'my-bucket'
+
+ it('points a folder link at the folder itself', () => {
+ const url = new URL(
+ getStorageExplorerUrlForItem({
+ openedFolders: [makeFolder('avatars')],
+ item: { ...makeFolder('2024'), columnIndex: 1 },
+ projectRef,
+ bucketId,
+ })
+ )
+
+ expect(url.pathname).toContain(`/project/${projectRef}/storage/files/buckets/${bucketId}`)
+ expect(url.searchParams.get('path')).toBe('avatars/2024')
+ expect(url.searchParams.get('preview')).toBeNull()
+ })
+
+ it('points a file link at its parent folder plus the file', () => {
+ const url = new URL(
+ getStorageExplorerUrlForItem({
+ openedFolders: [makeFolder('avatars'), makeFolder('2024')],
+ item: { ...makeFile('photo.png'), columnIndex: 2 },
+ projectRef,
+ bucketId,
+ })
+ )
+
+ expect(url.searchParams.get('path')).toBe('avatars/2024')
+ expect(url.searchParams.get('preview')).toBe('photo.png')
+ })
+
+ it('omits path at the bucket root', () => {
+ const url = new URL(
+ getStorageExplorerUrlForItem({
+ openedFolders: [],
+ item: { ...makeFile('photo.png'), columnIndex: 0 },
+ projectRef,
+ bucketId,
+ })
+ )
+
+ expect(url.searchParams.get('path')).toBeNull()
+ expect(url.searchParams.get('preview')).toBe('photo.png')
+ })
+
+ it('escapes a bucket id that needs encoding', () => {
+ const url = new URL(
+ getStorageExplorerUrlForItem({
+ openedFolders: [],
+ item: { ...makeFolder('a'), columnIndex: 0 },
+ projectRef,
+ bucketId: 'a b/c',
+ })
+ )
+
+ expect(url.pathname).toContain('a%20b%2Fc')
+ })
+})
+
+describe('clipboard helpers', () => {
+ beforeEach(() => {
+ vi.mocked(copyToClipboard).mockClear()
+ vi.mocked(toast.success).mockClear()
+ })
+
+ it('announces a copied relative path only once the write has landed', () => {
+ copyStoragePath([makeFolder('images')], { ...makeFile('photo.png'), columnIndex: 1 })
+
+ const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0]
+ expect(text).toBe('images/photo.png')
+ // The write is async and reports its own failure, so nothing is claimed up front
+ expect(toast.success).not.toHaveBeenCalled()
+
+ onCopied?.()
+ expect(toast.success).toHaveBeenCalledWith('Copied relative path for "photo.png"')
+ })
+
+ it('announces a copied URL only once the write has landed', () => {
+ copyStorageExplorerUrl({
+ openedFolders: [],
+ item: { ...makeFile('photo.png'), columnIndex: 0 },
+ projectRef: 'abcdefghijklmnopqrst',
+ bucketId: 'my-bucket',
+ })
+
+ const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0]
+ expect(text).toContain('preview=photo.png')
+ expect(toast.success).not.toHaveBeenCalled()
+
+ onCopied?.()
+ expect(toast.success).toHaveBeenCalledWith('Copied URL for "photo.png"')
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
index 3ea6ed6cbfd..98991c1125b 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
@@ -5,6 +5,7 @@ import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucket
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
import { StorageItem, StorageItemMetadata } from '../Storage.types'
import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation'
+import { BASE_PATH } from '@/lib/constants'
import type { StorageExplorerState } from '@/state/storage-explorer'
type UploadProgress = {
@@ -130,14 +131,60 @@ export function sanitizeNameForDuplicateInColumn(
return name
}
-export const copyPathToFolder = (
- openedFolders: StorageItem[],
+/** Bucket-relative path to an item */
+export function getStoragePathForItem(
+ openedFolders: readonly StorageItem[],
+ item: StorageItem & { columnIndex: number }
+): string {
+ const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
+ return folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name
+}
+
+/** Absolute dashboard URL that reopens the item in the storage explorer */
+export function getStorageExplorerUrlForItem({
+ openedFolders,
+ item,
+ projectRef,
+ bucketId,
+}: {
+ openedFolders: readonly StorageItem[]
+ item: StorageItem & { columnIndex: number }
+ projectRef: string
+ bucketId: string
+}): string {
+ const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
+ const isFolder = item.type === STORAGE_ROW_TYPES.FOLDER
+
+ const url = new URL(
+ `${BASE_PATH}/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucketId)}`,
+ location.origin
+ )
+ const path = serializeStoragePath(isFolder ? [...folders, item.name] : folders)
+ if (path.length > 0) url.searchParams.set('path', path)
+ if (!isFolder) url.searchParams.set('preview', item.name)
+
+ return url.toString()
+}
+
+export const copyStoragePath = (
+ openedFolders: readonly StorageItem[],
item: StorageItem & { columnIndex: number }
) => {
- const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
- const path = folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name
- copyToClipboard(path)
- toast.success(`Copied path to folder "${item.name}"`)
+ // Toast from the callback: the write is async and reports its own failures.
+ copyToClipboard(getStoragePathForItem(openedFolders, item), () =>
+ toast.success(`Copied relative path for "${item.name}"`)
+ )
+}
+
+export const copyStorageExplorerUrl = (params: {
+ openedFolders: readonly StorageItem[]
+ item: StorageItem & { columnIndex: number }
+ projectRef: string
+ bucketId: string
+}) => {
+ copyToClipboard(getStorageExplorerUrlForItem(params), () =>
+ toast.success(`Copied URL for "${params.item.name}"`)
+ )
}
export const formatTime = (seconds: number) => {