feat(studio): add copy path and copy link row actions (#50480)

| | PR | Base | Branch |
| --- | --- | --- | --- |
| 1 | #50476 | `master` | pre-existing correctness fixes |
| 2 | #50413 | `fix/storage-explorer-listing-and-scroll` |
`?path`/`?preview` deep-linking |
| 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link
test |
| 4 | **this PR** | `test/storage-deep-link-e2e` | copy path / copy link
row actions |

To read the whole change in one view:

```bash
git diff master...feat/storage-copy-row-actions -- apps/studio e2e
```

## What is the new behavior?

Both row menus now offer two actions:

- **Copy relative path** — the bucket-relative object key, i.e. what
`storage.from(bucket)` takes
- **Copy link** — the dashboard URL that reopens the item in the
explorer

**Copy path to folder** is replaced by **Copy relative path**. It
produces the same value for a folder and now works for files too, so
nothing is lost.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Storage Explorer now provides separate actions to copy a relative path
or a direct link for files and folders.
* Copied links open the relevant storage location, including folder
navigation and file preview details.
  * Success notifications appear after clipboard copying completes.

* **Tests**
* Added coverage for file and folder copy actions, generated paths and
links, URL encoding, and clipboard behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Francesco SansalvadoreandClaude authored and GitHub committed 2026-09-18 11:38:38 +02:00
1 parent cdbe2963fa
commit 47a532eef7
4 files changed
+314 -15

No files matched your search

@@ -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(
<FileExplorerRow
item={{ ...base, id: 'f1', name: 'photo.png', type: STORAGE_ROW_TYPES.FILE } as any}
index={0}
view={STORAGE_VIEWS.COLUMNS}
columnIndex={0}
selectedItems={[]}
/>
)
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(
<FileExplorerRow
item={
{
...base,
id: null,
name: 'avatars',
type: STORAGE_ROW_TYPES.FOLDER,
metadata: null,
} as any
}
index={0}
view={STORAGE_VIEWS.COLUMNS}
columnIndex={0}
selectedItems={[]}
/>
)
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()
})
})
@@ -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: <Copy size={12} className="text-foreground-light" />,
onClick: () => copyStoragePath(openedFolders, itemWithColumnIndex),
},
{
name: 'Copy link',
icon: <Link2 size={12} className="text-foreground-light" />,
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: <Download size={12} className="text-foreground-light" />,
onClick: () => downloadFolder(itemWithColumnIndex),
},
{
name: 'Copy path to folder',
icon: <Copy size={12} className="text-foreground-light" />,
onClick: () => copyPathToFolder(openedFolders, itemWithColumnIndex),
},
...copyPathOptions,
...(canUpdateFiles
? [
{ name: 'Separator', icon: undefined, onClick: undefined },
@@ -181,6 +205,7 @@ export const FileExplorerRow = ({
icon: <Download size={12} className="text-foreground-light" />,
onClick: () => downloadFile(itemWithColumnIndex),
},
...copyPathOptions,
...(canUpdateFiles
? [
{
@@ -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"')
})
})
@@ -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) => {