From 3a61ffbbb0612ba70fa516d9dc5ca54d8c3b513a Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 18 Oct 2025 07:41:33 -0700 Subject: [PATCH] Better drag for empty folders --- src-web/components/core/Icon.tsx | 8 ++-- src-web/components/core/tree/Tree.tsx | 11 ++++-- .../components/core/tree/TreeDropMarker.tsx | 16 ++++---- src-web/components/core/tree/TreeItem.tsx | 38 ++++++++++--------- src-web/components/core/tree/TreeItemList.tsx | 4 +- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index aa30b6f3..8b8de601 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -33,17 +33,19 @@ const icons = { chevron_left: lucide.ChevronLeftIcon, chevron_right: lucide.ChevronRightIcon, circle_alert: lucide.CircleAlertIcon, + circle_dashed: lucide.CircleDashedIcon, circle_dollar_sign: lucide.CircleDollarSignIcon, circle_fading_arrow_up: lucide.CircleFadingArrowUpIcon, clock: lucide.ClockIcon, code: lucide.CodeIcon, columns_2: lucide.Columns2Icon, command: lucide.CommandIcon, - corner_right_up: lucide.CornerRightUpIcon, - credit_card: lucide.CreditCardIcon, cookie: lucide.CookieIcon, copy: lucide.CopyIcon, copy_check: lucide.CopyCheck, + corner_right_up: lucide.CornerRightUpIcon, + credit_card: lucide.CreditCardIcon, + dot: lucide.DotIcon, download: lucide.DownloadIcon, ellipsis: lucide.EllipsisIcon, expand: lucide.ExpandIcon, @@ -55,8 +57,8 @@ const icons = { flame: lucide.FlameIcon, flask: lucide.FlaskConicalIcon, folder: lucide.FolderIcon, - folder_cog: lucide.FolderCogIcon, folder_code: lucide.FolderCodeIcon, + folder_cog: lucide.FolderCogIcon, folder_git: lucide.FolderGitIcon, folder_input: lucide.FolderInputIcon, folder_open: lucide.FolderOpenIcon, diff --git a/src-web/components/core/tree/Tree.tsx b/src-web/components/core/tree/Tree.tsx index 01313b13..b9ba9feb 100644 --- a/src-web/components/core/tree/Tree.tsx +++ b/src-web/components/core/tree/Tree.tsx @@ -267,10 +267,15 @@ function TreeInner( const collapsedMap = jotaiStore.get(collapsedFamily(treeId)); const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false; - if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) { + if (hovered?.children != null && side === 'below') { // Move into the folder if it's open and we're moving below it - hoveredParent = hovered; - hoveredChildIndex = 0; + if (isHoveredItemCollapsed) { + hoveredParent = hovered; + hoveredChildIndex = 0; + } else { + hoveredParent = hovered; + hoveredChildIndex = 0; + } } const parentId = hoveredParent?.item.id ?? null; diff --git a/src-web/components/core/tree/TreeDropMarker.tsx b/src-web/components/core/tree/TreeDropMarker.tsx index dd659bec..7e5d0185 100644 --- a/src-web/components/core/tree/TreeDropMarker.tsx +++ b/src-web/components/core/tree/TreeDropMarker.tsx @@ -3,28 +3,30 @@ import { useAtomValue } from 'jotai'; import { memo } from 'react'; import { DropMarker } from '../../DropMarker'; import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms'; +import type { TreeNode } from './common'; -export const TreeDropMarker = memo(function TreeDropMarker({ +export const TreeDropMarker = memo(function TreeDropMarker({ className, treeId, - itemId, + node, index, }: { treeId: string; index: number; - itemId: string | null; + node: TreeNode | null; className?: string; }) { + const itemId = node?.item.id; const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index })); const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId)); - const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: itemId ?? undefined })); + const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId })); // Only show if we're hovering over this index - if (!isHovered) return null; + if (!isHovered) return null; - // Don't show if we're right under a collapsed folder. We have a separate delayed expansion + // Don't show if we're right under a collapsed folder, or empty folder. We have a separate delayed expansion // animation for that. - if (collapsed) return null; + if (collapsed || node?.children?.length === 0) return null; return (
diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index be957019..5cb11a17 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -51,7 +51,7 @@ function TreeItem_({ const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id })); const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id })); const [editing, setEditing] = useState(false); - const [isDropHover, setIsDropHover] = useState(false); + const [dropHover, setDropHover] = useState(null); const startedHoverTimeout = useRef(undefined); const isAncestorCollapsedAtom = useMemo( @@ -147,29 +147,35 @@ function TreeItem_({ } }, [getEditOptions, node.children, toggleCollapsed]); - const clearHoverTimer = () => { + const clearDropHover = () => { if (startedHoverTimeout.current) { - setIsDropHover(false); // NEW - clearTimeout(startedHoverTimeout.current); // NEW - startedHoverTimeout.current = undefined; // NEW + clearTimeout(startedHoverTimeout.current); + startedHoverTimeout.current = undefined; } + setDropHover(null); }; // Toggle auto-expand of folders when hovering over them useDndMonitor({ + onDragEnd() { + clearDropHover(); + }, onDragMove(e: DragMoveEvent) { const side = computeSideForDragMove(node, e); - const isFolderWithChildren = (node.children?.length ?? 0) > 0; + const isFolder = node.children != null; + const hasChildren = (node.children?.length ?? 0) > 0; const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id })); - if (isCollapsed && isFolderWithChildren && side === 'below') { - setIsDropHover(true); + if (isCollapsed && isFolder && hasChildren && side === 'below') { + setDropHover('animate'); clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = setTimeout(() => { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false); - setIsDropHover(false); + clearDropHover(); }, HOVER_CLOSED_FOLDER_DELAY); + } else if (isFolder && !hasChildren && side === 'below') { + setDropHover('drop'); } else { - clearHoverTimer(); + clearDropHover(); } }, }); @@ -227,6 +233,9 @@ function TreeItem_({ 'tree-item', 'h-sm', 'grid grid-cols-[auto_minmax(0,1fr)]', + editing && 'ring-1 focus-within:ring-focus', + dropHover != null && 'relative z-10 ring-2 ring-primary', + dropHover === 'animate' && 'animate-blinkRing', isSelected && 'selected', )} > @@ -235,10 +244,7 @@ function TreeItem_({ className={classNames( 'tree-item-selectable', 'text-text-subtle', - isSelected && 'selected', 'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md', - editing && 'ring-1 focus-within:ring-focus', - isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing', )} > {showContextMenu && ( @@ -251,14 +257,12 @@ function TreeItem_({ {node.children != null ? ( diff --git a/src-web/components/core/tree/TreeItemList.tsx b/src-web/components/core/tree/TreeItemList.tsx index d81c37ea..ac1c3a37 100644 --- a/src-web/components/core/tree/TreeItemList.tsx +++ b/src-web/components/core/tree/TreeItemList.tsx @@ -32,7 +32,7 @@ function TreeItemList_({ }: TreeItemListProps) { return (
    - + {nodes.map((child, i) => ( ({ getItemKey={getItemKey} depth={forceDepth == null ? child.depth : forceDepth} /> - + ))}