mirror of
https://github.com/nextcloud/server.git
synced 2026-04-29 18:11:41 -04:00
feat(files): implement built-in renaming
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
a0597da460
commit
b9e6f4d259
6 changed files with 313 additions and 48 deletions
|
|
@ -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'" />
|
||||
<!-- 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 }" />
|
||||
<!-- 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 }" />
|
||||
<span v-else-if="mimeIconUrl"
|
||||
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
|
||||
:style="{ backgroundImage: mimeIconUrl }" />
|
||||
|
||||
<FileIcon v-else />
|
||||
<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>
|
||||
<!-- 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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
// Entry preview or mime icon
|
||||
.files-list__row-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -246,13 +247,18 @@ export default Vue.extend({
|
|||
margin-right: var(--checkbox-padding);
|
||||
color: var(--color-primary-element);
|
||||
|
||||
& > span {
|
||||
justify-content: flex-start;
|
||||
// Icon is also clickable
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&> span:not(.files-list__row-icon-favorite) svg {
|
||||
width: var(--icon-preview-size);
|
||||
height: var(--icon-preview-size);
|
||||
& > span {
|
||||
justify-content: flex-start;
|
||||
|
||||
&:not(.files-list__row-icon-favorite) svg {
|
||||
width: var(--icon-preview-size);
|
||||
height: var(--icon-preview-size);
|
||||
}
|
||||
}
|
||||
|
||||
&-preview {
|
||||
|
|
@ -274,6 +280,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
// Entry link
|
||||
.files-list__row-name {
|
||||
// Prevent link from overflowing
|
||||
overflow: hidden;
|
||||
|
|
@ -286,6 +293,8 @@ export default Vue.extend({
|
|||
// Fill cell height and width
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Necessary for flex grow to work
|
||||
min-width: 0;
|
||||
|
||||
// Keyboard indicator a11y
|
||||
&:focus .files-list__row-name-text,
|
||||
|
|
@ -300,6 +309,29 @@ export default Vue.extend({
|
|||
padding: 5px 10px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.files-list__row-name-ext {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
// Rename form
|
||||
.files-list__row-rename {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
input {
|
||||
width: 100%;
|
||||
// Align with text, 0 - padding - border
|
||||
margin-left: -8px;
|
||||
padding: 2px 6px;
|
||||
border-width: 2px;
|
||||
|
||||
&:invalid {
|
||||
// Show red border on invalid input
|
||||
border-color: var(--color-error);
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-actions {
|
||||
|
|
|
|||
48
apps/files/src/store/renaming.ts
Normal file
48
apps/files/src/store/renaming.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { defineStore } from 'pinia'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import type { Node } from '@nextcloud/files'
|
||||
import type { RenamingStore } from '../types'
|
||||
|
||||
export const useRenamingStore = function() {
|
||||
const store = defineStore('renaming', {
|
||||
state: () => ({
|
||||
renamingNode: undefined,
|
||||
newName: '',
|
||||
} as RenamingStore),
|
||||
})
|
||||
|
||||
const renamingStore = store(...arguments)
|
||||
|
||||
// Make sure we only register the listeners once
|
||||
if (!renamingStore._initialized) {
|
||||
subscribe('files:node:rename', function(node: Node) {
|
||||
renamingStore.renamingNode = node
|
||||
renamingStore.newName = node.basename
|
||||
})
|
||||
renamingStore._initialized = true
|
||||
}
|
||||
|
||||
return renamingStore
|
||||
}
|
||||
|
|
@ -96,3 +96,9 @@ export interface ViewConfigs {
|
|||
export interface ViewConfigStore {
|
||||
viewConfig: ViewConfigs
|
||||
}
|
||||
|
||||
// Renaming store
|
||||
export interface RenamingStore {
|
||||
renamingNode?: Node
|
||||
newName: string
|
||||
}
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -31,6 +31,7 @@
|
|||
"@nextcloud/vue": "^7.12.0",
|
||||
"@nextcloud/vue-dashboard": "^2.0.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.2.0",
|
||||
"autosize": "^6.0.1",
|
||||
"backbone": "^1.4.1",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"@nextcloud/vue": "^7.12.0",
|
||||
"@nextcloud/vue-dashboard": "^2.0.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.2.0",
|
||||
"autosize": "^6.0.1",
|
||||
"backbone": "^1.4.1",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue