]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat(files): unify drag and drop methods
authorskjnldsv <skjnldsv@protonmail.com>
Fri, 22 Mar 2024 13:39:51 +0000 (14:39 +0100)
committerbackportbot[bot] <backportbot[bot]@users.noreply.github.com>
Thu, 4 Apr 2024 11:58:34 +0000 (11:58 +0000)
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
apps/files/src/components/BreadCrumbs.vue
apps/files/src/components/DragAndDropNotice.vue
apps/files/src/components/FileEntryMixin.ts
apps/files/src/services/DropService.ts

index 5f72aac11af7f0ae73c19f9cb446403521754563..0c15f88fd13d44350d6b94e4c638ee90733cbb28 100644 (file)
@@ -36,7 +36,7 @@
                        :aria-description="ariaForSection(section)"
                        @click.native="onClick(section.to)"
                        @dragover.native="onDragOver($event, section.dir)"
-                       @dropped="onDrop($event, section.dir)">
+                       @drop.native="onDrop($event, section.dir)">
                        <template v-if="index === 0" #icon>
                                <NcIconSvgWrapper :size="20"
                                        :svg="viewIcon" />
@@ -61,7 +61,7 @@ import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
 import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
 import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
 
-import { onDropExternalFiles, onDropInternalFiles } from '../services/DropService'
+import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
 import { showError } from '@nextcloud/dialogs'
 import { useDragAndDropStore } from '../store/dragging.ts'
 import { useFilesStore } from '../store/files.ts'
@@ -70,8 +70,6 @@ import { useSelectionStore } from '../store/selection.ts'
 import { useUploaderStore } from '../store/uploader.ts'
 import filesListWidthMixin from '../mixins/filesListWidth.ts'
 import logger from '../logger'
-import { debug } from '../../../../core/src/OC/debug.js'
-import { F } from 'lodash/fp'
 
 export default defineComponent({
        name: 'BreadCrumbs',
@@ -143,7 +141,13 @@ export default defineComponent({
 
                // Hide breadcrumbs if an upload is ongoing
                shouldShowBreadcrumbs(): boolean {
-                       return this.filesListWidth > 400 && !this.isUploadInProgress
+                       // If we're uploading files, only show the breadcrumbs
+                       // if the files list is greater than 768px wide
+                       if (this.isUploadInProgress) {
+                               return this.filesListWidth > 768
+                       }
+                       // If we're not uploading, we have enough space from 400px
+                       return this.filesListWidth > 400
                },
 
                // used to show the views icon for the first breadcrumb
@@ -200,16 +204,22 @@ export default defineComponent({
 
                async onDrop(event: DragEvent, path: string) {
                        // skip if native drop like text drag and drop from files names
-                       if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
+                       if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
                                return
                        }
 
+                       // Do not stop propagation, so the main content
+                       // drop event can be triggered too and clear the
+                       // dragover state on the DragAndDropNotice component.
+                       event.preventDefault()
+
                        // Caching the selection
                        const selection = this.draggingFiles
-                       const files = event.dataTransfer?.files || new FileList()
+                       const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
 
-                       event.preventDefault()
-                       event.stopPropagation()
+                       // We need to process the dataTransfer ASAP before the
+                       // browser clears it. This is why we cache the items too.
+                       const fileTree = await dataTransferToFileTree(items)
 
                        // We might not have the target directory fetched yet
                        const contents = await this.currentView?.getContents(path)
@@ -228,17 +238,17 @@ export default defineComponent({
                                return
                        }
 
-                       logger.debug('Dropped', { event, folder, selection })
+                       logger.debug('Dropped', { event, folder, selection, fileTree })
 
                        // Check whether we're uploading files
-                       if (files.length > 0) {
-                               await onDropExternalFiles(folder, files)
+                       if (fileTree.contents.length > 0) {
+                               await onDropExternalFiles(fileTree, folder, contents.contents)
                                return
                        }
 
                        // Else we're moving/copying files
                        const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
-                       await onDropInternalFiles(folder, nodes, isCopy)
+                       await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
 
                        // Reset selection after we dropped the files
                        // if the dropped files are within the selection
index f9b830ca755e661a0891b48064c1d3896b447f89..d591f6ee934da111a3e26656a8878ea6d4061ea3 100644 (file)
 <script lang="ts">
 import { defineComponent } from 'vue'
 import { Folder, Permission } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
+import { showError } from '@nextcloud/dialogs'
 import { translate as t } from '@nextcloud/l10n'
 import { UploadStatus } from '@nextcloud/upload'
 
 import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
 
 import logger from '../logger.js'
-import { handleDrop } from '../services/DropService'
+import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
 
 export default defineComponent({
        name: 'DragAndDropNotice',
@@ -76,6 +76,10 @@ export default defineComponent({
        },
 
        computed: {
+               currentView() {
+                       return this.$navigation.active
+               },
+
                /**
                 * Check if the current folder has create permissions
                 */
@@ -146,8 +150,6 @@ export default defineComponent({
                },
 
                async onDrop(event: DragEvent) {
-                       logger.debug('Dropped on DragAndDropNotice', { event })
-
                        // cantUploadLabel is null if we can upload
                        if (this.cantUploadLabel) {
                                showError(this.cantUploadLabel)
@@ -161,38 +163,50 @@ export default defineComponent({
                        event.preventDefault()
                        event.stopPropagation()
 
-                       if (event.dataTransfer && event.dataTransfer.items.length > 0) {
-                               // Start upload
-                               logger.debug(`Uploading files to ${this.currentFolder.path}`)
-                               // Process finished uploads
-                               const uploads = await handleDrop(event.dataTransfer)
-                               logger.debug('Upload terminated', { uploads })
-
-                               if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) {
-                                       showError(t('files', 'Some files could not be uploaded'))
-                                       const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED)
-                                       logger.debug('Some files could not be uploaded', { failedUploads })
-                               } else {
-                                       showSuccess(t('files', 'Files uploaded successfully'))
-                               }
-
-                               // Scroll to last successful upload in current directory if terminated
-                               const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
-                                       && !upload.file.webkitRelativePath.includes('/')
-                                       && upload.response?.headers?.['oc-fileid'])
-
-                               if (lastUpload !== undefined) {
-                                       this.$router.push({
-                                               ...this.$route,
-                                               params: {
-                                                       view: this.$route.params?.view ?? 'files',
-                                                       fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
-                                               },
-                                       })
-                               }
+                       // Caching the selection
+                       const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
+
+                       // We need to process the dataTransfer ASAP before the
+                       // browser clears it. This is why we cache the items too.
+                       const fileTree = await dataTransferToFileTree(items)
+
+                       // We might not have the target directory fetched yet
+                       const contents = await this.currentView?.getContents(this.currentFolder.path)
+                       const folder = contents?.folder
+                       if (!folder) {
+                               showError(this.t('files', 'Target folder does not exist any more'))
+                               return
+                       }
+
+                       // If another button is pressed, cancel it. This
+                       // allows cancelling the drag with the right click.
+                       if (event.button !== 0) {
+                               return
+                       }
+
+                       logger.debug('Dropped', { event, folder, fileTree })
+
+                       // Check whether we're uploading files
+                       const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
+
+                       // Scroll to last successful upload in current directory if terminated
+                       const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
+                               && !upload.file.webkitRelativePath.includes('/')
+                               && upload.response?.headers?.['oc-fileid'])
+
+                       if (lastUpload !== undefined) {
+                               this.$router.push({
+                                       ...this.$route,
+                                       params: {
+                                               view: this.$route.params?.view ?? 'files',
+                                               fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
+                                       },
+                               })
                        }
+
                        this.dragover = false
                },
+
                t,
        },
 })
index 6310001532c4905daf75ef66846d59fd24ffe27d..9eead369322b9a1000508bdee77268c95e91b0c7 100644 (file)
@@ -32,8 +32,9 @@ import Vue, { defineComponent } from 'vue'
 import { action as sidebarAction } from '../actions/sidebarAction.ts'
 import { getDragAndDropPreview } from '../utils/dragUtils.ts'
 import { hashCode } from '../utils/hashUtils.ts'
-import { onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
+import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
 import logger from '../logger.js'
+import { showError } from '@nextcloud/dialogs'
 
 Vue.directive('onClickOutside', vOnClickOutside)
 
@@ -306,16 +307,28 @@ export default defineComponent({
 
                async onDrop(event: DragEvent) {
                        // skip if native drop like text drag and drop from files names
-                       if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
+                       if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
                                return
                        }
 
+                       event.preventDefault()
+                       event.stopPropagation()
+
                        // Caching the selection
                        const selection = this.draggingFiles
-                       const files = event.dataTransfer?.files || new FileList()
+                       const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
 
-                       event.preventDefault()
-                       event.stopPropagation()
+                       // We need to process the dataTransfer ASAP before the
+                       // browser clears it. This is why we cache the items too.
+                       const fileTree = await dataTransferToFileTree(items)
+
+                       // We might not have the target directory fetched yet
+                       const contents = await this.currentView?.getContents(this.source.path)
+                       const folder = contents?.folder
+                       if (!folder) {
+                               showError(this.t('files', 'Target folder does not exist any more'))
+                               return
+                       }
 
                        // If another button is pressed, cancel it. This
                        // allows cancelling the drag with the right click.
@@ -326,17 +339,17 @@ export default defineComponent({
                        const isCopy = event.ctrlKey
                        this.dragover = false
 
-                       logger.debug('Dropped', { event, selection })
+                       logger.debug('Dropped', { event, folder, selection, fileTree })
 
                        // Check whether we're uploading files
-                       if (files.length > 0) {
-                               await onDropExternalFiles(this.source as Folder, files)
+                       if (fileTree.contents.length > 0) {
+                               await onDropExternalFiles(fileTree, folder, contents.contents)
                                return
                        }
 
                        // Else we're moving/copying files
                        const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
-                       await onDropInternalFiles(this.source as Folder, nodes, isCopy)
+                       await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
 
                        // Reset selection after we dropped the files
                        // if the dropped files are within the selection
index a2a551f8f5e12b58f1247dde1998e8ef1d7c241e..e5f806d9f0b9d3e4867cf0e4bdea2b710eace82a 100644 (file)
@@ -12,7 +12,7 @@
  * License, or (at your option) any later version.
  *
  * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * but WITHOUT ANY WARRANTY without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  * GNU Affero General Public License for more details.
  *
@@ -26,9 +26,10 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'
 
 import { emit } from '@nextcloud/event-bus'
 import { Folder, Node, NodeStatus, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
-import { getUploader } from '@nextcloud/upload'
+import { getUploader, hasConflict, openConflictPicker } from '@nextcloud/upload'
+import { join } from 'path'
 import { joinPaths } from '@nextcloud/paths'
-import { showError, showSuccess } from '@nextcloud/dialogs'
+import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
 import { translate as t } from '@nextcloud/l10n'
 import Vue from 'vue'
 
@@ -36,95 +37,102 @@ import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
 import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
 import logger from '../logger.js'
 
-export const handleDrop = async (data: DataTransfer): Promise<Upload[]> => {
-       // TODO: Maybe handle `getAsFileSystemHandle()` in the future
+/**
+ * This represents a Directory in the file tree
+ * We extend the File class to better handling uploading
+ * and stay as close as possible as the Filesystem API.
+ * This also allow us to hijack the size or lastModified
+ * properties to compute them dynamically.
+ */
+class Directory extends File {
 
-       const uploads = [] as Upload[]
-       // we need to cache the entries to prevent Blink engine bug that clears the list (`data.items`) after first access props of one of the entries
-       const entries = [...data.items]
-               .filter((item) => {
-                       if (item.kind !== 'file') {
-                               logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
-                               return false
-                       }
-                       return true
-               })
-               .map((item) => {
-                       // MDN recommends to try both, as it might be renamed in the future
-                       return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined})?.getAsEntry?.() ?? item.webkitGetAsEntry() ?? item
-               })
+       /* eslint-disable no-use-before-define */
+       _contents: (Directory|File)[]
 
-       for (const entry of entries) {
-               // Handle browser issues if Filesystem API is not available. Fallback to File API
-               if (entry instanceof DataTransferItem) {
-                       logger.debug('Could not get FilesystemEntry of item, falling back to file')
-                       const file = entry.getAsFile()
-                       if (file === null) {
-                               logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
-                               showError(t('files', 'One of the dropped files could not be processed'))
-                       } else {
-                               uploads.push(await handleFileUpload(file))
-                       }
-               } else {
-                       logger.debug('Handle recursive upload', { entry: entry.name })
-                       // Use Filesystem API
-                       uploads.push(...await handleRecursiveUpload(entry))
+       constructor(name, contents: (Directory|File)[] = []) {
+               super([], name, { type: 'httpd/unix-directory' })
+               this._contents = contents
+       }
+
+       set contents(contents: (Directory|File)[]) {
+               this._contents = contents
+       }
+
+       get contents(): (Directory|File)[] {
+               return this._contents
+       }
+
+       get size() {
+               return this._computeDirectorySize(this)
+       }
+
+       get lastModified() {
+               if (this._contents.length === 0) {
+                       return Date.now()
                }
+               return this._computeDirectoryMtime(this)
        }
-       return uploads
-}
 
-const handleFileUpload = async (file: File, path: string = '') => {
-       const uploader = getUploader()
+       /**
+        * Get the last modification time of a file tree
+        * This is not perfect, but will get us a pretty good approximation
+        * @param directory the directory to traverse
+        */
+       _computeDirectoryMtime(directory: Directory): number {
+               return directory.contents.reduce((acc, file) => {
+                       return file.lastModified > acc
+                               // If the file is a directory, the lastModified will
+                               // also return the results of its _computeDirectoryMtime method
+                               // Fancy recursion, huh?
+                               ? file.lastModified
+                               : acc
+               }, 0)
+       }
 
-       try {
-               return await uploader.upload(`${path}${file.name}`, file)
-       } catch (e) {
-               showError(t('files', 'Uploading "{filename}" failed', { filename: file.name }))
-               throw e
+       /**
+        * Get the size of a file tree
+        * @param directory the directory to traverse
+        */
+       _computeDirectorySize(directory: Directory): number {
+               return directory.contents.reduce((acc: number, entry: Directory|File) => {
+                       // If the file is a directory, the size will
+                       // also return the results of its _computeDirectorySize method
+                       // Fancy recursion, huh?
+                       return acc + entry.size
+               }, 0)
        }
+
 }
 
-const handleRecursiveUpload = async (entry: FileSystemEntry, path: string = ''): Promise<Upload[]> => {
-       if (entry.isFile) {
-               return [
-                       await new Promise<Upload>((resolve, reject) => {
-                               (entry as FileSystemFileEntry).file(
-                                       async (file) => resolve(await handleFileUpload(file, path)),
-                                       (error) => reject(error),
-                               )
-                       }),
-               ]
-       } else {
-               const directory = entry as FileSystemDirectoryEntry
-
-               // TODO: Implement this on `@nextcloud/upload`
-               const absolutPath = joinPaths(davRootPath, getUploader().destination.path, path, directory.name)
-
-               logger.debug('Handle directory recursively', { name: directory.name, absolutPath })
-
-               const davClient = davGetClient()
-               const dirExists = await davClient.exists(absolutPath)
-               if (!dirExists) {
-                       logger.debug('Directory does not exist, creating it', { absolutPath })
-                       await davClient.createDirectory(absolutPath, { recursive: true })
-                       const stat = await davClient.stat(absolutPath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
-                       emit('files:node:created', davResultToNode(stat.data))
-               }
+type RootDirectory = Directory & {
+       name: 'root'
+}
 
-               const entries = await readDirectory(directory)
-               // sorted so we upload files first before starting next level
-               const promises = entries.sort((a) => a.isFile ? -1 : 1)
-                       .map((file) => handleRecursiveUpload(file, `${path}${directory.name}/`))
-               return (await Promise.all(promises)).flat()
+/**
+ * Traverse a file tree using the Filesystem API
+ * @param entry the entry to traverse
+ */
+const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
+       // Handle file
+       if (entry.isFile) {
+               return new Promise<File>((resolve, reject) => {
+                       (entry as FileSystemFileEntry).file(resolve, reject)
+               })
        }
+
+       // Handle directory
+       logger.debug('Handling recursive file tree', { entry: entry.name })
+       const directory = entry as FileSystemDirectoryEntry
+       const entries = await readDirectory(directory)
+       const contents = (await Promise.all(entries.map(traverseTree))).flat()
+       return new Directory(directory.name, contents)
 }
 
 /**
  * Read a directory using Filesystem API
  * @param directory the directory to read
  */
-function readDirectory(directory: FileSystemDirectoryEntry) {
+const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
        const dirReader = directory.createReader()
 
        return new Promise<FileSystemEntry[]>((resolve, reject) => {
@@ -146,51 +154,210 @@ function readDirectory(directory: FileSystemDirectoryEntry) {
        })
 }
 
-export const onDropExternalFiles = async (destination: Folder, files: FileList) => {
+const createDirectoryIfNotExists = async (absolutePath: string) => {
+       const davClient = davGetClient()
+       const dirExists = await davClient.exists(absolutePath)
+       if (!dirExists) {
+               logger.debug('Directory does not exist, creating it', { absolutePath })
+               await davClient.createDirectory(absolutePath, { recursive: true })
+               const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
+               emit('files:node:created', davResultToNode(stat.data))
+       }
+}
+
+const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
+       try {
+               // List all conflicting files
+               const conflicts = files.filter((file: File|Node) => {
+                       return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
+               }).filter(Boolean) as (File|Node)[]
+
+               // List of incoming files that are NOT in conflict
+               const uploads = files.filter((file: File|Node) => {
+                       return !conflicts.includes(file)
+               })
+
+               // Let the user choose what to do with the conflicting files
+               const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
+
+               logger.debug('Conflict resolution', { uploads, selected, renamed })
+
+               // If the user selected nothing, we cancel the upload
+               if (selected.length === 0 && renamed.length === 0) {
+                       // User skipped
+                       showInfo(t('files', 'Conflicts resolution skipped'))
+                       logger.info('User skipped the conflict resolution')
+                       return []
+               }
+
+               // Update the list of files to upload
+               return [...uploads, ...selected, ...renamed] as (typeof files)
+       } catch (error) {
+               console.error(error)
+               // User cancelled
+               showError(t('files', 'Upload cancelled'))
+               logger.error('User cancelled the upload')
+       }
+
+       return []
+}
+
+/**
+ * This function converts a list of DataTransferItems to a file tree.
+ * It uses the Filesystem API if available, otherwise it falls back to the File API.
+ * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
+ * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems
+ * will be cleared after the first access to the props of one of the entries.
+ *
+ * @param items the list of DataTransferItems
+ */
+export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
+       // Check if the browser supports the Filesystem API
+       // We need to cache the entries to prevent Blink engine bug that clears
+       // the list (`data.items`) after first access props of one of the entries
+       const entries = items
+               .filter((item) => {
+                       if (item.kind !== 'file') {
+                               logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
+                               return false
+                       }
+                       return true
+               }).map((item) => {
+                       // MDN recommends to try both, as it might be renamed in the future
+                       return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
+                               ?? item.webkitGetAsEntry()
+                               ?? item
+               }) as (FileSystemEntry | DataTransferItem)[]
+
+       let warned = false
+       const fileTree = new Directory('root') as RootDirectory
+
+       // Traverse the file tree
+       for (const entry of entries) {
+               // Handle browser issues if Filesystem API is not available. Fallback to File API
+               if (entry instanceof DataTransferItem) {
+                       logger.warn('Could not get FilesystemEntry of item, falling back to file')
+
+                       const file = entry.getAsFile()
+                       if (file === null) {
+                               logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
+                               showError(t('files', 'One of the dropped files could not be processed'))
+                               continue
+                       }
+
+                       // Warn the user that the browser does not support the Filesystem API
+                       // we therefore cannot upload directories recursively.
+                       if (file.type === 'httpd/unix-directory' || !file.type) {
+                               if (!warned) {
+                                       showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded.'))
+                                       warned = true
+                               }
+                               continue
+                       }
+
+                       fileTree.contents.push(file)
+                       continue
+               }
+
+               // Use Filesystem API
+               try {
+                       fileTree.contents.push(await traverseTree(entry))
+               } catch (error) {
+                       // Do not throw, as we want to continue with the other files
+                       logger.error('Error while traversing file tree', { error })
+               }
+       }
+
+       return fileTree
+}
+
+export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
        const uploader = getUploader()
 
-       // Check whether the uploader is in the same folder
-       // This should never happen™
-       if (!uploader.destination.path.startsWith(uploader.destination.path)) {
-               logger.error('The current uploader destination is not the same as the current folder')
-               showError(t('files', 'An error occurred while uploading. Please try again later.'))
-               return
+       // Check for conflicts on root elements
+       if (await hasConflict(root.contents, contents)) {
+               root.contents = await resolveConflict(root.contents, destination, contents)
        }
 
-       const previousDestination = uploader.destination
-       if (uploader.destination.path !== destination.path) {
-               logger.debug('Changing uploader destination', { previous: uploader.destination.path, new: destination.path })
-               uploader.destination = destination
+       if (root.contents.length === 0) {
+               logger.info('No files to upload', { root })
+               showInfo(t('files', 'No files to upload'))
+               return []
        }
 
-       logger.debug(`Uploading files to ${destination.path}`)
+       // Let's process the files
+       logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
        const queue = [] as Promise<Upload>[]
-       for (const file of files) {
-               // Because the uploader destination is properly set to the current folder
-               // we can just use the basename as the relative path.
-               queue.push(uploader.upload(file.name, file))
+
+       const uploadDirectoryContents = async (directory: Directory, path: string) => {
+               for (const file of directory.contents) {
+                       // This is the relative path to the resource
+                       // from the current uploader destination
+                       const relativePath = join(path, file.name)
+
+                       // If the file is a directory, we need to create it first
+                       // then browse its tree and upload its contents.
+                       if (file instanceof Directory) {
+                               const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
+                               try {
+                                       console.debug('Processing directory', { relativePath })
+                                       await createDirectoryIfNotExists(absolutePath)
+                                       await uploadDirectoryContents(file, relativePath)
+                               } catch (error) {
+                                       showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
+                                       logger.error('', { error, absolutePath, directory: file })
+                               }
+                               continue
+                       }
+
+                       // If we've reached a file, we can upload it
+                       logger.debug('Uploading file to ' + join(destination.path, relativePath), { file })
+
+                       // Overriding the root to avoid changing the current uploader context
+                       queue.push(uploader.upload(relativePath, file, destination.source))
+               }
        }
 
+       // Pause the uploader to prevent it from starting
+       // while we compute the queue
+       uploader.pause()
+
+       // Upload the files. Using '/' as the starting point
+       // as we already adjusted the uploader destination
+       await uploadDirectoryContents(root, '/')
+       uploader.start()
+
        // Wait for all promises to settle
        const results = await Promise.allSettled(queue)
 
-       // Reset the uploader destination
-       uploader.destination = previousDestination
-
        // Check for errors
        const errors = results.filter(result => result.status === 'rejected')
        if (errors.length > 0) {
                logger.error('Error while uploading files', { errors })
                showError(t('files', 'Some files could not be uploaded'))
-               return
+               return []
        }
 
        logger.debug('Files uploaded successfully')
        showSuccess(t('files', 'Files uploaded successfully'))
+
+       return Promise.all(queue)
 }
 
-export const onDropInternalFiles = async (destination: Folder, nodes: Node[], isCopy = false) => {
+export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
        const queue = [] as Promise<void>[]
+
+       // Check for conflicts on root elements
+       if (await hasConflict(nodes, contents)) {
+               nodes = await resolveConflict(nodes, destination, contents)
+       }
+
+       if (nodes.length === 0) {
+               logger.info('No files to process', { nodes })
+               showInfo(t('files', 'No files to process'))
+               return
+       }
+
        for (const node of nodes) {
                Vue.set(node, 'status', NodeStatus.LOADING)
                // TODO: resolve potential conflicts prior and force overwrite