aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components/FileEntryMixin.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components/FileEntryMixin.ts')
-rw-r--r--apps/files/src/components/FileEntryMixin.ts307
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()
}