Skip to content

Commit 233e863

Browse files
authored
Merge pull request #44652 from nextcloud/backport/44409/stable28
2 parents 3127999 + 7b326c1 commit 233e863

196 files changed

Lines changed: 4722 additions & 2831 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,6 @@ composer.phar
168168
core/js/mimetypelist.js
169169

170170
# Tests - cypress
171+
cypress/downloads
171172
cypress/snapshots
172173
cypress/videos

__tests__/FileSystemAPIUtils.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { basename } from 'node:path'
2+
import mime from 'mime'
3+
4+
class FileSystemEntry {
5+
6+
private _isFile: boolean
7+
private _fullPath: string
8+
9+
constructor(isFile: boolean, fullPath: string) {
10+
this._isFile = isFile
11+
this._fullPath = fullPath
12+
}
13+
14+
get isFile() {
15+
return !!this._isFile
16+
}
17+
18+
get isDirectory() {
19+
return !this.isFile
20+
}
21+
22+
get name() {
23+
return basename(this._fullPath)
24+
}
25+
26+
}
27+
28+
export class FileSystemFileEntry extends FileSystemEntry {
29+
30+
private _contents: string
31+
private _lastModified: number
32+
33+
constructor(fullPath: string, contents: string, lastModified = Date.now()) {
34+
super(true, fullPath)
35+
this._contents = contents
36+
this._lastModified = lastModified
37+
}
38+
39+
file(success: (file: File) => void) {
40+
const lastModified = this._lastModified
41+
// Faking the mime by using the file extension
42+
const type = mime.getType(this.name) || ''
43+
success(new File([this._contents], this.name, { lastModified, type }))
44+
}
45+
46+
}
47+
48+
export class FileSystemDirectoryEntry extends FileSystemEntry {
49+
50+
private _entries: FileSystemEntry[]
51+
52+
constructor(fullPath: string, entries: FileSystemEntry[]) {
53+
super(false, fullPath)
54+
this._entries = entries || []
55+
}
56+
57+
createReader() {
58+
let read = false
59+
return {
60+
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
61+
if (read) {
62+
return success([])
63+
}
64+
read = true
65+
success(this._entries)
66+
},
67+
}
68+
}
69+
70+
}
71+
72+
/**
73+
* This mocks the File API's File class
74+
* It will allow us to test the Filesystem API as well as the
75+
* File API in the same test suite.
76+
*/
77+
export class DataTransferItem {
78+
79+
private _type: string
80+
private _entry: FileSystemEntry
81+
82+
getAsEntry?: () => FileSystemEntry
83+
84+
constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) {
85+
this._type = type
86+
this._entry = entry
87+
88+
// Only when the Files API is available we are
89+
// able to get the entry
90+
if (isFileSystemAPIAvailable) {
91+
this.getAsEntry = () => this._entry
92+
}
93+
}
94+
95+
get kind() {
96+
return 'file'
97+
}
98+
99+
get type() {
100+
return this._type
101+
}
102+
103+
getAsFile(): File|null {
104+
if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
105+
let file: File | null = null
106+
this._entry.file((f) => {
107+
file = f
108+
})
109+
return file
110+
}
111+
112+
// The browser will return an empty File object if the entry is a directory
113+
return new File([], this._entry.name, { type: '' })
114+
}
115+
116+
}
117+
118+
export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
119+
return new DataTransferItem(
120+
entry.isFile ? 'text/plain' : 'httpd/unix-directory',
121+
entry,
122+
isFileSystemAPIAvailable,
123+
)
124+
}

apps/files/src/components/BreadCrumbs.vue

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
:force-icon-text="true"
1212
:title="titleForSection(index, section)"
1313
:aria-description="ariaForSection(section)"
14-
@click.native="onClick(section.to)">
14+
@click.native="onClick(section.to)"
15+
@dragover.native="onDragOver($event, section.dir)"
16+
@drop="onDrop($event, section.dir)">
1517
<template v-if="index === 0" #icon>
1618
<NcIconSvgWrapper :size="20"
1719
:svg="viewIcon" />
@@ -25,19 +27,28 @@
2527
</NcBreadcrumbs>
2628
</template>
2729

28-
<script>
30+
<script lang="ts">
31+
import type { Node } from '@nextcloud/files'
32+
2933
import { basename } from 'path'
30-
import { translate as t } from '@nextcloud/l10n'
31-
import homeSvg from '@mdi/svg/svg/home.svg?raw'
34+
import { defineComponent } from 'vue'
35+
import { Permission } from '@nextcloud/files'
36+
import { translate as t} from '@nextcloud/l10n'
37+
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
3238
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
3339
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
3440
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
35-
import Vue from 'vue'
3641
42+
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
43+
import { showError } from '@nextcloud/dialogs'
44+
import { useDragAndDropStore } from '../store/dragging.ts'
3745
import { useFilesStore } from '../store/files.ts'
3846
import { usePathsStore } from '../store/paths.ts'
47+
import { useSelectionStore } from '../store/selection.ts'
48+
import filesListWidthMixin from '../mixins/filesListWidth.ts'
49+
import logger from '../logger'
3950
40-
export default Vue.extend({
51+
export default defineComponent({
4152
name: 'BreadCrumbs',
4253
4354
components: {
@@ -46,6 +57,10 @@ export default Vue.extend({
4657
NcIconSvgWrapper,
4758
},
4859
60+
mixins: [
61+
filesListWidthMixin,
62+
],
63+
4964
props: {
5065
path: {
5166
type: String,
@@ -54,11 +69,15 @@ export default Vue.extend({
5469
},
5570
5671
setup() {
72+
const draggingStore = useDragAndDropStore()
5773
const filesStore = useFilesStore()
5874
const pathsStore = usePathsStore()
75+
const selectionStore = useSelectionStore()
5976
return {
77+
draggingStore,
6078
filesStore,
6179
pathsStore,
80+
selectionStore,
6281
}
6382
},
6483
@@ -76,22 +95,32 @@ export default Vue.extend({
7695
},
7796
7897
sections() {
79-
return this.dirs.map(dir => {
98+
return this.dirs.map((dir: string, index: number) => {
8099
const fileid = this.getFileIdFromPath(dir)
81100
const to = { ...this.$route, params: { fileid }, query: { dir } }
82101
return {
83102
dir,
84103
exact: true,
85104
name: this.getDirDisplayName(dir),
86105
to,
106+
// disable drop on current directory
107+
disableDrop: index === this.dirs.length - 1,
87108
}
88109
})
89110
},
90111
91112
// used to show the views icon for the first breadcrumb
92113
viewIcon() {
93-
return this.currentView?.icon ?? homeSvg
94-
}
114+
return this.currentView?.icon ?? HomeSvg
115+
},
116+
117+
selectedFiles() {
118+
return this.selectionStore.selected
119+
},
120+
121+
draggingFiles() {
122+
return this.draggingStore.dragging
123+
},
95124
},
96125
97126
methods: {
@@ -117,6 +146,77 @@ export default Vue.extend({
117146
}
118147
},
119148
149+
onDragOver(event: DragEvent, path: string) {
150+
// Cannot drop on the current directory
151+
if (path === this.dirs[this.dirs.length - 1]) {
152+
event.dataTransfer.dropEffect = 'none'
153+
return
154+
}
155+
156+
// Handle copy/move drag and drop
157+
if (event.ctrlKey) {
158+
event.dataTransfer.dropEffect = 'copy'
159+
} else {
160+
event.dataTransfer.dropEffect = 'move'
161+
}
162+
},
163+
164+
async onDrop(event: DragEvent, path: string) {
165+
// skip if native drop like text drag and drop from files names
166+
if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
167+
return
168+
}
169+
170+
// Do not stop propagation, so the main content
171+
// drop event can be triggered too and clear the
172+
// dragover state on the DragAndDropNotice component.
173+
event.preventDefault()
174+
175+
// Caching the selection
176+
const selection = this.draggingFiles
177+
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
178+
179+
// We need to process the dataTransfer ASAP before the
180+
// browser clears it. This is why we cache the items too.
181+
const fileTree = await dataTransferToFileTree(items)
182+
183+
// We might not have the target directory fetched yet
184+
const contents = await this.currentView?.getContents(path)
185+
const folder = contents?.folder
186+
if (!folder) {
187+
showError(this.t('files', 'Target folder does not exist any more'))
188+
return
189+
}
190+
191+
const canDrop = (folder.permissions & Permission.CREATE) !== 0
192+
const isCopy = event.ctrlKey
193+
194+
// If another button is pressed, cancel it. This
195+
// allows cancelling the drag with the right click.
196+
if (!canDrop || event.button !== 0) {
197+
return
198+
}
199+
200+
logger.debug('Dropped', { event, folder, selection, fileTree })
201+
202+
// Check whether we're uploading files
203+
if (fileTree.contents.length > 0) {
204+
await onDropExternalFiles(fileTree, folder, contents.contents)
205+
return
206+
}
207+
208+
// Else we're moving/copying files
209+
const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
210+
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
211+
212+
// Reset selection after we dropped the files
213+
// if the dropped files are within the selection
214+
if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
215+
logger.debug('Dropped selection, resetting select store...')
216+
this.selectionStore.reset()
217+
}
218+
},
219+
120220
titleForSection(index, section) {
121221
if (section?.to?.query?.dir === this.$route.query.dir) {
122222
return t('files', 'Reload current directory')

0 commit comments

Comments
 (0)