From 438a4c0155ee1490e9f3ad00e45a4cd09b2a3818 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Mon, 18 May 2026 23:31:53 +0200 Subject: [PATCH 1/3] fix(files): Chromium-based browsers drag-and-drop Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- apps/files/src/components/FileEntryMixin.ts | 71 ++++++--------- cypress/e2e/files/drag-n-drop.cy.ts | 96 ++++++++++++++++++++- 2 files changed, 122 insertions(+), 45 deletions(-) diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index addb48a57d5a9..75c3afa99d6b0 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -7,17 +7,15 @@ import type { IFileAction } from '@nextcloud/files' import type { PropType } from 'vue' import type { FileSource } from '../types.ts' -import { openConflictPicker } from '@nextcloud/dialogs' import { FileType, Folder, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { isPublicShare } from '@nextcloud/sharing/public' -import { getConflicts, getUploader } from '@nextcloud/upload' import { vOnClickOutside } from '@vueuse/components' -import { extname, relative } from 'path' +import { extname } from 'path' import Vue, { computed, defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { onDropInternalFiles } from '../services/DropService.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' import { hashCode } from '../utils/hashUtils.ts' import { logger } from '../utils/logger.ts' @@ -488,46 +486,31 @@ export default defineComponent({ const items = Array.from(event.dataTransfer?.items || []) if (selection.length === 0 && items.some((item) => item.kind === 'file')) { - const files = items.filter((item) => item.kind === 'file') - .map((item) => 'webkitGetAsEntry' in item ? item.webkitGetAsEntry() : item.getAsFile()) - .filter(Boolean) as (FileSystemEntry | File)[] - const uploader = getUploader() - const root = uploader.destination.path - const relativePath = relative(root, this.source.path) - logger.debug('Start uploading dropped files', { target: this.source.path, root, relativePath, files: files.map((file) => file.name) }) - - await uploader.batchUpload( - relativePath, - files, - async (nodes, path) => { - try { - const { contents, folder } = await this.activeView!.getContents(path) - const conflicts = getConflicts(nodes, contents) - if (conflicts.length === 0) { - return nodes - } - - const result = await openConflictPicker( - folder.displayname, - conflicts, - (contents as Node[]).filter((node) => conflicts.some((conflict) => conflict.name === node.basename)), - { - recursive: true, - }, - ) - if (result === null) { - return false - } - return [ - ...nodes.filter((node) => !conflicts.some((conflict) => conflict.name === node.name)), - ...result.selected, - ...result.renamed, - ] - } catch { - return nodes - } - }, - ) + // Snapshot DataTransfer items immediately so Blink clears data.items + // after the first async yield. Then convert FileSystemEntry to File + // inside dataTransferToFileTree (duck-typed via entry.isFile) rather + // than deferring to @nextcloud/upload's batchUpload, whose + // instanceof-based conversion silently no-ops on some Chromium builds. + // See https://github.com/nextcloud/server/issues/60139 + const fileTree = await dataTransferToFileTree(items) + + // canDrop already gates this branch on FileType.Folder, but the + // type system can't see that — narrow defensively so a future + // loosening of canDrop can't silently lie via the cast below. + if (!(this.source instanceof Folder)) { + logger.error('onDrop: external drop target is not a Folder', { source: this.source }) + this.dragover = false + return + } + + // Fetch destination contents for conflict resolution + const cachedContents = this.filesStore.getNodesByPath(this.activeView.id, this.source.path) + const contents = cachedContents.length === 0 + ? (await this.activeView!.getContents(this.source.path)).contents + : cachedContents + + logger.debug('Start uploading dropped files', { target: this.source.path, fileTree }) + await onDropExternalFiles(fileTree, this.source, contents) this.dragover = false return } diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts index b57390edb3438..811e100317bf7 100644 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ b/cypress/e2e/files/drag-n-drop.cy.ts @@ -2,7 +2,9 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getRowForFile } from './FilesUtils.ts' +import type { User } from '@nextcloud/e2e-test-server/cypress' + +import { getRowForFile, navigateToFolder } from './FilesUtils.ts' describe('files: Drag and Drop', { testIsolation: true }, () => { beforeEach(() => { @@ -146,3 +148,95 @@ describe('files: Drag and Drop', { testIsolation: true }, () => { getRowForFile('Bar').should('not.exist') }) }) + +// Regression coverage for https://github.com/nextcloud/server/issues/60139 +// The per-row drop handler in FileEntryMixin used to pass raw FileSystemEntry +// objects to @nextcloud/upload's batchUpload; on some Chromium builds the +// instanceof-based conversion silently failed and the chunk uploader crashed +// with "e.slice is not a function". The fix routes the per-row drop through +// the same dataTransferToFileTree pipeline as the main file-list drop. +// +// Sibling describe (not nested) so the outer suite's `beforeEach` doesn't +// spin up an unused user before each test in this block. +describe('files: Drag and Drop onto a folder row', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then((u) => { + user = u + cy.mkdir(user, '/subfolder') + cy.login(user) + }) + cy.visit('/apps/files') + getRowForFile('subfolder').should('be.visible') + }) + + it('can drop a single file onto a subfolder row', () => { + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + getRowForFile('subfolder').selectFile({ + fileName: 'dropped-into-subfolder.txt', + contents: ['hello '.repeat(1024)], + }, { action: 'drag-drop' }) + + cy.wait('@uploadFile').its('request.url') + .should('match', /\/subfolder\/dropped-into-subfolder\.txt$/) + + cy.get('[data-cy-upload-picker] progress').should('not.be.visible') + + navigateToFolder('/subfolder') + getRowForFile('dropped-into-subfolder.txt').should('be.visible') + }) + + it('can drop multiple files onto a subfolder row', () => { + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + getRowForFile('subfolder').selectFile([ + { fileName: 'one.txt', contents: ['A'.repeat(1024)] }, + { fileName: 'two.txt', contents: ['B'.repeat(1024)] }, + ], { action: 'drag-drop' }) + + // Both files must land under the subfolder, not the current dir. + cy.wait(['@uploadFile', '@uploadFile']).then((intercepts) => { + const urls = intercepts.map((i) => i.request.url).sort() + expect(urls).to.have.length(2) + urls.forEach((url) => { + expect(url).to.match(/\/subfolder\/(one|two)\.txt$/) + }) + }) + + cy.get('[data-cy-upload-picker] progress').should('not.be.visible') + + navigateToFolder('/subfolder') + getRowForFile('one.txt').should('be.visible') + getRowForFile('two.txt').should('be.visible') + }) + + it('opens the conflict picker when dropping a colliding name onto a subfolder row', () => { + // Pre-populate the subfolder with a file the drop will collide with. + cy.uploadContent(user, new Blob(['original']), 'text/plain', '/subfolder/collide.txt') + + // Reload so the pre-populated file lands in the store before the drop. + // The drop handler reads filesStore.getNodesByPath first and only + // fetches fresh contents when the cache is empty, so a stale cache + // from the beforeEach visit would let the upload proceed without + // triggering the conflict picker. If this ever flaps on CI, replace + // the visit with cy.reload() + an explicit wait on store settlement. + cy.visit('/apps/files') + getRowForFile('subfolder').should('be.visible') + + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + getRowForFile('subfolder').selectFile({ + fileName: 'collide.txt', + contents: ['replacement '.repeat(1024)], + }, { action: 'drag-drop' }) + + // Wait for the conflict picker to appear, then assert no PUT has + // fired yet — chained so the upload-count check happens *after* the + // dialog is visible, enforcing the "dialog blocks upload" invariant. + cy.findByRole('dialog').should('be.visible').then(() => { + cy.get('@uploadFile.all').should('have.length', 0) + }) + }) +}) From 49e6681a40bc5d21136f23611d781894a1619436 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Tue, 19 May 2026 23:09:04 +0200 Subject: [PATCH 2/3] fix(files): use type field instead of instanceof for Folder Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- apps/files/src/components/FileEntryMixin.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index 75c3afa99d6b0..2471ced400989 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -497,7 +497,10 @@ export default defineComponent({ // canDrop already gates this branch on FileType.Folder, but the // type system can't see that — narrow defensively so a future // loosening of canDrop can't silently lie via the cast below. - if (!(this.source instanceof Folder)) { + // Use the `type` field rather than `instanceof Folder`: apps + // bundle their own copy of @nextcloud/files, so a Folder from + // an app would not be `instanceof` the server's Folder class. + if (this.source.type !== FileType.Folder) { logger.error('onDrop: external drop target is not a Folder', { source: this.source }) this.dragover = false return @@ -510,7 +513,7 @@ export default defineComponent({ : cachedContents logger.debug('Start uploading dropped files', { target: this.source.path, fileTree }) - await onDropExternalFiles(fileTree, this.source, contents) + await onDropExternalFiles(fileTree, this.source as Folder, contents) this.dragover = false return } From cd8b287911a0c91e5edd838444f3017efd3fad6d Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Thu, 21 May 2026 16:14:37 +0200 Subject: [PATCH 3/3] fix(test): re-login after cy.uploadContent in conflict-picker Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- cypress/e2e/files/drag-n-drop.cy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts index 811e100317bf7..6f648aca8d238 100644 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ b/cypress/e2e/files/drag-n-drop.cy.ts @@ -214,7 +214,11 @@ describe('files: Drag and Drop onto a folder row', { testIsolation: true }, () = it('opens the conflict picker when dropping a colliding name onto a subfolder row', () => { // Pre-populate the subfolder with a file the drop will collide with. + // cy.uploadContent internally clears session cookies, so re-login + // before revisiting so it matches the uploadContent > login > visit + // pattern used elsewhere in the suite. cy.uploadContent(user, new Blob(['original']), 'text/plain', '/subfolder/collide.txt') + cy.login(user) // Reload so the pre-populated file lands in the store before the drop. // The drop handler reads filesStore.getNodesByPath first and only