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) => {