Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[stable29] fix(files): breadcrumbs drag-and-drop & unifying methods #44653

Merged
merged 7 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,6 @@ composer.phar
core/js/mimetypelist.js

# Tests - cypress
cypress/downloads
cypress/snapshots
cypress/videos
cypress/downloads
124 changes: 124 additions & 0 deletions __tests__/FileSystemAPIUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { basename } from 'node:path'
import mime from 'mime'

class FileSystemEntry {

private _isFile: boolean
private _fullPath: string

constructor(isFile: boolean, fullPath: string) {
this._isFile = isFile
this._fullPath = fullPath
}

get isFile() {
return !!this._isFile
}

get isDirectory() {
return !this.isFile
}

get name() {
return basename(this._fullPath)
}

}

export class FileSystemFileEntry extends FileSystemEntry {

private _contents: string
private _lastModified: number

constructor(fullPath: string, contents: string, lastModified = Date.now()) {
super(true, fullPath)
this._contents = contents
this._lastModified = lastModified
}

file(success: (file: File) => void) {
const lastModified = this._lastModified
// Faking the mime by using the file extension
const type = mime.getType(this.name) || ''
success(new File([this._contents], this.name, { lastModified, type }))
}

}

export class FileSystemDirectoryEntry extends FileSystemEntry {

private _entries: FileSystemEntry[]

constructor(fullPath: string, entries: FileSystemEntry[]) {
super(false, fullPath)
this._entries = entries || []
}

createReader() {
let read = false
return {
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
if (read) {
return success([])
}
read = true
success(this._entries)
},
}
}

}

/**
* This mocks the File API's File class
* It will allow us to test the Filesystem API as well as the
* File API in the same test suite.
*/
export class DataTransferItem {

private _type: string
private _entry: FileSystemEntry

getAsEntry?: () => FileSystemEntry

constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) {
this._type = type
this._entry = entry

// Only when the Files API is available we are
// able to get the entry
if (isFileSystemAPIAvailable) {
this.getAsEntry = () => this._entry
}
}

get kind() {
return 'file'
}

get type() {
return this._type
}

getAsFile(): File|null {
if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
let file: File | null = null
this._entry.file((f) => {
file = f
})
return file
}

// The browser will return an empty File object if the entry is a directory
return new File([], this._entry.name, { type: '' })
}

}

export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
return new DataTransferItem(
entry.isFile ? 'text/plain' : 'httpd/unix-directory',
entry,
isFileSystemAPIAvailable,
)
}
124 changes: 111 additions & 13 deletions apps/files/src/components/BreadCrumbs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
:force-icon-text="true"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
@click.native="onClick(section.to)">
@click.native="onClick(section.to)"
@dragover.native="onDragOver($event, section.dir)"
@drop="onDrop($event, section.dir)">
<template v-if="index === 0" #icon>
<NcIconSvgWrapper :size="20"
:svg="viewIcon" />
Expand All @@ -49,20 +51,25 @@
</template>

<script lang="ts">
import type { Node } from '@nextcloud/files'
import { Permission, type Node } from '@nextcloud/files'

import { translate as t} from '@nextcloud/l10n'
import { basename } from 'path'
import homeSvg from '@mdi/svg/svg/home.svg?raw'
import { defineComponent } from 'vue'
import { translate as t} from '@nextcloud/l10n'
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import { defineComponent } from 'vue'

import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
import { showError } from '@nextcloud/dialogs'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger'

export default defineComponent({
name: 'BreadCrumbs',
Expand All @@ -73,25 +80,29 @@ export default defineComponent({
NcIconSvgWrapper,
},

mixins: [
filesListWidthMixin,
],

props: {
path: {
type: String,
default: '/',
},
},

mixins: [
filesListWidthMixin,
],

setup() {
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()

return {
draggingStore,
filesStore,
pathsStore,
selectionStore,
uploaderStore,
}
},
Expand All @@ -110,14 +121,16 @@ export default defineComponent({
},

sections() {
return this.dirs.map((dir: string) => {
return this.dirs.map((dir: string, index: number) => {
const fileid = this.getFileIdFromPath(dir)
const to = { ...this.$route, params: { fileid }, query: { dir } }
return {
dir,
exact: true,
name: this.getDirDisplayName(dir),
to,
// disable drop on current directory
disableDrop: index === this.dirs.length - 1,
}
})
},
Expand All @@ -128,13 +141,27 @@ export default defineComponent({

// Hide breadcrumbs if an upload is ongoing
shouldShowBreadcrumbs(): boolean {
return this.filesListWidth > 400 && !this.isUploadInProgress
// If we're uploading files, only show the breadcrumbs
// if the files list is greater than 768px wide
if (this.isUploadInProgress) {
return this.filesListWidth > 768
}
// If we're not uploading, we have enough space from 400px
return this.filesListWidth > 400
},

// used to show the views icon for the first breadcrumb
viewIcon(): string {
return this.currentView?.icon ?? homeSvg
}
return this.currentView?.icon ?? HomeSvg
},

selectedFiles() {
return this.selectionStore.selected
},

draggingFiles() {
return this.draggingStore.dragging
},
},

methods: {
Expand All @@ -160,6 +187,77 @@ export default defineComponent({
}
},

onDragOver(event: DragEvent, path: string) {
// Cannot drop on the current directory
if (path === this.dirs[this.dirs.length - 1]) {
event.dataTransfer.dropEffect = 'none'
return
}

// Handle copy/move drag and drop
if (event.ctrlKey) {
event.dataTransfer.dropEffect = 'copy'
} else {
event.dataTransfer.dropEffect = 'move'
}
},

async onDrop(event: DragEvent, path: string) {
// skip if native drop like text drag and drop from files names
if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
return
}

// Do not stop propagation, so the main content
// drop event can be triggered too and clear the
// dragover state on the DragAndDropNotice component.
event.preventDefault()

// Caching the selection
const selection = this.draggingFiles
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]

// We need to process the dataTransfer ASAP before the
// browser clears it. This is why we cache the items too.
const fileTree = await dataTransferToFileTree(items)

// We might not have the target directory fetched yet
const contents = await this.currentView?.getContents(path)
const folder = contents?.folder
if (!folder) {
showError(this.t('files', 'Target folder does not exist any more'))
return
}

const canDrop = (folder.permissions & Permission.CREATE) !== 0
const isCopy = event.ctrlKey

// If another button is pressed, cancel it. This
// allows cancelling the drag with the right click.
if (!canDrop || event.button !== 0) {
return
}

logger.debug('Dropped', { event, folder, selection, fileTree })

// Check whether we're uploading files
if (fileTree.contents.length > 0) {
await onDropExternalFiles(fileTree, folder, contents.contents)
return
}

// Else we're moving/copying files
const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)

// Reset selection after we dropped the files
// if the dropped files are within the selection
if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
logger.debug('Dropped selection, resetting select store...')
this.selectionStore.reset()
}
},

titleForSection(index, section) {
if (section?.to?.query?.dir === this.$route.query.dir) {
return t('files', 'Reload current directory')
Expand Down
Loading
Loading