mirror of
https://github.com/nextcloud/server.git
synced 2026-06-08 16:26:59 -04:00
fix(files): Chromium-based browsers drag-and-drop
Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
This commit is contained in:
parent
b839335eae
commit
5ce47795c1
2 changed files with 120 additions and 43 deletions
|
|
@ -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) })
|
||||
// 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.activeView!.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.
|
||||
if (!(this.source instanceof 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, contents)
|
||||
this.dragover = false
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue