]> source.dussan.org Git - nextcloud-server.git/commitdiff
chore(files): move shared FileEntry and FileEntryGrid into a mixin
authorJohn Molakvoæ <skjnldsv@protonmail.com>
Thu, 1 Feb 2024 18:35:07 +0000 (19:35 +0100)
committernextcloud-command <nextcloud-command@users.noreply.github.com>
Wed, 7 Feb 2024 07:57:23 +0000 (07:57 +0000)
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
apps/files/src/components/FileEntry.vue
apps/files/src/components/FileEntry/FileEntryActions.vue
apps/files/src/components/FileEntryGrid.vue
apps/files/src/components/FileEntryMixin.ts [new file with mode: 0644]

index dc41e5b8b9377bb0ce00f0997f6f6c1256781d03..274656f5d70e308d546ab6216fd97883ce4538be 100644 (file)
 </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 { Upload, getUploader } from '@nextcloud/upload'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { vOnClickOutside } from '@vueuse/components'
+import { defineComponent } from 'vue'
+import { 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 +120,10 @@ export default defineComponent({
                NcDateTime,
        },
 
+       mixins: [
+               FileEntryMixin,
+       ],
+
        props: {
                isMtimeAvailable: {
                        type: Boolean,
@@ -149,46 +133,12 @@ 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,
                },
        },
 
-       setup() {
-               const actionsMenuStore = useActionsMenuStore()
-               const draggingStore = useDragAndDropStore()
-               const filesStore = useFilesStore()
-               const renamingStore = useRenamingStore()
-               const selectionStore = useSelectionStore()
-               return {
-                       actionsMenuStore,
-                       draggingStore,
-                       filesStore,
-                       renamingStore,
-                       selectionStore,
-               }
-       },
-
-       data() {
-               return {
-                       loading: '',
-                       dragover: false,
-               }
-       },
-
        computed: {
                /**
                 * Conditionally add drag and drop listeners
@@ -210,9 +160,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 +168,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
-               },
-               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)
                },
@@ -296,285 +211,9 @@ export default defineComponent({
                        }
                        return ''
                },
-
-               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
-                       }
-
-                       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,
                formatFileSize,
        },
 })
index d9d8cefdbad03b4e3ff27d7115e628c4c36923d2..c46990e971b97f2ee21ea586e6bed3b44c911ff8 100644 (file)
@@ -346,7 +346,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 {
+[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
index 30a5e2d94e3663cd563a6265a411f06bf072f0cb..1d10f3d2948d6de3e8bb5fc8296bbe4d01d5024d 100644 (file)
 </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 { Upload, getUploader } from '@nextcloud/upload'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
-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 { 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 defineComponent({
        name: 'FileEntryGrid',
@@ -112,345 +91,16 @@ export default defineComponent({
                FileEntryPreview,
        },
 
-       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,
-               },
-       },
+       mixins: [
+               FileEntryMixin,
+       ],
 
-       setup() {
-               const actionsMenuStore = useActionsMenuStore()
-               const draggingStore = useDragAndDropStore()
-               const filesStore = useFilesStore()
-               const renamingStore = useRenamingStore()
-               const selectionStore = useSelectionStore()
-               return {
-                       actionsMenuStore,
-                       draggingStore,
-                       filesStore,
-                       renamingStore,
-                       selectionStore,
-               }
-       },
+       inheritAttrs: false,
 
        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
-               },
-               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
-               },
-
-               isActive() {
-                       return this.fileid?.toString?.() === 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.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
-                       }
-
-                       // 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 || !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,
-       },
 })
 </script>
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
new file mode 100644 (file)
index 0000000..68320c8
--- /dev/null
@@ -0,0 +1,409 @@
+/**
+ * @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 { 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 { 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,
+               },
+       },
+
+       setup() {
+               const actionsMenuStore = useActionsMenuStore()
+               const draggingStore = useDragAndDropStore()
+               const filesStore = useFilesStore()
+               const renamingStore = useRenamingStore()
+               const selectionStore = useSelectionStore()
+               return {
+                       actionsMenuStore,
+                       draggingStore,
+                       filesStore,
+                       renamingStore,
+                       selectionStore,
+               }
+       },
+
+       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,
+       },
+})