diff options
Diffstat (limited to 'apps/files/src/components/FileEntry/FileEntryName.vue')
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue new file mode 100644 index 00000000000..418f9581eb6 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,288 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + ref="renameForm" + v-on-click-outside="onRename" + :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.esc="stopRenaming" /> + </form> + + <component :is="linkTo.is" + v-else + ref="basename" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + v-bind="linkTo.params"> + <!-- Filename --> + <span class="files-list__row-name-text" dir="auto"> + <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="basename" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> + </span> + </component> +</template> + +<script lang="ts"> +import type { FileAction, Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { showError, showSuccess } from '@nextcloud/dialogs' +import { FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent, inject } from 'vue' + +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation.ts' +import { useRenamingStore } from '../../store/renaming.ts' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + /** + * The filename without extension + */ + basename: { + type: String, + required: true, + }, + /** + * The extension of the filename + */ + extension: { + type: String, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() + const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') + + return { + currentView, + defaultFileAction, + directory, + filesListWidth, + + renamingStore, + userConfigStore, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get(): string { + return this.renamingStore.newNodeName + }, + set(newName: string) { + this.renamingStore.newNodeName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'Filename'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.status === NodeStatus.FAILED) { + return { + is: 'span', + params: { + title: t('files', 'This node is unavailable'), + }, + } + } + + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) + return { + is: 'button', + params: { + 'aria-label': displayName, + title: displayName, + tabindex: '0', + }, + } + } + + // nothing interactive here, there is no default action + // so if not even the download action works we only can show the list entry + return { + is: 'span', + } + }, + }, + + watch: { + /** + * If renaming starts, select the filename + * in the input, without the extension. + * @param renaming + */ + isRenaming: { + immediate: true, + handler(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + newName() { + // Check validity of the new name + const newName = this.newName.trim?.() || '' + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return + } + + let validity = getFilenameValidity(newName) + // Checking if already exists + if (validity === '' && this.checkIfNodeExists(newName)) { + validity = t('files', 'Another entry with the same name already exists.') + } + this.$nextTick(() => { + if (this.isRenaming) { + input.setCustomValidity(validity) + input.reportValidity() + } + }) + }, + }, + + methods: { + checkIfNodeExists(name: string) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.$nextTick(() => { + // Using split to get the true string length + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + logger.error('Could not find the rename input') + return + } + input.focus() + const length = this.source.basename.length - (this.source.extension ?? '').length + input.setSelectionRange(0, length) + + // 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 newName = this.newName.trim?.() || '' + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) + return + } + + const oldName = this.source.basename + if (newName === oldName) { + this.stopRenaming() + return + } + + try { + const status = await this.renamingStore.rename() + if (status) { + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) + this.$nextTick(() => { + const nameContainer = this.$refs.basename as HTMLElement | undefined + nameContainer?.focus() + }) + } else { + // Was cancelled - meaning the renaming state is just reset + } + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() + } + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +button.files-list__row-name-link { + background-color: unset; + border: none; + font-weight: normal; + + &:active { + // No active styles - handled by the row entry + background-color: unset !important; + } +} +</style> |