@@ -368,6 +368,7 @@ export function Files() {
368368 const [ isDirty , setIsDirty ] = useState ( false )
369369 const [ saveStatus , setSaveStatus ] = useState < SaveStatus > ( 'idle' )
370370 const [ activeDropTargetId , setActiveDropTargetId ] = useState < string | null > ( null )
371+ const [ isBodyDropActive , setIsBodyDropActive ] = useState ( false )
371372 const [ draggedRowIds , setDraggedRowIds ] = useState < Set < string > > ( ( ) => EMPTY_DRAGGED_ROW_IDS )
372373 const [ previewMode , setPreviewMode ] = useState < PreviewMode > ( ( ) => {
373374 if ( isNewFile ) return 'editor'
@@ -717,6 +718,42 @@ export function Files() {
717718
718719 const descendantFolderIdsByFolderId = useMemo ( ( ) => buildDescendantIndex ( folders ) , [ folders ] )
719720
721+ /**
722+ * Whether dropping `sourceRowIds` into `targetFolderId` would move anything.
723+ *
724+ * Takes a folder id rather than a row id because the destination is not always a row: the
725+ * list body files into the folder currently open, which has no row of its own, and a drag
726+ * that spring-opened into an empty folder has nothing else to land on.
727+ */
728+ const isInvalidFolderTarget = useCallback (
729+ ( targetFolderId : string | null , sourceRowIds : string [ ] ) => {
730+ for ( const sourceRowId of sourceRowIds ) {
731+ const source = parseRowId ( sourceRowId )
732+ if ( source . kind !== 'folder' ) continue
733+ if ( source . id === targetFolderId ) return true
734+ if (
735+ targetFolderId !== null &&
736+ descendantFolderIdsByFolderId . get ( source . id ) ?. has ( targetFolderId )
737+ )
738+ return true
739+ }
740+
741+ const allAlreadyInTarget = sourceRowIds . every ( ( sourceRowId ) => {
742+ const source = parseRowId ( sourceRowId )
743+ if ( source . kind === 'file' ) {
744+ return (
745+ ( filesRef . current . find ( ( f ) => f . id === source . id ) ?. folderId ?? null ) === targetFolderId
746+ )
747+ }
748+ return (
749+ ( foldersRef . current . find ( ( f ) => f . id === source . id ) ?. parentId ?? null ) === targetFolderId
750+ )
751+ } )
752+ return allAlreadyInTarget
753+ } ,
754+ [ descendantFolderIdsByFolderId ]
755+ )
756+
720757 const isInvalidDropTarget = useCallback (
721758 ( targetRowId : string , sourceRowIds : string [ ] ) => {
722759 const target = parseRowId ( targetRowId )
@@ -831,6 +868,7 @@ export function Files() {
831868 setDraggedRowIds ( EMPTY_DRAGGED_ROW_IDS )
832869 setIsDraggingOver ( false )
833870 setActiveDropTargetId ( null )
871+ setIsBodyDropActive ( false )
834872 } , [ dragGhost , springLoad ] )
835873
836874 useDragTeardown ( endDrag )
@@ -941,6 +979,56 @@ export function Files() {
941979 } )
942980 } ,
943981 onDragEnd : endDrag ,
982+ body : {
983+ isActive : isBodyDropActive ,
984+ canDrop : canEdit && draggedRowIds . size > 0 ,
985+ onDragOver : ( e : DragEvent < HTMLDivElement > ) => {
986+ const sourceRowIds = draggedRowIdsRef . current
987+ const isExternalFileDrag = hasExternalFiles ( e . dataTransfer )
988+ if ( ! isExternalFileDrag ) {
989+ if ( sourceRowIds . length === 0 ) return
990+ if ( isInvalidFolderTarget ( currentFolderId , sourceRowIds ) ) return
991+ }
992+ e . preventDefault ( )
993+ e . dataTransfer . dropEffect = isExternalFileDrag ? 'copy' : 'move'
994+ setIsBodyDropActive ( true )
995+ } ,
996+ onDragLeave : ( e : DragEvent < HTMLDivElement > ) => {
997+ const relatedTarget = e . relatedTarget
998+ if ( relatedTarget instanceof Node && e . currentTarget . contains ( relatedTarget ) ) return
999+ setIsBodyDropActive ( false )
1000+ } ,
1001+ onDrop : ( e : DragEvent < HTMLDivElement > ) => {
1002+ e . preventDefault ( )
1003+ const droppedFiles = Array . from ( e . dataTransfer . files ?? [ ] )
1004+ const sourceRowIds =
1005+ readRowDragPayload ( e . dataTransfer , FILE_ROW_DRAG_MIME ) ?? draggedRowIdsRef . current
1006+ const canMove =
1007+ droppedFiles . length === 0 &&
1008+ sourceRowIds . length > 0 &&
1009+ ! isInvalidFolderTarget ( currentFolderId , sourceRowIds )
1010+
1011+ endDrag ( )
1012+
1013+ if ( droppedFiles . length > 0 ) {
1014+ void uploadFiles ( droppedFiles , currentFolderId )
1015+ return
1016+ }
1017+ if ( ! canMove ) return
1018+
1019+ const fileIds : string [ ] = [ ]
1020+ const folderIds : string [ ] = [ ]
1021+ for ( const sourceRowId of sourceRowIds ) {
1022+ const source = parseRowId ( sourceRowId )
1023+ if ( source . kind === 'file' ) fileIds . push ( source . id )
1024+ else folderIds . push ( source . id )
1025+ }
1026+ void moveItems
1027+ . mutateAsync ( { workspaceId, fileIds, folderIds, targetFolderId : currentFolderId } )
1028+ . then ( ( ) => clearSelection ( ) )
1029+ . catch ( ( error ) => logger . error ( 'Failed to move items into the open folder:' , error ) )
1030+ } ,
1031+ } ,
9441032 } ) ,
9451033 [
9461034 activeDropTargetId ,
@@ -950,6 +1038,10 @@ export function Files() {
9501038 selectedRowIds ,
9511039 visibleRowIds ,
9521040 isInvalidDropTarget ,
1041+ isInvalidFolderTarget ,
1042+ isBodyDropActive ,
1043+ currentFolderId ,
1044+ clearSelection ,
9531045 uploadFiles ,
9541046 workspaceId ,
9551047 ]
0 commit comments