diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 2079499..ebea5a7 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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' diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index f45dffb..c76e661 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -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 { } /** 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() + }) })