diff options
Diffstat (limited to 'apps/files/src/actions/moveOrCopyAction.ts')
-rw-r--r-- | apps/files/src/actions/moveOrCopyAction.ts | 170 |
1 files changed, 103 insertions, 67 deletions
diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts index 61be2d946ac..06e32c98090 100644 --- a/apps/files/src/actions/moveOrCopyAction.ts +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -1,47 +1,27 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * 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 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import '@nextcloud/dialogs/style.css' import type { Folder, Node, View } from '@nextcloud/files' import type { IFilePickerButton } from '@nextcloud/dialogs' -import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav' import type { MoveCopyResult } from './moveOrCopyActionUtils' -// eslint-disable-next-line n/no-extraneous-import -import { AxiosError } from 'axios' -import { basename, join } from 'path' +import { isAxiosError } from '@nextcloud/axios' +import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs' -import { Permission, FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind } from '@nextcloud/files' +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 { basename, join } from 'path' import Vue from 'vue' -import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw' -import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' +import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' +import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils' import { getContents } from '../services/Files' import logger from '../logger' -import { getUniqueName } from '../utils/fileUtils' /** * Return the action that is possible for the given nodes @@ -61,6 +41,28 @@ 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 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 }) + + 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 }, + }, + ) + 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 @@ -100,6 +102,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth // 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 () => { @@ -143,38 +146,47 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth } } else { // show conflict file popup if we do not allow overwriting - 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) - // if the user selected to keep the old file, and did not select the new file - // that means they opted to delete the current node - if (!selected.length && !renamed.length) { - await client.deleteFile(currentPath) - emit('files:node:deleted', node) + 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 } - } catch (error) { - // User cancelled - showError(t('files', 'Move 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 - await client.moveFile(currentPath, join(destinationPath, node.basename)) + 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 (error instanceof AxiosError) { - if (error?.response?.status === 412) { + 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) { + } else if (error.response?.status === 423) { throw new Error(t('files', 'The files are locked')) - } else if (error?.response?.status === 404) { + } 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) @@ -183,34 +195,36 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth logger.debug(error as Error) throw new Error() } finally { - Vue.set(node, 'status', undefined) + Vue.set(node, 'status', '') + actionFinished() } }) } /** * Open a file picker for the given action - * @param {MoveCopyAction} action The action to open the file picker for - * @param {string} dir The directory to start the file picker in - * @param {Node[]} nodes The nodes to move/copy - * @return {Promise<MoveCopyResult>} The picked destination + * @param action The action to open the file picker for + * @param dir The directory to start the file picker in + * @param nodes The nodes to move/copy + * @return The picked destination or false if cancelled by user */ -const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: Node[]): Promise<MoveCopyResult> => { +async function openFilePickerForAction( + action: MoveCopyAction, + dir = '/', + nodes: Node[], +): Promise<MoveCopyResult | false> { + const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>() const fileIDs = nodes.map(node => node.fileid).filter(Boolean) const filePicker = getFilePickerBuilder(t('files', 'Choose destination')) .allowDirectories(true) .setFilter((n: Node) => { - // We only want to show folders that we can create nodes in - return (n.permissions & Permission.CREATE) !== 0 - // We don't want to show the current nodes in the file picker - && !fileIDs.includes(n.fileid) + // We don't want to show the current nodes in the file picker + return !fileIDs.includes(n.fileid) }) .setMimeTypeFilter([]) .setMultiSelect(false) .startAt(dir) - - return new Promise((resolve, reject) => { - filePicker.setButtonFactory((_selection, path: string) => { + .setButtonFactory((selection: Node[], path: string) => { const buttons: IFilePickerButton[] = [] const target = basename(path) @@ -222,6 +236,7 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'), type: 'primary', icon: CopyIconSvg, + disabled: selection.some((node) => (node.permissions & Permission.CREATE) === 0), async callback(destination: Node[]) { resolve({ destination: destination[0] as Folder, @@ -242,6 +257,11 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: return buttons } + if (selection.some((node) => (node.permissions & Permission.CREATE) === 0)) { + // Missing 'CREATE' permissions for selected destination + return buttons + } + if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) { buttons.push({ label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'), @@ -258,21 +278,24 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: return buttons }) + .build() - const picker = filePicker.build() - picker.pick().catch((error) => { + filePicker.pick() + .catch((error: Error) => { logger.debug(error as Error) if (error instanceof FilePickerClosed) { - reject(new Error(t('files', 'Cancelled move or copy operation'))) + resolve(false) } else { reject(new Error(t('files', 'Move or copy operation failed'))) } }) - }) + + return promise } +export const ACTION_COPY_MOVE = 'move-copy' export const action = new FileAction({ - id: 'move-copy', + id: ACTION_COPY_MOVE, displayName(nodes: Node[]) { switch (getActionForNodes(nodes)) { case MoveCopyAction.MOVE: @@ -284,7 +307,11 @@ export const action = new FileAction({ } }, iconSvgInline: () => FolderMoveSvg, - enabled(nodes: Node[]) { + 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 @@ -301,6 +328,10 @@ export const action = new FileAction({ logger.error(e as Error) return false } + if (result === false) { + return null + } + try { await handleCopyMoveNodeTo(node, result.destination, result.action) return true @@ -317,6 +348,11 @@ export const action = new FileAction({ 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) |