diff options
Diffstat (limited to 'apps/files/src')
33 files changed, 1257 insertions, 996 deletions
diff --git a/apps/files/src/FilesApp.vue b/apps/files/src/FilesApp.vue new file mode 100644 index 00000000000..a2a7f495c09 --- /dev/null +++ b/apps/files/src/FilesApp.vue @@ -0,0 +1,25 @@ +<template> + <NcContent app-name="files"> + <Navigation /> + <FilesList /> + </NcContent> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' + +import NcContent from '@nextcloud/vue/dist/Components/NcContent.js' + +import Navigation from './views/Navigation.vue' +import FilesList from './views/FilesList.vue' + +export default defineComponent({ + name: 'FilesApp', + + components: { + NcContent, + FilesList, + Navigation, + }, +}) +</script> diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts index c568ec59d9d..0adb302dc32 100644 --- a/apps/files/src/actions/deleteAction.spec.ts +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -22,9 +22,9 @@ import { action } from './deleteAction' import { expect } from '@jest/globals' import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' -import * as auth from '@nextcloud/auth' import * as eventBus from '@nextcloud/event-bus' import axios from '@nextcloud/axios' + import logger from '../logger' const view = { @@ -50,36 +50,81 @@ describe('Delete action conditions tests', () => { permissions: Permission.ALL, }) - // const file2 = new File({ - // id: 1, - // source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - // owner: 'admin', - // mime: 'text/plain', - // permissions: Permission.ALL, - // }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'shared', + }, + }) + + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const folder2 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'shared', + }, + }) + + const folder3 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'external', + }, + }) test('Default values', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('delete') - expect(action.displayName([file], view)).toBe('Delete') + expect(action.displayName([file], view)).toBe('Delete file') expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') expect(action.default).toBeUndefined() expect(action.order).toBe(100) }) - test('Default trashbin view values', () => { + test('Default folder displayName', () => { + expect(action.displayName([folder], view)).toBe('Delete folder') + }) + + test('Default trashbin view displayName', () => { expect(action.displayName([file], trashbinView)).toBe('Delete permanently') }) - // TODO: Fix this test - // test('Shared node values', () => { - // jest.spyOn(auth, 'getCurrentUser').mockReturnValue(null) - // expect(action.displayName([file2], view)).toBe('Unshare') - // }) + test('Shared root node displayName', () => { + expect(action.displayName([file2], view)).toBe('Leave this share') + expect(action.displayName([folder2], view)).toBe('Leave this share') + expect(action.displayName([file2, folder2], view)).toBe('Leave these shares') + }) + + test('External storage root node displayName', () => { + expect(action.displayName([folder3], view)).toBe('Disconnect storage') + expect(action.displayName([folder3, folder3], view)).toBe('Disconnect storages') + }) - // test('Shared and owned nodes values', () => { - // expect(action.displayName([file, file2], view)).toBe('Delete and unshare') - // }) + test('Shared and owned nodes displayName', () => { + expect(action.displayName([file, file2], view)).toBe('Delete and unshare') + }) }) describe('Delete action enabled tests', () => { diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 1bc07aaa6f9..a086eb2e666 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -20,21 +20,102 @@ * */ import { emit } from '@nextcloud/event-bus' -import { Permission, Node, View, FileAction } from '@nextcloud/files' -import { translate as t } from '@nextcloud/l10n' +import { Permission, Node, View, FileAction, FileType } from '@nextcloud/files' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' import axios from '@nextcloud/axios' + +import CloseSvg from '@mdi/svg/svg/close.svg?raw' +import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw' import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw' import logger from '../logger.js' +const canUnshareOnly = (nodes: Node[]) => { + return nodes.every(node => node.attributes['is-mount-root'] === true + && node.attributes['mount-type'] === 'shared') +} + +const canDisconnectOnly = (nodes: Node[]) => { + return nodes.every(node => node.attributes['is-mount-root'] === true + && node.attributes['mount-type'] === 'external') +} + +const isMixedUnshareAndDelete = (nodes: Node[]) => { + if (nodes.length === 1) { + return false + } + + const hasSharedItems = nodes.some(node => canUnshareOnly([node])) + const hasDeleteItems = nodes.some(node => !canUnshareOnly([node])) + return hasSharedItems && hasDeleteItems +} + +const isAllFiles = (nodes: Node[]) => { + return !nodes.some(node => node.type !== FileType.File) +} + +const isAllFolders = (nodes: Node[]) => { + return !nodes.some(node => node.type !== FileType.Folder) +} + export const action = new FileAction({ id: 'delete', displayName(nodes: Node[], view: View) { - return view.id === 'trashbin' - ? t('files', 'Delete permanently') - : t('files', 'Delete') + /** + * If we're in the trashbin, we can only delete permanently + */ + if (view.id === 'trashbin') { + return t('files', 'Delete permanently') + } + + /** + * If we're in the sharing view, we can only unshare + */ + if (isMixedUnshareAndDelete(nodes)) { + return t('files', 'Delete and unshare') + } + + /** + * If those nodes are all the root node of a + * share, we can only unshare them. + */ + if (canUnshareOnly(nodes)) { + return n('files', 'Leave this share', 'Leave these shares', nodes.length) + } + + /** + * If those nodes are all the root node of an + * external storage, we can only disconnect it. + */ + if (canDisconnectOnly(nodes)) { + return n('files', 'Disconnect storage', 'Disconnect storages', nodes.length) + } + + /** + * If we're only selecting files, use proper wording + */ + if (isAllFiles(nodes)) { + return n('files', 'Delete file', 'Delete files', nodes.length) + } + + /** + * If we're only selecting folders, use proper wording + */ + if (isAllFolders(nodes)) { + return n('files', 'Delete folder', 'Delete folders', nodes.length) + } + + return t('files', 'Delete') }, - iconSvgInline: () => { + iconSvgInline: (nodes: Node[]) => { + if (canUnshareOnly(nodes)) { + return CloseSvg + } + + if (canDisconnectOnly(nodes)) { + return NetworkOffSvg + } + return TrashCanSvg }, diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index bc177fb1989..eb90fac71f8 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -8,11 +8,13 @@ v-bind="section" dir="auto" :to="section.to" + :force-icon-text="true" :title="titleForSection(index, section)" :aria-description="ariaForSection(section)" @click.native="onClick(section.to)"> <template v-if="index === 0" #icon> - <Home :size="20"/> + <NcIconSvgWrapper v-if="section.icon" :size="20" :svg="section.icon" /> + <Home v-else :size="20"/> </template> </NcBreadcrumb> @@ -24,11 +26,12 @@ </template> <script> -import { translate as t} from '@nextcloud/l10n' import { basename } from 'path' +import { translate as t } from '@nextcloud/l10n' import Home from 'vue-material-design-icons/Home.vue' import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import Vue from 'vue' import { useFilesStore } from '../store/files.ts' @@ -41,6 +44,7 @@ export default Vue.extend({ Home, NcBreadcrumbs, NcBreadcrumb, + NcIconSvgWrapper, }, props: { @@ -81,6 +85,7 @@ export default Vue.extend({ exact: true, name: this.getDirDisplayName(dir), to, + icon: this.$navigation.active?.icon || null, } }) }, @@ -95,7 +100,7 @@ export default Vue.extend({ }, getDirDisplayName(path) { if (path === '/') { - return t('files', 'Home') + return this.$navigation?.active?.name || t('files', 'Home') } const fileId = this.getFileIdFromPath(path) diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 22de0f662de..a9c1d8e99ad 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -47,6 +47,7 @@ import { defineComponent } from 'vue' import { Folder, Permission } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { UploadStatus } from '@nextcloud/upload' import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' @@ -126,7 +127,7 @@ export default defineComponent({ // only when we're leaving the current element // Avoid flickering const currentTarget = event.currentTarget as HTMLElement - if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { + if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) { return } @@ -143,10 +144,11 @@ export default defineComponent({ } }, - onDrop(event: DragEvent) { - logger.debug('Dropped on DragAndDropNotice', { event, error: this.cantUploadLabel }) + async onDrop(event: DragEvent) { + logger.debug('Dropped on DragAndDropNotice', { event }) - if (!this.canUpload || this.isQuotaExceeded) { + // cantUploadLabel is null if we can upload + if (this.cantUploadLabel) { showError(this.cantUploadLabel) return } @@ -162,23 +164,31 @@ export default defineComponent({ // Start upload logger.debug(`Uploading files to ${this.currentFolder.path}`) // Process finished uploads - handleDrop(event.dataTransfer).then((uploads) => { - logger.debug('Upload terminated', { uploads }) - showSuccess(t('files', 'Upload successful')) - - // Scroll to last upload in current directory if terminated - const lastUpload = uploads.findLast((upload) => !upload.file.webkitRelativePath.includes('/') && upload.response?.headers?.['oc-fileid']) - if (lastUpload !== undefined) { - this.$router.push({ - ...this.$route, - params: { - view: this.$route.params?.view ?? 'files', - // Remove instanceid from header response - fileid: parseInt(lastUpload.response!.headers['oc-fileid']), - }, - }) - } - }) + const uploads = await handleDrop(event.dataTransfer) + logger.debug('Upload terminated', { uploads }) + + if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) { + showError(t('files', 'Some files could not be uploaded')) + const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED) + logger.debug('Some files could not be uploaded', { failedUploads }) + } else { + showSuccess(t('files', 'Files uploaded successfully')) + } + + // Scroll to last successful upload in current directory if terminated + const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED + && !upload.file.webkitRelativePath.includes('/') + && upload.response?.headers?.['oc-fileid']) + + if (lastUpload !== undefined) { + this.$router.push({ + ...this.$route, + params: { + view: this.$route.params?.view ?? 'files', + fileid: parseInt(lastUpload.response!.headers['oc-fileid']), + }, + }) + } } this.dragover = false }, diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 8b4c7b71ef9..973e1de667f 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -21,7 +21,11 @@ --> <template> - <tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" + <tr :class="{ + 'files-list__row--dragover': dragover, + 'files-list__row--loading': isLoading, + 'files-list__row--active': isActive, + }" data-cy-files-list-row :data-cy-files-list-row-fileid="fileid" :data-cy-files-list-row-name="source.basename" @@ -96,37 +100,23 @@ </template> <script lang="ts"> -import type { PropType } from 'vue' - -import { extname, join } from 'path' -import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' -import { getUploader } from '@nextcloud/upload' -import { showError } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { vOnClickOutside } from '@vueuse/components' +import { defineComponent } from 'vue' +import { Permission, formatFileSize } from '@nextcloud/files' import moment from '@nextcloud/moment' -import { generateUrl } from '@nextcloud/router' -import Vue, { defineComponent } from 'vue' -import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { getDragAndDropPreview } from '../utils/dragUtils.ts' -import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' -import { hashCode } from '../utils/hashUtils.ts' -import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { useRenamingStore } from '../store/renaming.ts' import { useSelectionStore } from '../store/selection.ts' + +import FileEntryMixin from './FileEntryMixin.ts' import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' import CustomElementRender from './CustomElementRender.vue' import FileEntryActions from './FileEntry/FileEntryActions.vue' import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' import FileEntryName from './FileEntry/FileEntryName.vue' import FileEntryPreview from './FileEntry/FileEntryPreview.vue' -import logger from '../logger.js' - -Vue.directive('onClickOutside', vOnClickOutside) export default defineComponent({ name: 'FileEntry', @@ -140,6 +130,10 @@ export default defineComponent({ NcDateTime, }, + mixins: [ + FileEntryMixin, + ], + props: { isMtimeAvailable: { type: Boolean, @@ -149,18 +143,6 @@ export default defineComponent({ type: Boolean, default: false, }, - source: { - type: [Folder, NcFile, Node] as PropType<Node>, - required: true, - }, - nodes: { - type: Array as PropType<Node[]>, - required: true, - }, - filesListWidth: { - type: Number, - default: 0, - }, compact: { type: Boolean, default: false, @@ -182,13 +164,6 @@ export default defineComponent({ } }, - data() { - return { - loading: '', - dragover: false, - } - }, - computed: { /** * Conditionally add drag and drop listeners @@ -210,9 +185,6 @@ export default defineComponent({ drop: this.onDrop, } }, - currentView(): View { - return this.$navigation.active as View - }, columns() { // Hide columns if the list is too small if (this.filesListWidth < 512 || this.compact) { @@ -221,42 +193,10 @@ export default defineComponent({ 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?.() - }, - uniqueId() { - return hashCode(this.source.source) - }, - isLoading() { - return this.source.status === NodeStatus.LOADING - }, - - 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 t('files', 'Pending') + return this.t('files', 'Pending') } return formatFileSize(size, true) }, @@ -297,260 +237,15 @@ export default defineComponent({ return '' }, - draggingFiles() { - return this.draggingStore.dragging - }, - selectedFiles() { - return this.selectionStore.selected - }, - isSelected() { - return this.selectedFiles.includes(this.fileid) - }, - - isRenaming() { - return this.renamingStore.renamingNode === this.source - }, - isRenamingSmallScreen() { - return this.isRenaming && this.filesListWidth < 512 - }, - - isActive() { - return this.fileid === this.currentFileId?.toString?.() - }, - - canDrag() { - if (this.isRenaming) { - return false - } - - 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 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId - }, - set(opened) { - // Only reset when opening a new menu - if (opened) { - // Reset any right click position override on close - // Wait for css animation to be done - const root = this.$root.$el as HTMLElement - root.style.removeProperty('--mouse-pos-x') - root.style.removeProperty('--mouse-pos-y') - } - - this.actionsMenuStore.opened = opened ? this.uniqueId : null - }, - }, - }, - - watch: { /** - * When the source changes, reset the preview - * and fetch the new one. + * This entry is the current active node */ - source() { - this.resetState() + isActive() { + return this.fileid === this.currentFileId?.toString?.() }, }, - beforeDestroy() { - this.resetState() - }, - methods: { - resetState() { - // Reset loading state - this.loading = '' - - this.$refs.preview.reset() - - // Close menu - this.openedMenu = false - }, - - // Open the actions menu on right click - onRightClick(event) { - // If already opened, fallback to default browser - if (this.openedMenu) { - return - } - - const root = this.$root.$el as HTMLElement - const contentRect = root.getBoundingClientRect() - // Using Math.min/max to prevent the menu from going out of the AppContent - // 200 = max width of the menu - root.style.setProperty('--mouse-pos-x', Math.max(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px') - root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px') - - // 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() - }, - - execDefaultAction(event) { - if (event.ctrlKey || event.metaKey) { - event.preventDefault() - window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) - return false - } - - this.$refs.actions.execDefaultAction(event) - }, - - openDetailsIfAvailable(event) { - event.preventDefault() - event.stopPropagation() - if (sidebarAction?.enabled?.([this.source], this.currentView)) { - sidebarAction.exec(this.source, this.currentView, this.currentDir) - } - }, - - onDragOver(event: DragEvent) { - this.dragover = this.canDrop - if (!this.canDrop) { - 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) { - // Counter bubbling, make sure we're ending the drag - // only when we're leaving the current element - const currentTarget = event.currentTarget as HTMLElement - if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { - return - } - - this.dragover = false - }, - - async onDragStart(event: DragEvent) { - event.stopPropagation() - if (!this.canDrag) { - event.preventDefault() - event.stopPropagation() - return - } - - logger.debug('Drag started', { event }) - - // Make sure that we're not dragging a file like the preview - event.dataTransfer?.clearData?.() - - // 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: DragEvent) { - // skip if native drop like text drag and drop from files names - if (!this.draggingFiles && !event.dataTransfer?.files?.length) { - return - } - - event.preventDefault() - event.stopPropagation() - - // 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 }) - - // Check whether we're uploading files - if (event.dataTransfer?.files?.length > 0) { - const uploader = getUploader() - event.dataTransfer.files.forEach((file: File) => { - uploader.upload(join(this.source.path, file.name), file) - }) - logger.debug(`Uploading files to ${this.source.path}`) - return - } - - 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(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) - } else { - showError(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, formatFileSize, }, }) diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 1e453fec706..86689cfe62b 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -35,7 +35,6 @@ <NcActions ref="actionsMenu" :boundaries-element="getBoundariesElement" :container="getBoundariesElement" - :disabled="isLoading || loading !== ''" :force-name="true" type="tertiary" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" @@ -45,6 +44,7 @@ <!-- Default actions list--> <NcActionButton v-for="action in enabledMenuActions" :key="action.id" + :ref="`action-${action.id}`" :class="{ [`files-list__row-action-${action.id}`]: true, [`files-list__row-action--menu`]: isMenu(action.id) @@ -64,7 +64,7 @@ <!-- Submenu actions list--> <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> <!-- Back to top-level button --> - <NcActionButton class="files-list__row-action-back" @click="openedSubmenu = null"> + <NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)"> <template #icon> <ArrowLeftIcon /> </template> @@ -271,6 +271,11 @@ export default Vue.extend({ }, async onActionClick(action, isSubmenu = false) { + // Skip click on loading + if (this.isLoading || this.loading !== '') { + return + } + // If the action is a submenu, we open it if (this.enabledSubmenuActions[action.id]) { this.openedSubmenu = action @@ -322,6 +327,21 @@ export default Vue.extend({ return this.enabledSubmenuActions[id]?.length > 0 }, + async onBackToMenuClick(action: FileAction) { + this.openedSubmenu = null + // Wait for first render + await this.$nextTick() + + // Focus the previous menu action button + this.$nextTick(() => { + // Focus the action button + const menuAction = this.$refs[`action-${action.id}`][0] + if (menuAction) { + menuAction.$el.querySelector('button')?.focus() + } + }) + }, + t, }, }) @@ -330,7 +350,7 @@ export default Vue.extend({ <style lang="scss"> // Allow right click to define the position of the menu // only if defined -.app-content[style*="mouse-pos-x"] .v-popper__popper { +.content[style*="mouse-pos-x"] .v-popper__popper { transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important; // If the menu is too close to the bottom, we move it up diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue index bb851ed1e0e..747ff8d6cc9 100644 --- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -33,7 +33,7 @@ <script lang="ts"> import { Node, FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import Vue, { PropType } from 'vue' +import { type PropType, defineComponent } from 'vue' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' @@ -42,7 +42,7 @@ import { useKeyboardStore } from '../../store/keyboard.ts' import { useSelectionStore } from '../../store/selection.ts' import logger from '../../logger.js' -export default Vue.extend({ +export default defineComponent({ name: 'FileEntryCheckbox', components: { @@ -52,7 +52,7 @@ export default Vue.extend({ props: { fileid: { - type: String, + type: Number, required: true, }, isLoading: { @@ -86,7 +86,7 @@ export default Vue.extend({ return this.selectedFiles.includes(this.fileid) }, index() { - return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid)) + return this.nodes.findIndex((node: Node) => node.fileid === this.fileid) }, isFile() { return this.source.type === FileType.File @@ -112,8 +112,9 @@ export default Vue.extend({ const lastSelection = this.selectionStore.lastSelection const filesToSelect = this.nodes - .map(file => file.fileid?.toString?.()) + .map(file => file.fileid) .slice(start, end + 1) + .filter(Boolean) as number[] // If already selected, update the new selection _without_ the current file const selection = [...lastSelection, ...filesToSelect] diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 99fd45813ed..9d332491bea 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -73,36 +73,20 @@ </template> <script lang="ts"> -import type { PropType } from 'vue' +import { defineComponent } from 'vue' -import { extname, join } from 'path' -import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' -import { getUploader } from '@nextcloud/upload' -import { showError } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' -import { vOnClickOutside } from '@vueuse/components' -import Vue from 'vue' - -import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { getDragAndDropPreview } from '../utils/dragUtils.ts' -import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' -import { hashCode } from '../utils/hashUtils.ts' -import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { useRenamingStore } from '../store/renaming.ts' import { useSelectionStore } from '../store/selection.ts' +import FileEntryMixin from './FileEntryMixin.ts' import FileEntryActions from './FileEntry/FileEntryActions.vue' import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' import FileEntryName from './FileEntry/FileEntryName.vue' import FileEntryPreview from './FileEntry/FileEntryPreview.vue' -import logger from '../logger.js' - -Vue.directive('onClickOutside', vOnClickOutside) -export default Vue.extend({ +export default defineComponent({ name: 'FileEntryGrid', components: { @@ -112,21 +96,11 @@ export default Vue.extend({ FileEntryPreview, }, + mixins: [ + FileEntryMixin, + ], + inheritAttrs: false, - props: { - source: { - type: [Folder, NcFile, Node] as PropType<Node>, - required: true, - }, - nodes: { - type: Array as PropType<Node[]>, - required: true, - }, - filesListWidth: { - type: Number, - default: 0, - }, - }, setup() { const actionsMenuStore = useActionsMenuStore() @@ -145,271 +119,8 @@ export default Vue.extend({ data() { return { - loading: '', - dragover: false, + gridMode: true, } }, - - computed: { - currentView(): View { - return this.$navigation.active as View - }, - - 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?.() - }, - uniqueId() { - return hashCode(this.source.source) - }, - isLoading() { - return this.source.status === NodeStatus.LOADING - }, - - 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) - }, - - draggingFiles() { - return this.draggingStore.dragging - }, - selectedFiles() { - return this.selectionStore.selected - }, - isSelected() { - return this.selectedFiles.includes(this.fileid) - }, - - isRenaming() { - return this.renamingStore.renamingNode === this.source - }, - - 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 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId - }, - set(opened) { - this.actionsMenuStore.opened = opened ? this.uniqueId : null - }, - }, - }, - - watch: { - /** - * When the source changes, reset the preview - * and fetch the new one. - */ - source() { - this.resetState() - }, - }, - - beforeDestroy() { - this.resetState() - }, - - methods: { - resetState() { - // Reset loading state - this.loading = '' - - this.$refs.preview.reset() - - // Close menu - this.openedMenu = false - }, - - // 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() - }, - - execDefaultAction(event) { - if (event.ctrlKey || event.metaKey) { - event.preventDefault() - window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) - return false - } - - this.$refs.actions.execDefaultAction(event) - }, - - openDetailsIfAvailable(event) { - event.preventDefault() - event.stopPropagation() - if (sidebarAction?.enabled?.([this.source], this.currentView)) { - sidebarAction.exec(this.source, this.currentView, this.currentDir) - } - }, - - onDragOver(event: DragEvent) { - this.dragover = this.canDrop - if (!this.canDrop) { - 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) { - // Counter bubbling, make sure we're ending the drag - // only when we're leaving the current element - const currentTarget = event.currentTarget as HTMLElement - if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { - 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) { - event.preventDefault() - event.stopPropagation() - - // 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 }) - - // Check whether we're uploading files - if (event.dataTransfer?.files?.length > 0) { - const uploader = getUploader() - event.dataTransfer.files.forEach((file: File) => { - uploader.upload(join(this.source.path, file.name), file) - }) - logger.debug(`Uploading files to ${this.source.path}`) - return - } - - 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(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) - } else { - showError(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, - }, }) </script> diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts new file mode 100644 index 00000000000..69638d33212 --- /dev/null +++ b/apps/files/src/components/FileEntryMixin.ts @@ -0,0 +1,388 @@ +/** + * @copyright Copyright (c) 2024 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/>. + * + */ + +import type { PropType } from 'vue' + +import { extname, join } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { Upload, getUploader } from '@nextcloud/upload' +import { vOnClickOutside } from '@vueuse/components' +import Vue, { defineComponent } from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' +import { hashCode } from '../utils/hashUtils.ts' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' +import logger from '../logger.js' + +Vue.directive('onClickOutside', vOnClickOutside) + +export default defineComponent({ + props: { + source: { + type: [Folder, NcFile, Node] as PropType<Node>, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + data() { + return { + loading: '', + dragover: false, + gridMode: false, + } + }, + + computed: { + currentView(): View { + return this.$navigation.active as View + }, + + 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 + }, + uniqueId() { + return hashCode(this.source.source) + }, + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + 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) + }, + + draggingFiles() { + return this.draggingStore.dragging + }, + selectedFiles() { + return this.selectionStore.selected + }, + isSelected() { + return this.fileid && this.selectedFiles.includes(this.fileid) + }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + + isActive() { + return this.fileid?.toString?.() === this.currentFileId?.toString?.() + }, + + canDrag() { + if (this.isRenaming) { + return false + } + + 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.fileid && this.draggingFiles.includes(this.fileid)) { + return false + } + + return (this.source.permissions & Permission.CREATE) !== 0 + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId.toString() + }, + set(opened) { + // Only reset when opening a new menu + if (opened) { + // Reset any right click position override on close + // Wait for css animation to be done + const root = this.$root.$el as HTMLElement + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + + this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null + }, + }, + }, + + watch: { + /** + * When the source changes, reset the preview + * and fetch the new one. + */ + source() { + this.resetState() + }, + }, + + beforeDestroy() { + this.resetState() + }, + + methods: { + resetState() { + // Reset loading state + this.loading = '' + + this.$refs.preview.reset() + + // Close menu + this.openedMenu = false + }, + + // Open the actions menu on right click + onRightClick(event) { + // If already opened, fallback to default browser + if (this.openedMenu) { + return + } + + // The grid mode is compact enough to not care about + // the actions menu mouse position + if (!this.gridMode) { + const root = this.$root.$el as HTMLElement + const contentRect = root.getBoundingClientRect() + // Using Math.min/max to prevent the menu from going out of the AppContent + // 200 = max width of the menu + root.style.setProperty('--mouse-pos-x', Math.max(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px') + root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px') + } + + // 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.toString() + + // Prevent any browser defaults + event.preventDefault() + event.stopPropagation() + }, + + execDefaultAction(event) { + if (event.ctrlKey || event.metaKey) { + event.preventDefault() + window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) + return false + } + + this.$refs.actions.execDefaultAction(event) + }, + + openDetailsIfAvailable(event) { + event.preventDefault() + event.stopPropagation() + if (sidebarAction?.enabled?.([this.source], this.currentView)) { + sidebarAction.exec(this.source, this.currentView, this.currentDir) + } + }, + + onDragOver(event: DragEvent) { + this.dragover = this.canDrop + if (!this.canDrop) { + 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) { + // Counter bubbling, make sure we're ending the drag + // only when we're leaving the current element + const currentTarget = event.currentTarget as HTMLElement + if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { + return + } + + this.dragover = false + }, + + async onDragStart(event: DragEvent) { + event.stopPropagation() + if (!this.canDrag || !this.fileid) { + event.preventDefault() + event.stopPropagation() + return + } + + logger.debug('Drag started', { event }) + + // Make sure that we're not dragging a file like the preview + event.dataTransfer?.clearData?.() + + // 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: DragEvent) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.files?.length) { + return + } + + event.preventDefault() + event.stopPropagation() + + // 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 }) + + // Check whether we're uploading files + if (event.dataTransfer?.files + && event.dataTransfer.files.length > 0) { + const uploader = getUploader() + + // Check whether the uploader is in the same folder + // This should never happen™ + if (!uploader.destination.path.startsWith(uploader.destination.path)) { + logger.error('The current uploader destination is not the same as the current folder') + showError(t('files', 'An error occurred while uploading. Please try again later.')) + return + } + + logger.debug(`Uploading files to ${this.source.path}`) + const queue = [] as Promise<Upload>[] + for (const file of event.dataTransfer.files) { + // Because the uploader destination is properly set to the current folder + // we can just use the basename as the relative path. + queue.push(uploader.upload(join(this.source.basename, file.name), file)) + } + + const results = await Promise.allSettled(queue) + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while uploading files', { errors }) + showError(t('files', 'Some files could not be uploaded')) + return + } + + logger.debug('Files uploaded successfully') + showSuccess(t('files', 'Files uploaded successfully')) + return + } + + 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(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) + } else { + showError(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, + }, +}) diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 148ce3bc4e5..c45090ca37d 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -73,22 +73,21 @@ <script lang="ts"> import { translate as t } from '@nextcloud/l10n' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import Vue from 'vue' +import { defineComponent, type PropType } from 'vue' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' -import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import filesSortingMixin from '../mixins/filesSorting.ts' import logger from '../logger.js' +import type { Node } from '@nextcloud/files' -export default Vue.extend({ +export default defineComponent({ name: 'FilesListTableHeader', components: { FilesListTableHeaderButton, NcCheckboxRadioSwitch, - FilesListTableHeaderActions, }, mixins: [ @@ -105,7 +104,7 @@ export default Vue.extend({ default: false, }, nodes: { - type: Array, + type: Array as PropType<Node[]>, required: true, }, filesListWidth: { @@ -181,13 +180,13 @@ export default Vue.extend({ 'files-list__column': true, 'files-list__column--sortable': !!column.sort, 'files-list__row-column-custom': true, - [`files-list__row-${this.currentView.id}-${column.id}`]: true, + [`files-list__row-${this.currentView?.id}-${column.id}`]: true, } }, onToggleAll(selected) { if (selected) { - const selection = this.nodes.map(node => node.fileid.toString()) + const selection = this.nodes.map(node => node.fileid).filter(Boolean) as number[] logger.debug('Added all nodes to selection', { selection }) this.selectionStore.setLastIndex(null) this.selectionStore.set(selection) diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 296be604820..ff9c0ee9bc5 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -42,25 +42,26 @@ </template> <script lang="ts"> -import { NodeStatus, getFileActions } from '@nextcloud/files' +import { Node, NodeStatus, View, getFileActions } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import Vue from 'vue' +import Vue, { defineComponent, type PropType } from 'vue' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' import logger from '../logger.js' +import type { FileId } from '../types' // The registered actions list const actions = getFileActions() -export default Vue.extend({ +export default defineComponent({ name: 'FilesListTableHeaderActions', components: { @@ -76,11 +77,11 @@ export default Vue.extend({ props: { currentView: { - type: Object, + type: Object as PropType<View>, required: true, }, selectedNodes: { - type: Array, + type: Array as PropType<FileId[]>, default: () => ([]), }, }, @@ -117,7 +118,7 @@ export default Vue.extend({ nodes() { return this.selectedNodes .map(fileid => this.getNode(fileid)) - .filter(node => node) + .filter(Boolean) as Node[] }, areSomeNodesLoading() { diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index ed0096e9792..b6a11391dc1 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -73,7 +73,7 @@ import type { Node as NcNode } from '@nextcloud/files' import type { PropType } from 'vue' import type { UserConfig } from '../types' -import { getFileListHeaders, Folder, View, getFileActions } from '@nextcloud/files' +import { getFileListHeaders, Folder, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { translate as t, translatePlural as n } from '@nextcloud/l10n' @@ -139,6 +139,7 @@ export default defineComponent({ FileEntryGrid, headers: getFileListHeaders(), scrollToIndex: 0, + openFileId: null as number|null, } }, @@ -151,6 +152,14 @@ export default defineComponent({ return parseInt(this.$route.params.fileid) || null }, + /** + * If the current `fileId` should be opened + * The state of the `openfile` query param + */ + openFile() { + return !!this.$route.query.openfile + }, + summary() { return getSummaryFor(this.nodes) }, @@ -199,6 +208,12 @@ export default defineComponent({ fileId(fileId) { this.scrollToFile(fileId, false) }, + + openFile(open: boolean) { + if (open) { + this.$nextTick(() => this.handleOpenFile(this.fileId)) + } + }, }, mounted() { @@ -206,9 +221,11 @@ export default defineComponent({ const mainContent = window.document.querySelector('main.app-content') as HTMLElement mainContent.addEventListener('dragover', this.onDragOver) - this.scrollToFile(this.fileId) - this.openSidebarForFile(this.fileId) - this.handleOpenFile() + // handle initially opening a given file + const { id } = loadState<{ id?: number }>('files', 'openFileInfo', {}) + this.scrollToFile(id ?? this.fileId) + this.openSidebarForFile(id ?? this.fileId) + this.handleOpenFile(id ?? null) }, beforeDestroy() { @@ -241,18 +258,22 @@ export default defineComponent({ } }, - handleOpenFile() { - const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number }) - if (openFileInfo === undefined) { + /** + * Handle opening a file (e.g. by ?openfile=true) + * @param fileId File to open + */ + handleOpenFile(fileId: number|null) { + if (fileId === null || this.openFileId === fileId) { return } - const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode - if (node === undefined) { + const node = this.nodes.find(n => n.fileid === fileId) as NcNode + if (node === undefined || node.type === FileType.Folder) { return } logger.debug('Opening file ' + node.path, { node }) + this.openFileId = fileId getFileActions() .filter(action => !action.enabled || action.enabled([node], this.currentView)) .sort((a, b) => (a.order || 0) - (b.order || 0)) @@ -339,14 +360,21 @@ export default defineComponent({ .files-list__table { display: block; + + &.files-list__table--with-thead-overlay { + // Hide the table header below the overlay + margin-top: calc(-1 * var(--row-height)); + } } .files-list__thead-overlay { - position: absolute; + // Pinned on top when scrolling + position: sticky; top: 0; - left: var(--row-height); // Save space for a row checkbox - right: 0; - z-index: 1000; + // Save space for a row checkbox + margin-left: var(--row-height); + // More than .files-list__thead + z-index: 20; display: flex; align-items: center; diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index 943d61cf0f5..8f96481232d 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -88,8 +88,17 @@ export default { }, mounted() { - // Warn the user if the available storage is 0 on page load - if (this.storageStats?.free <= 0) { + // If the user has a quota set, warn if the available account storage is <=0 + // + // NOTE: This doesn't catch situations where actual *server* + // disk (non-quota) space is low, but those should probably + // be handled differently anyway since a regular user can't + // can't do much about them (If we did want to indicate server disk + // space matters to users, we'd probably want to use a warning + // specific to that situation anyhow. So this covers warning covers + // our primary day-to-day concern (individual account quota usage). + // + if (this.storageStats?.quota > 0 && this.storageStats?.free <= 0) { this.showStorageFullWarning() } }, @@ -122,8 +131,9 @@ export default { throw new Error('Invalid storage stats') } - // Warn the user if the available storage changed from > 0 to 0 - if (this.storageStats?.free > 0 && response.data.data?.free <= 0) { + // Warn the user if the available account storage changed from > 0 to 0 + // (unless only because quota was intentionally set to 0 by admin in the interim) + if (this.storageStats?.free > 0 && response.data.data?.free <= 0 && response.data.data?.quota > 0) { this.showStorageFullWarning() } diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue new file mode 100644 index 00000000000..38337ddf4b8 --- /dev/null +++ b/apps/files/src/components/NewNodeDialog.vue @@ -0,0 +1,149 @@ +<!-- + - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + - + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @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> + <NcDialog :name="name" + :open="open" + close-on-click-outside + out-transition + @update:open="onClose"> + <template #actions> + <NcButton type="primary" + :disabled="!isUniqueName" + @click="onCreate"> + {{ t('files', 'Create') }} + </NcButton> + </template> + <form @submit.prevent="onCreate"> + <NcTextField ref="input" + :error="!isUniqueName" + :helper-text="errorMessage" + :label="label" + :value.sync="localDefaultName" /> + </form> + </NcDialog> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' + +import { defineComponent } from 'vue' +import { translate as t } from '@nextcloud/l10n' +import { getUniqueName } from '../utils/fileUtils' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +interface ICanFocus { + focus: () => void +} + +export default defineComponent({ + name: 'NewNodeDialog', + components: { + NcButton, + NcDialog, + NcTextField, + }, + props: { + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), + }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], + }, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, + }, + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), + }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), + }, + }, + emits: { + close: (name: string|null) => name === null || name, + }, + data() { + return { + localDefaultName: this.defaultName || t('files', 'New folder'), + } + }, + computed: { + errorMessage() { + if (this.isUniqueName) { + return '' + } else { + return t('files', 'A file or folder with that name already exists.') + } + }, + uniqueName() { + return getUniqueName(this.localDefaultName, this.otherNames) + }, + isUniqueName() { + return this.localDefaultName === this.uniqueName + }, + }, + watch: { + defaultName() { + this.localDefaultName = this.defaultName || t('files', 'New folder') + }, + }, + mounted() { + // on mounted lets use the unique name + this.localDefaultName = this.uniqueName + this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.()) + }, + methods: { + t, + onCreate() { + this.$emit('close', this.localDefaultName) + }, + onClose(state: boolean) { + if (!state) { + this.$emit('close', null) + } + }, + }, +}) +</script> diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 77454772f55..173fe284d27 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -1,15 +1,15 @@ <template> <div class="files-list" data-cy-files-list> - <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> - <slot name="header-overlay" /> - </div> - <!-- Header --> <div ref="before" class="files-list__before"> <slot name="before" /> </div> - <table class="files-list__table"> + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> + <slot name="header-overlay" /> + </div> + + <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -243,6 +243,11 @@ export default Vue.extend({ methods: { scrollTo(index: number) { + const targetRow = Math.ceil(this.dataSources.length / this.columnCount) + if (targetRow < this.rowCount) { + logger.debug('VirtualList: Skip scrolling. nothing to scroll', { index, targetRow, rowCount: this.rowCount }) + return + } this.index = index // Scroll to one row and a half before the index const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight diff --git a/apps/files/src/init-templates.ts b/apps/files/src/init-templates.ts deleted file mode 100644 index 6803143d4b2..00000000000 --- a/apps/files/src/init-templates.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @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/>. - * - */ -import type { Entry } from '@nextcloud/files' -import type { TemplateFile } from './types' - -import { Folder, Node, Permission, addNewFileMenuEntry, removeNewFileMenuEntry } from '@nextcloud/files' -import { generateOcsUrl } from '@nextcloud/router' -import { getLoggerBuilder } from '@nextcloud/logger' -import { join } from 'path' -import { loadState } from '@nextcloud/initial-state' -import { showError } from '@nextcloud/dialogs' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import axios from '@nextcloud/axios' -import Vue from 'vue' - -import PlusSvg from '@mdi/svg/svg/plus.svg?raw' - -import TemplatePickerView from './views/TemplatePicker.vue' -import { getUniqueName } from './utils/fileUtils.ts' -import { getCurrentUser } from '@nextcloud/auth' - -// Set up logger -const logger = getLoggerBuilder() - .setApp('files') - .detectUser() - .build() - -// Add translates functions -Vue.mixin({ - methods: { - t, - n, - }, -}) - -// Create document root -const TemplatePickerRoot = document.createElement('div') -TemplatePickerRoot.id = 'template-picker' -document.body.appendChild(TemplatePickerRoot) - -// Retrieve and init templates -let templates = loadState<TemplateFile[]>('files', 'templates', []) -let templatesPath = loadState('files', 'templates_path', false) -logger.debug('Templates providers', { templates }) -logger.debug('Templates folder', { templatesPath }) - -// Init vue app -const View = Vue.extend(TemplatePickerView) -const TemplatePicker = new View({ - name: 'TemplatePicker', - propsData: { - logger, - }, -}) -TemplatePicker.$mount('#template-picker') -if (!templatesPath) { - logger.debug('Templates folder not initialized') - addNewFileMenuEntry({ - id: 'template-picker', - displayName: t('files', 'Create new templates folder'), - iconSvgInline: PlusSvg, - order: 10, - enabled(context: Folder): boolean { - // Allow creation on your own folders only - if (context.owner !== getCurrentUser()?.uid) { - return false - } - return (context.permissions & Permission.CREATE) !== 0 - }, - handler(context: Folder, content: Node[]) { - // Check for conflicts - const contentNames = content.map((node: Node) => node.basename) - const name = getUniqueName(t('files', 'Templates'), contentNames) - - // Create the template folder - initTemplatesFolder(context, name) - - // Remove the menu entry - removeNewFileMenuEntry('template-picker') - }, - } as Entry) -} - -// Init template files menu -templates.forEach((provider, index) => { - addNewFileMenuEntry({ - id: `template-new-${provider.app}-${index}`, - displayName: provider.label, - // TODO: migrate to inline svg - iconClass: provider.iconClass || 'icon-file', - enabled(context: Folder): boolean { - return (context.permissions & Permission.CREATE) !== 0 - }, - order: 11, - handler(context: Folder, content: Node[]) { - // Check for conflicts - const contentNames = content.map((node: Node) => node.basename) - const name = getUniqueName(provider.label + provider.extension, contentNames) - - // Create the file - TemplatePicker.open(name, provider) - }, - } as Entry) -}) - -// Init template folder -const initTemplatesFolder = async function(directory: Folder, name: string) { - const templatePath = join(directory.path, name) - try { - logger.debug('Initializing the templates directory', { templatePath }) - const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), { - templatePath, - copySystemTemplates: true, - }) - - // Go to template directory - window.OCP.Files.Router.goToRoute( - null, // use default route - { view: 'files', fileid: undefined }, - { dir: templatePath }, - ) - - templates = response.data.ocs.data.templates - templatesPath = response.data.ocs.data.template_path - } catch (error) { - logger.error('Unable to initialize the templates directory') - showError(t('files', 'Unable to initialize the templates directory')) - } -} diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 8002f33ff56..c3b4b570e12 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -31,14 +31,15 @@ import { action as openInFilesAction } from './actions/openInFilesAction' import { action as renameAction } from './actions/renameAction' import { action as sidebarAction } from './actions/sidebarAction' import { action as viewInFolderAction } from './actions/viewInFolderAction' -import { entry as newFolderEntry } from './newMenu/newFolder' +import { entry as newFolderEntry } from './newMenu/newFolder.ts' +import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts' +import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' import registerFavoritesView from './views/favorites' import registerRecentView from './views/recent' import registerFilesView from './views/files' import registerPreviewServiceWorker from './services/ServiceWorker.js' -import './init-templates' import { initLivePhotos } from './services/LivePhotos' @@ -56,6 +57,8 @@ registerFileAction(viewInFolderAction) // Register new menu entry addNewFileMenuEntry(newFolderEntry) +addNewFileMenuEntry(newTemplatesFolder) +registerTemplateEntries() // Register files views registerFavoritesView() @@ -66,5 +69,6 @@ registerRecentView() registerPreviewServiceWorker() registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' }) initLivePhotos() diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 0c0c5b165e6..7b25c88a697 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -3,12 +3,11 @@ import { createPinia, PiniaVuePlugin } from 'pinia' import { getNavigation } from '@nextcloud/files' import { getRequestToken } from '@nextcloud/auth' -import FilesListView from './views/FilesList.vue' -import NavigationView from './views/Navigation.vue' import router from './router/router' import RouterService from './services/RouterService' import SettingsModel from './models/Setting.js' import SettingsService from './services/Settings.js' +import FilesApp from './FilesApp.vue' // @ts-expect-error __webpack_nonce__ is injected by webpack __webpack_nonce__ = btoa(getRequestToken()) @@ -43,23 +42,8 @@ const Settings = new SettingsService() Object.assign(window.OCA.Files, { Settings }) Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel }) -// Init Navigation View -const View = Vue.extend(NavigationView) -const FilesNavigationRoot = new View({ - name: 'FilesNavigationRoot', - propsData: { - Navigation, - }, +const FilesAppVue = Vue.extend(FilesApp) +new FilesAppVue({ router, pinia, -}) -FilesNavigationRoot.$mount('#app-navigation-files') - -// Init content list view -const ListView = Vue.extend(FilesListView) -const FilesList = new ListView({ - name: 'FilesListRoot', - router, - pinia, -}) -FilesList.$mount('#app-content-vue') +}).$mount('#content') diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts index 37dcf6d3d89..64ab8004e78 100644 --- a/apps/files/src/newMenu/newFolder.ts +++ b/apps/files/src/newMenu/newFolder.ts @@ -31,7 +31,7 @@ import axios from '@nextcloud/axios' import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw' -import { getUniqueName } from '../utils/fileUtils.ts' +import { newNodeName } from '../utils/newNodeDialog' import logger from '../logger' type createFolderResponse = { @@ -63,23 +63,27 @@ export const entry = { iconSvgInline: FolderPlusSvg, order: 0, async handler(context: Folder, content: Node[]) { - const contentNames = content.map((node: Node) => node.basename) - const name = getUniqueName(t('files', 'New folder'), contentNames) - const { fileid, source } = await createNewFolder(context, name) + const name = await newNodeName(t('files', 'New folder'), content) + if (name !== null) { + const { fileid, source } = await createNewFolder(context, name) + // Create the folder in the store + const folder = new Folder({ + source, + id: fileid, + mtime: new Date(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.ALL, + root: context?.root || '/files/' + getCurrentUser()?.uid, + }) - // Create the folder in the store - const folder = new Folder({ - source, - id: fileid, - mtime: new Date(), - owner: getCurrentUser()?.uid || null, - permissions: Permission.ALL, - root: context?.root || '/files/' + getCurrentUser()?.uid, - }) - - showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) })) - logger.debug('Created new folder', { folder, source }) - emit('files:node:created', folder) - emit('files:node:rename', folder) + showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) })) + logger.debug('Created new folder', { folder, source }) + emit('files:node:created', folder) + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: folder.fileid }, + { dir: context.path }, + ) + } }, } as Entry diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts new file mode 100644 index 00000000000..5e69181995e --- /dev/null +++ b/apps/files/src/newMenu/newFromTemplate.ts @@ -0,0 +1,88 @@ +/** + * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Julius Härtl <jus@bitgrid.net> + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @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/>. + * + */ + +import type { Entry } from '@nextcloud/files' +import type { ComponentInstance } from 'vue' +import type { TemplateFile } from '../types.ts' + +import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { newNodeName } from '../utils/newNodeDialog' +import { translate as t } from '@nextcloud/l10n' +import Vue, { defineAsyncComponent } from 'vue' + +// async to reduce bundle size +const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue')) +let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null + +const getTemplatePicker = async () => { + if (TemplatePicker === null) { + // Create document root + const mountingPoint = document.createElement('div') + mountingPoint.id = 'template-picker' + document.body.appendChild(mountingPoint) + + // Init vue app + TemplatePicker = new Vue({ + render: (h) => h(TemplatePickerVue, { ref: 'picker' }), + methods: { open(...args) { this.$refs.picker.open(...args) } }, + el: mountingPoint, + }) + } + return TemplatePicker +} + +/** + * Register all new-file-menu entries for all template providers + */ +export function registerTemplateEntries() { + const templates = loadState<TemplateFile[]>('files', 'templates', []) + + // Init template files menu + templates.forEach((provider, index) => { + addNewFileMenuEntry({ + id: `template-new-${provider.app}-${index}`, + displayName: provider.label, + // TODO: migrate to inline svg + iconClass: provider.iconClass || 'icon-file', + enabled(context: Folder): boolean { + return (context.permissions & Permission.CREATE) !== 0 + }, + order: 11, + async handler(context: Folder, content: Node[]) { + const templatePicker = getTemplatePicker() + const name = await newNodeName(`${provider.label}${provider.extension}`, content, { + label: t('files', 'Filename'), + name: provider.label, + }) + + if (name !== null) { + // Create the file + const picker = await templatePicker + picker.open(name, provider) + } + }, + } as Entry) + }) +} diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts new file mode 100644 index 00000000000..fafee553a10 --- /dev/null +++ b/apps/files/src/newMenu/newTemplatesFolder.ts @@ -0,0 +1,100 @@ +/** + * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Julius Härtl <jus@bitgrid.net> + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @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/>. + * + */ +import type { Entry, Folder, Node } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { showError } from '@nextcloud/dialogs' +import { Permission, removeNewFileMenuEntry } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateOcsUrl } from '@nextcloud/router' +import { join } from 'path' +import { newNodeName } from '../utils/newNodeDialog' + +import PlusSvg from '@mdi/svg/svg/plus.svg?raw' +import axios from '@nextcloud/axios' +import logger from '../logger.js' + +let templatesPath = loadState<string|false>('files', 'templates_path', false) +logger.debug('Initial templates folder', { templatesPath }) + +/** + * Init template folder + * @param directory Folder where to create the templates folder + * @param name Name to use or the templates folder + */ +const initTemplatesFolder = async function(directory: Folder, name: string) { + const templatePath = join(directory.path, name) + try { + logger.debug('Initializing the templates directory', { templatePath }) + const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), { + templatePath, + copySystemTemplates: true, + }) + + // Go to template directory + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: undefined }, + { dir: templatePath }, + ) + + logger.info('Created new templates folder', { + ...data.ocs.data, + }) + templatesPath = data.ocs.data.templates_path as string + } catch (error) { + logger.error('Unable to initialize the templates directory') + showError(t('files', 'Unable to initialize the templates directory')) + } +} + +export const entry = { + id: 'template-picker', + displayName: t('files', 'Create new templates folder'), + iconSvgInline: PlusSvg, + order: 10, + enabled(context: Folder): boolean { + // Templates folder already initialized + if (templatesPath) { + return false + } + // Allow creation on your own folders only + if (context.owner !== getCurrentUser()?.uid) { + return false + } + return (context.permissions & Permission.CREATE) !== 0 + }, + async handler(context: Folder, content: Node[]) { + const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') }) + + if (name !== null) { + // Create the template folder + initTemplatesFolder(context, name) + + // Remove the menu entry + removeNewFileMenuEntry('template-picker') + } + }, +} as Entry diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index 372b849bcc4..d1e8dd9ed5a 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -32,7 +32,7 @@ import { translate as t } from '@nextcloud/l10n' import logger from '../logger.js' -export const handleDrop = async (data: DataTransfer) => { +export const handleDrop = async (data: DataTransfer): Promise<Upload[]> => { // TODO: Maybe handle `getAsFileSystemHandle()` in the future const uploads = [] as Upload[] diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index a293154f625..bcfb368882d 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -40,9 +40,14 @@ interface ResponseProps extends DAVResultResponseProps { } export const resultToNode = function(node: FileStat): File | Folder { + const userId = getCurrentUser()?.uid + if (!userId) { + throw new Error('No user id found') + } + const props = node.props as ResponseProps const permissions = davParsePermissions(props?.permissions) - const owner = (props['owner-id'] || getCurrentUser()?.uid) as string + const owner = (props['owner-id'] || userId).toString() const source = generateRemoteUrl('dav' + rootPath + node.filename) const id = props?.fileid < 0 @@ -53,7 +58,7 @@ export const resultToNode = function(node: FileStat): File | Folder { id, source, mtime: new Date(node.lastmod), - mime: node.mime as string, + mime: node.mime || 'application/octet-stream', size: props?.size as number || 0, permissions, owner, diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index cbd3f71600a..c5f34c2dbe0 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -27,12 +27,13 @@ import { loadState } from '@nextcloud/initial-state' import axios from '@nextcloud/axios' import Vue from 'vue' -const userConfig = loadState('files', 'config', { +const userConfig = loadState<UserConfig>('files', 'config', { show_hidden: false, crop_image_previews: true, sort_favorites_first: true, + sort_folders_first: true, grid_view: false, -}) as UserConfig +}) export const useUserConfigStore = function(...args) { const store = defineStore('userconfig', { diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index d2bfcaed0ee..0e9dd6fb531 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -119,4 +119,5 @@ export interface TemplateFile { iconClass?: string mimetypes: string[] ratio?: number + templates?: Record<string, unknown>[] } diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts new file mode 100644 index 00000000000..f53694fc68c --- /dev/null +++ b/apps/files/src/utils/newNodeDialog.ts @@ -0,0 +1,57 @@ +/** + * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + * + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @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/>. + * + */ + +import type { Node } from '@nextcloud/files' +import { spawnDialog } from '@nextcloud/dialogs' +import NewNodeDialog from '../components/NewNodeDialog.vue' + +interface ILabels { + /** + * Dialog heading, defaults to "New folder name" + */ + name?: string + /** + * Label for input box, defaults to "New folder" + */ + label?: string +} + +/** + * Ask user for file or folder name + * @param defaultName Default name to use + * @param folderContent Nodes with in the current folder to check for unique name + * @param labels Labels to set on the dialog + * @return string if successfull otherwise null if aborted + */ +export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) { + const contentNames = folderContent.map((node: Node) => node.basename) + + return new Promise<string|null>((resolve) => { + spawnDialog(NewNodeDialog, { + ...labels, + defaultName, + otherNames: contentNames, + }, (folderName) => { + resolve(folderName as string|null) + }) + }) +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index fabfccd6ca1..4e80379f632 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -35,7 +35,7 @@ @click="openSharingSidebar"> <template #icon> <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" /> - <ShareVariantIcon v-else :size="20" /> + <AccountPlusIcon v-else :size="20" /> </template> </NcButton> @@ -143,7 +143,7 @@ import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import PlusIcon from 'vue-material-design-icons/Plus.vue' -import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue' +import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' @@ -177,7 +177,7 @@ export default defineComponent({ NcIconSvgWrapper, NcLoadingIcon, PlusIcon, - ShareVariantIcon, + AccountPlusIcon, UploadPicker, ViewGridIcon, }, @@ -257,7 +257,7 @@ export default defineComponent({ // 1: Sort favorites first if enabled ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []), // 2: Sort folders first if sorting by name - ...(this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : []), + ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []), // 3: Use sorting mode if NOT basename (to be able to use displayName too) ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []), // 4: Use displayName if available, fallback to name @@ -269,7 +269,7 @@ export default defineComponent({ // (for 1): always sort favorites before normal files ...(this.userConfig.sort_favorites_first ? ['asc'] : []), // (for 2): always sort folders before files - ...(this.sortingMode === 'basename' ? ['asc'] : []), + ...(this.userConfig.sort_folders_first ? ['asc'] : []), // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []), // (also for 3 so make sure not to conflict with 2 and 3) @@ -566,15 +566,20 @@ export default defineComponent({ /** * Refreshes the current folder on update. * - * @param {Node} node is the file/folder being updated. + * @param node is the file/folder being updated. */ - onUpdatedNode(node) { + onUpdatedNode(node?: Node) { if (node?.fileid === this.currentFolder?.fileid) { this.fetchContent() } }, openSharingSidebar() { + if (!this.currentFolder) { + logger.debug('No current folder found for opening sharing sidebar') + return + } + if (window?.OCA?.Files?.Sidebar?.setActiveTab) { window.OCA.Files.Sidebar.setActiveTab('sharing') } @@ -620,9 +625,9 @@ $navigationToggleSize: 50px; } &-share-button { - opacity: .3; + color: var(--color-text-maxcontrast) !important; &--shared { - opacity: 1; + color: var(--color-main-text) !important; } } } diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index cf3512bce0e..07d9eee80cb 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -7,11 +7,15 @@ import router from '../router/router' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' +import Vue from 'vue' + describe('Navigation renders', () => { delete window._nc_navigation const Navigation = getNavigation() before(() => { + Vue.prototype.$navigation = Navigation + cy.mockInitialState('files', 'storageStats', { used: 1000 * 1000 * 1000, quota: -1, @@ -22,9 +26,6 @@ describe('Navigation renders', () => { it('renders', () => { cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -42,6 +43,10 @@ describe('Navigation API', () => { delete window._nc_navigation const Navigation = getNavigation() + before(() => { + Vue.prototype.$navigation = Navigation + }) + it('Check API entries rendering', () => { Navigation.register(new View({ id: 'files', @@ -52,9 +57,6 @@ describe('Navigation API', () => { })) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -79,9 +81,6 @@ describe('Navigation API', () => { })) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -107,9 +106,6 @@ describe('Navigation API', () => { })) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -159,13 +155,14 @@ describe('Quota rendering', () => { delete window._nc_navigation const Navigation = getNavigation() + before(() => { + Vue.prototype.$navigation = Navigation + }) + afterEach(() => cy.unmockInitialState()) it('Unknown quota', () => { cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -183,9 +180,6 @@ describe('Quota rendering', () => { }) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -206,9 +200,6 @@ describe('Quota rendering', () => { }) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -230,9 +221,6 @@ describe('Quota rendering', () => { }) cy.mount(NavigationView, { - propsData: { - Navigation, - }, global: { plugins: [createTestingPinia({ createSpy: cy.spy, diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 118a960bf5c..ef82a036cee 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -75,7 +75,7 @@ </template> <script lang="ts"> -import { emit, subscribe } from '@nextcloud/event-bus' +import { emit } from '@nextcloud/event-bus' import { translate } from '@nextcloud/l10n' import Cog from 'vue-material-design-icons/Cog.vue' import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' @@ -85,7 +85,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js import { setPageHeading } from '../../../../core/src/OCP/accessibility.js' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.js' -import type { Navigation, View } from '@nextcloud/files' +import type { View } from '@nextcloud/files' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' @@ -101,14 +101,6 @@ export default { SettingsModal, }, - props: { - // eslint-disable-next-line vue/prop-name-casing - Navigation: { - type: Object as Navigation, - required: true, - }, - }, - setup() { const viewConfigStore = useViewConfigStore() return { @@ -132,7 +124,7 @@ export default { }, views(): View[] { - return this.Navigation.views + return this.$navigation.views }, parentViews(): View[] { @@ -164,7 +156,7 @@ export default { watch: { currentView(view, oldView) { if (view.id !== oldView?.id) { - this.Navigation.setActive(view) + this.$navigation.setActive(view) logger.debug('Navigation changed', { id: view.id, view }) this.showView(view) @@ -193,7 +185,7 @@ export default { showView(view: View) { // Closing any opened sidebar window?.OCA?.Files?.Sidebar?.close?.() - this.Navigation.setActive(view) + this.$navigation.setActive(view) setPageHeading(view.name) emit('files:navigation:changed', view) }, @@ -201,6 +193,7 @@ export default { /** * Expand/collapse a a view with children and permanently * save this setting in the server. + * @param view */ onToggleExpand(view: View) { // Invert state @@ -213,6 +206,7 @@ export default { /** * Check if a view is expanded by user config * or fallback to the default value. + * @param view */ isExpanded(view: View): boolean { return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' @@ -222,6 +216,7 @@ export default { /** * Generate the route to a view + * @param view */ generateToNavigation(view: View) { if (view.params) { diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index d3eb318d4fa..cefbdab4eec 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -30,6 +30,10 @@ @update:checked="setConfig('sort_favorites_first', $event)"> {{ t('files', 'Sort favorites first') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked="userConfig.sort_folders_first" + @update:checked="setConfig('sort_folders_first', $event)"> + {{ t('files', 'Sort folders before files') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch :checked="userConfig.show_hidden" @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index d0c6b90b49d..01cea14f8eb 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -25,7 +25,6 @@ ref="sidebar" v-bind="appSidebar" :force-menu="true" - tabindex="0" @close="close" @update:active="setActiveTab" @[defaultActionListener].stop.prevent="onDefaultAction" @@ -470,6 +469,10 @@ export default { throw new Error(`Invalid path '${path}'`) } + // Only focus the tab when the selected file/tab is changed in already opened sidebar + // Focusing the sidebar on first file open is handled by NcAppSidebar + const focusTabAfterLoad = !!this.Sidebar.file + // update current opened file this.Sidebar.file = path @@ -488,19 +491,23 @@ export default { view.setFileInfo(this.fileInfo) }) - this.$nextTick(() => { - if (this.$refs.tabs) { - this.$refs.tabs.updateTabs() - } - this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id) - }) + await this.$nextTick() + + this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id) + + this.loading = false + + await this.$nextTick() + + if (focusTabAfterLoad) { + this.$refs.sidebar.focusActiveTabContent() + } } catch (error) { + this.loading = false this.error = t('files', 'Error while loading the file data') console.error('Error while loading the file data', error) throw new Error(error) - } finally { - this.loading = false } }, diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 5f248602d4d..93dc7ca574f 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -61,23 +61,27 @@ </template> <script lang="ts"> -import { emit, subscribe } from '@nextcloud/event-bus' +import type { TemplateFile } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' import { File } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' import { normalize, extname, join } from 'path' -import { showError } from '@nextcloud/dialogs' +import { defineComponent } from 'vue' +import { createFromTemplate, getTemplates } from '../services/Templates.js' + import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' -import Vue from 'vue' - -import { createFromTemplate, getTemplates } from '../services/Templates.js' import TemplatePreview from '../components/TemplatePreview.vue' +import logger from '../logger.js' const border = 2 const margin = 8 -export default Vue.extend({ +export default defineComponent({ name: 'TemplatePicker', components: { @@ -86,40 +90,34 @@ export default Vue.extend({ TemplatePreview, }, - props: { - logger: { - type: Object, - required: true, - }, - }, - data() { return { // Check empty template by default checked: -1, loading: false, - name: null, + name: null as string|null, opened: false, - provider: null, + provider: null as TemplateFile|null, } }, computed: { extension() { - return extname(this.name) + return extname(this.name ?? '') }, + nameWithoutExt() { // Strip extension from name if defined return !this.extension ? this.name - : this.name.slice(0, 0 - this.extension.length) + : this.name!.slice(0, 0 - this.extension.length) }, emptyTemplate() { return { basename: t('files', 'Blank'), fileid: -1, - filename: this.t('files', 'Blank'), + filename: t('files', 'Blank'), hasPreview: false, mime: this.provider?.mimetypes[0] || this.provider?.mimetypes, } @@ -130,7 +128,7 @@ export default Vue.extend({ return null } - return this.provider.templates.find(template => template.fileid === this.checked) + return this.provider.templates!.find((template) => template.fileid === this.checked) }, /** @@ -159,6 +157,8 @@ export default Vue.extend({ }, methods: { + t, + /** * Open the picker * @@ -201,9 +201,9 @@ export default Vue.extend({ /** * Manages the radio template picker change * - * @param {number} fileid the selected template file id + * @param fileid the selected template file id */ - onCheck(fileid) { + onCheck(fileid: number) { this.checked = fileid }, @@ -213,22 +213,22 @@ export default Vue.extend({ // If the file doesn't have an extension, add the default one if (this.nameWithoutExt === this.name) { - this.logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) - this.name = this.name + this.provider?.extension + logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) + this.name = `${this.name}${this.provider?.extension ?? ''}` } try { const fileInfo = await createFromTemplate( normalize(`${currentDirectory}/${this.name}`), - this.selectedTemplate?.filename, - this.selectedTemplate?.templateType, + this.selectedTemplate?.filename as string ?? '', + this.selectedTemplate?.templateType as string ?? '', ) - this.logger.debug('Created new file', fileInfo) + logger.debug('Created new file', fileInfo) const owner = getCurrentUser()?.uid || null const node = new File({ id: fileInfo.fileid, - source: generateRemoteUrl(join('dav/files', owner, fileInfo.filename)), + source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)), root: `/files/${owner}`, mime: fileInfo.mime, mtime: new Date(fileInfo.lastmod * 1000), @@ -243,19 +243,13 @@ export default Vue.extend({ // Update files list emit('files:node:created', node) - - // Open the new file - window.OCP.Files.Router.goToRoute( - null, // use default route - { view: 'files', fileid: node.fileid }, - { dir: node.dirname, openfile: true }, - ) + emit('files:node:focus', node) // Close the picker this.close() } catch (error) { - this.logger.error('Error while creating the new file from template', { error }) - showError(this.t('files', 'Unable to create new file from template')) + logger.error('Error while creating the new file from template', { error }) + showError(t('files', 'Unable to create new file from template')) } finally { this.loading = false } |