Merge pull request #43267 from nextcloud/fix/files/selection-typing-and-drop-upload

pull/43394/head
John Molakvoæ 4 months ago committed by GitHub
commit 840e8fcb77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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'
@ -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
},

@ -96,37 +96,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 { 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 +126,10 @@ export default defineComponent({
NcDateTime,
},
mixins: [
FileEntryMixin,
],
props: {
isMtimeAvailable: {
type: Boolean,
@ -149,18 +139,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 +160,6 @@ export default defineComponent({
}
},
data() {
return {
loading: '',
dragover: false,
}
},
computed: {
/**
* Conditionally add drag and drop listeners
@ -210,9 +181,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 +189,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)
},
@ -296,261 +232,9 @@ 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.
*/
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
// 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,
},
})

@ -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

@ -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]

@ -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>

@ -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,
},
})

@ -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)

@ -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() {

@ -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[]

File diff suppressed because one or more lines are too long

@ -256,3 +256,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @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/>.
*
*/

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save