aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-10-29 21:58:20 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2024-10-29 22:58:39 +0100
commit23553ff0f27c11446de8944a3d8bfd9135613a16 (patch)
tree9f05deff573db5ef851c1db0603b9f2097dfe443
parent2d5060d1e3161aadfafb11d3690bc337b9092d31 (diff)
downloadnextcloud-server-refactor/drop-to-uploader.tar.gz
nextcloud-server-refactor/drop-to-uploader.zip
refactor(files): Use `@nextcloud/upload` for file drop handlingrefactor/drop-to-uploader
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r--apps/files/src/components/BreadCrumbs.vue36
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue26
-rw-r--r--apps/files/src/components/FileEntryMixin.ts47
-rw-r--r--apps/files/src/services/DropService.ts143
-rw-r--r--apps/files/src/services/DropServiceUtils.ts168
5 files changed, 112 insertions, 308 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index c423b698d40..c35d4fc34b3 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -47,7 +47,7 @@ import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import { useNavigation } from '../composables/useNavigation'
-import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+import { onDropInternalFiles, onDropExternalFiles } from '../services/DropService'
import { showError } from '@nextcloud/dialogs'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
@@ -222,14 +222,23 @@ export default defineComponent({
// dragover state on the DragAndDropNotice component.
event.preventDefault()
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (event.button !== 0) {
+ return
+ }
+
// 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)
+ // Check if we are uploading files
+ if (items.find((item) => item.kind === 'file') !== undefined) {
+ await onDropExternalFiles(items)
+ return
+ }
+ // Else we're moving/copying files
// We might not have the target directory fetched yet
const contents = await this.currentView?.getContents(path)
const folder = contents?.folder
@@ -238,24 +247,13 @@ export default defineComponent({
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)
+ const canDrop = Boolean(folder.permissions & Permission.CREATE)
+ if (!canDrop) {
return
}
- // Else we're moving/copying files
+ logger.debug('Dropped', { event, folder, selection })
+ const isCopy = event.ctrlKey
const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 23ebf7cd296..19fe8b6923d 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -38,7 +38,7 @@ import debounce from 'debounce'
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
import { useNavigation } from '../composables/useNavigation'
-import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+import { onDropExternalFiles } from '../services/DropService'
import logger from '../logger.ts'
import type { RawLocation } from 'vue-router'
@@ -168,38 +168,24 @@ export default defineComponent({
event.preventDefault()
event.stopPropagation()
- // 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 })
+ logger.debug('Dropped', { event })
- // Check whether we're uploading files
- const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
+ // Caching the selection
+ const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
+ const uploads = await onDropExternalFiles(items)
// 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)
+ && upload.source.replace(this.currentFolder.source, '').split('/').length === 2)
if (lastUpload !== undefined) {
logger.debug('Scrolling to last upload in current folder', { lastUpload })
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index e5d6a27e96c..7ad5229c933 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -18,9 +18,10 @@ 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 { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
+import { onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import logger from '../logger.ts'
import { isDownloadable } from '../utils/permissions.ts'
+import { useLink } from 'vue-router/composables'
Vue.directive('onClickOutside', vOnClickOutside)
@@ -337,15 +338,15 @@ export default defineComponent({
onDragOver(event: DragEvent) {
this.dragover = this.canDrop
- if (!this.canDrop) {
+ if (!this.canDrop && event.dataTransfer) {
event.dataTransfer.dropEffect = 'none'
return
}
// Handle copy/move drag and drop
- if (event.ctrlKey) {
+ if (event.ctrlKey && event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
- } else {
+ } else if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
},
@@ -405,40 +406,36 @@ export default defineComponent({
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) {
+ return
+ }
+
// 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
- }
+ logger.debug('Dropped', { event, selection })
- // If another button is pressed, cancel it. This
- // allows cancelling the drag with the right click.
- if (!this.canDrop || event.button) {
+ // Check whether we're uploading files
+ if (items.find((item) => item.kind === 'file') !== undefined) {
+ await onDropExternalFiles(items)
+ this.dragover = false
return
}
- const isCopy = event.ctrlKey
+ // Else we are copying / moving files
this.dragover = false
- logger.debug('Dropped', { event, folder, selection, fileTree })
-
- // Check whether we're uploading files
- if (fileTree.contents.length > 0) {
- await onDropExternalFiles(fileTree, folder, contents.contents)
+ 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
}
- // Else we're moving/copying files
+ const isCopy = event.ctrlKey
const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
index 1013baeda6c..58d8239deb1 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -4,17 +4,14 @@
*/
import type { Upload } from '@nextcloud/upload'
-import type { RootDirectory } from './DropServiceUtils'
-import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
+import { Folder, Node, NodeStatus } from '@nextcloud/files'
import { getUploader, hasConflict } from '@nextcloud/upload'
-import { join } from 'path'
-import { joinPaths } from '@nextcloud/paths'
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 { resolveConflict } from './DropServiceUtils'
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
import logger from '../logger.ts'
@@ -28,7 +25,7 @@ import logger from '../logger.ts'
*
* @param items the list of DataTransferItems
*/
-export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
+export async function onDropExternalFiles(items: DataTransferItem[]): Promise<Upload[]> {
// 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
@@ -41,125 +38,39 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise
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) {
- 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
+ return (item as unknown as DataTransferItem & { getAsEntry?: () => FileSystemEntry|undefined }).getAsEntry?.()
+ ?? item.webkitGetAsEntry?.()
+ ?? item.getAsFile()
+ }).filter((item: FileSystemEntry | File | null) => {
+ if (item === null) {
+ showWarning(t('files', 'One of the dropped files could not be processed'))
+ return false
}
+ return true
+ }) as (FileSystemEntry | File)[]
- 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 for conflicts on root elements
- if (await hasConflict(root.contents, contents)) {
- root.contents = await resolveConflict(root.contents, destination, contents)
- }
-
- if (root.contents.length === 0) {
- logger.info('No files to upload', { root })
- showInfo(t('files', 'No files to upload'))
+ if (entries.length === 0) {
+ logger.info('No valid files were dropped.')
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()
-
- // Wait for all promises to settle
- const results = await Promise.allSettled(queue)
+ // Get the uploader and start the batch upload
+ const uploader = getUploader()
- // Check for errors
- const errors = results.filter(result => result.status === 'rejected')
- if (errors.length > 0) {
- logger.error('Error while uploading files', { errors })
+ try {
+ uploader.pause()
+ const promise = uploader.batchUpload('', entries)
+ uploader.start()
+
+ const uploads = await promise
+ logger.debug('Files uploaded successfully', { uploads })
+ showSuccess(t('files', 'Files uploaded successfully'))
+ return uploads
+ } catch (error) {
+ logger.error('Error while uploading files', { error })
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)
}
export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts
index f10a09cfe27..8b6ba2bb71e 100644
--- a/apps/files/src/services/DropServiceUtils.ts
+++ b/apps/files/src/services/DropServiceUtils.ts
@@ -5,155 +5,68 @@
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 { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode, getNavigation, Permission } from '@nextcloud/files'
+import { getConflicts, openConflictPicker, type IDirectory } from '@nextcloud/upload'
import { showError, showInfo } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
-
import logger from '../logger.ts'
-/**
- * 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 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 type RootDirectory = Directory & {
- name: 'root'
}
/**
- * Traverse a file tree using the Filesystem API
- * @param entry the entry to traverse
+ * Helper function to resolve conflicts when using batchUpload from `@nextcloud/upload`
+ * @param files Files that are going to be uploaded
+ * @param currentPath The path where the files are uploaded to
*/
-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)
- })
+export async function resolveUploadConflicts(files: Array<IDirectory|File>, currentPath: string): Promise<Array<IDirectory|File>> {
+ const view = getNavigation().active!
+ try {
+ const { contents, folder } = await view.getContents(currentPath)
+ return await resolveConflict(files, folder, contents)
+ } catch (error) {
+ // If the folder does not exist then we can upload everything
+ logger.debug('Could not fetch folder with contents.', { error, currentPath })
}
-
- // 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)
+ return files
}
/**
- * Read a directory using Filesystem API
- * @param directory the directory to read
+ * Resolve conflicts on dropping files
+ * @param files Files to be uploaded
+ * @param destination The current folder to upload to
+ * @param contents The content of the folder
*/
-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 async function resolveConflict<T extends((IDirectory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> {
+ // No permissions
+ if (!(destination.permissions & Permission.CREATE)) {
+ return []
}
-}
-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)[]
+ const conflicts = getConflicts(files, contents)
+ // No conflicts thus upload all
+ if (conflicts.length === 0) {
+ return files
+ }
// List of incoming files that are NOT in conflict
const uploads = files.filter((file: File|Node) => {
- return !conflicts.includes(file)
+ return !(conflicts as unknown[]).includes(file)
})
+ logger.debug('Starting conflict resolution', { path: destination.path, conflicts, contents })
+
// Let the user choose what to do with the conflicting files
- const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
+ const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents, { recursive: true })
logger.debug('Conflict resolution', { uploads, selected, renamed })
@@ -168,10 +81,9 @@ export const resolveConflict = async <T extends ((Directory|File)|Node)>(files:
// Update the list of files to upload
return [...uploads, ...selected, ...renamed] as (typeof files)
} catch (error) {
- console.error(error)
+ logger.error('User cancelled the upload', { error })
// User cancelled
showError(t('files', 'Upload cancelled'))
- logger.error('User cancelled the upload')
}
return []