diff --git a/__tests__/fixtures/filesystem.ts b/__tests__/fixtures/filesystem.ts
new file mode 100644
index 000000000..f5ffbd92f
--- /dev/null
+++ b/__tests__/fixtures/filesystem.ts
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Helpers to create *real* `FileSystemEntry` objects, as they are handed to the
+ * uploader when files are dropped or selected using ``.
+ *
+ * They are backed by the sandboxed filesystem of the browser (Chromium only),
+ * so the entries behave exactly like the ones of a real drag-and-drop operation,
+ * including quirks like `readEntries` only returning a limited number of entries per call.
+ */
+
+// `FileWriter` is part of the deprecated (but still implemented) filesystem API and thus not typed.
+interface FileWriter {
+ write(data: Blob): void
+ onwriteend: (() => void) | null
+ onerror: ((error: unknown) => void) | null
+}
+
+/**
+ * Create a directory entry containing the given files in the sandboxed filesystem.
+ *
+ * @param files - Map of file paths (relative to the created directory) to their content
+ * @return The entry of the newly created directory
+ */
+export async function createDirectoryEntry(files: Record): Promise {
+ const filesystem = await new Promise((resolve, reject) => {
+ // 0 = window.TEMPORARY
+ window.webkitRequestFileSystem(0, 32 * 1024 * 1024, resolve, reject)
+ })
+
+ // Use a unique name as the sandboxed filesystem is shared between tests
+ const root = await getDirectory(filesystem.root as FileSystemDirectoryEntry, `test-${crypto.randomUUID()}`)
+ for (const [path, content] of Object.entries(files)) {
+ const segments = path.split('/')
+ const name = segments.pop()!
+
+ let directory = root
+ for (const segment of segments) {
+ directory = await getDirectory(directory, segment)
+ }
+ await writeFile(directory, name, content)
+ }
+ return root
+}
+
+/**
+ * Get or create a sub directory of the given directory.
+ *
+ * @param parent - The parent directory
+ * @param name - Name of the sub directory
+ */
+async function getDirectory(parent: FileSystemDirectoryEntry, name: string): Promise {
+ return await new Promise((resolve, reject) => parent.getDirectory(name, { create: true }, resolve, reject))
+}
+
+/**
+ * Create a file with the given content inside the given directory.
+ *
+ * @param parent - The directory to create the file in
+ * @param name - Name of the file
+ * @param content - Content of the file
+ */
+async function writeFile(parent: FileSystemDirectoryEntry, name: string, content: string): Promise {
+ const entry = await new Promise((resolve, reject) => parent.getFile(name, { create: true }, resolve as FileSystemEntryCallback, reject))
+ const writer = await new Promise((resolve, reject) => (entry as unknown as { createWriter(success: (writer: FileWriter) => void, error: (error: unknown) => void): void }).createWriter(resolve, reject))
+ await new Promise((resolve, reject) => {
+ writer.onwriteend = () => resolve()
+ writer.onerror = reject
+ writer.write(new Blob([content], { type: 'text/plain' }))
+ })
+}
diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts
index c6fc7160a..428d5d01d 100644
--- a/__tests__/index.spec.ts
+++ b/__tests__/index.spec.ts
@@ -15,7 +15,7 @@ import {
Permission,
removeNewFileMenuEntry,
} from '../lib/index.ts'
-import { getFileActions, registerFileAction } from '~/ui/actions/fileAction.ts'
+import { getFileActions, registerFileAction } from '@/ui/actions/fileAction.ts'
describe('Exports checks', () => {
test('formatFileSize', () => {
diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json
index 4cad69e30..802a60318 100644
--- a/__tests__/tsconfig.json
+++ b/__tests__/tsconfig.json
@@ -1,13 +1,11 @@
{
"extends": "../tsconfig.json",
"include": ["../lib/**/*.ts", "./**/*.ts", "../window.d.ts"],
+ "exclude": [],
"compilerOptions": {
// Allow us to pass js files to ts-jest without babel
"allowJs": true,
"rootDir": "..",
- "paths": {
- "~/*": ["../lib/*"]
- }
}
}
\ No newline at end of file
diff --git a/__tests__/ui/actions/fileAction.spec.ts b/__tests__/ui/actions/fileAction.spec.ts
index fe503fbac..e12f41b2d 100644
--- a/__tests__/ui/actions/fileAction.spec.ts
+++ b/__tests__/ui/actions/fileAction.spec.ts
@@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IFolder, INode } from '~/node/index.ts'
-import type { IFileAction } from '~/ui/index.ts'
-import type { View } from '~/ui/navigation/index.ts'
+import type { IFolder, INode } from '@/node/index.ts'
+import type { IFileAction } from '@/ui/index.ts'
+import type { View } from '@/ui/navigation/index.ts'
import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { DefaultType, getFileActions, getFilesRegistry, registerFileAction } from '~/ui/index.ts'
-import logger from '~/utils/logger.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { DefaultType, getFileActions, getFilesRegistry, registerFileAction } from '@/ui/index.ts'
+import logger from '@/utils/logger.ts'
const folder = {} as IFolder
const view = {} as View
diff --git a/__tests__/ui/actions/fileListAction.spec.ts b/__tests__/ui/actions/fileListAction.spec.ts
index f63548e6c..12663f094 100644
--- a/__tests__/ui/actions/fileListAction.spec.ts
+++ b/__tests__/ui/actions/fileListAction.spec.ts
@@ -3,13 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IFolder } from '~/node/index.ts'
-import type { IFileListAction, View } from '~/ui/index.ts'
+import type { IFolder } from '@/node/index.ts'
+import type { IFileListAction, View } from '@/ui/index.ts'
import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { getFileListActions, getFilesRegistry, registerFileListAction } from '~/ui/index.ts'
-import logger from '~/utils/logger.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { getFileListActions, getFilesRegistry, registerFileListAction } from '@/ui/index.ts'
+import logger from '@/utils/logger.ts'
const folder = {} as IFolder
const view = {} as View
diff --git a/__tests__/ui/filters/listFilter.spec.ts b/__tests__/ui/filters/listFilter.spec.ts
index b704305c3..6d2fe310e 100644
--- a/__tests__/ui/filters/listFilter.spec.ts
+++ b/__tests__/ui/filters/listFilter.spec.ts
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IFileListFilterChip } from '~/ui/index.ts'
+import type { IFileListFilterChip } from '@/ui/index.ts'
import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { FileListFilter, getFileListFilters, getFilesRegistry, registerFileListFilter, unregisterFileListFilter } from '~/ui/index.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { FileListFilter, getFileListFilters, getFilesRegistry, registerFileListFilter, unregisterFileListFilter } from '@/ui/index.ts'
class TestFilter extends FileListFilter {
public testUpdated() {
diff --git a/__tests__/ui/headers/listHeaders.spec.ts b/__tests__/ui/headers/listHeaders.spec.ts
index d1188c9b7..a56a54142 100644
--- a/__tests__/ui/headers/listHeaders.spec.ts
+++ b/__tests__/ui/headers/listHeaders.spec.ts
@@ -3,12 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IFileListHeader, IFolder, IView } from '~/index.ts'
+import type { IFileListHeader, IFolder, IView } from '@/index.ts'
import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { getFileListHeaders, getFilesRegistry, registerFileListHeader } from '~/ui/index.ts'
-import logger from '~/utils/logger.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { getFileListHeaders, getFilesRegistry, registerFileListHeader } from '@/ui/index.ts'
+import logger from '@/utils/logger.ts'
describe('FileListHeader init', () => {
beforeEach(() => {
diff --git a/__tests__/ui/navigation/navigation.spec.ts b/__tests__/ui/navigation/navigation.spec.ts
index 972d651c6..223eabc58 100644
--- a/__tests__/ui/navigation/navigation.spec.ts
+++ b/__tests__/ui/navigation/navigation.spec.ts
@@ -5,9 +5,9 @@
import { describe, expect, it, vi } from 'vitest'
import { mockView } from '../../fixtures/view.ts'
-import { scopedGlobals } from '~/globalScope.ts'
-import { View } from '~/index.ts'
-import { getNavigation, Navigation } from '~/ui/navigation/navigation.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { View } from '@/index.ts'
+import { getNavigation, Navigation } from '@/ui/navigation/navigation.ts'
describe('getNavigation', () => {
it('creates a new navigation if needed', () => {
diff --git a/__tests__/ui/navigation/view.spec.ts b/__tests__/ui/navigation/view.spec.ts
index dc38cedde..24b232261 100644
--- a/__tests__/ui/navigation/view.spec.ts
+++ b/__tests__/ui/navigation/view.spec.ts
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IView } from '~/ui/navigation/view.ts'
+import type { IView } from '@/ui/navigation/view.ts'
import { describe, expect, test } from 'vitest'
import { mockView } from '../../fixtures/view.ts'
-import { View } from '~/ui/navigation/index.ts'
+import { View } from '@/ui/navigation/index.ts'
describe('Invalid View creation', () => {
test('Invalid id', () => {
diff --git a/__tests__/ui/newMenu/newFileMenu.spec.ts b/__tests__/ui/newMenu/newFileMenu.spec.ts
index 807dc1f43..f170530a3 100644
--- a/__tests__/ui/newMenu/newFileMenu.spec.ts
+++ b/__tests__/ui/newMenu/newFileMenu.spec.ts
@@ -3,15 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { NewMenuEntry } from '~/ui/index.ts'
+import type { NewMenuEntry } from '@/ui/index.ts'
import { afterEach, describe, expect, test, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { Folder } from '~/node/index.ts'
-import { Permission } from '~/permissions.ts'
-import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries, removeNewFileMenuEntry } from '~/ui/index.ts'
-import { NewMenu, NewMenuEntryCategory } from '~/ui/newMenu/NewMenu.ts'
-import logger from '~/utils/logger.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { Folder } from '@/node/index.ts'
+import { Permission } from '@/permissions.ts'
+import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries, removeNewFileMenuEntry } from '@/ui/index.ts'
+import { NewMenu, NewMenuEntryCategory } from '@/ui/newMenu/NewMenu.ts'
+import logger from '@/utils/logger.ts'
describe('NewFileMenu init', () => {
test('Initializing NewFileMenu', () => {
diff --git a/__tests__/ui/sidebar/sidebar.spec.ts b/__tests__/ui/sidebar/sidebar.spec.ts
index abaa8edd9..91c5cbeb4 100644
--- a/__tests__/ui/sidebar/sidebar.spec.ts
+++ b/__tests__/ui/sidebar/sidebar.spec.ts
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ISidebar, ISidebarContext } from '~/ui/index.ts'
+import type { ISidebar, ISidebarContext } from '@/ui/index.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { File } from '~/node/index.ts'
-import { getSidebar } from '~/ui/index.ts'
+import { File } from '@/node/index.ts'
+import { getSidebar } from '@/ui/index.ts'
const node = new File({
id: 1,
diff --git a/__tests__/ui/sidebar/sidebarTab.spec.ts b/__tests__/ui/sidebar/sidebarTab.spec.ts
index 87c554c65..d9a2e9345 100644
--- a/__tests__/ui/sidebar/sidebarTab.spec.ts
+++ b/__tests__/ui/sidebar/sidebarTab.spec.ts
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ISidebarTab } from '~/ui/index.ts'
+import type { ISidebarTab } from '@/ui/index.ts'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { scopedGlobals } from '~/globalScope.ts'
-import { getSidebarTabs, registerSidebarTab } from '~/ui/index.ts'
+import { scopedGlobals } from '@/globalScope.ts'
+import { getSidebarTabs, registerSidebarTab } from '@/ui/index.ts'
// missing in JSDom but supported by every browser!
import 'css.escape'
diff --git a/__tests__/uploader/upload.e2e.spec.ts b/__tests__/uploader/upload.e2e.spec.ts
index 983859778..63c0906e0 100644
--- a/__tests__/uploader/upload.e2e.spec.ts
+++ b/__tests__/uploader/upload.e2e.spec.ts
@@ -3,10 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import type { FileStat } from 'webdav'
+
import { describe, expect, it, vi } from 'vitest'
-import { defaultRemoteURL, getClient } from '~/dav/index.ts'
-import { Folder } from '~/node/index.ts'
-import { Uploader, UploaderStatus, UploadStatus } from '~/upload/index.ts'
+import { createDirectoryEntry } from '../fixtures/filesystem.ts'
+import { defaultRemoteURL, getClient } from '@/dav/index.ts'
+import { Folder } from '@/node/index.ts'
+import { Uploader, UploaderStatus, UploadStatus } from '@/upload/index.ts'
vi.mock('@nextcloud/auth', async (def) => ({
...(await def()),
@@ -164,6 +167,33 @@ describe('Uploader (current API)', () => {
await expect(client.getFileContents('/files/admin/test-folder/upload/subdir/deep/deep.txt', { format: 'text' })).resolves.toBe('deep file')
})
+ it('should upload all files of a dropped folder with more entries than one `readEntries` call returns', async () => {
+ const client = getClient()
+ await client.deleteFile('/files/admin/test-drop').catch(() => {})
+ await client.createDirectory('/files/admin/test-drop')
+
+ const folder = new Folder({
+ owner: 'admin',
+ root: '/files/admin',
+ source: `${defaultRemoteURL}/files/admin/test-drop`,
+ })
+ const uploader = new Uploader(false, folder)
+
+ // Chromium returns at most 100 entries per `readEntries` call,
+ // so a folder with more entries is only fully read if `readEntries` is called repeatedly.
+ const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`file-${index}.txt`, `content-${index}`]))
+ const entry = await createDirectoryEntry(files)
+
+ const finishedPromise = new Promise((resolve) => uploader.addEventListener('finished', () => resolve()))
+ await uploader.batchUpload('', [entry])
+ await finishedPromise
+
+ const contents = await client.getDirectoryContents(`/files/admin/test-drop/${entry.name}`) as FileStat[]
+ const uploaded = contents.filter(({ type }) => type === 'file').map(({ basename }) => basename)
+ expect(uploaded.sort()).toEqual(Object.keys(files).sort())
+ await expect(client.getFileContents(`/files/admin/test-drop/${entry.name}/file-149.txt`, { format: 'text' })).resolves.toBe('content-149')
+ }, 120_000)
+
it('should track upload status transitions', async () => {
const client = getClient()
await client.deleteFile('/files/admin/test-status').catch(() => {})
diff --git a/lib/upload/utils/fileTree.spec.ts b/lib/upload/utils/fileTree.spec.ts
new file mode 100644
index 000000000..03b84ea60
--- /dev/null
+++ b/lib/upload/utils/fileTree.spec.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+import { isFileSystemDirectoryEntry, isFileSystemEntry, isFileSystemFileEntry } from './filesystem.ts'
+import { Directory } from './fileTree.ts'
+import { createDirectoryEntry } from '~/__tests__/fixtures/filesystem.ts'
+
+describe('file system entry detection', () => {
+ it('detects real file system entries', async () => {
+ const directoryEntry = await createDirectoryEntry({ 'a.txt': 'a' })
+ const fileEntry = await new Promise((resolve, reject) => directoryEntry.getFile('a.txt', {}, resolve, reject))
+
+ expect(isFileSystemEntry(directoryEntry)).toBe(true)
+ expect(isFileSystemDirectoryEntry(directoryEntry)).toBe(true)
+ expect(isFileSystemFileEntry(directoryEntry)).toBe(false)
+
+ expect(isFileSystemEntry(fileEntry)).toBe(true)
+ expect(isFileSystemFileEntry(fileEntry)).toBe(true)
+ expect(isFileSystemDirectoryEntry(fileEntry)).toBe(false)
+ })
+
+ it('does not detect files or directories as file system entries', () => {
+ expect(isFileSystemEntry(new File(['a'], 'a.txt'))).toBe(false)
+ expect(isFileSystemEntry(new Directory('folder'))).toBe(false)
+ expect(isFileSystemEntry(null)).toBe(false)
+ expect(isFileSystemEntry({ name: 'a.txt' })).toBe(false)
+ })
+})
+
+describe('Directory (file tree)', () => {
+ it('adds all entries of a directory with more entries than one `readEntries` call returns', async () => {
+ // Chromium returns at most 100 entries per `readEntries` call
+ const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`file-${index}.txt`, `content-${index}`]))
+ const entry = await createDirectoryEntry(files)
+
+ const root = new Directory('')
+ await root.addChild(entry)
+
+ const directory = root.getChild(entry.name) as Directory
+ expect(directory.children).toHaveLength(150)
+ expect(directory.children.map(({ name }) => name).sort()).toEqual(Object.keys(files).sort())
+ })
+
+ it('adds all entries of a nested directory with more entries than one `readEntries` call returns', async () => {
+ const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`nested/file-${index}.txt`, `content-${index}`]))
+ const entry = await createDirectoryEntry(files)
+
+ const root = new Directory('')
+ await root.addChild(entry)
+
+ const nested = (root.getChild(entry.name) as Directory).getChild('nested') as Directory
+ expect(nested.children).toHaveLength(150)
+ })
+})
diff --git a/lib/upload/utils/fileTree.ts b/lib/upload/utils/fileTree.ts
index b9cbbd651..b9f7f37f0 100644
--- a/lib/upload/utils/fileTree.ts
+++ b/lib/upload/utils/fileTree.ts
@@ -73,7 +73,15 @@ export class Directory extends File {
file = await new Promise((resolve, reject) => (file as FileSystemFileEntry).file(resolve, reject))
} else if (isFileSystemDirectoryEntry(file)) {
const reader = file.createReader()
- const entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
+ // `readEntries` does not necessarily return all entries at once,
+ // e.g. Chromium returns at most 100 entries per call,
+ // so we need to call it until it returns an empty array.
+ const entries: FileSystemEntry[] = []
+ let batch: FileSystemEntry[]
+ do {
+ batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
+ entries.push(...batch)
+ } while (batch.length > 0)
// Create a new child directory and add the entries
const child = new Directory(`${rootPath}${file.name}`)
diff --git a/lib/upload/utils/filesystem.ts b/lib/upload/utils/filesystem.ts
index 0602cc49c..df05f7e94 100644
--- a/lib/upload/utils/filesystem.ts
+++ b/lib/upload/utils/filesystem.ts
@@ -3,10 +3,37 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// Helpers for the File and Directory API
+//
+// The interfaces of the (legacy) File and Directory Entries API are not exposed as global
+// constructors by Chromium, so `instanceof` can not be used and we check the shape instead.
+// This also works for browsers that do not support the API at all.
-// Helper to support browser that do not support the API
-export const isFileSystemDirectoryEntry = (o: unknown): o is FileSystemDirectoryEntry => 'FileSystemDirectoryEntry' in window && o instanceof FileSystemDirectoryEntry
+/**
+ * Check whether the given object is a `FileSystemEntry`
+ *
+ * @param o - The object to check
+ */
+export function isFileSystemEntry(o: unknown): o is FileSystemEntry {
+ return typeof o === 'object' && o !== null
+ && 'isFile' in o && typeof o.isFile === 'boolean'
+ && 'isDirectory' in o && typeof o.isDirectory === 'boolean'
+ && 'fullPath' in o && typeof o.fullPath === 'string'
+}
-export const isFileSystemFileEntry = (o: unknown): o is FileSystemFileEntry => 'FileSystemFileEntry' in window && o instanceof FileSystemFileEntry
+/**
+ * Check whether the given object is a `FileSystemDirectoryEntry`
+ *
+ * @param o - The object to check
+ */
+export function isFileSystemDirectoryEntry(o: unknown): o is FileSystemDirectoryEntry {
+ return isFileSystemEntry(o) && o.isDirectory && typeof (o as FileSystemDirectoryEntry).createReader === 'function'
+}
-export const isFileSystemEntry = (o: unknown): o is FileSystemEntry => 'FileSystemEntry' in window && o instanceof FileSystemEntry
+/**
+ * Check whether the given object is a `FileSystemFileEntry`
+ *
+ * @param o - The object to check
+ */
+export function isFileSystemFileEntry(o: unknown): o is FileSystemFileEntry {
+ return isFileSystemEntry(o) && o.isFile && typeof (o as FileSystemFileEntry).file === 'function'
+}
diff --git a/tsconfig.json b/tsconfig.json
index 9032b449d..2f73a3eb1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"include": ["lib"],
+ "exclude": ["**/*.spec.ts"],
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
@@ -13,5 +14,9 @@
"outDir": "./dist",
"rootDir": "./lib",
"noEmit": true,
+ "paths": {
+ "~/*": ["./*"],
+ "@/*": ["./lib/*"]
+ }
}
}
diff --git a/vitest.config.ts b/vitest.config.ts
index d363c88bf..4ae9d887f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -42,7 +42,8 @@ export default defineConfig({
},
resolve: {
alias: {
- '~': resolve(__dirname, 'lib'),
+ '@': resolve(__dirname, 'lib'),
+ '~': __dirname,
},
},
server: {