Merge pull request #60901 from nextcloud/backport/60519/stable32
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable32, 8.1, stable32, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis (push) Waiting to run
Psalm static code analysis / static-code-analysis-security (push) Waiting to run
Psalm static code analysis / static-code-analysis-ocp (push) Waiting to run
Psalm static code analysis / static-code-analysis-ncu (push) Waiting to run

[stable32] fix(files): Chromium-based browsers drag-and-drop
This commit is contained in:
Stephan Orbaugh 2026-06-09 17:51:30 +02:00 committed by GitHub
commit 5343ccb2c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 129 additions and 46 deletions

View file

@ -6,19 +6,16 @@
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { openConflictPicker } from '@nextcloud/dialogs'
import { FileType, Folder, getFileActions, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { extname } from '@nextcloud/paths'
import { isPublicShare } from '@nextcloud/sharing/public'
import { generateUrl } from '@nextcloud/router'
import { getConflicts, getUploader } from '@nextcloud/upload'
import { vOnClickOutside } from '@vueuse/components'
import { relative } 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 { isDownloadable } from '../utils/permissions.ts'
@ -472,46 +469,34 @@ 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) })
// 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)
await uploader.batchUpload(
relativePath,
files,
async (nodes, path) => {
try {
const { contents, folder } = await this.currentView!.getContents(path)
const conflicts = getConflicts(nodes, contents)
if (conflicts.length === 0) {
return nodes
}
// 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.
// 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
}
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
}
},
)
// 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 as Folder, contents)
this.dragover = false
return
}

View file

@ -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(() => {
@ -138,3 +140,99 @@ 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 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
// 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)
})
})
})

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long