diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index f19da89385d..68bb89730cb 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -86,6 +86,29 @@ const FileExplorerColumn = ({ const snap = useStorageExplorerStateSnapshot() const canUpdateStorage = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + // Helper function to get the path for this column + const getColumnPath = () => { + // Use the index prop directly instead of trying to find it dynamically + const columnIndex = index + + if (columnIndex >= 0) { + // Column index 0 = root level (empty path) + // Column index 1 = first folder level (e.g., "foldher") + // Column index 2 = second folder level (e.g., "foldher/nested-folder") + const path = + columnIndex === 0 + ? '' + : snap.openedFolders + .slice(0, columnIndex) + .map((folder) => folder.name) + .join('/') + + return path + } + + return '' + } + // Drop target for column background const [{ isOver }, drop] = useDrop({ accept: 'storage-item', @@ -110,14 +133,12 @@ const FileExplorerColumn = ({ if (canUpdateStorage) { const targetDirectory = getColumnPath() - console.log('Column background drop - Moving item to:', targetDirectory) - // Use the drag & drop function that doesn't interfere with the modal snap.moveFilesDragAndDrop([draggedItem], targetDirectory) } }, collect: (monitor) => ({ - isOver: monitor.isOver({ shallow: true }), + isOver: monitor.isOver({ shallow: false }), }), }) @@ -138,40 +159,6 @@ const FileExplorerColumn = ({ setIsDraggedOver(false) }, [column.id]) - // Helper function to get the path for this column - const getColumnPath = () => { - // Use the index prop directly instead of trying to find it dynamically - const columnIndex = index - console.log( - 'getColumnPath called - Column index:', - columnIndex, - 'Opened folders:', - snap.openedFolders.map((f) => f.name) - ) - console.log( - 'All columns:', - snap.columns.map((col, idx) => `${idx}: ${col.name} (${col.id})`) - ) - console.log('Current column:', column.name, 'ID:', column.id, 'Index prop:', index) - - if (columnIndex >= 0) { - // Column index 0 = root level (empty path) - // Column index 1 = first folder level (e.g., "foldher") - // Column index 2 = second folder level (e.g., "foldher/nested-folder") - const path = - columnIndex === 0 - ? '' - : snap.openedFolders - .slice(0, columnIndex) - .map((folder) => folder.name) - .join('/') - console.log('Calculated column path:', path, '(column index:', columnIndex, ')') - return path - } - console.log('No column index found, returning empty path') - return '' - } - const haveSelectedItems = selectedItems.length > 0 const columnItemsId = column.items.map((item) => item.id) const columnFiles = column.items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) @@ -233,9 +220,7 @@ const FileExplorerColumn = ({ onDragLeave={(event) => { // Only reset if we're actually leaving the column (not just moving to a child element) if (!event.currentTarget.contains(event.relatedTarget as Node)) { - console.log('Leaving column, resetting drag states') setIsDraggedOver(false) - setIsInternalDragOver(false) } }} onClick={(event) => { @@ -308,67 +293,6 @@ const FileExplorerColumn = ({ onLoadNextPage={() => onColumnLoadMore(index, column)} /> - {/* List View Background Drag & Drop (separate from content) */} - {snap.view === STORAGE_VIEWS.LIST && ( -
{ - // Handle list view background drag over for internal file moves - if (event.dataTransfer.types.includes('application/json')) { - event.preventDefault() - if (!isInternalDragOver) { - setIsInternalDragOver(true) - // For list view, the target is the current folder level - const currentPath = snap.openedFolders.map((folder) => folder.name).join('/') - setMoveToPath(currentPath) - console.log('List view background drag over - Target path:', currentPath) - } - } - }} - onDrop={(event) => { - // Handle list view background drop for internal file moves - if (event.dataTransfer.types.includes('application/json')) { - event.preventDefault() - try { - const draggedItem = JSON.parse(event.dataTransfer.getData('application/json')) - if (draggedItem && draggedItem.type === STORAGE_ROW_TYPES.FILE) { - // Check if this is a drop to the same location (same opened folders) - const draggedItemPath = snap.openedFolders - .slice(0, draggedItem.columnIndex) - .map((folder) => folder.name) - .join('/') - const currentPath = snap.openedFolders.map((folder) => folder.name).join('/') - - if (draggedItemPath === currentPath) { - console.log('Same location drop detected in list view, ignoring move operation') - return - } - - const targetDirectory = moveToPath - console.log('List view background drop - Target directory:', targetDirectory) - - // Use the new drag & drop function that doesn't interfere with the modal - snap.moveFilesDragAndDrop([draggedItem], targetDirectory) - - // Reset drag states - setIsDraggedOver(false) - setIsInternalDragOver(false) - setMoveToPath('') - - console.log('List view file move completed') - return - } - } catch (error) { - console.error('Failed to parse dragged item in list view:', error) - } - } - }} - /> - )} - {/* Drag drop upload CTA for when column is empty */} {!(snap.isSearching && itemSearchString.length > 0) && column.items.length === 0 && @@ -404,11 +328,9 @@ const FileExplorerColumn = ({ folderIsEmpty={isEmpty} onDragLeave={() => { setIsDraggedOver(false) - setIsInternalDragOver(false) }} onDrop={() => { setIsDraggedOver(false) - setIsInternalDragOver(false) }} /> diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 37b522b97a0..afca9efeeee 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -134,6 +134,7 @@ const FileExplorerRow: ItemRenderer = ({ openFolder, downloadFolder, selectRangeItems, + foldersBeingMoved, } = snap const { show } = useContextMenu() const { onCopyUrl } = useCopyUrl() @@ -146,6 +147,18 @@ const FileExplorerRow: ItemRenderer = ({ const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + // Check if this folder is currently being moved + const isBeingMoved = + item.type === STORAGE_ROW_TYPES.FOLDER && + (() => { + const folderPath = openedFolders + .slice(0, columnIndex) + .map((folder) => folder.name) + .concat(item.name) + .join('/') + return foldersBeingMoved.has(folderPath) + })() + // Drag source for files and folders const [{ isDragging }, drag] = useDrag({ type: 'storage-item', @@ -164,26 +177,48 @@ const FileExplorerRow: ItemRenderer = ({ accept: 'storage-item', canDrop: (draggedItem: any) => { // Only allow drops on folders - if (item.type !== STORAGE_ROW_TYPES.FOLDER) return false + if (item.type !== STORAGE_ROW_TYPES.FOLDER) { + // Not a folder, canDrop returning false + return false + } - // Don't allow dropping on itself - if (draggedItem.id === item.id) return false + // Don't allow dropping on itself - compare paths instead of IDs + const draggedItemPath = snap.openedFolders + .slice(0, draggedItem.columnIndex) + .map((folder) => folder.name) + .join('/') + const targetItemPath = snap.openedFolders + .slice(0, columnIndex) + .map((folder) => folder.name) + .join('/') + + const draggedItemFullPath = + draggedItemPath.length > 0 ? `${draggedItemPath}/${draggedItem.name}` : draggedItem.name + const targetItemFullPath = + targetItemPath.length > 0 ? `${targetItemPath}/${item.name}` : item.name + + if (draggedItemFullPath === targetItemFullPath) { + // Dropping on itself (same path), canDrop returning false + return false + } // Don't allow dropping a folder into its own subfolder (circular reference) if (draggedItem.type === STORAGE_ROW_TYPES.FOLDER) { - const draggedItemPath = snap.openedFolders - .slice(0, draggedItem.columnIndex) - .map((folder) => folder.name) - .join('/') const targetPath = snap.openedFolders .slice(0, columnIndex) .map((folder) => folder.name) .concat(item.name) .join('/') - return !targetPath.startsWith(draggedItemPath + '/') + const wouldCreateCircularReference = targetPath.startsWith(draggedItemFullPath + '/') + + if (wouldCreateCircularReference) { + // Would create circular reference, canDrop returning false + return false + } } + // canDrop returning true return true }, drop: (draggedItem: any) => { @@ -202,18 +237,20 @@ const FileExplorerRow: ItemRenderer = ({ .join('/') if (draggedItemPath === targetDirectory) { - console.log('Same location drop detected on folder, ignoring move operation') + // Same location drop detected on folder, ignoring move operation return } - console.log('Folder drop - Moving item to:', targetDirectory) - // Use the drag & drop function that doesn't interfere with the modal snap.moveFilesDragAndDrop([draggedItem], targetDirectory) + } else { + // 'Drop conditions not met:', { + // isFolder: item.type === STORAGE_ROW_TYPES.FOLDER, + // canUpdateFiles, } }, collect: (monitor) => ({ - isOver: monitor.isOver({ shallow: true }), + isOver: monitor.isOver({ shallow: false }), }), }) @@ -392,7 +429,11 @@ const FileExplorerRow: ItemRenderer = ({ return (
{ diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 2ab1815e514..bf53e8a15af 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -50,7 +50,35 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { tryParseJson } from 'lib/helpers' import { lookupMime } from 'lib/mime' -import { Button, SONNER_DEFAULT_DURATION, SonnerProgress } from 'ui' +import { Button, SONNER_DEFAULT_DURATION, SonnerProgress, Progress } from 'ui' + +// Custom progress toast component for folder moves +const FolderMoveProgressToast = ({ + current, + total, + folderName, +}: { + current: number + total: number + folderName: string +}) => { + const progress = total > 0 ? Math.round((current / total) * 100) : 0 + + return ( +
+
+
+

Moving folder "{folderName}"...

+

{`${progress}%`}

+
+ + + Moved {current} / {total} items... + +
+
+ ) +} type UploadProgress = { percentage: number @@ -158,6 +186,29 @@ function createStorageExplorerState({ state.selectedItemsToMove = items }, + // Track folders that are currently being moved (for visual feedback) + foldersBeingMoved: new Set(), + addFolderBeingMoved: (folderPath: string) => { + state.foldersBeingMoved.add(folderPath) + }, + removeFolderBeingMoved: (folderPath: string) => { + state.foldersBeingMoved.delete(folderPath) + }, + // Progress tracking for folder moves + folderMoveProgress: new Map< + string, + { current: number; total: number; toastId: string | number } + >(), + setFolderMoveProgress: ( + folderPath: string, + progress: { current: number; total: number; toastId: string | number } + ) => { + state.folderMoveProgress.set(folderPath, progress) + }, + removeFolderMoveProgress: (folderPath: string) => { + state.folderMoveProgress.delete(folderPath) + }, + selectedBucket: {} as Bucket, setSelectedBucket: (bucket: Bucket) => { state.selectedBucket = bucket @@ -1410,13 +1461,69 @@ function createStorageExplorerState({ const formattedNewPathToFile = newPaths.join('/') let numberOfFilesMovedFail = 0 - const toastId = toast(`Moving ${items.length} file${items.length > 1 ? 's' : ''}...`, { - description: STORAGE_PROGRESS_INFO_TEXT, - duration: Infinity, - }) + // Check if any of the items are folders that need special handling + const folders = items.filter((item) => item.type === STORAGE_ROW_TYPES.FOLDER) + const files = items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) + // Show appropriate toast message based on what's being moved + let toastId: string | number | undefined + + if (folders.length > 0 && files.length === 0) { + // For folders only, show progress toast + if (folders.length === 1) { + // Single folder - show progress toast + const folder = folders[0] + const folderPath = state.openedFolders + .slice(0, folder.columnIndex) + .map((folder) => folder.name) + .join('/') + const fullFolderPath = + folderPath.length > 0 ? `${folderPath}/${folder.name}` : folder.name + + // Initialize progress tracking + state.setFolderMoveProgress(fullFolderPath, { current: 0, total: 0, toastId: 'temp' }) + + toastId = toast( + , + { + duration: Infinity, + } + ) + + // Update the progress tracking with the actual toast ID + state.setFolderMoveProgress(fullFolderPath, { current: 0, total: 0, toastId }) + } else { + // Multiple folders - show simple toast + toastId = toast(`Moving ${folders.length} folders...`, { + description: STORAGE_PROGRESS_INFO_TEXT, + duration: Infinity, + }) + } + } else if (folders.length === 0 && files.length > 0) { + toastId = toast(`Moving ${files.length} file${files.length > 1 ? 's' : ''}...`, { + description: STORAGE_PROGRESS_INFO_TEXT, + duration: Infinity, + }) + } else { + toastId = toast(`Moving ${items.length} item${items.length > 1 ? 's' : ''}...`, { + description: STORAGE_PROGRESS_INFO_TEXT, + duration: Infinity, + }) + } + + // Handle folder moves first (create new folder, move contents, delete original) + for (const folder of folders) { + try { + await state.moveFolderWithContents(folder, formattedNewPathToFile) + } catch (error: any) { + numberOfFilesMovedFail += 1 + toast.error(`Failed to move folder ${folder.name}: ${error.message}`) + } + } + + // Handle regular file moves await Promise.all( - items.map(async (item) => { + files.map(async (item) => { const pathToFile = state.openedFolders .slice(0, item.columnIndex) .map((folder) => folder.name) @@ -1441,12 +1548,12 @@ function createStorageExplorerState({ ) if (numberOfFilesMovedFail === items.length) { - toast.error('Failed to move files') + toast.error('Failed to move items') } else { toast( `Successfully moved ${ items.length - numberOfFilesMovedFail - } files to ${formattedNewPathToFile.length > 0 ? formattedNewPathToFile : 'the root of your bucket'}` + } items to ${formattedNewPathToFile.length > 0 ? formattedNewPathToFile : 'the root of your bucket'}` ) } @@ -1456,6 +1563,266 @@ function createStorageExplorerState({ await state.refetchAllOpenedFolders() }, + // New function to handle folder moves by creating new folder and moving contents + moveFolderWithContents: async (folder: StorageItemWithColumn, targetPath: string) => { + const folderName = folder.name + const sourcePath = state.openedFolders + .slice(0, folder.columnIndex) + .map((folder) => folder.name) + .join('/') + + const sourceFullPath = sourcePath.length > 0 ? `${sourcePath}/${folderName}` : folderName + const targetFullPath = targetPath.length > 0 ? `${targetPath}/${folderName}` : folderName + + // Add folder to the "being moved" set for visual feedback + state.addFolderBeingMoved(targetFullPath) + + // Create a placeholder file to create the new folder + const placeholderContent = new Blob([''], { type: 'text/plain' }) + + try { + // Check if supabaseClient is available + if (!state.supabaseClient) { + throw new Error('Supabase Client is not available') + } + + // Check if selectedBucket and projectRef are available + if (!state.selectedBucket || !state.selectedBucket.id) { + throw new Error('Selected bucket is not available') + } + + if (!state.projectRef) { + throw new Error('Project reference is not available') + } + + // Create the new folder by uploading a placeholder file + const { data: uploadData, error: uploadError } = await ( + await state.supabaseClient() + ).storage + .from(state.selectedBucket.id) + .upload(targetFullPath + '/.folder-placeholder', placeholderContent, { + upsert: false, + }) + + if (uploadError) { + throw new Error(`Failed to create target folder: ${uploadError.message}`) + } + + // Recursively discover all files and folders in the source directory + const getAllFilesRecursively = async ( + path: string + ): Promise> => { + const allItems: Array<{ path: string; name: string; isFolder: boolean }> = [] + + const { data: items, error: listError } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .list(path, { + limit: 1000, + offset: 0, + sortBy: { column: 'name', order: 'asc' }, + }) + + if (listError) { + console.error(`Failed to list contents of ${path}:`, listError) + return allItems + } + + if (!items) return allItems + + for (const item of items) { + if (item.name === '.folder-placeholder') continue + + const itemPath = `${path}/${item.name}` + + // Check if this is a folder by trying to list its contents + try { + const { data: subItems } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .list(itemPath, { limit: 1 }) + + if (subItems && subItems.length > 0) { + // This is a folder, recursively get its contents + const subFolderItems = await getAllFilesRecursively(itemPath) + allItems.push(...subFolderItems) + } else { + // This is a file + allItems.push({ path: itemPath, name: item.name, isFolder: false }) + } + } catch (error) { + // If listing fails, assume it's a file + allItems.push({ path: itemPath, name: item.name, isFolder: false }) + } + } + + return allItems + } + + // Get all files recursively + const allItemsToMove = await getAllFilesRecursively(sourceFullPath) + const filesToMove = allItemsToMove.filter((item) => !item.isFolder) + + // Update progress tracking with total count + const initialProgressData = state.folderMoveProgress.get(sourceFullPath) + if (initialProgressData) { + state.setFolderMoveProgress(sourceFullPath, { + ...initialProgressData, + total: filesToMove.length, + }) + } + + // Move all files from source to target + let movedCount = 0 + for (const fileItem of filesToMove) { + // Calculate the relative path from the source folder + const relativePath = fileItem.path.substring(sourceFullPath.length + 1) // +1 for the '/' + const toPath = `${targetFullPath}/${relativePath}` + + try { + const moveParams = { + projectRef: state.projectRef, + bucketId: state.selectedBucket.id, + from: fileItem.path, + to: toPath, + } + + await moveStorageObject(moveParams) + movedCount++ + + // Update progress and toast + const progressData = state.folderMoveProgress.get(sourceFullPath) + if (progressData) { + const newProgress = { ...progressData, current: movedCount } + state.setFolderMoveProgress(sourceFullPath, newProgress) + + // Update the toast with new progress + toast.loading( + , + { id: progressData.toastId } + ) + } + } catch (error: any) { + console.error(`Failed to move file ${fileItem.path}:`, error) + // Continue with other files even if one fails + } + } + + // Show completion toast if we have progress tracking + const finalProgressData = state.folderMoveProgress.get(sourceFullPath) + if (finalProgressData && filesToMove.length > 0) { + toast.success( + , + { id: finalProgressData.toastId, duration: 3000 } + ) + } + + // Now delete all files from the source folder to effectively "delete" the folder + const sourceFilePaths = filesToMove.map((fileItem) => fileItem.path) + + if (sourceFilePaths.length > 0) { + // Delete files in batches to avoid overwhelming the API + const batchSize = 10 + for (let i = 0; i < sourceFilePaths.length; i += batchSize) { + const batch = sourceFilePaths.slice(i, i + batchSize) + const { error: deleteError } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .remove(batch) + + if (deleteError) { + console.error(`Failed to delete batch of files:`, deleteError) + } + } + } else { + console.log('Source folder is empty, no files to delete') + } + + // Recursively clean up the entire source folder hierarchy + const cleanupFolderRecursively = async (path: string) => { + try { + const { data: items, error: listError } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .list(path, { + limit: 1000, + offset: 0, + sortBy: { column: 'name', order: 'asc' }, + }) + + if (listError || !items) return + + // Remove all items in this directory + if (items.length > 0) { + const itemPaths = items.map((item) => `${path}/${item.name}`) + const { error: removeError } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .remove(itemPaths) + + if (removeError) { + console.error(`Failed to remove items from ${path}:`, removeError) + } + } + + // Try to remove common hidden files + const hiddenFiles = [ + `${path}/.DS_Store`, + `${path}/Thumbs.db`, + `${path}/.empty`, + `${path}/.folder-placeholder`, + ] + + try { + await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .remove(hiddenFiles) + } catch (hiddenError) { + // Hidden file removal errors are normal + } + } catch (error) { + console.error(`Error cleaning up ${path}:`, error) + } + } + + // Clean up the source folder hierarchy recursively + await cleanupFolderRecursively(sourceFullPath) + + // Delete the target folder placeholder + const { error: deleteTargetError } = await (await state.supabaseClient()).storage + .from(state.selectedBucket.id) + .remove([targetFullPath + '/.folder-placeholder']) + + if (deleteTargetError) { + console.error('Failed to delete target folder placeholder:', deleteTargetError) + } + + // Remove folder from "being moved" set + state.removeFolderBeingMoved(targetFullPath) + + // Clean up progress tracking + state.removeFolderMoveProgress(sourceFullPath) + + try { + await state.refetchAllOpenedFolders() + console.log('Storage explorer refresh completed') + } catch (refreshError) { + console.error('Failed to refresh storage explorer:', refreshError) + } + } catch (error: any) { + // Remove folder from "being moved" set on error + state.removeFolderBeingMoved(targetFullPath) + + // Clean up progress tracking on error + state.removeFolderMoveProgress(sourceFullPath) + + throw new Error(`Failed to move folder ${folderName}: ${error.message}`) + } + }, + deleteFiles: async ({ files, isDeleteFolder = false,