Skip to content

Commit 6207d8e

Browse files
committed
Drag: Add folder-level drop targeting (phase 2)
- Hovering a directory row during drag highlights it and uses it as drop destination - Falls back to pane-level targeting for files, "..", and pane background - Same-pane subfolder drops are valid; same-pane pane-level drops stay suppressed - New pure logic module: drop-target-hit-testing.ts with full test coverage
1 parent 1ad1493 commit 6207d8e

8 files changed

Lines changed: 352 additions & 24 deletions

File tree

apps/desktop/src/lib/file-explorer/drag-drop.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export function getIsDraggingFromSelf(): boolean {
5050
return draggingFromSelf
5151
}
5252

53+
/** Resets the self-drag flag. Call from the drop event handler after processing. */
54+
export function resetDraggingFromSelf(): void {
55+
draggingFromSelf = false
56+
}
57+
5358
/** Global state for active drag operation */
5459
let activeDrag: {
5560
startX: number
@@ -219,7 +224,9 @@ async function performSingleFileDrag(filePath: string, iconId: string, mode: 'co
219224
mode,
220225
})
221226
} finally {
222-
draggingFromSelf = false
227+
// Don't reset draggingFromSelf here — startDrag may resolve before
228+
// the OS delivers drop/leave events. The flag is cleared by the
229+
// drop event handler via resetDraggingFromSelf().
223230
void cleanupTempIcon()
224231
}
225232
}
@@ -257,7 +264,7 @@ async function performSelectionDrag(context: SelectionDragContext, mode: 'copy'
257264
iconPath,
258265
)
259266
} finally {
260-
draggingFromSelf = false
267+
// Don't reset draggingFromSelf here — see performSingleFileDrag comment.
261268
void cleanupTempIcon()
262269
}
263270
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2+
import { resolveDropTarget } from './drop-target-hit-testing'
3+
4+
// jsdom doesn't implement elementFromPoint — stub it so we can mock per-test
5+
function mockElementFromPoint(el: Element | null) {
6+
document.elementFromPoint = vi.fn().mockReturnValue(el)
7+
}
8+
9+
function getElement(id: string): HTMLElement {
10+
const el = document.getElementById(id)
11+
if (!el) throw new Error(`Element #${id} not found`)
12+
return el
13+
}
14+
15+
describe('resolveDropTarget', () => {
16+
let leftPane: HTMLDivElement
17+
let rightPane: HTMLDivElement
18+
19+
beforeEach(() => {
20+
document.body.innerHTML = ''
21+
22+
// Build left pane with a directory row, a file row, and a ".." row
23+
leftPane = document.createElement('div')
24+
leftPane.className = 'pane-wrapper'
25+
26+
const dirEntry = document.createElement('div')
27+
dirEntry.className = 'file-entry'
28+
dirEntry.setAttribute('data-drop-target-path', '/Users/test/Documents')
29+
dirEntry.id = 'left-dir'
30+
// Add a nested child inside the directory entry
31+
const nestedSpan = document.createElement('span')
32+
nestedSpan.className = 'col-name'
33+
nestedSpan.textContent = 'Documents'
34+
nestedSpan.id = 'left-dir-name'
35+
dirEntry.appendChild(nestedSpan)
36+
leftPane.appendChild(dirEntry)
37+
38+
const fileEntry = document.createElement('div')
39+
fileEntry.className = 'file-entry'
40+
// No data-drop-target-path — it's a file
41+
fileEntry.id = 'left-file'
42+
leftPane.appendChild(fileEntry)
43+
44+
const parentEntry = document.createElement('div')
45+
parentEntry.className = 'file-entry'
46+
// ".." entry has no data-drop-target-path
47+
parentEntry.id = 'left-parent'
48+
leftPane.appendChild(parentEntry)
49+
50+
// Pane background area (not inside any file-entry)
51+
const bgArea = document.createElement('div')
52+
bgArea.className = 'pane-bg'
53+
bgArea.id = 'left-bg'
54+
leftPane.appendChild(bgArea)
55+
56+
document.body.appendChild(leftPane)
57+
58+
// Build right pane with one directory
59+
rightPane = document.createElement('div')
60+
rightPane.className = 'pane-wrapper'
61+
62+
const rightDir = document.createElement('div')
63+
rightDir.className = 'file-entry'
64+
rightDir.setAttribute('data-drop-target-path', '/Users/test/Downloads')
65+
rightDir.id = 'right-dir'
66+
rightPane.appendChild(rightDir)
67+
68+
document.body.appendChild(rightPane)
69+
})
70+
71+
it('returns folder target when cursor is over a directory row', () => {
72+
const el = getElement('left-dir')
73+
mockElementFromPoint(el)
74+
75+
const result = resolveDropTarget(100, 50, leftPane, rightPane)
76+
77+
expect(result).toEqual({
78+
type: 'folder',
79+
path: '/Users/test/Documents',
80+
element: el,
81+
paneId: 'left',
82+
})
83+
})
84+
85+
it('walks up from nested child to find .file-entry with data-drop-target-path', () => {
86+
const nestedEl = getElement('left-dir-name')
87+
mockElementFromPoint(nestedEl)
88+
89+
const result = resolveDropTarget(100, 50, leftPane, rightPane)
90+
91+
expect(result).toEqual({
92+
type: 'folder',
93+
path: '/Users/test/Documents',
94+
element: getElement('left-dir'),
95+
paneId: 'left',
96+
})
97+
})
98+
99+
it('returns pane target when cursor is over a file row', () => {
100+
mockElementFromPoint(getElement('left-file'))
101+
102+
const result = resolveDropTarget(100, 50, leftPane, rightPane)
103+
104+
expect(result).toEqual({ type: 'pane', paneId: 'left' })
105+
})
106+
107+
it('returns pane target when cursor is over ".." entry', () => {
108+
mockElementFromPoint(getElement('left-parent'))
109+
110+
const result = resolveDropTarget(100, 50, leftPane, rightPane)
111+
112+
expect(result).toEqual({ type: 'pane', paneId: 'left' })
113+
})
114+
115+
it('returns pane target when cursor is over pane background', () => {
116+
mockElementFromPoint(getElement('left-bg'))
117+
118+
const result = resolveDropTarget(100, 50, leftPane, rightPane)
119+
120+
expect(result).toEqual({ type: 'pane', paneId: 'left' })
121+
})
122+
123+
it('returns folder target in the right pane', () => {
124+
const el = getElement('right-dir')
125+
mockElementFromPoint(el)
126+
127+
const result = resolveDropTarget(500, 50, leftPane, rightPane)
128+
129+
expect(result).toEqual({
130+
type: 'folder',
131+
path: '/Users/test/Downloads',
132+
element: el,
133+
paneId: 'right',
134+
})
135+
})
136+
137+
it('returns null when cursor is outside both panes', () => {
138+
const outsideEl = document.createElement('div')
139+
document.body.appendChild(outsideEl)
140+
mockElementFromPoint(outsideEl)
141+
142+
const result = resolveDropTarget(0, 0, leftPane, rightPane)
143+
144+
expect(result).toBeNull()
145+
})
146+
147+
it('returns null when elementFromPoint returns null', () => {
148+
mockElementFromPoint(null)
149+
150+
const result = resolveDropTarget(0, 0, leftPane, rightPane)
151+
152+
expect(result).toBeNull()
153+
})
154+
155+
it('handles undefined left pane element', () => {
156+
const el = getElement('right-dir')
157+
mockElementFromPoint(el)
158+
159+
const result = resolveDropTarget(500, 50, undefined, rightPane)
160+
161+
expect(result).toEqual({
162+
type: 'folder',
163+
path: '/Users/test/Downloads',
164+
element: el,
165+
paneId: 'right',
166+
})
167+
})
168+
169+
it('returns null when both pane elements are undefined', () => {
170+
mockElementFromPoint(getElement('left-dir'))
171+
172+
const result = resolveDropTarget(100, 50, undefined, undefined)
173+
174+
expect(result).toBeNull()
175+
})
176+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** Resolved drop target: either a specific folder row or a pane-level target. */
2+
export type DropTarget =
3+
| { type: 'folder'; path: string; element: HTMLElement; paneId: 'left' | 'right' }
4+
| { type: 'pane'; paneId: 'left' | 'right' }
5+
6+
/**
7+
* Resolves the drop target at (x, y) using the browser's hit-testing.
8+
* If the cursor is over a directory row with `data-drop-target-path`, returns a folder target.
9+
* Otherwise falls back to pane-level targeting.
10+
* Returns null if the cursor is outside both panes.
11+
*/
12+
export function resolveDropTarget(
13+
x: number,
14+
y: number,
15+
leftPaneEl: HTMLElement | undefined,
16+
rightPaneEl: HTMLElement | undefined,
17+
): DropTarget | null {
18+
const el = document.elementFromPoint(x, y)
19+
if (!el) return null
20+
21+
// Determine which pane contains the element
22+
let paneId: 'left' | 'right' | null = null
23+
if (leftPaneEl?.contains(el)) paneId = 'left'
24+
else if (rightPaneEl?.contains(el)) paneId = 'right'
25+
if (!paneId) return null
26+
27+
// Walk up to the closest .file-entry and check for the drop target attribute
28+
const fileEntry = el.closest('.file-entry')
29+
if (fileEntry) {
30+
const dropPath = fileEntry.getAttribute('data-drop-target-path')
31+
if (dropPath) {
32+
return { type: 'folder', path: dropPath, element: fileEntry as HTMLElement, paneId }
33+
}
34+
}
35+
36+
return { type: 'pane', paneId }
37+
}

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@
6565
import type { TransferOperationType } from '../types'
6666
import { getInitialFolderName, moveCursorToNewFolder } from '$lib/file-operations/mkdir/new-folder-operations'
6767
import { getCurrentWebview } from '@tauri-apps/api/webview'
68-
import { getIsDraggingFromSelf } from '../drag-drop'
68+
import { getIsDraggingFromSelf, resetDraggingFromSelf } from '../drag-drop'
69+
import { resolveDropTarget } from '../drop-target-hit-testing'
6970
7071
const log = getAppLogger('fileExplorer')
7172
@@ -100,6 +101,10 @@
100101
// Drop target highlight state: which pane (if any) is the active drop target
101102
let dropTargetPane = $state<'left' | 'right' | null>(null)
102103
104+
// Folder-level drop target state: when hovering over a directory row
105+
let dropTargetFolderPath = $state<string | null>(null)
106+
let dropTargetFolderEl = $state<HTMLElement | null>(null)
107+
103108
// Refs for pane wrapper elements (used for hit-testing drop targets)
104109
let leftPaneWrapperEl: HTMLDivElement | undefined = $state()
105110
let rightPaneWrapperEl: HTMLDivElement | undefined = $state()
@@ -483,20 +488,12 @@
483488
activePaneRef?.handleKeyUp(e)
484489
}
485490
486-
/** Resolves cursor position to a target pane using the browser's own hit-testing. */
487-
function resolveDropTargetPane(x: number, y: number): 'left' | 'right' | null {
488-
const el = document.elementFromPoint(x, y)
489-
if (el && leftPaneWrapperEl?.contains(el)) return 'left'
490-
if (el && rightPaneWrapperEl?.contains(el)) return 'right'
491-
return null
492-
}
493-
494491
/** Handles a file drop onto a target pane by opening the transfer confirmation dialog. */
495-
function handleFileDrop(paths: string[], targetPane: 'left' | 'right') {
492+
function handleFileDrop(paths: string[], targetPane: 'left' | 'right', targetFolderPath?: string) {
496493
if (paths.length === 0) return
497494
498495
const { sortBy, sortOrder } = getPaneSort(targetPane)
499-
const destPath = getPanePath(targetPane)
496+
const destPath = targetFolderPath ?? getPanePath(targetPane)
500497
const destVolId = getPaneVolumeId(targetPane)
501498
502499
transferDialogProps = {
@@ -506,6 +503,45 @@
506503
showTransferDialog = true
507504
}
508505
506+
/** Updates drop-target highlights as the cursor moves during a drag. */
507+
function handleDragOver(position: { x: number; y: number }) {
508+
const resolved = resolveDropTarget(position.x, position.y, leftPaneWrapperEl, rightPaneWrapperEl)
509+
510+
if (resolved?.type === 'folder') {
511+
dropTargetPane = null
512+
dropTargetFolderPath = resolved.path
513+
dropTargetFolderEl = resolved.element
514+
} else if (resolved?.type === 'pane') {
515+
// Suppress highlight when self-drag targets the source pane (no-op)
516+
const suppress = getIsDraggingFromSelf() && resolved.paneId === focusedPane
517+
dropTargetPane = suppress ? null : resolved.paneId
518+
dropTargetFolderPath = null
519+
dropTargetFolderEl = null
520+
} else {
521+
clearDropTargets()
522+
}
523+
}
524+
525+
/** Handles the drop event: resolves the target and opens the transfer dialog. */
526+
function handleDrop(paths: string[], position: { x: number; y: number }) {
527+
const resolved = resolveDropTarget(position.x, position.y, leftPaneWrapperEl, rightPaneWrapperEl)
528+
const folderPath = dropTargetFolderPath
529+
clearDropTargets()
530+
531+
if (!resolved) return
532+
const targetPane = resolved.paneId
533+
// For same-pane pane-level drops (not folder), suppress (no-op)
534+
if (resolved.type === 'pane' && getIsDraggingFromSelf() && targetPane === focusedPane) return
535+
handleFileDrop(paths, targetPane, resolved.type === 'folder' ? (folderPath ?? undefined) : undefined)
536+
}
537+
538+
/** Clears all drop target highlight state. */
539+
function clearDropTargets() {
540+
dropTargetPane = null
541+
dropTargetFolderPath = null
542+
dropTargetFolderEl = null
543+
}
544+
509545
onMount(async () => {
510546
// Start font metrics measurement in background (non-blocking)
511547
void ensureFontMetricsLoaded()
@@ -615,20 +651,14 @@
615651
unlistenDragDrop = await getCurrentWebview().onDragDropEvent((event) => {
616652
const { type } = event.payload
617653
if (type === 'enter' || type === 'over') {
618-
const { position } = event.payload
619-
const resolved = resolveDropTargetPane(position.x, position.y)
620-
// Suppress highlight on the source pane during a self-drag (no-op target)
621-
dropTargetPane = getIsDraggingFromSelf() && resolved === focusedPane ? null : resolved
654+
handleDragOver(event.payload.position)
622655
} else if (type === 'drop') {
623-
const { paths, position } = event.payload
624-
const targetPane = resolveDropTargetPane(position.x, position.y)
625-
dropTargetPane = null
626-
if (targetPane) {
627-
handleFileDrop(paths, targetPane)
628-
}
656+
handleDrop(event.payload.paths, event.payload.position)
657+
resetDraggingFromSelf()
629658
} else {
630659
// 'leave' — cursor left the window or drag was cancelled
631-
dropTargetPane = null
660+
clearDropTargets()
661+
resetDraggingFromSelf()
632662
}
633663
})
634664
})
@@ -1030,6 +1060,17 @@
10301060
}
10311061
})
10321062
1063+
// Manage folder drop-target highlight class imperatively (elements live in child components)
1064+
$effect(() => {
1065+
const el = dropTargetFolderEl
1066+
if (el) {
1067+
el.classList.add('folder-drop-target')
1068+
return () => {
1069+
el.classList.remove('folder-drop-target')
1070+
}
1071+
}
1072+
})
1073+
10331074
/**
10341075
* Refocus the file explorer container.
10351076
* Call this after closing modals to restore keyboard navigation.
@@ -1568,4 +1609,11 @@
15681609
pointer-events: none;
15691610
z-index: 1;
15701611
}
1612+
1613+
/* Folder-level drop target highlight (class managed imperatively, elements in child components) */
1614+
:global(.file-entry.folder-drop-target) {
1615+
outline: 2px solid var(--color-accent);
1616+
outline-offset: -2px;
1617+
background-color: var(--color-bg-hover);
1618+
}
15711619
</style>

0 commit comments

Comments
 (0)