fix(explorer): 非文件拖拽不再触发文件树上传遮罩 (#317)

拖动 Tab(重排/跨 pane split)经过资源管理器文件树时,FileTree 的
dragenter/dragover/drop 此前对任何拖拽都响应:显示上传遮罩并吞掉
事件,导致 pane 根的 Tab-drop 处理收不到、Tab 无法落下。

统一按 dataTransfer.types 含 'Files' 门控(与 Sidebar.tsx 面板宿主
shield 一致):非文件拖拽原样冒泡给 pane 根,OS 文件拖拽行为不变。

回归测试:应用内 Tab 拖拽不出现 uploadDropZone、不改 dropTarget、
不 preventDefault、不触发 onUploadRequest。
This commit is contained in:
Atlas Zeng
2026-08-22 17:22:10 +08:00
committed by GitHub
parent 29b8615bff
commit 9a40164d1a
2 changed files with 39 additions and 4 deletions
+12
View File
@@ -52,6 +52,13 @@ function parentOf(path: string): string {
return at <= 0 ? path : path.slice(0, at)
}
/** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
* split zones) must pass through untouched to the pane's tab-drop handling
* (mirror of Sidebar.tsx's panel-host shield gate). */
function isFileDrag(event: DragEvent): boolean {
return event.dataTransfer?.types.includes('Files') ?? false
}
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
@@ -160,12 +167,14 @@ export function FileTree(props: {
})
}
const handleBodyDrop = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
}
const handleDirDrop = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
@@ -176,6 +185,7 @@ export function FileTree(props: {
handleDirDrop(event, parentOf(path))
}
const handleBodyDragEnter = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
dropDepth.current += 1
@@ -195,6 +205,7 @@ export function FileTree(props: {
setDropRect(null)
}
const handleBodyDragOver = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
@@ -205,6 +216,7 @@ export function FileTree(props: {
setDropTarget(null)
}
const handleRowDragOver = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
+27 -4
View File
@@ -14,6 +14,7 @@ import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx'
import type { UploadItem } from '../src/client/upload.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
@@ -72,12 +73,12 @@ async function mountTree(busy = false): Promise<Harness> {
}
/** A bubbling drag event carrying a stub dataTransfer (jsdom has no DragEvent). */
function dragEvent(type: string): Event {
function dragEvent(type: string, dataTypes: string[] = ['Files']): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
// No entry API on the items, so drops fall back to the flat file list.
value: {
types: ['Files'],
types: dataTypes,
items: [],
files: [new File(['x'], 'dropped.txt')],
dropEffect: '',
@@ -87,8 +88,8 @@ function dragEvent(type: string): Event {
}
/** Dispatch inside act() so React flushes the state update. */
function fire(target: Element, type: string): Event {
const event = dragEvent(type)
function fire(target: Element, type: string, dataTypes?: string[]): Event {
const event = dragEvent(type, dataTypes)
act(() => { target.dispatchEvent(event) })
return event
}
@@ -179,4 +180,26 @@ describe('FileTree drag-drop surface', () => {
expect(busyHarness.uploads).toHaveLength(0)
busyHarness.unmount()
})
it('ignores an in-app tab drag: no upload drop zone and events pass through', () => {
const enter = fire(harness.body, 'dragenter', [TAB_DRAG_TYPE])
expect(enter.defaultPrevented).toBe(false)
expect(dropZone()).toBeNull()
const over = fire(harness.body, 'dragover', [TAB_DRAG_TYPE])
expect(over.defaultPrevented).toBe(false)
expect(dropZone()).toBeNull()
// A row hover must not retarget the drop directory either.
const dir = rowByName(harness.container, 'src')
const rowOver = fire(dir, 'dragover', [TAB_DRAG_TYPE])
expect(rowOver.defaultPrevented).toBe(false)
expect(dir.className).not.toContain('explorerRowDropTarget')
})
it('passes an in-app tab drop through without reporting an upload', async () => {
const drop = fire(harness.body, 'drop', [TAB_DRAG_TYPE])
await act(async () => {})
expect(drop.defaultPrevented).toBe(false)
expect(harness.uploads).toHaveLength(0)
expect(dropZone()).toBeNull()
})
})