Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,25 @@ describe('useSpringLoadedFolder', () => {
expect(onSpringOpen).not.toHaveBeenCalled()
})

it('opens a folder at most once per drag', () => {
it('opens a folder again when the drag comes back to it', () => {
// Descend, walk back out through the breadcrumb, change your mind and descend again — one
// gesture, and the second entry has to work. Re-entry costs another full delay, and
// `useSpringNavigation` refuses the folder already on screen, so nothing oscillates.
const onSpringOpen = vi.fn()
const harness = renderSpringLoad(onSpringOpen)

act(() => harness.get().arm('folder-a'))
rest()
expect(onSpringOpen).toHaveBeenCalledTimes(1)

// Dragging back out and returning must not re-open it, which would loop at a boundary.
act(() => harness.get().arm('folder-b'))
act(() => harness.get().arm(null))
rest()
act(() => harness.get().arm('folder-a'))
rest()
expect(onSpringOpen).toHaveBeenCalledTimes(1)

expect(onSpringOpen.mock.calls).toEqual([
['folder-a', { history: 'push' }],
[null, { history: 'replace' }],
['folder-a', { history: 'replace' }],
])
})

it('pushes the first spring-open of a drag and replaces the rest', () => {
Expand Down Expand Up @@ -197,17 +203,21 @@ describe('useSpringLoadedFolder', () => {
expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' })
})

it('opens the root at most once per drag, like any other folder', () => {
it('re-opens the root like any other folder, and only after a full rest', () => {
const onSpringOpen = vi.fn()
const harness = renderSpringLoad(onSpringOpen)

act(() => harness.get().arm(null))
rest()

// Passing over another row cancels the countdown, so returning to the root has to wait out
// the delay again rather than firing on whatever was left of the previous one.
act(() => harness.get().arm('folder-a'))
act(() => harness.get().arm(null))
expect(onSpringOpen).toHaveBeenCalledTimes(1)
rest()

expect(onSpringOpen).toHaveBeenCalledTimes(1)
expect(onSpringOpen).toHaveBeenCalledTimes(2)
})

it('never opens a folder after unmount', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface SpringLoadedFolder {
arm: (folderId: string | null) => void
/** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */
disarm: () => void
/** Cancels the pending open and forgets which folders already opened. Call when the drag ends. */
/** Cancels the pending open and forgets that this drag opened anything. Call when the drag ends. */
reset: () => void
}

Expand All @@ -51,9 +51,11 @@ export interface SpringLoadedFolder {
* The dragged rows unmount when the list re-renders into the newly opened folder, which is why
* the drag payload has to live in `dataTransfer` rather than only in the source row's state.
*
* A folder opens at most once per drag. Without that, dragging back out to a parent and
* returning would re-open it on a loop, and a drag that rests near a boundary would flicker
* between two levels.
* A folder may open more than once in a single drag: walking back out through the breadcrumb and
* descending again is a normal way to change your mind mid-gesture, and refusing the second entry
* strands the drag one level up. Nothing oscillates, because every open costs another full
* {@link SPRING_LOAD_DELAY_MS} of the drag holding still, and {@link useSpringNavigation} refuses
* to arm the folder already on screen.
*/
export function useSpringLoadedFolder({
onSpringOpen,
Expand All @@ -65,9 +67,8 @@ export function useSpringLoadedFolder({
* nothing is armed — `null` is a real destination here, the workspace root.
*/
const armedFolderIdRef = useRef<string | null | undefined>(undefined)
/** Folders already opened during this drag; each may only spring once. */
const openedFolderIdsRef = useRef<Set<string | null> | null>(null)
const openedFolderIds = (openedFolderIdsRef.current ??= new Set<string | null>())
/** Whether this drag has already sprung a folder open, which decides push vs. replace. */
const hasOpenedRef = useRef(false)

const onSpringOpenRef = useRef(onSpringOpen)
onSpringOpenRef.current = onSpringOpen
Expand All @@ -91,27 +92,24 @@ export function useSpringLoadedFolder({
* this would let the folder the drag just left open behind the cursor.
*/
clearTimer()
if (openedFolderIds.has(folderId)) return

armedFolderIdRef.current = folderId
timerRef.current = setTimeout(() => {
timerRef.current = null
armedFolderIdRef.current = undefined
/** Read before the add: an empty set means nothing has opened in this drag yet. */
const isFirstOpenOfDrag = openedFolderIds.size === 0
openedFolderIds.add(folderId)
const isFirstOpenOfDrag = !hasOpenedRef.current
hasOpenedRef.current = true
onSpringOpenRef.current(folderId, {
history: isFirstOpenOfDrag ? 'push' : 'replace',
})
}, delayMs)
},
[clearTimer, delayMs, openedFolderIds]
[clearTimer, delayMs]
)

const reset = useCallback(() => {
clearTimer()
openedFolderIds.clear()
}, [clearTimer, openedFolderIds])
hasOpenedRef.current = false
}, [clearTimer])

/**
* Stable identity, not a fresh object per render. Consumers feed this handle into a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ function rest(harness: { rerender: () => void }) {
harness.rerender()
}

/** One spring-open: rest the drag on `folderId` until the timer fires and the list follows. */
function descend(nav: ReturnType<typeof renderSpringNavigation>, folderId: string | null) {
act(() => nav.get().arm(folderId))
rest(nav)
}

beforeEach(() => {
vi.useFakeTimers()
})
Expand Down Expand Up @@ -155,6 +161,130 @@ describe('useSpringNavigation', () => {
expect(nav.navigate).not.toHaveBeenCalled()
})

describe('walking a drag back out and in again', () => {
it('re-enters a folder it already left through the breadcrumb', () => {
// The whole point of the breadcrumb accepting a drag: descend, think better of it, walk
// back up, then descend again — all inside one gesture without releasing the mouse.
const nav = renderSpringNavigation(null)

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')
expect(nav.currentFolderId()).toBe('folder-a')

descend(nav, null)
expect(nav.currentFolderId()).toBeNull()

descend(nav, 'folder-a')
expect(nav.currentFolderId()).toBe('folder-a')

expect(nav.navigate.mock.calls).toEqual([
['folder-a', 'push'],
[null, 'replace'],
['folder-a', 'replace'],
])
})

it('never re-opens the folder already on screen', () => {
// The crumb for the current folder is a legal drop target but not a navigation. Arming it
// would re-enter the folder the drag is already standing in, on a loop.
const nav = renderSpringNavigation(null)

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')

descend(nav, 'folder-a')

expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push')
})

it('cancels a pending open when the drag moves onto the current folder', () => {
// Hovering a sibling folder starts its countdown; sliding onto the crumb of the folder
// you are already in has to call that off, not let it fire from under the cursor.
const nav = renderSpringNavigation('folder-a')

act(() => nav.get().rememberOrigin())
act(() => nav.get().arm('folder-b'))
act(() => {
vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS - 1)
})
descend(nav, 'folder-a')

expect(nav.navigate).not.toHaveBeenCalled()
})

it('returns to the origin in one hop after a round trip that dropped nothing', () => {
const nav = renderSpringNavigation('origin')

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')
descend(nav, 'folder-b')
descend(nav, 'folder-a')

nav.navigate.mockClear()
act(() => nav.get().end())

expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('origin', 'replace')
expect(nav.currentFolderId()).toBe('origin')
})

it('stays put when the round trip ends in a real drop', () => {
const nav = renderSpringNavigation('origin')

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')
descend(nav, null)
descend(nav, 'folder-a')

nav.navigate.mockClear()
act(() => {
nav.get().markDropHandled()
nav.get().end()
})

expect(nav.navigate).not.toHaveBeenCalled()
expect(nav.currentFolderId()).toBe('folder-a')
})

it('walks back to the origin folder itself without then bouncing away from it', () => {
// Ending a drag whose spring-opens happen to land back on the origin must not navigate
// again — the guard is origin-vs-current, not "did anything open".
const nav = renderSpringNavigation('origin')

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')
descend(nav, 'origin')
expect(nav.currentFolderId()).toBe('origin')

nav.navigate.mockClear()
act(() => nav.get().end())

expect(nav.navigate).not.toHaveBeenCalled()
})

it('starts the next drag from where the previous one left the user', () => {
// A drag that ended on a new folder is the new origin. Reusing the old one would yank the
// list back several folders on the next unrelated drag.
const nav = renderSpringNavigation('origin')

act(() => nav.get().rememberOrigin())
descend(nav, 'folder-a')
act(() => {
nav.get().markDropHandled()
nav.get().end()
})

nav.navigate.mockClear()
act(() => nav.get().rememberOrigin())
descend(nav, 'folder-b')
act(() => nav.get().end())

expect(nav.navigate.mock.calls).toEqual([
['folder-b', 'push'],
['folder-a', 'replace'],
])
})
})

it('does not carry drop state into the next drag', () => {
const nav = renderSpringNavigation(null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export interface SpringNavigation {
* treated as part of the drag: unless a drop actually landed, ending the drag returns to where
* it started. The workflow sidebar collapses its own spring-opened folders for the same reason.
*
* Shared by every foldered list. Files keeps its own drag configuration for OS file drops, but
* this lifecycle is identical everywhere.
* Shared by every foldered list, including a drag of OS files onto the Files page.
*/
export function useSpringNavigation({
currentFolderId,
Expand Down Expand Up @@ -73,16 +72,25 @@ export function useSpringNavigation({
* Seeds the origin for a drag that never reached {@link SpringNavigation.rememberOrigin} — a
* drag of OS files starts outside the page, so there is no `dragstart` of ours to record it.
* Without this the return lands on whatever folder the PREVIOUS drag began in.
*
* Refuses the folder already on screen. That target is not a navigation, and arming it is how
* a drag resting on one spot would re-open the same folder over and over: the underlying timer
* lets a folder spring more than once per drag so the user can descend, back out through the
* breadcrumb, and descend again.
*/
const arm = useCallback(
(folderId: string | null) => {
if (folderId === currentFolderIdRef.current) {
springLoad.disarm()
return
}
if (!hasOriginRef.current) {
originFolderIdRef.current = currentFolderIdRef.current
hasOriginRef.current = true
}
springLoad.arm(folderId)
},
[springLoad.arm]
[springLoad.arm, springLoad.disarm]
)

const markDropHandled = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -712,13 +712,7 @@ const DataRow = memo(function DataRow({
onRowClick && 'cursor-pointer',
isDraggable && 'cursor-grab active:cursor-grabbing',
isRowActive && chipActiveSurfaceClass,
/**
* Neutral, matching the workflow sidebar's own drop-inside affordance
* (`bg-[var(--text-subtle)] opacity-10` there, and `--text-subtle` for its reorder
* line). A brand colour here would be the only place in the app that signals "release
* here" with hue rather than weight. Drawn inside the row's own box
* (`outline-offset-[-1px]`) so the ring never overlaps the rows above and below.
*/
/** See {@link chipDropTargetSurfaceClass} for why this is neutral and drawn inset. */
isActiveDropTarget && chipDropTargetSurfaceClass,
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
Expand Down
Loading
Loading