aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@users.noreply.github.com>2024-04-04 13:56:23 +0200
committerGitHub <noreply@github.com>2024-04-04 13:56:23 +0200
commit32e86052d57775e8e519d9bdd7d78b2921d8e5fa (patch)
treeb517ee2ba17db7fc79f9918f93d07609cdd79b14 /apps
parent908d7a5fe1c99aa27f3e5b297a8c025adbb7aeaf (diff)
parentaebebde34d5d186aa2b5ae94c7c834f9dea6a2c0 (diff)
downloadnextcloud-server-32e86052d57775e8e519d9bdd7d78b2921d8e5fa.tar.gz
nextcloud-server-32e86052d57775e8e519d9bdd7d78b2921d8e5fa.zip
Merge pull request #44409 from nextcloud/fix/files-dnd-files
Diffstat (limited to 'apps')
-rw-r--r--apps/files/src/components/BreadCrumbs.vue124
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue83
-rw-r--r--apps/files/src/components/FileEntryMixin.ts88
-rw-r--r--apps/files/src/services/DropService.ts242
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts142
-rw-r--r--apps/files/src/services/DropServiceUtils.ts195
6 files changed, 686 insertions, 188 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index d76ed714f20..89312d8dff3 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -34,7 +34,9 @@
:force-icon-text="true"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
- @click.native="onClick(section.to)">
+ @click.native="onClick(section.to)"
+ @dragover.native="onDragOver($event, section.dir)"
+ @drop="onDrop($event, section.dir)">
<template v-if="index === 0" #icon>
<NcIconSvgWrapper :size="20"
:svg="viewIcon" />
@@ -49,20 +51,25 @@
</template>
<script lang="ts">
-import type { Node } from '@nextcloud/files'
+import { Permission, type Node } from '@nextcloud/files'
-import { translate as t} from '@nextcloud/l10n'
import { basename } from 'path'
-import homeSvg from '@mdi/svg/svg/home.svg?raw'
+import { defineComponent } from 'vue'
+import { translate as t} from '@nextcloud/l10n'
+import HomeSvg from '@mdi/svg/svg/home.svg?raw'
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 { defineComponent } from 'vue'
+import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+import { showError } from '@nextcloud/dialogs'
+import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
+import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
+import logger from '../logger'
export default defineComponent({
name: 'BreadCrumbs',
@@ -73,6 +80,10 @@ export default defineComponent({
NcIconSvgWrapper,
},
+ mixins: [
+ filesListWidthMixin,
+ ],
+
props: {
path: {
type: String,
@@ -80,18 +91,18 @@ export default defineComponent({
},
},
- mixins: [
- filesListWidthMixin,
- ],
-
setup() {
+ const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const pathsStore = usePathsStore()
+ const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()
return {
+ draggingStore,
filesStore,
pathsStore,
+ selectionStore,
uploaderStore,
}
},
@@ -110,7 +121,7 @@ export default defineComponent({
},
sections() {
- return this.dirs.map((dir: string) => {
+ return this.dirs.map((dir: string, index: number) => {
const fileid = this.getFileIdFromPath(dir)
const to = { ...this.$route, params: { fileid }, query: { dir } }
return {
@@ -118,6 +129,8 @@ export default defineComponent({
exact: true,
name: this.getDirDisplayName(dir),
to,
+ // disable drop on current directory
+ disableDrop: index === this.dirs.length - 1,
}
})
},
@@ -128,13 +141,27 @@ 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
viewIcon(): string {
- return this.currentView?.icon ?? homeSvg
- }
+ return this.currentView?.icon ?? HomeSvg
+ },
+
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging
+ },
},
methods: {
@@ -160,6 +187,77 @@ export default defineComponent({
}
},
+ onDragOver(event: DragEvent, path: string) {
+ // Cannot drop on the current directory
+ if (path === this.dirs[this.dirs.length - 1]) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+
+ async onDrop(event: DragEvent, path: string) {
+ // skip if native drop like text drag and drop from files names
+ 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 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(path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ const canDrop = (folder.permissions & Permission.CREATE) !== 0
+ const isCopy = event.ctrlKey
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (!canDrop || event.button !== 0) {
+ return
+ }
+
+ logger.debug('Dropped', { event, folder, selection, fileTree })
+
+ // Check whether we're uploading 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(nodes, folder, contents.contents, isCopy)
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
titleForSection(index, section) {
if (section?.to?.query?.dir === this.$route.query.dir) {
return t('files', 'Reload current directory')
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index f9b830ca755..c036c86fb64 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -46,14 +46,14 @@
<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,53 @@ 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) {
+ 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']
+ // Only use the last ID if it's in the current folder
+ && upload.source.replace(folder.source, '').split('/').length === 2)
+
+ if (lastUpload !== undefined) {
+ logger.debug('Scrolling to last upload in current folder', { lastUpload })
+ 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/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index e4e05e821e7..b822363885c 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -22,21 +22,19 @@
import type { PropType } from 'vue'
-import { extname, join } from 'path'
+import { extname } from 'path'
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
-import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
-import { Upload, getUploader } from '@nextcloud/upload'
import { vOnClickOutside } from '@vueuse/components'
import Vue, { defineComponent } from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
import { hashCode } from '../utils/hashUtils.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import logger from '../logger.js'
+import { showError } from '@nextcloud/dialogs'
Vue.directive('onClickOutside', vOnClickOutside)
@@ -309,79 +307,53 @@ 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()
- // If another button is pressed, cancel it
- // This allows cancelling the drag with the right click
- if (!this.canDrop || event.button !== 0) {
+ // Caching the selection
+ const selection = this.draggingFiles
+ 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.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.
+ if (!this.canDrop || event.button) {
return
}
const isCopy = event.ctrlKey
this.dragover = false
- logger.debug('Dropped', { event, selection: this.draggingFiles })
+ logger.debug('Dropped', { event, folder, selection, fileTree })
// Check whether we're uploading files
- if (event.dataTransfer?.files
- && event.dataTransfer.files.length > 0) {
- 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
- }
-
- logger.debug(`Uploading files to ${this.source.path}`)
- const queue = [] as Promise<Upload>[]
- for (const file of event.dataTransfer.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(join(this.source.basename, file.name), file))
- }
-
- const results = await Promise.allSettled(queue)
- 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
- }
-
- logger.debug('Files uploaded successfully')
- showSuccess(t('files', 'Files uploaded successfully'))
+ if (fileTree.contents.length > 0) {
+ await onDropExternalFiles(fileTree, folder, contents.contents)
return
}
- const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- nodes.forEach(async (node: Node) => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- try {
- // TODO: resolve potential conflicts prior and force overwrite
- await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
- } catch (error) {
- logger.error('Error while moving file', { error })
- if (isCopy) {
- showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
- } else {
- showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
- }
- } finally {
- Vue.set(node, 'status', undefined)
- }
- })
+ // Else we're moving/copying files
+ const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
// Reset selection after we dropped the files
// if the dropped files are within the selection
- if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
+ if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
logger.debug('Dropped selection, resetting select store...')
this.selectionStore.reset()
}
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
index b3371f44337..d3711741753 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -2,6 +2,7 @@
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
@@ -11,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.
*
@@ -21,123 +22,196 @@
*/
import type { Upload } from '@nextcloud/upload'
-import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { RootDirectory } from './DropServiceUtils'
-import { davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
-import { emit } from '@nextcloud/event-bus'
-import { getUploader } from '@nextcloud/upload'
+import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
+import { getUploader, hasConflict } from '@nextcloud/upload'
+import { join } from 'path'
import { joinPaths } from '@nextcloud/paths'
-import { showError } from '@nextcloud/dialogs'
+import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
+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
-
- 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]
+/**
+ * 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) => {
+ }).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
- })
+ 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.debug('Could not get FilesystemEntry of item, falling back to file')
+ 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'))
- } else {
- uploads.push(await handleFileUpload(file))
+ continue
}
- } else {
- logger.debug('Handle recursive upload', { entry: entry.name })
- // Use Filesystem API
- uploads.push(...await handleRecursiveUpload(entry))
+
+ // 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) {
+ logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
+ 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 uploads
+
+ return fileTree
}
-const handleFileUpload = async (file: File, path: string = '') => {
+export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
const uploader = getUploader()
- try {
- return await uploader.upload(`${path}${file.name}`, file)
- } catch (e) {
- showError(t('files', 'Uploading "{filename}" failed', { filename: file.name }))
- throw e
+ // Check for conflicts on root elements
+ if (await hasConflict(root.contents, contents)) {
+ root.contents = await resolveConflict(root.contents, destination, contents)
}
-}
-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))
+ 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
+ logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
+ const queue = [] as Promise<Upload>[]
+
+ 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()
- 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()
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+
+ // 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 []
}
+
+ logger.debug('Files uploaded successfully')
+ showSuccess(t('files', 'Files uploaded successfully'))
+
+ return Promise.all(queue)
}
-/**
- * Read a directory using Filesystem API
- * @param directory the directory to read
- */
-function readDirectory(directory: FileSystemDirectoryEntry) {
- const dirReader = directory.createReader()
-
- return new Promise<FileSystemEntry[]>((resolve, reject) => {
- const entries = [] as FileSystemEntry[]
- const getEntries = () => {
- dirReader.readEntries((results) => {
- if (results.length) {
- entries.push(...results)
- getEntries()
- } else {
- resolve(entries)
- }
- }, (error) => {
- reject(error)
- })
- }
+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
+ queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
+ }
+
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+ nodes.forEach(node => Vue.set(node, 'status', undefined))
+
+ // Check for errors
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while copying or moving files', { errors })
+ showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
+ return
+ }
- getEntries()
- })
+ logger.debug('Files copy/move successful')
+ showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
}
diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts
new file mode 100644
index 00000000000..1502d83d9ce
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.spec.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect } from '@jest/globals'
+
+import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
+import { join } from 'node:path'
+import { Directory, traverseTree } from './DropServiceUtils'
+import { dataTransferToFileTree } from './DropService'
+import logger from '../logger'
+
+const dataTree = {
+ 'file0.txt': ['Hello, world!', 1234567890],
+ dir1: {
+ 'file1.txt': ['Hello, world!', 4567891230],
+ 'file2.txt': ['Hello, world!', 7891234560],
+ },
+ dir2: {
+ 'file3.txt': ['Hello, world!', 1234567890],
+ },
+}
+
+// This is mocking a file tree using the FileSystem API
+const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => {
+ const entries = Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ return new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ } else {
+ return buildFileSystemDirectoryEntry(fullPath, contents)
+ }
+ })
+ return new FileSystemDirectoryEntry(path, entries)
+}
+
+const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => {
+ return Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ }
+
+ const entry = buildFileSystemDirectoryEntry(fullPath, contents)
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ })
+}
+
+describe('Filesystem API traverseTree', () => {
+ it('Should traverse a file tree from root', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('root')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(3)
+ expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should traverse a file tree from a subdirectory', async () => {
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2)
+ const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('dir2')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(1)
+ expect(tree.contents[0].name).toBe('file3.txt')
+ expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should properly compute the last modified', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(rootTree.lastModified).toBe(7891234560)
+
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2)
+ const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+ expect(dir2Tree.lastModified).toBe(1234567890)
+ })
+})
+
+describe('DropService dataTransferToFileTree', () => {
+
+ beforeAll(() => {
+ // DataTransferItem doesn't exists in jsdom, let's mock
+ // a dumb one so we can check the instanceof
+ // @ts-expect-error jsdom doesn't have DataTransferItem
+ window.DataTransferItem = DataTransferItemMock
+ })
+
+ afterAll(() => {
+ // @ts-expect-error jsdom doesn't have DataTransferItem
+ delete window.DataTransferItem
+ })
+
+ it('Should return a RootDirectory with Filesystem API', async () => {
+ jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
+
+ const dataTransferItems = buildDataTransferItemArray('root', dataTree)
+ const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(3)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[1]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[1] as Directory).contents).toHaveLength(2)
+ expect(fileTree.contents[2]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[2] as Directory).contents).toHaveLength(1)
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).not.toBeCalled()
+ })
+
+ it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
+ jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
+
+ const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
+
+ const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(1)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[0]).not.toBeInstanceOf(Directory)
+ expect((fileTree.contents[0].name)).toBe('file0.txt')
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded')
+ expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenCalledTimes(4)
+ })
+})
diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts
new file mode 100644
index 00000000000..6fd051f9dae
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.ts
@@ -0,0 +1,195 @@
+/**
+ * @copyright Copyright (c) 2024 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * 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
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { emit } from '@nextcloud/event-bus'
+import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
+import { openConflictPicker } from '@nextcloud/upload'
+import { showError, showInfo } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+import logger from '../logger.js'
+
+/**
+ * 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.
+ */
+export class Directory extends File {
+
+ /* eslint-disable no-use-before-define */
+ _contents: (Directory|File)[]
+
+ 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)
+ }
+
+ /**
+ * 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)
+ }
+
+ /**
+ * 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)
+ }
+
+}
+
+export type RootDirectory = Directory & {
+ name: 'root'
+}
+
+/**
+ * Traverse a file tree using the Filesystem API
+ * @param entry the entry to traverse
+ */
+export 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
+ */
+const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
+ const dirReader = directory.createReader()
+
+ return new Promise<FileSystemEntry[]>((resolve, reject) => {
+ const entries = [] as FileSystemEntry[]
+ const getEntries = () => {
+ dirReader.readEntries((results) => {
+ if (results.length) {
+ entries.push(...results)
+ getEntries()
+ } else {
+ resolve(entries)
+ }
+ }, (error) => {
+ reject(error)
+ })
+ }
+
+ getEntries()
+ })
+}
+
+export 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))
+ }
+}
+
+export 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 []
+}