From ad765e2edd007de1a8d6ce119498892fc6ebf0e4 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 29 Aug 2025 17:39:58 +0200 Subject: [PATCH] improve dropzones --- .../StorageExplorer/FileExplorerColumn.tsx | 59 ++++++++++++++----- .../StorageExplorer/FileExplorerRow.tsx | 42 ++++++++----- apps/studio/state/storage-explorer.tsx | 28 +++++++-- 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index 68bb89730cb..5e43c46d716 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -92,16 +92,11 @@ const FileExplorerColumn = ({ 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('/') + // The column index represents the folder level we're currently viewing + const path = snap.openedFolders + .slice(0, columnIndex) + .map((folder) => folder.name) + .join('/') return path } @@ -110,13 +105,22 @@ const FileExplorerColumn = ({ } // Drop target for column background - const [{ isOver }, drop] = useDrop({ + const [dropProps, drop] = useDrop({ accept: 'storage-item', - canDrop: (draggedItem: any) => { + canDrop: (draggedItem: any, monitor: any) => { // Don't allow dropping on the same column if (draggedItem.sourceColumnIndex === index) return false - // Don't allow dropping a folder into its own subfolder (circular reference) + // Only allow drops when we're actually hovering over the column background + // This prevents interference with folder item drops + const isOverColumnBackground = monitor.isOver({ shallow: true }) + + if (!isOverColumnBackground) { + // Not over column background, blocking drop + return false + } + + // Don't allow dropping a folder into itself or any of its subdirectories (circular reference) if (draggedItem.type === STORAGE_ROW_TYPES.FOLDER) { const draggedItemPath = snap.openedFolders .slice(0, draggedItem.sourceColumnIndex) @@ -124,12 +128,35 @@ const FileExplorerColumn = ({ .join('/') const targetPath = getColumnPath() - return !targetPath.startsWith(draggedItemPath + '/') + // Build the full path of the dragged folder + const draggedItemFullPath = + draggedItemPath.length > 0 ? `${draggedItemPath}/${draggedItem.name}` : draggedItem.name + + // Check if target path is the same as dragged item path (dropping on itself) + if (targetPath === draggedItemFullPath) { + // Cannot drop folder on itself + return false + } + + // Additional check: Block column background drops for folders when the target would be invalid + // This prevents the column from intercepting drops that should go to folder items + // Allow valid moves (like moving a folder to root) while preventing invalid ones + const draggedItemCurrentPath = snap.openedFolders + .slice(0, draggedItem.sourceColumnIndex) + .map((folder) => folder.name) + .join('/') + + // Block if the target path would be the same as the dragged item's current location + // (This prevents self-drops, but allows moves from subdirectories to parent directories) + if (targetPath === draggedItemCurrentPath) { + // Blocking folder drop to same location + return false + } } return true }, - drop: (draggedItem: any) => { + drop: (draggedItem: any, monitor: any) => { if (canUpdateStorage) { const targetDirectory = getColumnPath() @@ -213,7 +240,7 @@ const FileExplorerColumn = ({ fullWidth ? 'w-full' : 'w-64 border-r border-overlay', snap.view === STORAGE_VIEWS.LIST && 'h-full', 'hide-scrollbar relative flex flex-shrink-0 flex-col overflow-auto', - (isOver || isDraggedOver) && 'bg-selection/10' + (dropProps.isOver || isDraggedOver) && 'bg-selection/10' )} onContextMenu={displayMenu} onDrop={handleExternalFileDrop} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index afca9efeeee..617112ee10b 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -15,7 +15,7 @@ import { Trash2, } from 'lucide-react' import { useContextMenu } from 'react-contexify' -import { useState, useRef } from 'react' +import { useRef } from 'react' import { useDrag, useDrop } from 'react-dnd' import SVG from 'react-inlinesvg' @@ -114,7 +114,6 @@ const FileExplorerRow: ItemRenderer = ({ selectedItems = [], }) => { const { ref: projectRef, bucketId } = useParams() - const [isFolderDragOver, setIsFolderDragOver] = useState(false) const ref = useRef(null) const snap = useStorageExplorerStateSnapshot() @@ -178,7 +177,7 @@ const FileExplorerRow: ItemRenderer = ({ canDrop: (draggedItem: any) => { // Only allow drops on folders if (item.type !== STORAGE_ROW_TYPES.FOLDER) { - // Not a folder, canDrop returning false + // Not a folder return false } @@ -198,32 +197,49 @@ const FileExplorerRow: ItemRenderer = ({ targetItemPath.length > 0 ? `${targetItemPath}/${item.name}` : item.name if (draggedItemFullPath === targetItemFullPath) { - // Dropping on itself (same path), canDrop returning false + // Can't drop on itself (same path) return false } - // Don't allow dropping a folder into its own subfolder (circular reference) + // Don't allow dropping a folder into itself or any of its subdirectories (circular reference) if (draggedItem.type === STORAGE_ROW_TYPES.FOLDER) { + // For folder drops, the target is the folder itself + // When dropping on a folder item, we want to move INTO that folder const targetPath = snap.openedFolders .slice(0, columnIndex) .map((folder) => folder.name) .concat(item.name) .join('/') - const wouldCreateCircularReference = targetPath.startsWith(draggedItemFullPath + '/') + // Check if target path is the same as dragged item path (dropping on itself) + if (targetPath === draggedItemFullPath) { + return false + } - if (wouldCreateCircularReference) { - // Would create circular reference, canDrop returning false + // Check if target path is a subdirectory of the dragged item (would create circular reference) + const droppedOnOwnSubdir = targetPath.startsWith(draggedItemFullPath + '/') + + if (droppedOnOwnSubdir) { + // Can't drop in own subdirectory + return false + } + + // Check if target path is the IMMEDIATE parent of the dragged item + // This prevents dropping a folder into its direct parent, but allows moving to ancestor directories + const draggedItemImmediateParent = draggedItemFullPath.split('/').slice(0, -1).join('/') + const isDroppingOnImmediateParent = targetPath === draggedItemImmediateParent + + if (isDroppingOnImmediateParent) { + // Cannot drop folder into its immediate parent directory (same directory) return false } } - // canDrop returning true return true }, - drop: (draggedItem: any) => { + drop: (draggedItem: any, monitor: any) => { if (item.type === STORAGE_ROW_TYPES.FOLDER && canUpdateFiles) { - // Calculate target directory path + // Calculate target directory path - for folder drops, target is the folder itself const targetDirectory = snap.openedFolders .slice(0, columnIndex) .map((folder) => folder.name) @@ -244,9 +260,7 @@ const FileExplorerRow: ItemRenderer = ({ // 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, + // Drop conditions not met } }, collect: (monitor) => ({ diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index bf53e8a15af..f76819706e7 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -51,6 +51,7 @@ import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { tryParseJson } from 'lib/helpers' import { lookupMime } from 'lib/mime' import { Button, SONNER_DEFAULT_DURATION, SonnerProgress, Progress } from 'ui' +import { Loader2 } from 'lucide-react' // Custom progress toast component for folder moves const FolderMoveProgressToast = ({ @@ -63,17 +64,22 @@ const FolderMoveProgressToast = ({ folderName: string }) => { const progress = total > 0 ? Math.round((current / total) * 100) : 0 + const isCalculating = total === 0 return (
-

Moving folder "{folderName}"...

-

{`${progress}%`}

+

Moving folder "{folderName}"

+ {isCalculating ? ( + + ) : ( +

{`${progress}%`}

+ )}
- - - Moved {current} / {total} items... + {!isCalculating && } + + {isCalculating ? 'Calculating items to move...' : `Moved ${current} / ${total} items...`}
@@ -1661,13 +1667,23 @@ function createStorageExplorerState({ const allItemsToMove = await getAllFilesRecursively(sourceFullPath) const filesToMove = allItemsToMove.filter((item) => !item.isFolder) - // Update progress tracking with total count + // Update progress tracking with total count and show calculation complete const initialProgressData = state.folderMoveProgress.get(sourceFullPath) if (initialProgressData) { state.setFolderMoveProgress(sourceFullPath, { ...initialProgressData, total: filesToMove.length, }) + + // Update toast to show calculation complete and move starting + toast.loading( + , + { id: initialProgressData.toastId } + ) } // Move all files from source to target