Skip to content

Commit d24b062

Browse files
committed
chore: add drag and drop recursion and FilesystemAPI testing
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 80c9426 commit d24b062

File tree

8 files changed

+516
-279
lines changed

8 files changed

+516
-279
lines changed

__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/services/DropService.ts

Lines changed: 7 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -22,186 +22,21 @@
2222
*/
2323

2424
import type { Upload } from '@nextcloud/upload'
25-
import type { FileStat, ResponseDataDetailed } from 'webdav'
25+
import type { RootDirectory } from './DropServiceUtils'
2626

27-
import { emit } from '@nextcloud/event-bus'
28-
import { Folder, Node, NodeStatus, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
29-
import { getUploader, hasConflict, openConflictPicker } from '@nextcloud/upload'
27+
import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
28+
import { getUploader, hasConflict } from '@nextcloud/upload'
3029
import { join } from 'path'
3130
import { joinPaths } from '@nextcloud/paths'
3231
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
3332
import { translate as t } from '@nextcloud/l10n'
3433
import Vue from 'vue'
3534

35+
import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
3636
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
3737
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
3838
import logger from '../logger.js'
3939

40-
/**
41-
* This represents a Directory in the file tree
42-
* We extend the File class to better handling uploading
43-
* and stay as close as possible as the Filesystem API.
44-
* This also allow us to hijack the size or lastModified
45-
* properties to compute them dynamically.
46-
*/
47-
class Directory extends File {
48-
49-
/* eslint-disable no-use-before-define */
50-
_contents: (Directory|File)[]
51-
52-
constructor(name, contents: (Directory|File)[] = []) {
53-
super([], name, { type: 'httpd/unix-directory' })
54-
this._contents = contents
55-
}
56-
57-
set contents(contents: (Directory|File)[]) {
58-
this._contents = contents
59-
}
60-
61-
get contents(): (Directory|File)[] {
62-
return this._contents
63-
}
64-
65-
get size() {
66-
return this._computeDirectorySize(this)
67-
}
68-
69-
get lastModified() {
70-
if (this._contents.length === 0) {
71-
return Date.now()
72-
}
73-
return this._computeDirectoryMtime(this)
74-
}
75-
76-
/**
77-
* Get the last modification time of a file tree
78-
* This is not perfect, but will get us a pretty good approximation
79-
* @param directory the directory to traverse
80-
*/
81-
_computeDirectoryMtime(directory: Directory): number {
82-
return directory.contents.reduce((acc, file) => {
83-
return file.lastModified > acc
84-
// If the file is a directory, the lastModified will
85-
// also return the results of its _computeDirectoryMtime method
86-
// Fancy recursion, huh?
87-
? file.lastModified
88-
: acc
89-
}, 0)
90-
}
91-
92-
/**
93-
* Get the size of a file tree
94-
* @param directory the directory to traverse
95-
*/
96-
_computeDirectorySize(directory: Directory): number {
97-
return directory.contents.reduce((acc: number, entry: Directory|File) => {
98-
// If the file is a directory, the size will
99-
// also return the results of its _computeDirectorySize method
100-
// Fancy recursion, huh?
101-
return acc + entry.size
102-
}, 0)
103-
}
104-
105-
}
106-
107-
type RootDirectory = Directory & {
108-
name: 'root'
109-
}
110-
111-
/**
112-
* Traverse a file tree using the Filesystem API
113-
* @param entry the entry to traverse
114-
*/
115-
const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
116-
// Handle file
117-
if (entry.isFile) {
118-
return new Promise<File>((resolve, reject) => {
119-
(entry as FileSystemFileEntry).file(resolve, reject)
120-
})
121-
}
122-
123-
// Handle directory
124-
logger.debug('Handling recursive file tree', { entry: entry.name })
125-
const directory = entry as FileSystemDirectoryEntry
126-
const entries = await readDirectory(directory)
127-
const contents = (await Promise.all(entries.map(traverseTree))).flat()
128-
return new Directory(directory.name, contents)
129-
}
130-
131-
/**
132-
* Read a directory using Filesystem API
133-
* @param directory the directory to read
134-
*/
135-
const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
136-
const dirReader = directory.createReader()
137-
138-
return new Promise<FileSystemEntry[]>((resolve, reject) => {
139-
const entries = [] as FileSystemEntry[]
140-
const getEntries = () => {
141-
dirReader.readEntries((results) => {
142-
if (results.length) {
143-
entries.push(...results)
144-
getEntries()
145-
} else {
146-
resolve(entries)
147-
}
148-
}, (error) => {
149-
reject(error)
150-
})
151-
}
152-
153-
getEntries()
154-
})
155-
}
156-
157-
const createDirectoryIfNotExists = async (absolutePath: string) => {
158-
const davClient = davGetClient()
159-
const dirExists = await davClient.exists(absolutePath)
160-
if (!dirExists) {
161-
logger.debug('Directory does not exist, creating it', { absolutePath })
162-
await davClient.createDirectory(absolutePath, { recursive: true })
163-
const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
164-
emit('files:node:created', davResultToNode(stat.data))
165-
}
166-
}
167-
168-
const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
169-
try {
170-
// List all conflicting files
171-
const conflicts = files.filter((file: File|Node) => {
172-
return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
173-
}).filter(Boolean) as (File|Node)[]
174-
175-
// List of incoming files that are NOT in conflict
176-
const uploads = files.filter((file: File|Node) => {
177-
return !conflicts.includes(file)
178-
})
179-
180-
// Let the user choose what to do with the conflicting files
181-
const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
182-
183-
logger.debug('Conflict resolution', { uploads, selected, renamed })
184-
185-
// If the user selected nothing, we cancel the upload
186-
if (selected.length === 0 && renamed.length === 0) {
187-
// User skipped
188-
showInfo(t('files', 'Conflicts resolution skipped'))
189-
logger.info('User skipped the conflict resolution')
190-
return []
191-
}
192-
193-
// Update the list of files to upload
194-
return [...uploads, ...selected, ...renamed] as (typeof files)
195-
} catch (error) {
196-
console.error(error)
197-
// User cancelled
198-
showError(t('files', 'Upload cancelled'))
199-
logger.error('User cancelled the upload')
200-
}
201-
202-
return []
203-
}
204-
20540
/**
20641
* This function converts a list of DataTransferItems to a file tree.
20742
* It uses the Filesystem API if available, otherwise it falls back to the File API.
@@ -225,7 +60,7 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise
22560
}).map((item) => {
22661
// MDN recommends to try both, as it might be renamed in the future
22762
return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
228-
?? item.webkitGetAsEntry()
63+
?? item?.webkitGetAsEntry?.()
22964
?? item
23065
}) as (FileSystemEntry | DataTransferItem)[]
23166

@@ -249,7 +84,8 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise
24984
// we therefore cannot upload directories recursively.
25085
if (file.type === 'httpd/unix-directory' || !file.type) {
25186
if (!warned) {
252-
showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded.'))
87+
logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
88+
showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded'))
25389
warned = true
25490
}
25591
continue

0 commit comments

Comments
 (0)