recursively move nested folders

This commit is contained in:
Francesco Sansalvadore committed 2025-08-29 11:49:28 +02:00
1 parent f1dd230828
commit 9ea88cfc40
3 files changed
+453 -123

No files matched your search

@@ -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 && (
<div
className={cn(
'absolute inset-0 pointer-events-none',
isInternalDragOver && 'pointer-events-auto'
)}
onDragOver={(event) => {
// 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)
}}
/>
@@ -134,6 +134,7 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
openFolder,
downloadFolder,
selectRangeItems,
foldersBeingMoved,
} = snap
const { show } = useContextMenu()
const { onCopyUrl } = useCopyUrl()
@@ -146,6 +147,18 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
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<StorageItem, FileExplorerRowProps> = ({
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<StorageItem, FileExplorerRowProps> = ({
.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<StorageItem, FileExplorerRowProps> = ({
return (
<div
ref={ref}
className={cn('h-full border-b border-default', isDragging && 'opacity-50')}
className={cn(
'h-full border-b border-default',
isDragging && 'opacity-50',
isBeingMoved && 'opacity-50'
)}
data-item-type={item.type.toLowerCase()}
data-item-name={item.name}
onContextMenu={(event) => {
+375 -8
View File
@@ -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 (
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-2 w-full">
<div className="flex w-full justify-between">
<p className="text-foreground text-sm">Moving folder "{folderName}"...</p>
<p className="text-foreground-light text-sm font-mono">{`${progress}%`}</p>
</div>
<Progress value={progress} className="w-full" />
<small className="text-foreground-light text-xs">
Moved {current} / {total} items...
</small>
</div>
</div>
)
}
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<string>(),
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(
<FolderMoveProgressToast current={0} total={0} folderName={folder.name} />,
{
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<Array<{ path: string; name: string; isFolder: boolean }>> => {
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(
<FolderMoveProgressToast
current={movedCount}
total={filesToMove.length}
folderName={folderName}
/>,
{ 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(
<FolderMoveProgressToast
current={filesToMove.length}
total={filesToMove.length}
folderName={folderName}
/>,
{ 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,