aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-08-24 12:16:53 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-09-26 20:15:59 +0200
commitf9d2e3af0cfb087ed57c8c4a445618e0d24dde8f (patch)
treeadca8f3b0f1b58c6e6fbfbadb24d5617fa079d86 /apps/files
parent16094c7db52253a0875eaab31ae820efa6c1a386 (diff)
downloadnextcloud-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.ts248
-rw-r--r--apps/files/src/actions/moveOrCopyActionUtils.ts71
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue180
-rw-r--r--apps/files/src/components/FileEntry.vue237
-rw-r--r--apps/files/src/components/FilesListHeaderActions.vue7
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue8
-rw-r--r--apps/files/src/components/FilesListVirtual.vue11
-rw-r--r--apps/files/src/components/TemplatePreview.vue2
-rw-r--r--apps/files/src/init.ts2
-rw-r--r--apps/files/src/store/files.ts7
-rw-r--r--apps/files/src/store/paths.ts4
-rw-r--r--apps/files/src/store/selection.ts2
-rw-r--r--apps/files/src/types.ts1
-rw-r--r--apps/files/src/utils/dragUtils.ts42
-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.vue5
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>