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,