You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nextcloud/apps/files/src/components/FileEntry.vue

1083 lines
30 KiB
Vue

<!--
- @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/>.
-
-->
<template>
<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"
@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">
<NcLoadingIcon v-if="isLoading" />
<NcCheckboxRadioSwitch v-else-if="visible"
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
:checked="isSelected"
@update:checked="onSelectionChange" />
</td>
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<span class="files-list__row-icon" @click="execDefaultAction">
<template v-if="source.type === 'folder'">
<FolderOpenIcon v-if="dragover" />
<template v-else>
<FolderIcon />
<OverlayIcon :is="folderOverlay"
v-if="folderOverlay"
class="files-list__row-icon-overlay" />
</template>
</template>
<!-- Decorative image, should not be aria documented -->
<img v-else-if="previewUrl && backgroundFailed !== true"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
:class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
:src="previewUrl"
@error="backgroundFailed = true"
@load="backgroundFailed = false">
<FileIcon v-else />
<!-- Favorite icon -->
<span v-if="isFavorite"
class="files-list__row-icon-favorite"
:aria-label="t('files', 'Favorite')">
<FavoriteIcon :aria-hidden="true" />
</span>
</span>
<!-- Rename input -->
<form v-if="isRenaming"
v-on-click-outside="stopRenaming"
:aria-hidden="!isRenaming"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
<NcTextField ref="renameInput"
:label="renameLabel"
:autofocus="true"
:minlength="1"
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<a v-else
ref="basename"
:aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo"
@click="execDefaultAction">
<!-- File name -->
<span class="files-list__row-name-text">
<!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="displayName" />
<span class="files-list__row-name-ext" v-text="extension" />
</span>
</a>
</td>
<!-- Actions -->
<td v-show="!isRenamingSmallScreen"
:class="`files-list__row-actions-${uniqueId}`"
class="files-list__row-actions"
data-cy-files-list-row-actions>
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:current-view="currentView"
:render="action.renderInline"
:source="source"
class="files-list__row-action--inline" />
<!-- Menu actions -->
<NcActions v-if="visible"
ref="actionsMenu"
:boundaries-element="getBoundariesElement()"
:container="getBoundariesElement()"
:disabled="isLoading"
:force-name="true"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:close-after-click="true"
:data-cy-files-list-row-action="action.id"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
</NcActions>
</td>
<!-- Size -->
<td v-if="isSizeAvailable"
:style="sizeOpacity"
class="files-list__row-size"
data-cy-files-list-row-size
@click="openDetailsIfAvailable">
<span>{{ size }}</span>
</td>
<!-- Mtime -->
<td v-if="isMtimeAvailable"
:style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
<span>{{ mtime }}</span>
</td>
<!-- View columns -->
<td v-for="column in columns"
:key="column.id"
:class="`files-list__row-${currentView?.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
<CustomElementRender v-if="visible"
:current-view="currentView"
:render="column.render"
:source="source" />
</td>
</tr>
</template>
<script lang='ts'>
import type { PropType } from 'vue'
import { emit } from '@nextcloud/event-bus'
import { extname } from 'path'
import { generateUrl } from '@nextcloud/router'
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'
import Vue from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NetworkIcon from 'vue-material-design-icons/Network.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
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 { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useKeyboardStore } from '../store/keyboard.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import FavoriteIcon from './FavoriteIcon.vue'
import logger from '../logger.js'
import { loadState } from '@nextcloud/initial-state'
// The registered actions list
const actions = getFileActions()
Vue.directive('onClickOutside', vOnClickOutside)
const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
export default Vue.extend({
name: 'FileEntry',
components: {
AccountGroupIcon,
AccountPlusIcon,
CustomElementRender,
CustomSvgIconRender,
FavoriteIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
KeyIcon,
LinkIcon,
NcActionButton,
NcActions,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcTextField,
NetworkIcon,
TagIcon,
},
props: {
visible: {
type: Boolean,
default: false,
},
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
source: {
type: [Folder, File, Node] as PropType<Node>,
required: true,
},
index: {
type: Number,
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const keyboardStore = useKeyboardStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
return {
actionsMenuStore,
draggingStore,
filesStore,
keyboardStore,
renamingStore,
selectionStore,
userConfigStore,
}
},
data() {
return {
backgroundFailed: undefined,
loading: '',
dragover: false,
NodeStatus,
}
},
computed: {
userConfig() {
return this.userConfigStore.userConfig
},
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
currentDir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
},
currentFileId() {
return this.$route.params?.fileid || this.$route.query?.fileid || null
},
fileid() {
return this.source?.fileid?.toString?.()
},
extension() {
if (this.source.attributes?.displayName) {
return extname(this.source.attributes.displayName)
}
return this.source.extension || ''
},
displayName() {
const ext = this.extension
const name = (this.source.attributes.displayName
|| this.source.basename)
// Strip extension from name if defined
return !ext ? name : name.slice(0, 0 - ext.length)
},
size() {
const size = parseInt(this.source.size, 10) || 0
if (typeof size !== 'number' || size < 0) {
return this.t('files', 'Pending')
}
return formatFileSize(size, true)
},
sizeOpacity() {
const maxOpacitySize = 10 * 1024 * 1024
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
return {}
}
const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2)))
return {
color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
}
},
mtime() {
if (this.source.mtime) {
return moment(this.source.mtime).fromNow()
}
return this.t('files_trashbin', 'A long time ago')
},
mtimeOpacity() {
const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
const mtime = this.source.mtime?.getTime?.()
if (!mtime) {
return {}
}
// 1 = today, 0 = 31 days ago
const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime))
if (ratio < 0) {
return {}
}
return {
color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
}
},
mtimeTitle() {
if (this.source.mtime) {
return moment(this.source.mtime).format('LLL')
}
return ''
},
folderOverlay() {
if (this.source.type !== FileType.Folder) {
return null
}
// Encrypted folders
if (this.source?.attributes?.['is-encrypted'] === 1) {
return KeyIcon
}
// System tags
if (this.source?.attributes?.['is-tag']) {
return TagIcon
}
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
return LinkIcon
}
// Shared folders
if (shareTypes.length > 0) {
return AccountPlusIcon
}
switch (this.source?.attributes?.['mount-type']) {
case 'external':
case 'external-session':
return NetworkIcon
case 'group':
return AccountGroupIcon
}
return null
},
linkTo() {
if (this.source.attributes.failed) {
return {
title: this.t('files', 'This node is unavailable'),
is: 'span',
}
}
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
title: displayName,
role: 'button',
}
}
if (this.source?.permissions & Permission.READ) {
return {
download: this.source.basename,
href: this.source.source,
title: this.t('files', 'Download file {name}', { name: this.displayName }),
}
}
return {
is: 'span',
}
},
draggingFiles() {
return this.draggingStore.dragging
},
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
return this.selectedFiles.includes(this.fileid)
},
cropPreviews() {
return this.userConfig.crop_image_previews
},
previewUrl() {
if (this.source.type === FileType.Folder) {
return null
}
if (this.backgroundFailed === true) {
return null
}
try {
const previewUrl = this.source.attributes.previewUrl
|| generateUrl('/core/preview?fileid={fileid}', {
fileid: this.fileid,
})
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', '32')
url.searchParams.set('y', '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch (e) {
return null
}
},
// Sorted actions that are enabled for this node
enabledActions() {
if (this.source.attributes.failed) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.visible) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
},
// Default actions
enabledDefaultActions() {
return this.enabledActions.filter(action => !!action?.default)
},
// Actions shown in the menu
enabledMenuActions() {
return [
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
})
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === this.uniqueId
},
set(opened) {
this.actionsMenuStore.opened = opened ? this.uniqueId : null
},
},
uniqueId() {
return hashCode(this.source.source)
},
isFavorite() {
return this.source.attributes.favorite === 1
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
[FileType.File]: t('files', 'File name'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
},
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
},
newName: {
get() {
return this.renamingStore.newName
},
set(newName) {
this.renamingStore.newName = newName
},
},
isActive() {
return this.fileid === this.currentFileId?.toString?.()
},
canDrag() {
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() {
if (this.source.type !== FileType.Folder) {
return false
}
// If the current folder is also being dragged, we can't drop it on itself
if (this.draggingFiles.includes(this.fileid)) {
return false
}
return (this.source.permissions & Permission.CREATE) !== 0
},
},
watch: {
/**
* When the source changes, reset the preview
* and fetch the new one.
*/
source() {
this.resetState()
},
/**
* If renaming starts, select the file name
* in the input, without the extension.
*/
isRenaming(renaming) {
if (renaming) {
this.startRenaming()
}
},
},
beforeDestroy() {
this.resetState()
},
methods: {
resetState() {
// Reset loading state
this.loading = ''
// Reset background state
this.backgroundFailed = undefined
if (this.$refs.previewImg) {
this.$refs.previewImg.src = ''
}
// Close menu
this.openedMenu = false
},
async onActionClick(action) {
const displayName = action.displayName([this.source], this.currentView)
try {
// Set the loading marker
this.loading = action.id
Vue.set(this.source, 'status', NodeStatus.LOADING)
const success = await action.exec(this.source, this.currentView, this.currentDir)
// If the action returns null, we stay silent
if (success === null) {
return
}
if (success) {
showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(this.t('files', '"{displayName}" action failed', { displayName }))
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(this.t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Reset the loading marker
this.loading = ''
Vue.set(this.source, 'status', undefined)
}
},
execDefaultAction(event) {
if (this.enabledDefaultActions.length > 0) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
}
},
openDetailsIfAvailable(event) {
event.preventDefault()
event.stopPropagation()
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
sidebarAction.exec(this.source, this.currentView, this.currentDir)
}
},
onSelectionChange(selected: boolean) {
const newSelectedIndex = this.index
const lastSelectedIndex = this.selectionStore.lastSelectedIndex
// Get the last selected and select all files in between
if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
const isAlreadySelected = this.selectedFiles.includes(this.fileid)
const start = Math.min(newSelectedIndex, lastSelectedIndex)
const end = Math.max(lastSelectedIndex, newSelectedIndex)
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
.map(file => file.fileid?.toString?.())
.slice(start, end + 1)
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
.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
this.selectionStore.set(selection)
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)
},
// Open the actions menu on right click
onRightClick(event) {
// If already opened, fallback to default browser
if (this.openedMenu) {
return
}
// If the clicked row is in the selection, open global menu
const isMoreThanOneSelected = this.selectedFiles.length > 1
this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
// Prevent any browser defaults
event.preventDefault()
event.stopPropagation()
},
/**
* Check if the file name is valid and update the
* input validity using browser's native validation.
* @param event the keyup event
*/
checkInputValidity(event?: KeyboardEvent) {
const input = event.target as HTMLInputElement
const newName = this.newName.trim?.() || ''
logger.debug('Checking input validity', { newName })
try {
this.isFileNameValid(newName)
input.setCustomValidity('')
input.title = ''
} catch (e) {
input.setCustomValidity(e.message)
input.title = e.message
} finally {
input.reportValidity()
}
},
isFileNameValid(name) {
const trimmedName = name.trim()
if (trimmedName === '.' || trimmedName === '..') {
throw new Error(this.t('files', '"{name}" is an invalid file name.', { name }))
} else if (trimmedName.length === 0) {
throw new Error(this.t('files', 'File name cannot be empty.'))
} else if (trimmedName.indexOf('/') !== -1) {
throw new Error(this.t('files', '"/" is not allowed inside a file name.'))
} else if (trimmedName.match(OC.config.blacklist_files_regex)) {
throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name }))
} else if (this.checkIfNodeExists(name)) {
throw new Error(this.t('files', '{newName} already exists.', { newName: name }))
}
const toCheck = trimmedName.split('')
toCheck.forEach(char => {
if (forbiddenCharacters.indexOf(char) !== -1) {
throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char }))
}
})
return true
},
checkIfNodeExists(name) {
return this.nodes.find(node => node.basename === name && node !== this.source)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
const extLength = (this.source.extension || '').split('').length
const length = this.source.basename.split('').length - extLength
const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
if (!input) {
logger.error('Could not find the rename input')
return
}
input.setSelectionRange(0, length)
input.focus()
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
if (!this.isRenaming) {
return
}
// Reset the renaming store
this.renamingStore.$reset()
},
// Rename and move the file
async onRename() {
const oldName = this.source.basename
const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
if (newName === '') {
showError(this.t('files', 'Name cannot be empty'))
return
}
if (oldName === newName) {
this.stopRenaming()
return
}
// Checking if already exists
if (this.checkIfNodeExists(newName)) {
showError(this.t('files', 'Another entry with the same name already exists'))
return
}
// Set loading state
this.loading = 'renaming'
Vue.set(this.source, 'status', NodeStatus.LOADING)
// Update node
this.source.rename(newName)
logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
await axios({
method: 'MOVE',
url: oldEncodedSource,
headers: {
Destination: this.source.encodedSource,
},
})
// Success 🎉
emit('files:node:updated', this.source)
emit('files:node:renamed', this.source)
showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
// Reset the renaming store
this.stopRenaming()
this.$nextTick(() => {
this.$refs.basename.focus()
})
} catch (error) {
logger.error('Error while renaming file', { error })
this.source.rename(oldName)
this.$refs.renameInput.focus()
// TODO: 409 means current folder does not exist, redirect ?
if (error?.response?.status === 404) {
showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(this.t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
return
}
// Unknown error
showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
} finally {
this.loading = false
Vue.set(this.source, 'status', undefined)
}
},
/**
* Making this a function in case the files-list
* reference changes in the future. That way we're
* sure there is one at the time we call it.
*/
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
actionDisplayName(action: FileAction) {
if (this.filesListWidth < 768 && action.inline && typeof action.title === 'function') {
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title([this.source], this.currentView)
if (title) return title
}
return action.displayName([this.source], this.currentView)
},
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(event: DragEvent) {
if (this.$el.contains(event.target) && event.target !== this.$el) {
return
}
this.dragover = false
},
async onDragStart(event: DragEvent) {
event.stopPropagation()
if (!this.canDrag) {
event.preventDefault()
event.stopPropagation()
return
}
logger.debug('Drag started')
// 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)
} else {
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()
this.dragover = false
logger.debug('Drag ended')
},
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,
formatFileSize,
},
})
</script>
<style scoped lang='scss'>
/* Hover effect on tbody lines only */
tr {
&:hover,
&:focus {
background-color: var(--color-background-dark);
}
}
// Folder overlay
.files-list__row-icon-overlay {
position: absolute;
max-height: 18px;
max-width: 18px;
color: var(--color-main-background);
// better alignment with the folder icon
margin-top: 2px;
}
/* Preview not loaded animation effect */
.files-list__row-icon-preview:not(.files-list__row-icon-preview--loaded) {
background: var(--color-loading-dark);
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
}
</style>
<style>
/* @keyframes preview-gradient-fade {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
} */
</style>