Skip to content

Commit

Permalink
fixup! feat(files): unify drag and drop methods
Browse files Browse the repository at this point in the history
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
  • Loading branch information
skjnldsv committed Mar 24, 2024
1 parent c95741c commit 5d6b689
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 87 deletions.
10 changes: 8 additions & 2 deletions apps/files/src/components/BreadCrumbs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,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
Expand Down Expand Up @@ -240,7 +246,7 @@ export default defineComponent({
// 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
Expand Down
78 changes: 46 additions & 32 deletions apps/files/src/components/DragAndDropNotice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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, handleDrop, onDropExternalFiles } from '../services/DropService'
export default defineComponent({
name: 'DragAndDropNotice',
Expand All @@ -76,6 +76,10 @@ export default defineComponent({
},
computed: {
currentView() {
return this.$navigation.active
},
/**
* Check if the current folder has create permissions
*/
Expand Down Expand Up @@ -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)
Expand All @@ -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 = [...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.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,
},
})
Expand Down
131 changes: 78 additions & 53 deletions apps/files/src/services/DropService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ type RootDirectory = Directory & {
name: 'root'
}

type Only<T, U> = {
[P in keyof T]: T[P];
} & {
[P in keyof U]?: never;
}

type Either<T, U> = Only<T, U> | Only<U, T>
type FileOrNode = Either<(Directory|File), Node>

/**
* 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.
Expand Down Expand Up @@ -210,57 +219,55 @@ const createDirectoryIfNotExists = async (absolutePath: string) => {
}
}

export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]) => {
const uploader = getUploader()
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)[]

// 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
}
// 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 })

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

// Check for conflicts on root elements
if (await hasConflict(root.contents, contents)) {
try {
// List all conflicting files
const conflicts = root.contents.filter((file: Directory|File) => {
return contents.find((node: Node) => node.basename === file.name)
}).filter(Boolean) as (Directory|File)[]

// List of incoming files that are NOT in conflict
const uploads = root.contents.filter((file: Directory|File) => {
return !conflicts.includes(file)
})
return []
}

// Let the user choose what to do with the conflicting files
const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
const uploader = getUploader()

logger.debug('Conflict resolution', { uploads, selected, renamed })
// Check for conflicts on root elements
if (await hasConflict(root.contents, contents)) {
root.contents = await resolveConflict(root.contents, destination, contents)
}

// 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')
} else {
// Update the list of files to upload
root.contents = [...uploads, ...selected, ...renamed] as (Directory|File)[]
}
} catch (error) {
console.error(error)
// User cancelled
showError(t('files', 'Upload cancelled'))
logger.error('User cancelled the upload')
return
}
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
Expand All @@ -278,7 +285,7 @@ export const onDropExternalFiles = async (root: RootDirectory, destination: Fold
if (file instanceof Directory) {
const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
try {
console.debug('Creating directory', { relativePath })
console.debug('Processing directory', { relativePath })
await createDirectoryIfNotExists(absolutePath)
await uploadDirectoryContents(file, relativePath)
} catch (error) {
Expand All @@ -289,35 +296,53 @@ export const onDropExternalFiles = async (root: RootDirectory, destination: Fold
}

// If we've reached a file, we can upload it
logger.debug('Uploading file', { path: relativePath })
queue.push(uploader.upload(relativePath, file))
logger.debug('Uploading file to ' + 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
uploadDirectoryContents(root, '/')
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
Expand Down

0 comments on commit 5d6b689

Please sign in to comment.