Skip to content

Commit

Permalink
feat(files): Migrate drop handling to Uploader functions
Browse files Browse the repository at this point in the history
So upload and drag-and-drop use the same API now.
Also this de-duplicates some code by providing a shared directive for
elements that support dropping files.

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Nov 19, 2024
1 parent 078a6bd commit 7b32979
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 475 deletions.
75 changes: 75 additions & 0 deletions apps/files/src/directives/vFilesDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { IFolder, INode } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
import type { DirectiveHook } from 'vue'
import type { VNode } from 'vue/types/umd'
import {
onDropExternalFiles,
onDropInternalFiles,
} from '../services/DropService'
import { useDragAndDropStore } from '../store/dragging'
import { useFilesStore } from '../store/files'

interface OnFileDropProperties {
disabled?: boolean,
/** Folder where to upload */
targetFolder: IFolder
/** Optional callback called after uploading files - even if disabled */
callback?: (uploads: INode[]|Upload[]) => void
}

/**
* Vue directive to handle uploading files on drop events.
*
* @param el The element where to bound to
* @param bindings Directive bindings
* @param bindings.modifiers Modifiers used on the component - e.g. ".stop"
* @param bindings.value The value passed through the component
*/
const onFileDrop: DirectiveHook<HTMLElement, VNode | null, OnFileDropProperties> = function(
el,
{
modifiers,
value: options,
},
) {
// We need to use `ondrop` instead of addEventListener as we have no reference to previous
// event listener to remove it from the component
el.ondrop = async (event: DragEvent) => {
// Stop the event if called with "v-on-file-drop.stop"
if (modifiers.stop) {
event.stopPropagation()
}
// Prevent default drop behavior if called with "v-on-file-drop.prevent"
if (modifiers.prevent) {
event.preventDefault()
}
// Skip any drop handling if disabled or aborted (right click)
if (options.disabled || event.button > 0) {
return options.callback?.([])
}

let result: INode[]|Upload[] = []
const draggingStore = useDragAndDropStore()
if (draggingStore.isDragging) {
// Internal files are being dragged
const filesStore = useFilesStore()
const nodes = filesStore.getNodes(draggingStore.dragging)
await onDropInternalFiles(
nodes,
options.targetFolder,
event.ctrlKey,
)
result = nodes
} else if (event.dataTransfer) {
const uploads = await onDropExternalFiles(
event.dataTransfer,
options.targetFolder,
)
result = uploads
}

return options.callback?.(result)
}
}

export default onFileDrop
281 changes: 127 additions & 154 deletions apps/files/src/services/DropService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,172 +2,73 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Upload } from '@nextcloud/upload'
import type { RootDirectory } from './DropServiceUtils'

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, 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 type { IFolder, INode } from '@nextcloud/files'
import { Folder, getNavigation, Node, NodeStatus } from '@nextcloud/files'
import { showError, showInfo, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { getUploader, hasConflict, openConflictPicker, Upload, uploadConflictHandler } from '@nextcloud/upload'
import { relative } from 'path'
import { useFilesStore } from '../store/files.ts'
import { handleCopyMoveNode } from './MoveOrCopyService.ts'
import { MoveCopyAction } from '../types.ts'
import logger from '../logger.ts'
import Vue from 'vue'

/**
* 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.
* Resolve conflicts when dropping nodes.
*
* @param items the list of DataTransferItems
* @param files Files to move or upload
* @param destination Destination folder
* @param contents Content of the destination
*/
export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
// Check if the browser supports the Filesystem API
// We need to cache the entries to prevent Blink engine bug that clears
// the list (`data.items`) after first access props of one of the entries
const entries = items
.filter((item) => {
if (item.kind !== 'file') {
logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
return false
}
return true
}).map((item) => {
// MDN recommends to try both, as it might be renamed in the future
return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
?? item?.webkitGetAsEntry?.()
?? item
}) as (FileSystemEntry | DataTransferItem)[]

let warned = false
const fileTree = new Directory('root') as RootDirectory

// Traverse the file tree
for (const entry of entries) {
// Handle browser issues if Filesystem API is not available. Fallback to File API
if (entry instanceof DataTransferItem) {
logger.warn('Could not get FilesystemEntry of item, falling back to file')

const file = entry.getAsFile()
if (file === null) {
logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
showError(t('files', 'One of the dropped files could not be processed'))
continue
}

// Warn the user that the browser does not support the Filesystem API
// we therefore cannot upload directories recursively.
if (file.type === 'httpd/unix-directory' || !file.type) {
if (!warned) {
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 })
export async function resolveConflict<T extends INode>(files: T[], destination: Folder, contents: INode[]): Promise<T[]> {
try {
// List all conflicting files
const conflicts = files.filter((file: INode) => {
return contents.find((node: INode) => node.basename === (file instanceof File ? file.name : file.basename))
}).filter(Boolean)

// List of incoming files that are NOT in conflict
const uploads = files.filter((file: T) => {
return !conflicts.includes(file)
})

// Let the user choose what to do with the conflicting files
const { selected, renamed } = await openConflictPicker(destination.path, conflicts as unknown as Node[], contents as Node[])

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 []
}
}

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'))
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)

// 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 []
// 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')
}

logger.debug('Files uploaded successfully')
showSuccess(t('files', 'Files uploaded successfully'))

return Promise.all(queue)
return []
}

export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
const queue = [] as Promise<void>[]

/**
* Handle drop of internal files (e.g. drag and drop a file within the web ui)
* @param nodes The nodes that were dragged
* @param destination The destination where the files where dropped
* @param isCopy True if the nodes should be copied rather than moved
*/
export async function onDropInternalFiles(nodes: INode[], destination: IFolder, isCopy = false) {
const contents = await getContent(destination.path)
// Check for conflicts on root elements
if (await hasConflict(nodes, contents)) {
nodes = await resolveConflict(nodes, destination, contents)
if (await hasConflict(nodes as Node[], contents)) {
nodes = await resolveConflict(nodes, destination as Folder, contents)
}

if (nodes.length === 0) {
Expand All @@ -176,13 +77,16 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co
return
}

const promises: Promise<void>[] = []
for (const node of nodes) {
Vue.set(node, 'status', NodeStatus.LOADING)
queue.push(handleCopyMoveNode(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
promises.push(
handleCopyMoveNode(node as Node, destination as Folder, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true),
)
}

// Wait for all promises to settle
const results = await Promise.allSettled(queue)
const results = await Promise.allSettled(promises)
nodes.forEach(node => Vue.set(node, 'status', undefined))

// Check for errors
Expand All @@ -196,3 +100,72 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co
logger.debug('Files copy/move successful')
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
}

// MDN recommends to also try `getAsEntry` so we extend the interface with it
interface DataTransferItemFutureSafe extends DataTransferItem {
webkitGetAsEntry(): FileSystemEntry | null,
getAsEntry?(): FileSystemEntry | null
}

/**
* This is a typescript helper function that asserts that a passed value is not null.
* Helpful for passing as callbacks to automatically infer types for `.filter` functions.
* @param value The value to check
*/
function isNotNull<T>(value: T|null): value is T {
return value !== null
}

/**
* Helper function to either use cached files or fetch directory content from server
*/
async function getContent(path: string) {
const store = useFilesStore()
const view = getNavigation().active!
const nodes = store.getNodesByPath(view.id, path)
if (nodes.length > 0) {
return nodes
}

try {
const { contents } = await view.getContents(path)
return contents
} catch (error) {
logger.error('Error while fetch directory content', { error })
return []
}
}

export async function onDropExternalFiles(dataTransfer: DataTransfer, targetFolder: IFolder): Promise<Upload[]> {
const items = Array.from(dataTransfer.items)

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) => (
(item as unknown as DataTransferItemFutureSafe).getAsEntry?.()
?? item.webkitGetAsEntry?.()
?? item.getAsFile()
)).filter(isNotNull)

logger.debug(`Uploading files to ${targetFolder.path}`, { entries })

try {
const uploader = getUploader()
// Create a recursive conflict handler
const conflictHandler = uploadConflictHandler((path: string) => (
// Make the relative path absolute to the current view
getContent(`${targetFolder.path}${path}`)
))

const targetPath = relative(uploader.destination.path, targetFolder.path)
return await uploader.batchUpload(targetPath, entries, conflictHandler)
} catch (error) {
logger.error('Failed to upload dropped files', { error })
return []
}
}
Loading

0 comments on commit 7b32979

Please sign in to comment.