Skip to content

Commit 94127c6

Browse files
committed
feat(resources): drag onto breadcrumbs to move back up, and round the drop ring
1 parent b1b53db commit 94127c6

12 files changed

Lines changed: 346 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,15 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br
9191
const trailing = options.trailing ?? NO_TRAILING_CRUMBS
9292

9393
const items: BreadcrumbItem[] = [
94-
{ label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) },
94+
{ label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) },
9595
]
9696

9797
breadcrumbs.forEach((folder, index) => {
9898
/** Where you already are — and on a detail page that is a trailing crumb, not a folder. */
9999
const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1
100100
items.push({
101101
label: folder.name,
102+
folderId: folder.id,
102103
onClick: isOpenFolder ? undefined : () => onNavigate(folder.id),
103104
dropdownItems:
104105
isOpenFolder && options.currentFolderActions?.length

apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components
1111
import {
1212
folderRowId,
1313
parseFolderedRowId,
14+
splitFolderedRowIds,
1415
} from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
1516
import {
1617
buildDescendantIndex,
1718
buildMoveOptions,
19+
buildMoveOptionsExcludingSubtrees,
1820
parseMoveOptionValue,
1921
ROOT_MOVE_OPTION_VALUE,
2022
} from '@/app/workspace/[workspaceId]/components/folders/move-options'
@@ -331,3 +333,114 @@ describe('folderAncestorChain', () => {
331333
expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a'])
332334
})
333335
})
336+
337+
describe('splitFolderedRowIds', () => {
338+
it('separates folder rows from resource rows', () => {
339+
const { folderIds, resourceIds } = splitFolderedRowIds([
340+
folderRowId('f-1'),
341+
'res-1',
342+
folderRowId('f-2'),
343+
'res-2',
344+
])
345+
346+
expect(folderIds).toEqual(['f-1', 'f-2'])
347+
expect(resourceIds).toEqual(['res-1', 'res-2'])
348+
})
349+
350+
it('returns empty lists for an empty selection', () => {
351+
expect(splitFolderedRowIds([])).toEqual({ folderIds: [], resourceIds: [] })
352+
})
353+
354+
it('accepts a Set, which is how a selection is actually held', () => {
355+
const { folderIds, resourceIds } = splitFolderedRowIds(new Set([folderRowId('f-1'), 'res-1']))
356+
expect(folderIds).toEqual(['f-1'])
357+
expect(resourceIds).toEqual(['res-1'])
358+
})
359+
})
360+
361+
describe('buildMoveOptionsExcludingSubtrees', () => {
362+
/** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */
363+
const folders = [makeFolder('a'), makeFolder('a1', 'a'), makeFolder('a1x', 'a1'), makeFolder('b')]
364+
const descendantsByFolderId = buildDescendantIndex(folders)
365+
const valuesOf = (nodes: ReturnType<typeof buildMoveOptions>): string[] =>
366+
nodes.flatMap((node) => [node.value, ...valuesOf(node.children)])
367+
368+
it('offers every folder when nothing is excluded', () => {
369+
const options = buildMoveOptionsExcludingSubtrees({
370+
folders,
371+
rootLabel: 'Root',
372+
excludeFolderIds: [],
373+
descendantsByFolderId,
374+
})
375+
expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a', 'a1', 'a1x', 'b'])
376+
})
377+
378+
it('excludes a moving folder and its whole subtree, never offering a cycle', () => {
379+
// The invariant this helper exists to hold: a folder can never be filed into itself or
380+
// anything beneath it, at any depth.
381+
const options = buildMoveOptionsExcludingSubtrees({
382+
folders,
383+
rootLabel: 'Root',
384+
excludeFolderIds: ['a'],
385+
descendantsByFolderId,
386+
})
387+
expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'b'])
388+
})
389+
390+
it('excludes the union of several selected subtrees', () => {
391+
const options = buildMoveOptionsExcludingSubtrees({
392+
folders,
393+
rootLabel: 'Root',
394+
excludeFolderIds: ['a1', 'b'],
395+
descendantsByFolderId,
396+
})
397+
expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a'])
398+
})
399+
400+
it('always keeps the workspace root as a destination', () => {
401+
const options = buildMoveOptionsExcludingSubtrees({
402+
folders,
403+
rootLabel: 'Root',
404+
excludeFolderIds: ['a', 'b'],
405+
descendantsByFolderId,
406+
})
407+
expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE])
408+
})
409+
})
410+
411+
describe('folderBreadcrumbItems drag destinations', () => {
412+
const chain = [makeFolder('a'), makeFolder('a1', 'a')]
413+
414+
it('names the folder each crumb points at, so the header can accept a drop on it', () => {
415+
const items = folderBreadcrumbItems({
416+
rootLabel: 'Files',
417+
breadcrumbs: chain,
418+
onNavigate: vi.fn(),
419+
})
420+
421+
expect(items.map((item) => item.folderId)).toEqual([null, 'a', 'a1'])
422+
})
423+
424+
it('leaves a trailing crumb without a folder id, so it stays inert', () => {
425+
const items = folderBreadcrumbItems({
426+
rootLabel: 'Files',
427+
breadcrumbs: chain,
428+
onNavigate: vi.fn(),
429+
trailing: [{ label: 'report.md', terminal: true }],
430+
})
431+
432+
expect(items.at(-1)).toMatchObject({ label: 'report.md' })
433+
expect(items.at(-1)?.folderId).toBeUndefined()
434+
})
435+
436+
it('gives the root crumb null rather than omitting it — the root is a real destination', () => {
437+
const items = folderBreadcrumbItems({
438+
rootLabel: 'Files',
439+
breadcrumbs: [],
440+
onNavigate: vi.fn(),
441+
})
442+
443+
expect(items).toHaveLength(1)
444+
expect(items[0]).toHaveProperty('folderId', null)
445+
})
446+
})

apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ export function renderMoveOptions(
184184
* Shared because that exclusion is a correctness invariant, not a preference: hand-copying it
185185
* per surface is how one list eventually offers a cyclic destination. Covers the single-folder
186186
* case too — pass a one-element array.
187+
*
188+
* Expanding each selection to its descendants is deliberately belt-and-braces: {@link
189+
* buildMoveOptions} descends from the root, so an excluded folder already takes its subtree out
190+
* of the walk. The explicit expansion keeps the invariant true of the exclusion set itself, so
191+
* it survives that walk ever being replaced by a flat render.
187192
*/
188193
export function buildMoveOptionsExcludingSubtrees({
189194
folders,

apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function useFolderRowDragDrop({
9898
}: UseFolderRowDragDropOptions): RowDragDropConfig {
9999
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(null)
100100
const [isBodyDropActive, setIsBodyDropActive] = useState(false)
101+
const [activeBreadcrumbIndex, setActiveBreadcrumbIndex] = useState<number | null>(null)
101102
const [draggedRowIds, setDraggedRowIds] = useState<Set<string>>(() => EMPTY_ROW_IDS)
102103
/**
103104
* The in-flight drag source, mirrored outside React state because `onDragOver` fires far
@@ -172,6 +173,7 @@ export function useFolderRowDragDrop({
172173
setDraggedRowIds(EMPTY_ROW_IDS)
173174
setActiveDropTargetId(null)
174175
setIsBodyDropActive(false)
176+
setActiveBreadcrumbIndex(null)
175177
}, [dragGhost, springLoad])
176178

177179
useDragTeardown(endDrag)
@@ -289,6 +291,7 @@ export function useFolderRowDragDrop({
289291
* clearing here the row and the body would both render as the target at once.
290292
*/
291293
setIsBodyDropActive(false)
294+
setActiveBreadcrumbIndex(null)
292295
/**
293296
* Armed on the same condition as the highlight, so a folder only springs open where a
294297
* drop was already possible. A folder the drag cannot legally enter never opens.
@@ -327,6 +330,48 @@ export function useFolderRowDragDrop({
327330
if (move) optionsRef.current.onMoveRows(move, target.id)
328331
},
329332
onDragEnd: endDrag,
333+
/**
334+
* The breadcrumb is how a drag walks back UP. Spring-loading only ever goes deeper, so
335+
* without this a drag that entered a folder can only leave it by being abandoned.
336+
* Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on
337+
* one files the drag there directly.
338+
*/
339+
breadcrumb: {
340+
activeIndex: activeBreadcrumbIndex,
341+
onDragOver: (e: DragEvent<HTMLElement>, folderId: string | null, index: number) => {
342+
const sourceRowIds = draggedRowIdsRef.current
343+
const canDrop =
344+
sourceRowIds.length > 0 && resolveMoveToFolder(folderId, sourceRowIds) !== null
345+
/**
346+
* Armed even when the drop itself would be a no-op — walking back through a crumb the
347+
* rows already live in is exactly how a user returns to where they started, and
348+
* refusing to navigate there would strand them.
349+
*/
350+
if (sourceRowIds.length > 0 && folderId !== currentFolderIdRef.current) {
351+
springLoad.arm(folderId)
352+
}
353+
setActiveBreadcrumbIndex(canDrop ? index : null)
354+
setIsBodyDropActive(false)
355+
if (!canDrop) return
356+
e.preventDefault()
357+
e.stopPropagation()
358+
e.dataTransfer.dropEffect = 'move'
359+
},
360+
onDragLeave: (_e: DragEvent<HTMLElement>, index: number) => {
361+
springLoad.disarm()
362+
setActiveBreadcrumbIndex((current) => (current === index ? null : current))
363+
},
364+
onDrop: (e: DragEvent<HTMLElement>, folderId: string | null) => {
365+
e.preventDefault()
366+
e.stopPropagation()
367+
const sourceRowIds =
368+
readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current
369+
const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null
370+
if (move) dropHandledRef.current = true
371+
endDrag()
372+
if (move) optionsRef.current.onMoveRows(move, folderId)
373+
},
374+
},
330375
body: {
331376
isActive: isBodyDropActive,
332377
onDragOver: (e: DragEvent<HTMLDivElement>) => {

apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,31 @@ describe('useSpringLoadedFolder', () => {
185185
expect(onSpringOpen).toHaveBeenCalledTimes(2)
186186
})
187187

188+
it('springs to the workspace root, which a breadcrumb targets as null', () => {
189+
// Walking a drag back UP goes through the breadcrumb, whose first crumb is the root — so
190+
// null has to be a real destination here, distinct from "nothing armed".
191+
const onSpringOpen = vi.fn()
192+
const harness = renderSpringLoad(onSpringOpen)
193+
194+
act(() => harness.get().arm(null))
195+
rest()
196+
197+
expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' })
198+
})
199+
200+
it('opens the root at most once per drag, like any other folder', () => {
201+
const onSpringOpen = vi.fn()
202+
const harness = renderSpringLoad(onSpringOpen)
203+
204+
act(() => harness.get().arm(null))
205+
rest()
206+
act(() => harness.get().arm('folder-a'))
207+
act(() => harness.get().arm(null))
208+
rest()
209+
210+
expect(onSpringOpen).toHaveBeenCalledTimes(1)
211+
})
212+
188213
it('never opens a folder after unmount', () => {
189214
const onSpringOpen = vi.fn()
190215
const harness = renderSpringLoad(onSpringOpen)

apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface UseSpringLoadedFolderOptions {
2727
* over while deciding where to drop; replacing every level would overwrite the entry they
2828
* were actually standing on, so Back would leave the page instead of returning to it.
2929
*/
30-
onSpringOpen: (folderId: string, options: SpringOpenOptions) => void
30+
onSpringOpen: (folderId: string | null, options: SpringOpenOptions) => void
3131
delayMs?: number
3232
}
3333

@@ -37,7 +37,7 @@ export interface SpringLoadedFolder {
3737
* fires continuously: re-arming the folder already being timed does not restart it, so the
3838
* countdown reflects how long the drag has actually rested there.
3939
*/
40-
arm: (folderId: string) => void
40+
arm: (folderId: string | null) => void
4141
/** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */
4242
disarm: () => void
4343
/** Cancels the pending open and forgets which folders already opened. Call when the drag ends. */
@@ -60,26 +60,29 @@ export function useSpringLoadedFolder({
6060
delayMs = SPRING_LOAD_DELAY_MS,
6161
}: UseSpringLoadedFolderOptions): SpringLoadedFolder {
6262
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
63-
/** Folder the timer is currently counting down for, so re-arming it is a no-op. */
64-
const armedFolderIdRef = useRef<string | null>(null)
63+
/**
64+
* Folder the timer is counting down for, so re-arming it is a no-op. `undefined` means
65+
* nothing is armed — `null` is a real destination here, the workspace root.
66+
*/
67+
const armedFolderIdRef = useRef<string | null | undefined>(undefined)
6568
/** Folders already opened during this drag; each may only spring once. */
66-
const openedFolderIdsRef = useRef<Set<string> | null>(null)
67-
const openedFolderIds = (openedFolderIdsRef.current ??= new Set<string>())
69+
const openedFolderIdsRef = useRef<Set<string | null> | null>(null)
70+
const openedFolderIds = (openedFolderIdsRef.current ??= new Set<string | null>())
6871

6972
const onSpringOpenRef = useRef(onSpringOpen)
7073
onSpringOpenRef.current = onSpringOpen
7174

7275
const clearTimer = useCallback(() => {
7376
if (timerRef.current !== null) clearTimeout(timerRef.current)
7477
timerRef.current = null
75-
armedFolderIdRef.current = null
78+
armedFolderIdRef.current = undefined
7679
}, [])
7780

7881
/** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */
7982
useEffect(() => clearTimer, [clearTimer])
8083

8184
const arm = useCallback(
82-
(folderId: string) => {
85+
(folderId: string | null) => {
8386
if (armedFolderIdRef.current === folderId) return
8487

8588
/**
@@ -93,7 +96,7 @@ export function useSpringLoadedFolder({
9396
armedFolderIdRef.current = folderId
9497
timerRef.current = setTimeout(() => {
9598
timerRef.current = null
96-
armedFolderIdRef.current = null
99+
armedFolderIdRef.current = undefined
97100
/** Read before the add: an empty set means nothing has opened in this drag yet. */
98101
const isFirstOpenOfDrag = openedFolderIds.size === 0
99102
openedFolderIds.add(folderId)

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type {
2+
BreadcrumbDropConfig,
23
BreadcrumbEditing,
34
BreadcrumbItem,
45
DropdownOption,

0 commit comments

Comments
 (0)