Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(files): implement built-in renaming
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
  • Loading branch information
skjnldsv committed Jul 5, 2023
commit b9e6f4d25955e6ff4267a2f38fb7b5a76064831a
273 changes: 225 additions & 48 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->

Expand All @@ -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" />
Expand Down Expand Up @@ -115,18 +141,21 @@

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

Expand All @@ -139,13 +168,16 @@ 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'

// The registered actions list
const actions = getFileActions()

Vue.directive('onClickOutside', vOnClickOutside)

export default Vue.extend({
name: 'FileEntry',

Expand All @@ -159,6 +191,7 @@ export default Vue.extend({
NcActions,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcTextField,
StarIcon,
},

Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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 }),
Expand Down Expand Up @@ -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: {
Expand All @@ -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()
},
},

/**
Expand Down Expand Up @@ -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,
},
Expand Down
Loading