diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-10-29 21:58:20 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-10-29 22:58:39 +0100 |
commit | 23553ff0f27c11446de8944a3d8bfd9135613a16 (patch) | |
tree | 9f05deff573db5ef851c1db0603b9f2097dfe443 | |
parent | 2d5060d1e3161aadfafb11d3690bc337b9092d31 (diff) | |
download | nextcloud-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.vue | 36 | ||||
-rw-r--r-- | apps/files/src/components/DragAndDropNotice.vue | 26 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 47 | ||||
-rw-r--r-- | apps/files/src/services/DropService.ts | 143 | ||||
-rw-r--r-- | apps/files/src/services/DropServiceUtils.ts | 168 |
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 [] |