mirror of
https://github.com/nextcloud/server.git
synced 2026-04-21 22:27:31 -04:00
fix(files): improve handling of copy-move action
1. only show 1 loading toast instead of N for N files in batch operation. 2. Reuse more code to reduce duplicated logic. 3. Show the conflict picker once for all files instead of opening a new conflict picker for every file to copy / move. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
7536ddc4a0
commit
cf76fa5213
4 changed files with 258 additions and 281 deletions
|
|
@ -2,17 +2,18 @@
|
|||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { Folder, Node, View } from '@nextcloud/files'
|
||||
import type { Folder, IFolder, INode, Node, View } from '@nextcloud/files'
|
||||
import type { IFilePickerButton } from '@nextcloud/dialogs'
|
||||
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
|
||||
import type { MoveCopyResult } from './moveOrCopyActionUtils'
|
||||
|
||||
import { isAxiosError } from '@nextcloud/axios'
|
||||
import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
|
||||
import { FilePickerClosed, getFilePickerBuilder, openConflictPicker, showError, showLoading } from '@nextcloud/dialogs'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { openConflictPicker, hasConflict } from '@nextcloud/upload'
|
||||
import { FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files'
|
||||
import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { getConflicts } from '@nextcloud/upload'
|
||||
import { basename, join } from 'path'
|
||||
import Vue from 'vue'
|
||||
|
||||
|
|
@ -23,6 +24,202 @@ import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUt
|
|||
import { getContents } from '../services/Files'
|
||||
import logger from '../logger'
|
||||
|
||||
/**
|
||||
* Exception to hint the user about something.
|
||||
* The message is intended to be shown to the user.
|
||||
*/
|
||||
export class HintException extends Error {}
|
||||
|
||||
export const ACTION_COPY_MOVE = 'move-copy'
|
||||
|
||||
export const action = new FileAction({
|
||||
id: ACTION_COPY_MOVE,
|
||||
order: 15,
|
||||
displayName(nodes: Node[]) {
|
||||
switch (getActionForNodes(nodes)) {
|
||||
case MoveCopyAction.MOVE:
|
||||
return t('files', 'Move')
|
||||
case MoveCopyAction.COPY:
|
||||
return t('files', 'Copy')
|
||||
case MoveCopyAction.MOVE_OR_COPY:
|
||||
return t('files', 'Move or copy')
|
||||
}
|
||||
},
|
||||
iconSvgInline: () => FolderMoveSvg,
|
||||
enabled(nodes: Node[], view: View): boolean {
|
||||
// We can not copy or move in single file shares
|
||||
if (view.id === 'public-file-share') {
|
||||
return false
|
||||
}
|
||||
// We only support moving/copying files within the user folder
|
||||
if (!nodes.every((node) => node.root?.startsWith('/files/'))) {
|
||||
return false
|
||||
}
|
||||
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
|
||||
},
|
||||
|
||||
async exec(node, view, dir) {
|
||||
return (await this.execBatch!([node], view, dir))[0]
|
||||
},
|
||||
|
||||
async execBatch(nodes, view, path) {
|
||||
const action = getActionForNodes(nodes)
|
||||
const target = await openFilePickerForAction(action, path, nodes)
|
||||
// Handle cancellation silently
|
||||
if (target === false) {
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Array.fromAsync(handleCopyMoveNodesTo(nodes, target.destination, target.action))
|
||||
return result.map(() => true)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${target.action} node`, { nodes, error })
|
||||
if (error instanceof HintException && !!error.message) {
|
||||
showError(error.message)
|
||||
// Silent action as we handle the toast
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
// We need to keep the selection on error!
|
||||
// So we do not return null, and for batch action
|
||||
return nodes.map(() => false)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle the copy/move of a node to a destination
|
||||
* This can be imported and used by other scripts/components on server
|
||||
*
|
||||
* @param nodes The nodes to copy/move
|
||||
* @param destination The destination to copy/move the nodes to
|
||||
* @param method The method to use for the copy/move
|
||||
* @param overwrite Whether to overwrite the destination if it exists
|
||||
* @yields {AsyncGenerator<void, void, never>} A promise that resolves when the copy/move is done
|
||||
*/
|
||||
export async function * handleCopyMoveNodesTo(nodes: INode[], destination: IFolder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false): AsyncGenerator<void, void, never> {
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
if (destination.type !== FileType.Folder) {
|
||||
throw new Error(t('files', 'Destination is not a folder'))
|
||||
}
|
||||
|
||||
// Do not allow to MOVE a node to the same folder it is already located
|
||||
if (method === MoveCopyAction.MOVE && nodes.some((node) => node.dirname === destination.path)) {
|
||||
throw new Error(t('files', 'This file/folder is already in that directory'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Example:
|
||||
* - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
|
||||
* Allow move of /foo does not start with /foo/bar/file.txt so allow
|
||||
* - node: /foo , destination: /foo/bar
|
||||
* Do not allow as it would copy foo within itself
|
||||
* - node: /foo/bar.txt, destination: /foo
|
||||
* Allow copy a file to the same directory
|
||||
* - node: "/foo/bar", destination: "/foo/bar 1"
|
||||
* Allow to move or copy but we need to check with trailing / otherwise it would report false positive
|
||||
*/
|
||||
if (nodes.some((node) => `${destination.path}/`.startsWith(`${node.path}/`))) {
|
||||
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
|
||||
}
|
||||
|
||||
const nameMapping = new Map<string, string>()
|
||||
// Check for conflicts if we do not want to overwrite
|
||||
if (!overwrite) {
|
||||
const otherNodes = (await getContents(destination.path)).contents
|
||||
const conflicts = getConflicts(nodes, otherNodes) as unknown as INode[]
|
||||
const nodesToRename: INode[] = []
|
||||
if (conflicts.length > 0) {
|
||||
if (method === MoveCopyAction.MOVE) {
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const content = otherNodes.filter((n) => conflicts.some((c) => c.basename === n.basename))
|
||||
const result = await openConflictPicker(destination.path, conflicts, content)
|
||||
if (!result) {
|
||||
// User cancelled
|
||||
return
|
||||
}
|
||||
|
||||
nodes = nodes.filter((n) => !result.skipped.includes(n as never))
|
||||
nodesToRename.push(...(result.renamed as unknown as INode[]))
|
||||
} else {
|
||||
// for COPY we always rename conflicting files
|
||||
nodesToRename.push(...conflicts)
|
||||
}
|
||||
|
||||
const usedNames = [...otherNodes, ...nodes.filter((n) => !conflicts.includes(n))].map((n) => n.basename)
|
||||
for (const node of nodesToRename) {
|
||||
const newName = getUniqueName(node.basename, usedNames, { ignoreFileExtension: node.type === FileType.Folder })
|
||||
nameMapping.set(node.source, newName)
|
||||
usedNames.push(newName) // add the new name to avoid duplicates for following re-namimgs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionFinished = createLoadingNotification(method, nodes.map((node) => node.basename), destination.path)
|
||||
const queue = getQueue()
|
||||
try {
|
||||
for (const node of nodes) {
|
||||
// Set loading state
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
yield queue.add(async () => {
|
||||
try {
|
||||
const client = getClient()
|
||||
|
||||
const currentPath = join(defaultRootPath, node.path)
|
||||
const destinationPath = join(defaultRootPath, destination.path, nameMapping.get(node.source) ?? node.basename)
|
||||
|
||||
if (method === MoveCopyAction.COPY) {
|
||||
await client.copyFile(currentPath, destinationPath)
|
||||
// If the node is copied into current directory the view needs to be updated
|
||||
if (node.dirname === destination.path) {
|
||||
const { data } = await client.stat(
|
||||
destinationPath,
|
||||
{
|
||||
details: true,
|
||||
data: getDefaultPropfind(),
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', resultToNode(data))
|
||||
}
|
||||
} else {
|
||||
await client.moveFile(currentPath, destinationPath)
|
||||
// Delete the node as it will be fetched again
|
||||
// when navigating to the destination folder
|
||||
emit('files:node:deleted', node)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error while trying to ${method === MoveCopyAction.COPY ? 'copy' : 'move'} node`, { node, error })
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 412) {
|
||||
throw new HintException(t('files', 'A file or folder with that name already exists in this folder'))
|
||||
} else if (error.response?.status === 423) {
|
||||
throw new HintException(t('files', 'The files are locked'))
|
||||
} else if (error.response?.status === 404) {
|
||||
throw new HintException(t('files', 'The file does not exist anymore'))
|
||||
} else if ('response' in error && error.response) {
|
||||
const parser = new DOMParser()
|
||||
const text = await (error as WebDAVClientError).response!.text()
|
||||
const message = parser.parseFromString(text ?? '', 'text/xml')
|
||||
.querySelector('message')?.textContent
|
||||
if (message) {
|
||||
throw new HintException(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
Vue.set(node, 'status', undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
actionFinished()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action that is possible for the given nodes
|
||||
* @param {Node[]} nodes The nodes to check against
|
||||
|
|
@ -43,164 +240,25 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
|
|||
/**
|
||||
* Create a loading notification toast
|
||||
* @param mode The move or copy mode
|
||||
* @param source Name of the node that is copied / moved
|
||||
* @param sources Names of the nodes that are copied / moved
|
||||
* @param destination Destination path
|
||||
* @return {() => void} Function to hide the notification
|
||||
*/
|
||||
function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
|
||||
const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
|
||||
function createLoadingNotification(mode: MoveCopyAction, sources: string[], destination: string): () => void {
|
||||
const text = mode === MoveCopyAction.MOVE
|
||||
? (sources.length === 1
|
||||
? t('files', 'Moving "{source}" to "{destination}" …', { source: sources[0], destination })
|
||||
: t('files', 'Moving {count} files to "{destination}" …', { count: sources.length, destination })
|
||||
)
|
||||
: (sources.length === 1
|
||||
? t('files', 'Copying "{source}" to "{destination}" …', { source: sources[0], destination })
|
||||
: t('files', 'Copying {count} files to "{destination}" …', { count: sources.length, destination })
|
||||
)
|
||||
|
||||
let toast: ReturnType<typeof showInfo>|undefined
|
||||
toast = showInfo(
|
||||
`<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
|
||||
{
|
||||
isHTML: true,
|
||||
timeout: TOAST_PERMANENT_TIMEOUT,
|
||||
onRemove: () => { toast?.hideToast(); toast = undefined },
|
||||
},
|
||||
)
|
||||
const toast = showLoading(text)
|
||||
return () => toast && toast.hideToast()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the copy/move of a node to a destination
|
||||
* This can be imported and used by other scripts/components on server
|
||||
* @param {Node} node The node to copy/move
|
||||
* @param {Folder} destination The destination to copy/move the node to
|
||||
* @param {MoveCopyAction} method The method to use for the copy/move
|
||||
* @param {boolean} overwrite Whether to overwrite the destination if it exists
|
||||
* @return {Promise<void>} A promise that resolves when the copy/move is done
|
||||
*/
|
||||
export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
if (destination.type !== FileType.Folder) {
|
||||
throw new Error(t('files', 'Destination is not a folder'))
|
||||
}
|
||||
|
||||
// Do not allow to MOVE a node to the same folder it is already located
|
||||
if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
|
||||
throw new Error(t('files', 'This file/folder is already in that directory'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Example:
|
||||
* - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
|
||||
* Allow move of /foo does not start with /foo/bar/file.txt so allow
|
||||
* - node: /foo , destination: /foo/bar
|
||||
* Do not allow as it would copy foo within itself
|
||||
* - node: /foo/bar.txt, destination: /foo
|
||||
* Allow copy a file to the same directory
|
||||
* - node: "/foo/bar", destination: "/foo/bar 1"
|
||||
* Allow to move or copy but we need to check with trailing / otherwise it would report false positive
|
||||
*/
|
||||
if (`${destination.path}/`.startsWith(`${node.path}/`)) {
|
||||
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
const actionFinished = createLoadingNotification(method, node.basename, destination.path)
|
||||
|
||||
const queue = getQueue()
|
||||
return await queue.add(async () => {
|
||||
const copySuffix = (index: number) => {
|
||||
if (index === 1) {
|
||||
return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
|
||||
}
|
||||
return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
|
||||
}
|
||||
|
||||
try {
|
||||
const client = davGetClient()
|
||||
const currentPath = join(davRootPath, node.path)
|
||||
const destinationPath = join(davRootPath, destination.path)
|
||||
|
||||
if (method === MoveCopyAction.COPY) {
|
||||
let target = node.basename
|
||||
// If we do not allow overwriting then find an unique name
|
||||
if (!overwrite) {
|
||||
const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
|
||||
target = getUniqueName(
|
||||
node.basename,
|
||||
otherNodes.map((n) => n.basename),
|
||||
{
|
||||
suffix: copySuffix,
|
||||
ignoreFileExtension: node.type === FileType.Folder,
|
||||
},
|
||||
)
|
||||
}
|
||||
await client.copyFile(currentPath, join(destinationPath, target))
|
||||
// If the node is copied into current directory the view needs to be updated
|
||||
if (node.dirname === destination.path) {
|
||||
const { data } = await client.stat(
|
||||
join(destinationPath, target),
|
||||
{
|
||||
details: true,
|
||||
data: davGetDefaultPropfind(),
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', davResultToNode(data))
|
||||
}
|
||||
} else {
|
||||
// show conflict file popup if we do not allow overwriting
|
||||
if (!overwrite) {
|
||||
const otherNodes = await getContents(destination.path)
|
||||
if (hasConflict([node], otherNodes.contents)) {
|
||||
try {
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
|
||||
// two empty arrays: either only old files or conflict skipped -> no action required
|
||||
if (!selected.length && !renamed.length) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
// User cancelled
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// getting here means either no conflict, file was renamed to keep both files
|
||||
// in a conflict, or the selected file was chosen to be kept during the conflict
|
||||
try {
|
||||
await client.moveFile(currentPath, join(destinationPath, node.basename))
|
||||
} catch (error) {
|
||||
const parser = new DOMParser()
|
||||
const text = await (error as WebDAVClientError).response?.text()
|
||||
const message = parser.parseFromString(text ?? '', 'text/xml')
|
||||
.querySelector('message')?.textContent
|
||||
if (message) {
|
||||
showError(message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Delete the node as it will be fetched again
|
||||
// when navigating to the destination folder
|
||||
emit('files:node:deleted', node)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 412) {
|
||||
throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
|
||||
} else if (error.response?.status === 423) {
|
||||
throw new Error(t('files', 'The files are locked'))
|
||||
} else if (error.response?.status === 404) {
|
||||
throw new Error(t('files', 'The file does not exist anymore'))
|
||||
} else if (error.message) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
logger.debug(error as Error)
|
||||
throw new Error()
|
||||
} finally {
|
||||
Vue.set(node, 'status', '')
|
||||
actionFinished()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file picker for the given action
|
||||
* @param action The action to open the file picker for
|
||||
|
|
@ -295,82 +353,3 @@ async function openFilePickerForAction(
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
export const ACTION_COPY_MOVE = 'move-copy'
|
||||
export const action = new FileAction({
|
||||
id: ACTION_COPY_MOVE,
|
||||
displayName(nodes: Node[]) {
|
||||
switch (getActionForNodes(nodes)) {
|
||||
case MoveCopyAction.MOVE:
|
||||
return t('files', 'Move')
|
||||
case MoveCopyAction.COPY:
|
||||
return t('files', 'Copy')
|
||||
case MoveCopyAction.MOVE_OR_COPY:
|
||||
return t('files', 'Move or copy')
|
||||
}
|
||||
},
|
||||
iconSvgInline: () => FolderMoveSvg,
|
||||
enabled(nodes: Node[], view: View) {
|
||||
// We can not copy or move in single file shares
|
||||
if (view.id === 'public-file-share') {
|
||||
return false
|
||||
}
|
||||
// We only support moving/copying files within the user folder
|
||||
if (!nodes.every(node => node.root?.startsWith('/files/'))) {
|
||||
return false
|
||||
}
|
||||
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
|
||||
},
|
||||
|
||||
async exec(node: Node, view: View, dir: string) {
|
||||
const action = getActionForNodes([node])
|
||||
let result
|
||||
try {
|
||||
result = await openFilePickerForAction(action, dir, [node])
|
||||
} catch (e) {
|
||||
logger.error(e as Error)
|
||||
return false
|
||||
}
|
||||
if (result === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
await handleCopyMoveNodeTo(node, result.destination, result.action)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !!error.message) {
|
||||
showError(error.message)
|
||||
// Silent action as we handle the toast
|
||||
return null
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
async execBatch(nodes: Node[], view: View, dir: string) {
|
||||
const action = getActionForNodes(nodes)
|
||||
const result = await openFilePickerForAction(action, dir, nodes)
|
||||
// Handle cancellation silently
|
||||
if (result === false) {
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
|
||||
const promises = nodes.map(async node => {
|
||||
try {
|
||||
await handleCopyMoveNodeTo(node, result.destination, result.action)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${result.action} node`, { node, error })
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// We need to keep the selection on error!
|
||||
// So we do not return null, and for batch action
|
||||
// we let the front handle the error.
|
||||
return await Promise.all(promises)
|
||||
},
|
||||
|
||||
order: 15,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,21 +3,19 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Folder, IFolder, INode } from '@nextcloud/files'
|
||||
import type { Upload } from '@nextcloud/upload'
|
||||
import type { RootDirectory } from './DropServiceUtils'
|
||||
import type { RootDirectory } from './DropServiceUtils.ts'
|
||||
|
||||
import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
|
||||
import { getUploader, hasConflict } from '@nextcloud/upload'
|
||||
import { join } from 'path'
|
||||
import { joinPaths } from '@nextcloud/paths'
|
||||
import { createDirectoryIfNotExists, Directory, resolveConflict, traverseTree } from './DropServiceUtils.ts'
|
||||
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 { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { join } from '@nextcloud/paths'
|
||||
import { getUploader, hasConflict } from '@nextcloud/upload'
|
||||
import { handleCopyMoveNodesTo, HintException } from '../actions/moveOrCopyAction.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { defaultRootPath } from '@nextcloud/files/dav'
|
||||
|
||||
/**
|
||||
* This function converts a list of DataTransferItems to a file tree.
|
||||
|
|
@ -100,7 +98,7 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold
|
|||
const uploader = getUploader()
|
||||
|
||||
// Check for conflicts on root elements
|
||||
if (await hasConflict(root.contents, contents)) {
|
||||
if (hasConflict(root.contents, contents)) {
|
||||
root.contents = await resolveConflict(root.contents, destination, contents)
|
||||
if (root.contents.length === 0) {
|
||||
// user cancelled the upload
|
||||
|
|
@ -125,7 +123,7 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold
|
|||
// 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)
|
||||
const absolutePath = join(defaultRootPath, destination.path, relativePath)
|
||||
try {
|
||||
console.debug('Processing directory', { relativePath })
|
||||
await createDirectoryIfNotExists(absolutePath)
|
||||
|
|
@ -171,11 +169,17 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold
|
|||
return Promise.all(queue)
|
||||
}
|
||||
|
||||
export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
|
||||
const queue = [] as Promise<void>[]
|
||||
|
||||
/**
|
||||
* Handle dropping internal files
|
||||
*
|
||||
* @param nodes - The nodes being dropped
|
||||
* @param destination - The destination folder
|
||||
* @param contents - The contents of the destination folder
|
||||
* @param isCopy - Whether the operation is a copy
|
||||
*/
|
||||
export async function onDropInternalFiles(nodes: INode[], destination: IFolder, contents: INode[], isCopy = false) {
|
||||
// Check for conflicts on root elements
|
||||
if (await hasConflict(nodes, contents)) {
|
||||
if (hasConflict(nodes, contents)) {
|
||||
nodes = await resolveConflict(nodes, destination, contents)
|
||||
}
|
||||
|
||||
|
|
@ -185,23 +189,17 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co
|
|||
return
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
|
||||
try {
|
||||
const promises = Array.fromAsync(handleCopyMoveNodesTo(nodes, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
|
||||
await promises
|
||||
logger.debug('Files copy/move successful')
|
||||
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
|
||||
} catch (error) {
|
||||
logger.error('Error while processing dropped files', { error })
|
||||
if (error instanceof HintException) {
|
||||
showError(error.message)
|
||||
} else {
|
||||
showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all promises to settle
|
||||
const results = await Promise.allSettled(queue)
|
||||
nodes.forEach(node => Vue.set(node, 'status', undefined))
|
||||
|
||||
// Check for errors
|
||||
const errors = results.filter(result => result.status === 'rejected')
|
||||
if (errors.length > 0) {
|
||||
logger.error('Error while copying or moving files', { errors })
|
||||
showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Files copy/move successful')
|
||||
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,23 +108,23 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
|
|||
copyFile('original.txt', '.')
|
||||
|
||||
getRowForFile('original.txt').should('be.visible')
|
||||
getRowForFile('original (copy).txt').should('be.visible')
|
||||
getRowForFile('original (1).txt').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can copy a file multiple times to same folder', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt')
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
copyFile('original.txt', '.')
|
||||
|
||||
getRowForFile('original.txt').should('be.visible')
|
||||
getRowForFile('original (copy 2).txt').should('be.visible')
|
||||
getRowForFile('original (2).txt').should('be.visible')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (copy)')
|
||||
* Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)')
|
||||
* Test for: https://github.com/nextcloud/server/issues/43843
|
||||
*/
|
||||
it('Can copy a folder to same folder', () => {
|
||||
|
|
@ -135,7 +135,7 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
|
|||
copyFile('foo.bar', '.')
|
||||
|
||||
getRowForFile('foo.bar').should('be.visible')
|
||||
getRowForFile('foo.bar (copy)').should('be.visible')
|
||||
getRowForFile('foo.bar (1)').should('be.visible')
|
||||
})
|
||||
|
||||
/** Test for https://github.com/nextcloud/server/issues/43329 */
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Copies both files when copying the .mov', () => {
|
||||
|
|
@ -64,15 +64,15 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
clickOnBreadcrumbs('All files')
|
||||
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Keeps live photo link when copying folder', () => {
|
||||
createFolder('folder')
|
||||
moveFile(`${randomFileName}.jpg`, 'folder')
|
||||
copyFile('folder', '.')
|
||||
navigateToFolder('folder (copy)')
|
||||
navigateToFolder('folder (1)')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
|
|
@ -94,7 +94,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 0)
|
||||
})
|
||||
|
||||
it('Moves files when moving the .jpg', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue