|
|
|
|
@ -33,38 +33,63 @@
|
|
|
|
|
|
|
|
|
|
<!-- Link to file -->
|
|
|
|
|
<td class="files-list__row-name">
|
|
|
|
|
<a ref="name" v-bind="linkAttrs" @click="execDefaultAction">
|
|
|
|
|
<!-- Icon or preview -->
|
|
|
|
|
<span class="files-list__row-icon">
|
|
|
|
|
<FolderIcon v-if="source.type === 'folder'" />
|
|
|
|
|
|
|
|
|
|
<!-- Decorative image, should not be aria documented -->
|
|
|
|
|
<span v-else-if="previewUrl && !backgroundFailed"
|
|
|
|
|
ref="previewImg"
|
|
|
|
|
class="files-list__row-icon-preview"
|
|
|
|
|
:style="{ backgroundImage }" />
|
|
|
|
|
|
|
|
|
|
<span v-else-if="mimeIconUrl"
|
|
|
|
|
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
|
|
|
|
|
:style="{ backgroundImage: mimeIconUrl }" />
|
|
|
|
|
|
|
|
|
|
<FileIcon v-else />
|
|
|
|
|
|
|
|
|
|
<!-- Favorite icon -->
|
|
|
|
|
<span v-if="isFavorite"
|
|
|
|
|
class="files-list__row-icon-favorite"
|
|
|
|
|
:aria-label="t('files', 'Favorite')">
|
|
|
|
|
<StarIcon aria-hidden="true" :size="20" />
|
|
|
|
|
</span>
|
|
|
|
|
<!-- Icon or preview -->
|
|
|
|
|
<span class="files-list__row-icon" @click="execDefaultAction">
|
|
|
|
|
<FolderIcon v-if="source.type === 'folder'" />
|
|
|
|
|
|
|
|
|
|
<!-- Decorative image, should not be aria documented -->
|
|
|
|
|
<span v-else-if="previewUrl && !backgroundFailed"
|
|
|
|
|
ref="previewImg"
|
|
|
|
|
class="files-list__row-icon-preview"
|
|
|
|
|
:style="{ backgroundImage }" />
|
|
|
|
|
|
|
|
|
|
<span v-else-if="mimeIconUrl"
|
|
|
|
|
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
|
|
|
|
|
:style="{ backgroundImage: mimeIconUrl }" />
|
|
|
|
|
|
|
|
|
|
<FileIcon v-else />
|
|
|
|
|
|
|
|
|
|
<!-- Favorite icon -->
|
|
|
|
|
<span v-if="isFavorite"
|
|
|
|
|
class="files-list__row-icon-favorite"
|
|
|
|
|
:aria-label="t('files', 'Favorite')">
|
|
|
|
|
<StarIcon aria-hidden="true" :size="20" />
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<!-- Rename input -->
|
|
|
|
|
<form v-show="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"
|
|
|
|
|
:aria-label="t('files', 'File name')"
|
|
|
|
|
:autofocus="true"
|
|
|
|
|
:minlength="1"
|
|
|
|
|
:required="true"
|
|
|
|
|
:value.sync="newName"
|
|
|
|
|
enterkeyhint="done"
|
|
|
|
|
@keyup="checkInputValidity"
|
|
|
|
|
@keyup.esc="stopRenaming" />
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<a v-show="!isRenaming"
|
|
|
|
|
ref="basename"
|
|
|
|
|
:aria-hidden="isRenaming"
|
|
|
|
|
v-bind="linkTo"
|
|
|
|
|
@click="execDefaultAction">
|
|
|
|
|
<!-- File name -->
|
|
|
|
|
<span class="files-list__row-name-text">{{ displayName }}</span>
|
|
|
|
|
<span class="files-list__row-name-text">
|
|
|
|
|
<!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
|
|
|
|
|
{{ displayName }}<span class="files-list__row-name-ext" v-text="source.extension" />
|
|
|
|
|
</span>
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
<!-- Actions -->
|
|
|
|
|
<td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
|
|
|
|
|
<td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
|
|
|
|
|
<!-- Inline actions -->
|
|
|
|
|
<!-- TODO: implement CustomElementRender -->
|
|
|
|
|
|
|
|
|
|
@ -81,6 +106,7 @@
|
|
|
|
|
<NcActionButton v-for="action in enabledMenuActions"
|
|
|
|
|
:key="action.id"
|
|
|
|
|
:class="'files-list__row-action-' + action.id"
|
|
|
|
|
:close-after-click="true"
|
|
|
|
|
@click="onActionClick(action)">
|
|
|
|
|
<template #icon>
|
|
|
|
|
<NcLoadingIcon v-if="loading === action.id" :size="18" />
|
|
|
|
|
@ -115,11 +141,13 @@
|
|
|
|
|
|
|
|
|
|
<script lang='ts'>
|
|
|
|
|
import { debounce } from 'debounce'
|
|
|
|
|
import { emit, subscribe } from '@nextcloud/event-bus'
|
|
|
|
|
import { formatFileSize } from '@nextcloud/files'
|
|
|
|
|
import { Fragment } from 'vue-frag'
|
|
|
|
|
import { join } from 'path'
|
|
|
|
|
import { showError, showSuccess } from '@nextcloud/dialogs'
|
|
|
|
|
import { translate } from '@nextcloud/l10n'
|
|
|
|
|
import { vOnClickOutside } from '@vueuse/components'
|
|
|
|
|
import axios from '@nextcloud/axios'
|
|
|
|
|
import CancelablePromise from 'cancelable-promise'
|
|
|
|
|
import FileIcon from 'vue-material-design-icons/File.vue'
|
|
|
|
|
import FolderIcon from 'vue-material-design-icons/Folder.vue'
|
|
|
|
|
@ -127,6 +155,7 @@ 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 StarIcon from 'vue-material-design-icons/Star.vue'
|
|
|
|
|
import Vue from 'vue'
|
|
|
|
|
|
|
|
|
|
@ -139,6 +168,7 @@ import { useFilesStore } from '../store/files.ts'
|
|
|
|
|
import { useKeyboardStore } from '../store/keyboard.ts'
|
|
|
|
|
import { useSelectionStore } from '../store/selection.ts'
|
|
|
|
|
import { useUserConfigStore } from '../store/userconfig.ts'
|
|
|
|
|
import { useRenamingStore } from '../store/renaming.ts'
|
|
|
|
|
import CustomElementRender from './CustomElementRender.vue'
|
|
|
|
|
import CustomSvgIconRender from './CustomSvgIconRender.vue'
|
|
|
|
|
import logger from '../logger.js'
|
|
|
|
|
@ -146,6 +176,8 @@ import logger from '../logger.js'
|
|
|
|
|
// The registered actions list
|
|
|
|
|
const actions = getFileActions()
|
|
|
|
|
|
|
|
|
|
Vue.directive('onClickOutside', vOnClickOutside)
|
|
|
|
|
|
|
|
|
|
export default Vue.extend({
|
|
|
|
|
name: 'FileEntry',
|
|
|
|
|
|
|
|
|
|
@ -159,6 +191,7 @@ export default Vue.extend({
|
|
|
|
|
NcActions,
|
|
|
|
|
NcCheckboxRadioSwitch,
|
|
|
|
|
NcLoadingIcon,
|
|
|
|
|
NcTextField,
|
|
|
|
|
StarIcon,
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
@ -193,12 +226,14 @@ export default Vue.extend({
|
|
|
|
|
const actionsMenuStore = useActionsMenuStore()
|
|
|
|
|
const filesStore = useFilesStore()
|
|
|
|
|
const keyboardStore = useKeyboardStore()
|
|
|
|
|
const renamingStore = useRenamingStore()
|
|
|
|
|
const selectionStore = useSelectionStore()
|
|
|
|
|
const userConfigStore = useUserConfigStore()
|
|
|
|
|
return {
|
|
|
|
|
actionsMenuStore,
|
|
|
|
|
filesStore,
|
|
|
|
|
keyboardStore,
|
|
|
|
|
renamingStore,
|
|
|
|
|
selectionStore,
|
|
|
|
|
userConfigStore,
|
|
|
|
|
}
|
|
|
|
|
@ -237,8 +272,12 @@ export default Vue.extend({
|
|
|
|
|
return this.source?.fileid?.toString?.()
|
|
|
|
|
},
|
|
|
|
|
displayName() {
|
|
|
|
|
return this.source.attributes.displayName
|
|
|
|
|
|| this.source.basename
|
|
|
|
|
const ext = (this.source.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() {
|
|
|
|
|
@ -261,32 +300,18 @@ export default Vue.extend({
|
|
|
|
|
return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
linkAttrs() {
|
|
|
|
|
linkTo() {
|
|
|
|
|
if (this.enabledDefaultActions.length > 0) {
|
|
|
|
|
const action = this.enabledDefaultActions[0]
|
|
|
|
|
const displayName = action.displayName([this.source], this.currentView)
|
|
|
|
|
return {
|
|
|
|
|
class: ['files-list__row-default-action', 'files-list__row-action-' + action.id],
|
|
|
|
|
role: 'button',
|
|
|
|
|
title: displayName,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A folder would never reach this point
|
|
|
|
|
* as it has open-folder as default action.
|
|
|
|
|
* Just to be safe, let's handle it.
|
|
|
|
|
*/
|
|
|
|
|
if (this.source.type === 'folder') {
|
|
|
|
|
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
|
|
|
|
|
return {
|
|
|
|
|
is: 'router-link',
|
|
|
|
|
title: this.t('files', 'Open folder {name}', { name: this.displayName }),
|
|
|
|
|
to,
|
|
|
|
|
role: 'button',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
download: this.source.basename,
|
|
|
|
|
href: this.source.source,
|
|
|
|
|
// TODO: Use first action title ?
|
|
|
|
|
title: this.t('files', 'Download file {name}', { name: this.displayName }),
|
|
|
|
|
@ -378,6 +403,21 @@ export default Vue.extend({
|
|
|
|
|
isFavorite() {
|
|
|
|
|
return this.source.attributes.favorite === 1
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
watch: {
|
|
|
|
|
@ -400,10 +440,18 @@ export default Vue.extend({
|
|
|
|
|
* When the source changes, reset the preview
|
|
|
|
|
* and fetch the new one.
|
|
|
|
|
*/
|
|
|
|
|
previewUrl() {
|
|
|
|
|
this.clearImg()
|
|
|
|
|
source() {
|
|
|
|
|
this.resetState()
|
|
|
|
|
this.debounceIfNotCached()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* If renaming starts, select the file name
|
|
|
|
|
* in the input, without the extension.
|
|
|
|
|
*/
|
|
|
|
|
isRenaming() {
|
|
|
|
|
this.startRenaming()
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -596,6 +644,135 @@ export default Vue.extend({
|
|
|
|
|
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?.() || ''
|
|
|
|
|
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 }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
checkIfNodeExists(name) {
|
|
|
|
|
return this.nodes.find(node => node.basename === name && node !== this.source)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
startRenaming() {
|
|
|
|
|
this.checkInputValidity()
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
const extLength = (this.source.extension || '').length
|
|
|
|
|
const length = this.source.basename.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()
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
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 oldSource = this.source.source
|
|
|
|
|
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, '_loading', true)
|
|
|
|
|
|
|
|
|
|
// Update node
|
|
|
|
|
this.source.rename(newName)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await axios({
|
|
|
|
|
method: 'MOVE',
|
|
|
|
|
url: oldSource,
|
|
|
|
|
headers: {
|
|
|
|
|
Destination: encodeURI(this.source.source),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Success 🎉
|
|
|
|
|
emit('files:node:updated', this.source)
|
|
|
|
|
emit('files:node:renamed', this.source)
|
|
|
|
|
showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
|
|
|
|
|
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.dir }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unknown error
|
|
|
|
|
showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
|
|
|
|
|
} finally {
|
|
|
|
|
this.loading = false
|
|
|
|
|
Vue.set(this.source, '_loading', false)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
t: translate,
|
|
|
|
|
formatFileSize,
|
|
|
|
|
},
|
|
|
|
|
|