feat(files): unify drag and drop methods

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
pull/44409/head
skjnldsv 2024-03-22 14:39:51 +07:00
parent f28157e91b
commit 15bf34dac8
4 changed files with 355 additions and 151 deletions

@ -36,7 +36,7 @@
:aria-description="ariaForSection(section)"
@click.native="onClick(section.to)"
@dragover.native="onDragOver($event, section.dir)"
@dropped="onDrop($event, section.dir)">
@drop.native="onDrop($event, section.dir)">
<template v-if="index === 0" #icon>
<NcIconSvgWrapper :size="20"
:svg="viewIcon" />
@ -61,7 +61,7 @@ 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 { onDropExternalFiles, onDropInternalFiles } from '../services/DropService'
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
import { showError } from '@nextcloud/dialogs'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
@ -70,8 +70,6 @@ import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger'
import { debug } from '../../../../core/src/OC/debug.js'
import { F } from 'lodash/fp'
export default defineComponent({
name: 'BreadCrumbs',
@ -143,7 +141,13 @@ export default defineComponent({
// Hide breadcrumbs if an upload is ongoing
shouldShowBreadcrumbs(): boolean {
return this.filesListWidth > 400 && !this.isUploadInProgress
// If we're uploading files, only show the breadcrumbs
// if the files list is greater than 768px wide
if (this.isUploadInProgress) {
return this.filesListWidth > 768
}
// If we're not uploading, we have enough space from 400px
return this.filesListWidth > 400
},
// used to show the views icon for the first breadcrumb
@ -200,16 +204,22 @@ export default defineComponent({
async onDrop(event: DragEvent, path: string) {
// 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
}
// 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 files = event.dataTransfer?.files || new FileList()
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
event.preventDefault()
event.stopPropagation()
// 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)
@ -228,17 +238,17 @@ export default defineComponent({
return
}
logger.debug('Dropped', { event, folder, selection })
logger.debug('Dropped', { event, folder, selection, fileTree })
// Check whether we're uploading files
if (files.length > 0) {
await onDropExternalFiles(folder, 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(folder, nodes, isCopy)
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
// Reset selection after we dropped the files
// if the dropped files are within the selection

@ -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,50 @@ export default defineComponent({
event.preventDefault()
event.stopPropagation()
if (event.dataTransfer && event.dataTransfer.items.length > 0) {
// Start upload
logger.debug(`Uploading files to ${this.currentFolder.path}`)
// Process finished uploads
const uploads = await handleDrop(event.dataTransfer)
logger.debug('Upload terminated', { uploads })
if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) {
showError(t('files', 'Some files could not be uploaded'))
const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED)
logger.debug('Some files could not be uploaded', { failedUploads })
} else {
showSuccess(t('files', 'Files uploaded successfully'))
}
// Scroll to last successful upload in current directory if terminated
const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
&& !upload.file.webkitRelativePath.includes('/')
&& upload.response?.headers?.['oc-fileid'])
if (lastUpload !== undefined) {
this.$router.push({
...this.$route,
params: {
view: this.$route.params?.view ?? 'files',
fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
},
})
}
// Caching the selection
const items: 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 !== 0) {
return
}
logger.debug('Dropped', { event, folder, fileTree })
// Check whether we're uploading files
const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
// Scroll to last successful upload in current directory if terminated
const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
&& !upload.file.webkitRelativePath.includes('/')
&& upload.response?.headers?.['oc-fileid'])
if (lastUpload !== undefined) {
this.$router.push({
...this.$route,
params: {
view: this.$route.params?.view ?? 'files',
fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
},
})
}
this.dragover = false
},
t,
},
})

@ -32,8 +32,9 @@ 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 { onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import logger from '../logger.js'
import { showError } from '@nextcloud/dialogs'
Vue.directive('onClickOutside', vOnClickOutside)
@ -306,16 +307,28 @@ 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()
// Caching the selection
const selection = this.draggingFiles
const files = event.dataTransfer?.files || new FileList()
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
event.preventDefault()
event.stopPropagation()
// 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.
@ -326,17 +339,17 @@ export default defineComponent({
const isCopy = event.ctrlKey
this.dragover = false
logger.debug('Dropped', { event, selection })
logger.debug('Dropped', { event, folder, selection, fileTree })
// Check whether we're uploading files
if (files.length > 0) {
await onDropExternalFiles(this.source as Folder, 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(this.source as Folder, nodes, isCopy)
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
// Reset selection after we dropped the files
// if the dropped files are within the selection

@ -12,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.
*
@ -26,9 +26,10 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'
import { emit } from '@nextcloud/event-bus'
import { Folder, Node, NodeStatus, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
import { getUploader } from '@nextcloud/upload'
import { getUploader, hasConflict, openConflictPicker } from '@nextcloud/upload'
import { join } from 'path'
import { joinPaths } from '@nextcloud/paths'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
@ -36,95 +37,102 @@ 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
/**
* 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.
*/
class Directory extends File {
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]
.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
})
/* eslint-disable no-use-before-define */
_contents: (Directory|File)[]
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')
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))
}
} else {
logger.debug('Handle recursive upload', { entry: entry.name })
// Use Filesystem API
uploads.push(...await handleRecursiveUpload(entry))
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)
}
return uploads
}
const handleFileUpload = async (file: File, path: string = '') => {
const uploader = getUploader()
/**
* 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)
}
try {
return await uploader.upload(`${path}${file.name}`, file)
} catch (e) {
showError(t('files', 'Uploading "{filename}" failed', { filename: file.name }))
throw e
/**
* 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)
}
}
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))
}
type RootDirectory = Directory & {
name: 'root'
}
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()
/**
* Traverse a file tree using the Filesystem API
* @param entry the entry to traverse
*/
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
*/
function readDirectory(directory: FileSystemDirectoryEntry) {
const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
const dirReader = directory.createReader()
return new Promise<FileSystemEntry[]>((resolve, reject) => {
@ -146,51 +154,210 @@ function readDirectory(directory: FileSystemDirectoryEntry) {
})
}
export const onDropExternalFiles = async (destination: Folder, files: FileList) => {
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))
}
}
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 []
}
/**
* 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) => {
// 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) {
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 fileTree
}
export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
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
// Check for conflicts on root elements
if (await hasConflict(root.contents, contents)) {
root.contents = await resolveConflict(root.contents, destination, contents)
}
const previousDestination = uploader.destination
if (uploader.destination.path !== destination.path) {
logger.debug('Changing uploader destination', { previous: uploader.destination.path, new: destination.path })
uploader.destination = destination
if (root.contents.length === 0) {
logger.info('No files to upload', { root })
showInfo(t('files', 'No files to upload'))
return []
}
logger.debug(`Uploading files to ${destination.path}`)
// Let's process the files
logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
const queue = [] as Promise<Upload>[]
for (const file of 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(file.name, file))
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)
// Reset the uploader destination
uploader.destination = previousDestination
// Check for errors
const errors = results.filter(result => result.status === 'rejected')
if (errors.length > 0) {
logger.error('Error while uploading files', { errors })
showError(t('files', 'Some files could not be uploaded'))
return
return []
}
logger.debug('Files uploaded successfully')
showSuccess(t('files', 'Files uploaded successfully'))
return Promise.all(queue)
}
export const onDropInternalFiles = async (destination: Folder, nodes: Node[], isCopy = false) => {
export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
const queue = [] as Promise<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