diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-08-24 12:16:53 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-09-26 20:15:59 +0200 |
commit | f9d2e3af0cfb087ed57c8c4a445618e0d24dde8f (patch) | |
tree | adca8f3b0f1b58c6e6fbfbadb24d5617fa079d86 /apps/files | |
parent | 16094c7db52253a0875eaab31ae820efa6c1a386 (diff) | |
download | nextcloud-server-f9d2e3af0cfb087ed57c8c4a445618e0d24dde8f.tar.gz nextcloud-server-f9d2e3af0cfb087ed57c8c4a445618e0d24dde8f.zip |
feat(files): add move or copy action
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/src/actions/moveOrCopyAction.ts | 248 | ||||
-rw-r--r-- | apps/files/src/actions/moveOrCopyActionUtils.ts | 71 | ||||
-rw-r--r-- | apps/files/src/components/DragAndDropPreview.vue | 180 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 237 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderActions.vue | 7 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableHeaderActions.vue | 8 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 11 | ||||
-rw-r--r-- | apps/files/src/components/TemplatePreview.vue | 2 | ||||
-rw-r--r-- | apps/files/src/init.ts | 2 | ||||
-rw-r--r-- | apps/files/src/store/files.ts | 7 | ||||
-rw-r--r-- | apps/files/src/store/paths.ts | 4 | ||||
-rw-r--r-- | apps/files/src/store/selection.ts | 2 | ||||
-rw-r--r-- | apps/files/src/types.ts | 1 | ||||
-rw-r--r-- | apps/files/src/utils/dragUtils.ts | 42 | ||||
-rw-r--r-- | apps/files/src/utils/fileUtils.ts (renamed from apps/files/src/utils/fileUtils.js) | 32 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 5 |
16 files changed, 722 insertions, 137 deletions
diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts new file mode 100644 index 00000000000..51113f7ba31 --- /dev/null +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -0,0 +1,248 @@ +/** + * @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/>. + * + */ +import '@nextcloud/dialogs/style.css' +import type { Folder, Node, View } from '@nextcloud/files' +import type { IFilePickerButton } from '@nextcloud/dialogs' + +// eslint-disable-next-line n/no-extraneous-import +import { AxiosError } from 'axios' +import { basename, join } from 'path' +import { emit } from '@nextcloud/event-bus' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' +import { Permission, FileAction, FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import Vue from 'vue' + +import CopyIcon from 'vue-material-design-icons/FileMultiple.vue' +import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' +import MoveIcon from 'vue-material-design-icons/FolderMove.vue' + +import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils' +import logger from '../logger' + +/** + * Return the action that is possible for the given nodes + * @param {Node[]} nodes The nodes to check against + * @return {MoveCopyAction} The action that is possible for the given nodes + */ +const getActionForNodes = (nodes: Node[]): MoveCopyAction => { + if (canMove(nodes)) { + if (canCopy(nodes)) { + return MoveCopyAction.MOVE_OR_COPY + } + return MoveCopyAction.MOVE + } + + // Assuming we can copy as the enabled checks for copy permissions + return MoveCopyAction.COPY +} + +/** + * 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')) + } + + if (node.dirname === destination.path) { + throw new Error(t('files', 'This file/folder is already in that directory')) + } + + if (node.path.startsWith(destination.path)) { + throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself')) + } + + const relativePath = join(destination.path, node.basename) + const destinationUrl = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}${relativePath}`) + logger.debug(`${method} ${node.basename} to ${destinationUrl}`) + + // Set loading state + Vue.set(node, 'status', NodeStatus.LOADING) + + const queue = getQueue() + return await queue.add(async () => { + try { + await axios({ + method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE', + url: encodeURI(node.source), + headers: { + Destination: encodeURI(destinationUrl), + Overwrite: overwrite ? undefined : 'F', + }, + }) + + // If we're moving, update the node + // if we're copying, we don't need to update the node + // the view will refresh itself + if (method === MoveCopyAction.MOVE) { + // 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) { + 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 is 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) + } + } + throw new Error() + } finally { + Vue.set(node, 'status', undefined) + } + }) +} + +/** + * 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} node The node to move/copy + * @return {Promise<boolean>} A promise that resolves to true if the action was successful + */ +const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', node: Node): Promise<boolean> => { + const filePicker = getFilePickerBuilder(t('files', 'Chose 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 node in the file picker + && node.fileid !== n.fileid + }) + .setMimeTypeFilter([]) + .setMultiSelect(false) + .startAt(dir) + + return new Promise((resolve, reject) => { + filePicker.setButtonFactory((nodes: Node[], path: string) => { + const buttons: IFilePickerButton[] = [] + const target = basename(path) + + if (node.dirname === path) { + // This file/folder is already in that directory + return buttons + } + + if (node.path === path) { + // You cannot move a file/folder onto itself + return buttons + } + + if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Copy to {target}', { target }) : t('files', 'Copy'), + type: 'primary', + icon: CopyIcon, + async callback(destination: Node[]) { + try { + await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.COPY) + resolve(true) + } catch (error) { + reject(error) + } + }, + }) + } + + if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Move to {target}', { target }) : t('files', 'Move'), + type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary', + icon: MoveIcon, + async callback(destination: Node[]) { + try { + await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.MOVE) + resolve(true) + } catch (error) { + reject(error) + } + }, + }) + } + + return buttons + }) + + const picker = filePicker.build() + picker.pick().catch(() => { + reject(new Error(t('files', 'Cancelled move or copy operation'))) + }) + }) +} + +export const action = new FileAction({ + id: 'move-copy', + 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[]) { + // 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]) + try { + await openFilePickerForAction(action, dir, node) + return true + } catch (error) { + if (error instanceof Error && !!error.message) { + showError(error.message) + // Silent action as we handle the toast + return null + } + return false + } + }, + + order: 15, +}) diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts new file mode 100644 index 00000000000..82aaa02f9ed --- /dev/null +++ b/apps/files/src/actions/moveOrCopyActionUtils.ts @@ -0,0 +1,71 @@ +/** + * @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/>. + * + */ + +import '@nextcloud/dialogs/style.css' + +import type { Node } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' +import PQueue from 'p-queue' + +// This is the processing queue. We only want to allow 3 concurrent requests +let queue: PQueue + +/** + * Get the processing queue + */ +export const getQueue = () => { + if (!queue) { + queue = new PQueue({ concurrency: 3 }) + } + return queue +} + +type ShareAttribute = { + enabled: boolean + key: string + scope: string +} + +export enum MoveCopyAction { + MOVE = 'Move', + COPY = 'Copy', + MOVE_OR_COPY = 'move-or-copy', +} + +export const canMove = (nodes: Node[]) => { + const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL) + return (minPermission & Permission.UPDATE) !== 0 +} + +export const canDownload = (nodes: Node[]) => { + return nodes.every(node => { + const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute> + return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.enabled === false && attribute.key === 'download') + + }) +} + +export const canCopy = (nodes: Node[]) => { + // For now the only restriction is that a shared file + // cannot be copied if the download is disabled + return canDownload(nodes) +} diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue new file mode 100644 index 00000000000..1284eed2566 --- /dev/null +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -0,0 +1,180 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - 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/>. + - + --> +<template> + <div class="files-list-drag-image"> + <span class="files-list-drag-image__icon"> + <span ref="previewImg" /> + <FolderIcon v-if="isSingleFolder" /> + <FileMultipleIcon v-else /> + </span> + <span class="files-list-drag-image__name">{{ name }}</span> + </div> +</template> + +<script lang="ts"> +import { FileType, Node, formatFileSize } from '@nextcloud/files' +import Vue from 'vue' + +import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' + +import { getSummaryFor } from '../utils/fileUtils.ts' + +export default Vue.extend({ + name: 'DragAndDropPreview', + + components: { + FileMultipleIcon, + FolderIcon, + }, + + data() { + return { + nodes: [] as Node[], + } + }, + + computed: { + isSingleNode() { + return this.nodes.length === 1 + }, + isSingleFolder() { + return this.isSingleNode + && this.nodes[0].type === FileType.Folder + }, + + name() { + if (!this.size) { + return this.summary + } + return `${this.summary} – ${this.size}` + }, + size() { + const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0) + const size = parseInt(totalSize, 10) || 0 + if (typeof size !== 'number' || size < 0) { + return null + } + return formatFileSize(size, true) + }, + summary(): string { + if (this.isSingleNode) { + const node = this.nodes[0] + return node.attributes?.displayName || node.basename + } + + return getSummaryFor(this.nodes) + }, + }, + + methods: { + update(nodes: Node[]) { + this.nodes = nodes + this.$refs.previewImg.replaceChildren() + + // Clone icon node from the list + nodes.slice(0, 3).forEach(node => { + const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`) + if (preview) { + const previewElmt = this.$refs.previewImg as HTMLElement + previewElmt.appendChild(preview.parentNode.cloneNode(true)) + } + }) + + this.$nextTick(() => { + this.$emit('loaded', this.$el) + }) + }, + }, +}) +</script> + +<style lang="scss"> +$size: 32px; +$stack-shift: 6px; + +.files-list-drag-image { + position: absolute; + top: -9999px; + left: -9999px; + display: flex; + overflow: hidden; + align-items: center; + height: 44px; + padding: 6px 12px; + background: var(--color-main-background); + + &__icon, + .files-list__row-icon { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--border-radius); + } + + &__icon { + overflow: visible; + margin-right: 12px; + + img { + max-width: 100%; + max-height: 100%; + } + + .material-design-icon { + color: var(--color-text-maxcontrast); + &.folder-icon { + color: var(--color-primary-element); + } + } + + // Previews container + > span { + display: flex; + + // Stack effect if more than one element + .files-list__row-icon + .files-list__row-icon { + margin-top: $stack-shift; + margin-left: $stack-shift - $size; + & + .files-list__row-icon { + margin-top: $stack-shift * 2; + } + } + // If we have manually clone the preview, + // let's hide any fallback icons + &:not(:empty) + * { + display: none; + } + } + } + + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +</style> diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index ede7f1fad2b..3cef907990c 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -21,22 +21,27 @@ --> <template> - <tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive}" + <tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" data-cy-files-list-row :data-cy-files-list-row-fileid="fileid" :data-cy-files-list-row-name="source.basename" + :draggable="canDrag" class="files-list__row" - @contextmenu="onRightClick"> + @contextmenu="onRightClick" + @dragover="onDragOver" + @dragleave="onDragLeave" + @dragstart="onDragStart" + @dragend="onDragEnd" + @drop="onDrop"> <!-- Failed indicator --> <span v-if="source.attributes.failed" class="files-list__row--failed" /> <!-- Checkbox --> <td class="files-list__row-checkbox"> - <NcCheckboxRadioSwitch v-if="visible" + <NcLoadingIcon v-if="isLoading" /> + <NcCheckboxRadioSwitch v-else-if="visible" :aria-label="t('files', 'Select the row for {displayName}', { displayName })" - :checked="selectedFiles" - :value="fileid" - name="selectedFiles" + :checked="isSelected" @update:checked="onSelectionChange" /> </td> @@ -55,10 +60,11 @@ </template> <!-- Decorative image, should not be aria documented --> - <span v-else-if="previewUrl && !backgroundFailed" + <img v-else-if="previewUrl && !backgroundFailed" ref="previewImg" class="files-list__row-icon-preview" - :style="{ backgroundImage }" /> + :src="previewUrl" + @error="backgroundFailed = true"> <FileIcon v-else /> @@ -123,7 +129,7 @@ ref="actionsMenu" :boundaries-element="getBoundariesElement()" :container="getBoundariesElement()" - :disabled="source._loading" + :disabled="isLoading" :force-name="true" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" @@ -178,17 +184,14 @@ <script lang='ts'> import type { PropType } from 'vue' -import type { Node } from '@nextcloud/files' -import { CancelablePromise } from 'cancelable-promise' -import { debounce } from 'debounce' import { emit } from '@nextcloud/event-bus' import { extname } from 'path' import { generateUrl } from '@nextcloud/router' -import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File, Node, FileAction } from '@nextcloud/files' -import { Type as ShareType } from '@nextcloud/sharing' +import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File, FileAction, NodeStatus, Node } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' +import { Type as ShareType } from '@nextcloud/sharing' import { vOnClickOutside } from '@vueuse/components' import axios from '@nextcloud/axios' import moment from '@nextcloud/moment' @@ -210,8 +213,10 @@ import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' import { hashCode } from '../utils/hashUtils.ts' -import { isCachedPreview } from '../services/PreviewService.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -304,10 +309,12 @@ export default Vue.extend({ data() { return { + dummyPreviewUrl: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>', backgroundFailed: false, - backgroundImage: '', loading: '', dragover: false, + + NodeStatus, } }, @@ -332,7 +339,7 @@ export default Vue.extend({ return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') }, currentFileId() { - return this.$route.params.fileid || this.$route.query.fileid || null + return this.$route.params?.fileid || this.$route.query?.fileid || null }, fileid() { return this.source?.fileid?.toString?.() @@ -472,10 +479,14 @@ export default Vue.extend({ return null } + if (this.backgroundFailed === true) { + return null + } + try { const previewUrl = this.source.attributes.previewUrl - || generateUrl('/core/preview?fileId={fileid}', { - fileid: this.source.fileid, + || generateUrl('/core/preview?fileid={fileid}', { + fileid: this.fileid, }) const url = new URL(window.location.origin + previewUrl) @@ -552,6 +563,9 @@ export default Vue.extend({ isFavorite() { return this.source.attributes.favorite === 1 }, + isLoading() { + return this.source.status === NodeStatus.LOADING + }, renameLabel() { const matchLabel: Record<FileType, string> = { @@ -581,7 +595,16 @@ export default Vue.extend({ }, canDrag() { - return (this.source.permissions & Permission.UPDATE) !== 0 + const canDrag = (node: Node): boolean => { + return (node.permissions & Permission.UPDATE) !== 0 + } + + // If we're dragging a selection, we need to check all files + if (this.selectedFiles.length > 0) { + const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[] + return nodes.every(canDrag) + } + return canDrag(this.source) }, canDrop() { @@ -590,7 +613,7 @@ export default Vue.extend({ } // If the current folder is also being dragged, we can't drop it on itself - if (this.draggingFiles.find(fileId => fileId === this.fileid)) { + if (this.draggingFiles.includes(this.fileid)) { return false } @@ -605,7 +628,6 @@ export default Vue.extend({ */ source() { this.resetState() - this.debounceIfNotCached() }, /** @@ -619,106 +641,31 @@ export default Vue.extend({ }, }, - /** - * The row is mounted once and reused as we scroll. - */ - mounted() { - // ⚠ Init the debounce function on mount and - // not when the module is imported to - // avoid sharing between recycled components - this.debounceGetPreview = debounce(function() { - this.fetchAndApplyPreview() - }, 150, false) - - // Fetch the preview on init - this.debounceIfNotCached() - }, - beforeDestroy() { this.resetState() }, methods: { - async debounceIfNotCached() { - if (!this.previewUrl) { - return - } - - // Check if we already have this preview cached - const isCached = await isCachedPreview(this.previewUrl) - if (isCached) { - this.backgroundImage = `url(${this.previewUrl})` - this.backgroundFailed = false - return - } - - // We don't have this preview cached or it expired, requesting it - this.debounceGetPreview() - }, - - fetchAndApplyPreview() { - // Ignore if no preview - if (!this.previewUrl) { - return - } - - // If any image is being processed, reset it - if (this.previewPromise) { - this.clearImg() - } - - // Store the promise to be able to cancel it - this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => { - const img = new Image() - // If visible, load the preview with higher priority - img.fetchpriority = this.visible ? 'high' : 'auto' - img.onload = () => { - this.backgroundImage = `url(${this.previewUrl})` - this.backgroundFailed = false - resolve(img) - } - img.onerror = () => { - this.backgroundFailed = true - reject(img) - } - img.src = this.previewUrl - - // Image loading has been canceled - onCancel(() => { - img.onerror = null - img.onload = null - img.src = '' - }) - }) - }, - resetState() { // Reset loading state this.loading = '' - // Reset the preview - this.clearImg() + // Reset background state + this.backgroundFailed = false + if (this.$refs.previewImg) { + this.$refs.previewImg.src = '' + } // Close menu this.openedMenu = false }, - clearImg() { - this.backgroundImage = '' - this.backgroundFailed = false - - if (this.previewPromise) { - this.previewPromise.cancel() - this.previewPromise = null - } - }, - async onActionClick(action) { const displayName = action.displayName([this.source], this.currentView) try { // Set the loading marker this.loading = action.id - Vue.set(this.source, '_loading', true) + Vue.set(this.source, 'status', NodeStatus.LOADING) const success = await action.exec(this.source, this.currentView, this.currentDir) @@ -738,7 +685,7 @@ export default Vue.extend({ } finally { // Reset the loading marker this.loading = '' - Vue.set(this.source, '_loading', false) + Vue.set(this.source, 'status', undefined) } }, execDefaultAction(event) { @@ -758,7 +705,7 @@ export default Vue.extend({ } }, - onSelectionChange(selection) { + onSelectionChange(selected: boolean) { const newSelectedIndex = this.index const lastSelectedIndex = this.selectionStore.lastSelectedIndex @@ -776,7 +723,7 @@ export default Vue.extend({ // If already selected, update the new selection _without_ the current file const selection = [...lastSelection, ...filesToSelect] - .filter(fileId => !isAlreadySelected || fileId !== this.fileid) + .filter(fileid => !isAlreadySelected || fileid !== this.fileid) logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected }) // Keep previous lastSelectedIndex to be use for further shift selections @@ -784,6 +731,10 @@ export default Vue.extend({ return } + const selection = selected + ? [...this.selectedFiles, this.fileid] + : this.selectedFiles.filter(fileid => fileid !== this.fileid) + logger.debug('Updating selection', { selection }) this.selectionStore.set(selection) this.selectionStore.setLastIndex(newSelectedIndex) @@ -894,7 +845,7 @@ export default Vue.extend({ // Set loading state this.loading = 'renaming' - Vue.set(this.source, '_loading', true) + Vue.set(this.source, 'status', NodeStatus.LOADING) // Update node this.source.rename(newName) @@ -936,7 +887,7 @@ export default Vue.extend({ showError(this.t('files', 'Could not rename "{oldName}"', { oldName })) } finally { this.loading = false - Vue.set(this.source, '_loading', false) + Vue.set(this.source, 'status', undefined) } }, @@ -959,14 +910,31 @@ export default Vue.extend({ return action.displayName([this.source], this.currentView) }, - onDragEnter() { + onDragOver(event: DragEvent) { this.dragover = this.canDrop + if (!this.canDrop) { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } }, - onDragLeave() { + onDragLeave(event: DragEvent) { + if (this.$el.contains(event.target) && event.target !== this.$el) { + return + } this.dragover = false }, - onDragStart(event) { + async onDragStart(event: DragEvent) { + event.stopPropagation() if (!this.canDrag) { event.preventDefault() event.stopPropagation() @@ -975,13 +943,22 @@ export default Vue.extend({ logger.debug('Drag started') - // Dragging set of files - if (this.selectedFiles.length > 0) { + // Reset any renaming + this.renamingStore.$reset() + + // Dragging set of files, if we're dragging a file + // that is already selected, we use the entire selection + if (this.selectedFiles.includes(this.fileid)) { this.draggingStore.set(this.selectedFiles) - return + } else { + this.draggingStore.set([this.fileid]) } - this.draggingStore.set([this.fileid]) + const nodes = this.draggingStore.dragging + .map(fileid => this.filesStore.getNode(fileid)) as Node[] + + const image = await getDragAndDropPreview(nodes) + event.dataTransfer.setDragImage(image, -10, -10) }, onDragEnd() { this.draggingStore.reset() @@ -989,14 +966,42 @@ export default Vue.extend({ logger.debug('Drag ended') }, - onDrop(event) { + async onDrop(event) { // If another button is pressed, cancel it // This allows cancelling the drag with the right click if (!this.canDrop || event.button !== 0) { return } + const isCopy = event.ctrlKey + this.dragover = false + logger.debug('Dropped', { event, selection: this.draggingFiles }) + + const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[] + nodes.forEach(async (node: Node) => { + Vue.set(node, 'status', NodeStatus.LOADING) + try { + // TODO: resolve potential conflicts prior and force overwrite + await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE) + } catch (error) { + logger.error('Error while moving file', { error }) + if (isCopy) { + showError(this.t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) + } else { + showError(this.t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' })) + } + } finally { + Vue.set(node, 'status', undefined) + } + }) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } }, t: translate, diff --git a/apps/files/src/components/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue index b1544c41888..dfe892af772 100644 --- a/apps/files/src/components/FilesListHeaderActions.vue +++ b/apps/files/src/components/FilesListHeaderActions.vue @@ -55,6 +55,7 @@ import { useSelectionStore } from '../store/selection.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' import CustomSvgIconRender from './CustomSvgIconRender.vue' import logger from '../logger.js' +import { NodeStatus } from '@nextcloud/files' // The registered actions list const actions = getFileActions() @@ -120,7 +121,7 @@ export default Vue.extend({ }, areSomeNodesLoading() { - return this.nodes.some(node => node._loading) + return this.nodes.some(node => node.status === NodeStatus.LOADING) }, openedMenu: { @@ -164,7 +165,7 @@ export default Vue.extend({ // Set loading markers this.loading = action.id this.nodes.forEach(node => { - Vue.set(node, '_loading', true) + Vue.set(node, 'status', NodeStatus.LOADING) }) // Dispatch action execution @@ -198,7 +199,7 @@ export default Vue.extend({ // Remove loading markers this.loading = null this.nodes.forEach(node => { - Vue.set(node, '_loading', false) + Vue.set(node, 'status', undefined) }) } }, diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index f2ba4b7a921..e5247fb4b94 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -42,7 +42,7 @@ </template> <script lang="ts"> -import { getFileActions } from '@nextcloud/files' +import { NodeStatus, getFileActions } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' @@ -121,7 +121,7 @@ export default Vue.extend({ }, areSomeNodesLoading() { - return this.nodes.some(node => node._loading) + return this.nodes.some(node => node.status === NodeStatus.LOADING) }, openedMenu: { @@ -165,7 +165,7 @@ export default Vue.extend({ // Set loading markers this.loading = action.id this.nodes.forEach(node => { - Vue.set(node, '_loading', true) + Vue.set(node, 'status', NodeStatus.LOADING) }) // Dispatch action execution @@ -199,7 +199,7 @@ export default Vue.extend({ // Remove loading markers this.loading = null this.nodes.forEach(node => { - Vue.set(node, '_loading', false) + Vue.set(node, 'status', undefined) }) } }, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index e5a37986cac..3bc773a614a 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -310,16 +310,22 @@ export default Vue.extend({ } .files-list__row { - &:hover, &:focus, &:active, &--active { + &:hover, &:focus, &:active, &--active, &--dragover { background-color: var(--color-background-dark); > * { --color-border: var(--color-border-dark); } + // Hover state of the row should also change the favorite markers background .favorite-marker-icon svg path { stroke: var(--color-background-dark); } } + + &--dragover * { + // Prevent dropping on row children + pointer-events: none; + } } // Entry preview or mime icon @@ -351,7 +357,8 @@ export default Vue.extend({ } // Slightly increase the size of the folder icon - &.folder-icon { + &.folder-icon, + &.folder-open-icon { margin: -3px; svg { width: calc(var(--icon-preview-size) + 6px); diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue index 9b7827e1ebe..53195d028c6 100644 --- a/apps/files/src/components/TemplatePreview.vue +++ b/apps/files/src/components/TemplatePreview.vue @@ -48,7 +48,7 @@ <script> import { generateUrl } from '@nextcloud/router' -import { encodeFilePath } from '../utils/fileUtils.js' +import { encodeFilePath } from '../utils/fileUtils.ts' import { getToken, isPublic } from '../utils/davUtils.js' // preview width generation diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 64e3846979e..c3b70641ca1 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -23,6 +23,7 @@ import { action as deleteAction } from './actions/deleteAction' import { action as downloadAction } from './actions/downloadAction' import { action as editLocallyAction } from './actions/editLocallyAction' import { action as favoriteAction } from './actions/favoriteAction' +import { action as moveOrCopyAction } from './actions/moveOrCopyAction' import { action as openFolderAction } from './actions/openFolderAction' import { action as openInFilesAction } from './actions/openInFilesAction' import { action as renameAction } from './actions/renameAction' @@ -41,6 +42,7 @@ registerFileAction(deleteAction) registerFileAction(downloadAction) registerFileAction(editLocallyAction) registerFileAction(favoriteAction) +registerFileAction(moveOrCopyAction) registerFileAction(openFolderAction) registerFileAction(openInFilesAction) registerFileAction(renameAction) diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 8653cc8e449..56ae01192ef 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -87,6 +87,10 @@ export const useFilesStore = function(...args) { onCreatedNode(node: Node) { this.updateNodes([node]) }, + + onUpdatedNode(node: Node) { + this.updateNodes([node]) + }, }, }) @@ -95,8 +99,7 @@ export const useFilesStore = function(...args) { if (!fileStore._initialized) { subscribe('files:node:created', fileStore.onCreatedNode) subscribe('files:node:deleted', fileStore.onDeletedNode) - // subscribe('files:node:moved', fileStore.onMovedNode) - // subscribe('files:node:updated', fileStore.onUpdatedNode) + subscribe('files:node:updated', fileStore.onUpdatedNode) fileStore._initialized = true } diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts index 1b86c69ac57..d678b5bc592 100644 --- a/apps/files/src/store/paths.ts +++ b/apps/files/src/store/paths.ts @@ -19,12 +19,12 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -import { Node, getNavigation } from '@nextcloud/files' import type { FileId, PathsStore, PathOptions, ServicesState } from '../types' import { defineStore } from 'pinia' +import { Node, getNavigation } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' import Vue from 'vue' import logger from '../logger' -import { subscribe } from '@nextcloud/event-bus' export const usePathsStore = function(...args) { const store = defineStore('paths', { diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts index 251bb804b9a..e304d27340e 100644 --- a/apps/files/src/store/selection.ts +++ b/apps/files/src/store/selection.ts @@ -35,7 +35,7 @@ export const useSelectionStore = defineStore('selection', { * Set the selection of fileIds */ set(selection = [] as FileId[]) { - Vue.set(this, 'selected', selection) + Vue.set(this, 'selected', [...new Set(selection)]) }, /** diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index 4ea49e9d1ac..778e9ff2971 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -111,4 +111,3 @@ export interface UploaderStore { export interface DragAndDropStore { dragging: FileId[] } - diff --git a/apps/files/src/utils/dragUtils.ts b/apps/files/src/utils/dragUtils.ts new file mode 100644 index 00000000000..fc4b33d847d --- /dev/null +++ b/apps/files/src/utils/dragUtils.ts @@ -0,0 +1,42 @@ +/** + * @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/>. + * + */ +import type { Node } from '@nextcloud/files' +import DragAndDropPreview from '../components/DragAndDropPreview.vue' +import Vue from 'vue' + +const Preview = Vue.extend(DragAndDropPreview) +let preview: Vue + +export const getDragAndDropPreview = async (nodes: Node[]): Promise<Element> => { + return new Promise((resolve) => { + if (!preview) { + preview = new Preview().$mount() + document.body.appendChild(preview.$el) + } + + preview.update(nodes) + preview.$on('loaded', () => { + resolve(preview.$el) + preview.$off('loaded') + }) + }) +} diff --git a/apps/files/src/utils/fileUtils.js b/apps/files/src/utils/fileUtils.ts index 5ab88c6eb63..9e2bfc44417 100644 --- a/apps/files/src/utils/fileUtils.js +++ b/apps/files/src/utils/fileUtils.ts @@ -19,8 +19,10 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +import { FileType, type Node } from '@nextcloud/files' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' -const encodeFilePath = function(path) { +export const encodeFilePath = function(path) { const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') let relativePath = '' pathSections.forEach((section) => { @@ -37,11 +39,35 @@ const encodeFilePath = function(path) { * @param {string} path the full path * @return {string[]} [dirPath, fileName] */ -const extractFilePaths = function(path) { +export const extractFilePaths = function(path) { const pathSections = path.split('/') const fileName = pathSections[pathSections.length - 1] const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') return [dirPath, fileName] } -export { encodeFilePath, extractFilePaths } +/** + * Generate a translated summary of an array of nodes + * @param {Node[]} nodes the nodes to summarize + * @return {string} + */ +export const getSummaryFor = (nodes: Node[]): string => { + const fileCount = nodes.filter(node => node.type === FileType.File).length + const folderCount = nodes.filter(node => node.type === FileType.Folder).length + + if (fileCount === 0) { + return n('files', '{folderCount} folder', '{folderCount} folders', folderCount, { folderCount }) + } else if (folderCount === 0) { + return n('files', '{fileCount} file', '{fileCount} files', fileCount, { fileCount }) + } + + if (fileCount === 1) { + return n('files', '1 file and {folderCount} folder', '1 file and {folderCount} folders', folderCount, { folderCount }) + } + + if (folderCount === 1) { + return n('files', '{fileCount} file and 1 folder', '{fileCount} files and 1 folder', fileCount, { fileCount }) + } + + return t('files', '{fileCount} files and {folderCount} folders', { fileCount, folderCount }) +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index b7785e623b0..d43a2432dff 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -63,7 +63,7 @@ data-cy-files-content-empty> <template #action> <NcButton v-if="dir !== '/'" - aria-label="t('files', 'Go to the previous folder')" + :aria-label="t('files', 'Go to the previous folder')" type="primary" :to="toPreviousDir"> {{ t('files', 'Go back') }} @@ -93,7 +93,7 @@ import { Folder, Node, Permission } from '@nextcloud/files' import { getCapabilities } from '@nextcloud/capabilities' import { join, dirname } from 'path' import { orderBy } from 'natural-orderby' -import { translate } from '@nextcloud/l10n' +import { translate, translatePlural } from '@nextcloud/l10n' import { UploadPicker } from '@nextcloud/upload' import { Type } from '@nextcloud/sharing' import Vue from 'vue' @@ -425,6 +425,7 @@ export default Vue.extend({ }, t: translate, + n: translatePlural, }, }) </script> |