mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
feat(files): add move or copy action
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
16094c7db5
commit
f9d2e3af0c
17 changed files with 723 additions and 138 deletions
248
apps/files/src/actions/moveOrCopyAction.ts
Normal file
248
apps/files/src/actions/moveOrCopyAction.ts
Normal 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,
|
||||
})
|
||||
71
apps/files/src/actions/moveOrCopyActionUtils.ts
Normal file
71
apps/files/src/actions/moveOrCopyActionUtils.ts
Normal 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)
|
||||
}
|
||||
180
apps/files/src/components/DragAndDropPreview.vue
Normal file
180
apps/files/src/components/DragAndDropPreview.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -111,4 +111,3 @@ export interface UploaderStore {
|
|||
export interface DragAndDropStore {
|
||||
dragging: FileId[]
|
||||
}
|
||||
|
||||
|
|
|
|||
42
apps/files/src/utils/dragUtils.ts
Normal file
42
apps/files/src/utils/dragUtils.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue