diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index bd2f3cfbd9cc6..72af0abc0efbf 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -141,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 @@ -240,7 +246,7 @@ export default defineComponent({ // 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 diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index f9b830ca755e6..deec3f72e4670 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -53,7 +53,7 @@ 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, handleDrop, 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 = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // 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, }, }) diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index deefea17795d9..96ccd4f124bd6 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -96,6 +96,15 @@ type RootDirectory = Directory & { name: 'root' } +type Only = { + [P in keyof T]: T[P]; +} & { + [P in keyof U]?: never; +} + +type Either = Only | Only +type FileOrNode = Either<(Directory|File), Node> + /** * 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. @@ -210,57 +219,55 @@ const createDirectoryIfNotExists = async (absolutePath: string) => { } } -export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]) => { - const uploader = getUploader() +const resolveConflict = async (files: Array, destination: Folder, contents: Node[]): Promise => { + 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)[] - // 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 - } + // 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 }) - 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 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') } - // Check for conflicts on root elements - if (await hasConflict(root.contents, contents)) { - try { - // List all conflicting files - const conflicts = root.contents.filter((file: Directory|File) => { - return contents.find((node: Node) => node.basename === file.name) - }).filter(Boolean) as (Directory|File)[] - - // List of incoming files that are NOT in conflict - const uploads = root.contents.filter((file: Directory|File) => { - return !conflicts.includes(file) - }) + return [] +} - // Let the user choose what to do with the conflicting files - const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents) +export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise => { + const uploader = getUploader() - logger.debug('Conflict resolution', { uploads, selected, renamed }) + // Check for conflicts on root elements + if (await hasConflict(root.contents, contents)) { + root.contents = await resolveConflict(root.contents, destination, contents) + } - // 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') - } else { - // Update the list of files to upload - root.contents = [...uploads, ...selected, ...renamed] as (Directory|File)[] - } - } catch (error) { - console.error(error) - // User cancelled - showError(t('files', 'Upload cancelled')) - logger.error('User cancelled the upload') - return - } + if (root.contents.length === 0) { + logger.info('No files to upload', { root }) + showInfo(t('files', 'No files to upload')) + return [] } // Let's process the files @@ -278,7 +285,7 @@ export const onDropExternalFiles = async (root: RootDirectory, destination: Fold if (file instanceof Directory) { const absolutePath = joinPaths(davRootPath, destination.path, relativePath) try { - console.debug('Creating directory', { relativePath }) + console.debug('Processing directory', { relativePath }) await createDirectoryIfNotExists(absolutePath) await uploadDirectoryContents(file, relativePath) } catch (error) { @@ -289,35 +296,53 @@ export const onDropExternalFiles = async (root: RootDirectory, destination: Fold } // If we've reached a file, we can upload it - logger.debug('Uploading file', { path: relativePath }) - queue.push(uploader.upload(relativePath, file)) + logger.debug('Uploading file to ' + 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 - uploadDirectoryContents(root, '/') + 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[] + + // 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