feat(files): add move or copy action

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-08-24 12:16:53 +02:00
parent 16094c7db5
commit f9d2e3af0c
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
17 changed files with 723 additions and 138 deletions

View file

@ -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,
})

View file

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

View file

@ -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>

View file

@ -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,

View file

@ -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)
})
}
},

View file

@ -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)
})
}
},

View file

@ -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);

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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', {

View file

@ -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)])
},
/**

View file

@ -111,4 +111,3 @@ export interface UploaderStore {
export interface DragAndDropStore {
dragging: FileId[]
}

View file

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

View file

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

View file

@ -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>

View file

@ -24,7 +24,7 @@ import { joinPaths } from '@nextcloud/paths'
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import moment from '@nextcloud/moment'
import { encodeFilePath } from '../../../files/src/utils/fileUtils.js'
import { encodeFilePath } from '../../../files/src/utils/fileUtils.ts'
import client from '../utils/davClient.js'
import davRequest from '../utils/davRequest.js'