diff options
Diffstat (limited to 'apps/files/src/components/FileEntryMixin.ts')
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 307 |
1 files changed, 226 insertions, 81 deletions
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index b822363885c..735490c45b3 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -1,43 +1,31 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' import { extname } from 'path' -import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' -import { translate as t } from '@nextcloud/l10n' +import { isPublicShare } from '@nextcloud/sharing/public' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' import { vOnClickOutside } from '@vueuse/components' -import Vue, { defineComponent } from 'vue' +import Vue, { computed, defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' import { hashCode } from '../utils/hashUtils.ts' -import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' -import logger from '../logger.js' -import { showError } from '@nextcloud/dialogs' +import { isDownloadable } from '../utils/permissions.ts' +import logger from '../logger.ts' Vue.directive('onClickOutside', vOnClickOutside) +const actions = getFileActions() + export default defineComponent({ props: { source: { @@ -52,62 +40,79 @@ export default defineComponent({ type: Number, default: 0, }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + compact: { + type: Boolean, + default: false, + }, + }, + + provide() { + return { + defaultFileAction: computed(() => this.defaultFileAction), + enabledFileActions: computed(() => this.enabledFileActions), + } }, 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 + return this.source.fileid ?? 0 }, + 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) + /** + * The display name of the current node + * Either the nodes filename or a custom display name (e.g. for shares) + */ + displayName() { + // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0 + return this.source.displayname || this.source.basename + }, + /** + * The display name without extension + */ + basename() { + if (this.extension === '') { + return this.displayName } - return this.source.extension || '' + return this.displayName.slice(0, 0 - this.extension.length) }, - displayName() { - const ext = this.extension - const name = String(this.source.attributes.displayName - || this.source.basename) + /** + * The extension of the file + */ + extension() { + if (this.source.type === FileType.Folder) { + return '' + } - // Strip extension from name if defined - return !ext ? name : name.slice(0, 0 - ext.length) + return extname(this.displayName) }, draggingFiles() { - return this.draggingStore.dragging + return this.draggingStore.dragging as FileSource[] }, selectedFiles() { - return this.selectionStore.selected + return this.selectionStore.selected as FileSource[] }, isSelected() { - return this.fileid && this.selectedFiles.includes(this.fileid) + return this.selectedFiles.includes(this.source.source) }, isRenaming() { @@ -118,33 +123,50 @@ export default defineComponent({ }, isActive() { - return this.fileid?.toString?.() === this.currentFileId?.toString?.() + return String(this.fileid) === String(this.currentFileId) }, - canDrag() { + /** + * Check if the source is in a failed state after an API request + */ + isFailedSource() { + return this.source.status === NodeStatus.FAILED + }, + + canDrag(): boolean { if (this.isRenaming) { return false } + // Ignore if the node is not available + if (this.isFailedSource) { + 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[] + const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[] return nodes.every(canDrag) } return canDrag(this.source) }, - canDrop() { + canDrop(): boolean { if (this.source.type !== FileType.Folder) { return false } + // Ignore if the node is not available + if (this.isFailedSource) { + 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)) { + if (this.draggingFiles.includes(this.source.source)) { return false } @@ -156,30 +178,112 @@ export default defineComponent({ 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.$el?.closest('main.app-content') as HTMLElement - root.style.removeProperty('--mouse-pos-x') - root.style.removeProperty('--mouse-pos-y') + // If the menu is opened on another file entry, we ignore closed events + if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) { + return } - this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null + // If opened, we specify the current file id + // else we set it to null to close the menu + this.actionsMenuStore.opened = opened + ? this.uniqueId.toString() + : null }, }, + + mtime() { + // If the mtime is not a valid date, return it as is + if (this.source.mtime && !isNaN(this.source.mtime.getDate())) { + return this.source.mtime + } + + if (this.source.crtime && !isNaN(this.source.crtime.getDate())) { + return this.source.crtime + } + + return null + }, + + mtimeOpacity() { + if (!this.mtime) { + return {} + } + + // The time when we start reducing the opacity + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + // everything older than the maxOpacityTime will have the same value + const timeDiff = Date.now() - this.mtime.getTime() + if (timeDiff < 0) { + // this means we have an invalid mtime which is in the future! + return {} + } + + // inversed time difference from 0 to maxOpacityTime (which would mean today) + const opacityTime = Math.max(0, maxOpacityTime - timeDiff) + // 100 = today, 0 = 31 days ago or older + const percentage = Math.round(opacityTime * 100 / maxOpacityTime) + return { + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, + } + }, + + /** + * Sorted actions that are enabled for this node + */ + enabledFileActions() { + if (this.source.status === NodeStatus.FAILED) { + return [] + } + + return actions + .filter(action => { + if (!action.enabled) { + return true + } + + // In case something goes wrong, since we don't want to break + // the entire list, we filter out actions that throw an error. + try { + return action.enabled([this.source], this.currentView) + } catch (error) { + logger.error('Error while checking action', { action, error }) + return false + } + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + defaultFileAction() { + return this.enabledFileActions.find((action) => action.default !== undefined) + }, }, watch: { /** * When the source changes, reset the preview * and fetch the new one. + * @param newSource The new value of the source prop + * @param oldSource The previous value */ - source(a: Node, b: Node) { - if (a.source !== b.source) { + source(newSource: Node, oldSource: Node) { + if (newSource.source !== oldSource.source) { this.resetState() } }, + + openedMenu() { + // Checking if the menu is really closed and not + // just a change in the open state to another file entry. + if (this.actionsMenuStore.opened === null) { + // Reset any right menu position potentially set + logger.debug('All actions menu closed, resetting right menu position...') + const root = this.$el?.closest('main.app-content') as HTMLElement + if (root !== null) { + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + } + }, }, beforeDestroy() { @@ -188,9 +292,6 @@ export default defineComponent({ methods: { resetState() { - // Reset loading state - this.loading = '' - // Reset the preview state this.$refs?.preview?.reset?.() @@ -205,6 +306,11 @@ export default defineComponent({ return } + // Ignore right click if the node is not available + if (this.isFailedSource) { + return + } + // The grid mode is compact enough to not care about // the actions menu mouse position if (!this.gridMode) { @@ -213,8 +319,14 @@ export default defineComponent({ const contentRect = root.getBoundingClientRect() // Using Math.min/max to prevent the menu from going out of the AppContent // 200 = max width of the menu + logger.debug('Setting actions menu position...') root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px') root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px') + } else { + // Reset any right menu position potentially set + const root = this.$el?.closest('main.app-content') as HTMLElement + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') } // If the clicked row is in the selection, open global menu @@ -226,14 +338,47 @@ export default defineComponent({ event.stopPropagation() }, - execDefaultAction(event) { - if (event.ctrlKey || event.metaKey) { + execDefaultAction(event: MouseEvent) { + // Ignore click if we are renaming + if (this.isRenaming) { + return + } + + // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4) + if (Boolean(event.button & 2) || event.button > 4) { + return + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return + } + + // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab + // also if there is no default action use this as a fallback + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 + if (metaKeyPressed || !this.defaultFileAction) { + // If no download permission, then we can not allow to download (direct link) the files + if (isPublicShare() && !isDownloadable(this.source)) { + return + } + + const url = isPublicShare() + ? this.source.encodedSource + : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() - window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) - return false + event.stopPropagation() + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') + return } - this.$refs.actions.execDefaultAction(event) + // every special case handled so just execute the default action + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.defaultFileAction.exec(this.source, this.currentView, this.currentDir) }, openDetailsIfAvailable(event) { @@ -287,14 +432,14 @@ export default defineComponent({ // 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)) { + if (this.selectedFiles.includes(this.source.source)) { this.draggingStore.set(this.selectedFiles) } else { - this.draggingStore.set([this.fileid]) + this.draggingStore.set([this.source.source]) } const nodes = this.draggingStore.dragging - .map(fileid => this.filesStore.getNode(fileid)) as Node[] + .map(source => this.filesStore.getNode(source)) as Node[] const image = await getDragAndDropPreview(nodes) event.dataTransfer?.setDragImage(image, -10, -10) @@ -342,18 +487,18 @@ export default defineComponent({ logger.debug('Dropped', { event, folder, selection, fileTree }) // Check whether we're uploading files - if (fileTree.contents.length > 0) { + if (selection.length === 0 && fileTree.contents.length > 0) { await onDropExternalFiles(fileTree, folder, contents.contents) return } // Else we're moving/copying files - const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[] + const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] await onDropInternalFiles(nodes, folder, contents.contents, isCopy) // Reset selection after we dropped the files // if the dropped files are within the selection - if (selection.some(fileid => this.selectedFiles.includes(fileid))) { + if (selection.some(source => this.selectedFiles.includes(source))) { logger.debug('Dropped selection, resetting select store...') this.selectionStore.reset() } |