diff --git a/src-web/components/DropMarker.tsx b/src-web/components/DropMarker.tsx index 80e42319..49e6d214 100644 --- a/src-web/components/DropMarker.tsx +++ b/src-web/components/DropMarker.tsx @@ -1,14 +1,17 @@ import classNames from 'classnames'; +import type { CSSProperties} from 'react'; import React, { memo } from 'react'; interface Props { className?: string; + style?: CSSProperties; } export const DropMarker = memo( - function DropMarker({ className }: Props) { + function DropMarker({ className, style }: Props) { return (
; } else if (item.model === 'workspace') { @@ -86,9 +92,9 @@ function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) { /> ); } -} +}); -function SidebarInnerItem({ item }: { treeId: string; item: Model }) { +const SidebarInnerItem = memo(function SidebarInnerItem({ item }: { treeId: string; item: Model }) { const response = useAtomValue( useMemo( () => @@ -119,7 +125,7 @@ function SidebarInnerItem({ item }: { treeId: string; item: Model }) { )}
); -} +}); function NewSidebar({ className }: { className?: string }) { const [hidden, setHidden] = useSidebarHidden(); @@ -292,7 +298,7 @@ const sidebarTreeAtom = atom((get) => { } // Put requests and folders into a tree structure - const next = (node: TreeNode): TreeNode => { + const next = (node: TreeNode, depth: number): TreeNode => { const childItems = childrenMap[node.item.id] ?? []; // Recurse to children @@ -301,18 +307,22 @@ const sidebarTreeAtom = atom((get) => { node.children = node.children ?? []; for (const item of childItems) { treeParentMap[item.id] = node; - node.children.push(next({ item, parent: node })); + node.children.push(next({ item, parent: node, depth }, depth + 1)); } } return node; }; - return next({ - item: activeWorkspace, - children: [], - parent: null, - }); + return next( + { + item: activeWorkspace, + children: [], + parent: null, + depth: 0, + }, + 1, + ); }); const actions = { diff --git a/src-web/components/core/HttpMethodTag.tsx b/src-web/components/core/HttpMethodTag.tsx index e2192056..04605870 100644 --- a/src-web/components/core/HttpMethodTag.tsx +++ b/src-web/components/core/HttpMethodTag.tsx @@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern import { settingsAtom } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; +import { memo } from 'react'; interface Props { request: HttpRequest | GrpcRequest | WebsocketRequest; @@ -23,7 +24,7 @@ const methodNames: Record = { websocket: 'WS', }; -export function HttpMethodTag({ request, className, short }: Props) { +export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) { const settings = useAtomValue(settingsAtom); const method = request.model === 'http_request' && request.bodyType === 'graphql' @@ -42,9 +43,9 @@ export function HttpMethodTag({ request, className, short }: Props) { short={short} /> ); -} +}); -export function HttpMethodTagRaw({ +function HttpMethodTagRaw({ className, method, colored, diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index efd033ff..aa30b6f3 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -1,7 +1,7 @@ import type { Color } from '@yaakapp-internal/plugins'; import classNames from 'classnames'; import * as lucide from 'lucide-react'; -import type { HTMLAttributes } from 'react'; +import type { CSSProperties, HTMLAttributes } from 'react'; import { memo } from 'react'; const icons = { @@ -127,6 +127,7 @@ const icons = { export interface IconProps { icon: keyof typeof icons; className?: string; + style?: CSSProperties; size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; spin?: boolean; title?: string; @@ -138,12 +139,14 @@ export const Icon = memo(function Icon({ color = 'default', spin, size = 'md', + style, className, title, }: IconProps) { const Component = icons[icon] ?? icons._unknown; return ( { root: TreeNode; @@ -75,8 +71,7 @@ function TreeInner( ref: Ref, ) { const treeRef = useRef(null); - const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey); - const [isFocused, setIsFocused] = useState(false); + const selectableItems = useSelectableItems(root); const tryFocus = useCallback(() => { treeRef.current?.querySelector('.tree-item button[tabindex="0"]')?.focus(); @@ -228,7 +223,12 @@ function TreeInner( const over = e.over; if (!over) { // Clear the drop indicator when hovering outside the tree - jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null }); + jotaiStore.set(hoveredParentFamily(treeId), { + parentId: null, + parentDepth: null, + childIndex: null, + index: null, + }); return; } @@ -242,39 +242,59 @@ function TreeInner( if (hoveringRoot) { jotaiStore.set(hoveredParentFamily(treeId), { parentId: root.item.id, - index: root.children?.length ?? 0, + parentDepth: root.depth, + index: selectableItems.length, + childIndex: selectableItems.length, }); return; } - const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null; - if (node == null) { + const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null; + if (selectableItem == null) { return; } + const node = selectableItem.node; const side = computeSideForDragMove(node, e); const item = node.item; - let hoveredParent = treeParentMap[item.id] ?? null; - const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99; - const hovered = hoveredParent?.children?.[dragIndex] ?? null; - let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); + let hoveredParent = node.parent; + const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1; + const hovered = selectableItems[dragIndex]?.node ?? null; + const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); + let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1); - const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom)); + const collapsedMap = jotaiStore.get(collapsedFamily(treeId)); const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false; if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) { // Move into the folder if it's open and we're moving below it - hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null; - hoveredIndex = 0; + hoveredParent = hovered; + hoveredChildIndex = 0; } - jotaiStore.set(hoveredParentFamily(treeId), { - parentId: hoveredParent?.item.id ?? null, - index: hoveredIndex, - }); + const parentId = hoveredParent?.item.id ?? null; + const parentDepth = hoveredParent?.depth ?? null; + const index = hoveredIndex; + const childIndex = hoveredChildIndex; + const existing = jotaiStore.get(hoveredParentFamily(treeId)); + if ( + !( + parentId === existing.parentId && + parentDepth === existing.parentDepth && + index === existing.index && + childIndex === existing.childIndex + ) + ) { + jotaiStore.set(hoveredParentFamily(treeId), { + parentId: hoveredParent?.item.id ?? null, + parentDepth: hoveredParent?.depth ?? null, + index: hoveredIndex, + childIndex: hoveredChildIndex, + }); + } }, - [root.children?.length, root.item.id, selectableItems, treeId, treeParentMap], + [root.depth, root.item.id, selectableItems, treeId], ); const handleDragStart = useCallback( @@ -299,46 +319,57 @@ function TreeInner( ); const clearDragState = useCallback(() => { - jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null }); + jotaiStore.set(hoveredParentFamily(treeId), { + parentId: null, + parentDepth: null, + index: null, + childIndex: null, + }); jotaiStore.set(draggingIdsFamily(treeId), []); }, [treeId]); const handleDragEnd = useCallback( function handleDragEnd(e: DragEndEvent) { // Get this from the store so our callback doesn't change all the time - const hovered = jotaiStore.get(hoveredParentFamily(treeId)); + const { + index: hoveredIndex, + parentId: hoveredParentId, + childIndex: hoveredChildIndex, + } = jotaiStore.get(hoveredParentFamily(treeId)); const draggingItems = jotaiStore.get(draggingIdsFamily(treeId)); clearDragState(); // Dropped outside the tree? - if (e.over == null) return; + if (e.over == null) { + return; + } - const hoveredParent = - hovered.parentId == root.item.id - ? root - : selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node; + const hoveredParentS = + hoveredParentId === root.item.id + ? { node: root, depth: 0, index: 0 } + : (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null); + const hoveredParent = hoveredParentS?.node ?? null; - if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return; - - // Optional tiny guard: don't drop into itself - if (draggingItems.some((id) => id === hovered.parentId)) return; + if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) { + return; + } // Resolve the actual tree nodes for each dragged item (keeps order of draggingItems) const draggedNodes: TreeNode[] = draggingItems .map((id) => { - const parent = treeParentMap[id]; - const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1; - return idx >= 0 ? parent!.children![idx]! : null; + return selectableItems.find((i) => i.node.item.id === id)?.node ?? null; }) .filter((n) => n != null) // Filter out invalid drags (dragging into descendant) - .filter((n) => !hasAncestor(hoveredParent, n.item.id)); + .filter( + (n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id), + ); // Work on a local copy of target children const nextChildren = [...(hoveredParent.children ?? [])]; // Remove any of the dragged nodes already in the target, adjusting hoveredIndex - let insertAt = hovered.index; + let insertAt = hoveredChildIndex ?? 0; for (const node of draggedNodes) { const i = nextChildren.findIndex((n) => n.item.id === node.item.id); if (i !== -1) { @@ -355,14 +386,13 @@ function TreeInner( insertAt, }); }, - [treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap], + [treeId, clearDragState, selectableItems, root, onDragEnd], ); const treeItemListProps: Omit< TreeItemListProps, - 'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex' + 'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex' > = { - depth: 0, getItemKey, getContextMenu: handleGetContextMenu, onClick: handleClick, @@ -371,14 +401,6 @@ function TreeInner( ItemLeftSlot, }; - const handleFocus = useCallback(function handleFocus() { - setIsFocused(true); - }, []); - - const handleBlur = useCallback(function handleBlur() { - setIsFocused(false); - }, []); - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); return ( @@ -396,30 +418,37 @@ function TreeInner( >
- +
+ +
{/* Assign root ID so we can reuse our same move/end logic */} -
+ ); @@ -447,63 +476,6 @@ function DropRegionAfterList({ id }: { id: string }) { return
; } -function useTreeParentMap( - root: TreeNode, - getItemKey: (item: T) => string, -) { - const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom)); - const [{ treeParentMap, selectableItems }, setData] = useState(() => { - return compute(root, collapsedMap); - }); - - const prevRoot = useRef | null>(null); - - useEffect(() => { - const shouldRecompute = - root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey); - if (!shouldRecompute) return; - setData(compute(root, collapsedMap)); - prevRoot.current = root; - }, [collapsedMap, getItemKey, root]); - - return { treeParentMap, selectableItems }; -} - -function compute( - root: TreeNode, - collapsedMap: Record, -) { - const treeParentMap: Record> = {}; - const selectableItems: SelectableTreeNode[] = []; - - // Put requests and folders into a tree structure - const next = (node: TreeNode, depth: number = 0) => { - const isCollapsed = collapsedMap[node.item.id] === true; - // console.log("IS COLLAPSED", node.item.name, isCollapsed); - if (node.children == null) { - return; - } - - // Recurse to children - let selectableIndex = 0; - for (const child of node.children) { - treeParentMap[child.item.id] = node; - if (!isCollapsed) { - selectableItems.push({ - node: child, - index: selectableIndex++, - depth, - }); - } - - next(child, depth + 1); - } - }; - - next(root); - return { treeParentMap, selectableItems }; -} - interface TreeHotKeyProps extends HotKeyOptions { action: HotkeyAction; selectableItems: SelectableTreeNode[]; diff --git a/src-web/components/core/tree/TreeDragOverlay.tsx b/src-web/components/core/tree/TreeDragOverlay.tsx index 4a029d0b..0fdf61fd 100644 --- a/src-web/components/core/tree/TreeDragOverlay.tsx +++ b/src-web/components/core/tree/TreeDragOverlay.tsx @@ -1,20 +1,18 @@ import { DragOverlay } from '@dnd-kit/core'; import { useAtomValue } from 'jotai'; import { draggingIdsFamily } from './atoms'; -import type { SelectableTreeNode, TreeNode } from './common'; +import type { SelectableTreeNode } from './common'; import type { TreeProps } from './Tree'; import { TreeItemList } from './TreeItemList'; export function TreeDragOverlay({ treeId, - root, selectableItems, getItemKey, ItemInner, ItemLeftSlot, }: { treeId: string; - root: TreeNode; selectableItems: SelectableTreeNode[]; } & Pick, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) { const draggingItems = useAtomValue(draggingIdsFamily(treeId)); @@ -22,22 +20,11 @@ export function TreeDragOverlay({ { - const child = selectableItems.find((i2) => { - return i2.node.item.id === id; - })?.node; - return child == null ? null : { ...child, children: undefined }; - }) - .filter((c) => c != null), - }} + nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))} getItemKey={getItemKey} ItemInner={ItemInner} ItemLeftSlot={ItemLeftSlot} - depth={0} + forceDepth={0} /> ); diff --git a/src-web/components/core/tree/TreeDropMarker.tsx b/src-web/components/core/tree/TreeDropMarker.tsx new file mode 100644 index 00000000..19fcaa4e --- /dev/null +++ b/src-web/components/core/tree/TreeDropMarker.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; +import { memo } from 'react'; +import { DropMarker } from '../../DropMarker'; +import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms'; + +export const TreeDropMarker = memo(function TreeDropMarker({ + className, + treeId, + itemId, + index, +}: { + treeId: string; + index: number; + itemId: string | null; + className?: string; +}) { + const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index })); + const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId)); + const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: itemId ?? undefined })); + + // Only show if we're hovering over this index + if (!isHovered) return null; + + // Don't show if we're right under a collapsed folder. We have a separate delayed expansion + // animation for that. + if (collapsed) return null; + + return ( +
+ +
+ ); +}); diff --git a/src-web/components/core/tree/TreeIndentGuide.tsx b/src-web/components/core/tree/TreeIndentGuide.tsx new file mode 100644 index 00000000..942719c3 --- /dev/null +++ b/src-web/components/core/tree/TreeIndentGuide.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; +import { memo } from 'react'; +import { hoveredParentDepthFamily } from './atoms'; + +export const TreeIndentGuide = memo(function TreeIndentGuide({ + treeId, + depth, +}: { + treeId: string; + depth: number; +}) { + const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId)); + + return ( +
+ {Array.from({ length: depth }).map((_, i) => ( +
+ ))} +
+ ); +}); diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 37cc5941..be957019 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -2,21 +2,18 @@ import type { DragMoveEvent } from '@dnd-kit/core'; import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; +import { selectAtom } from 'jotai/utils'; import type { MouseEvent, PointerEvent } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { jotaiStore } from '../../../lib/jotai'; import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import { ContextMenu } from '../Dropdown'; import { Icon } from '../Icon'; -import { - isCollapsedFamily, - isLastFocusedFamily, - isParentHoveredFamily, - isSelectedFamily, -} from './atoms'; +import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms'; import type { TreeNode } from './common'; import { computeSideForDragMove } from './common'; import type { TreeProps } from './Tree'; +import { TreeIndentGuide } from './TreeIndentGuide'; interface OnClickEvent { shiftKey: boolean; @@ -26,17 +23,18 @@ interface OnClickEvent { export type TreeItemProps = Pick< TreeProps, - 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' + 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' > & { node: TreeNode; className?: string; onClick?: (item: T, e: OnClickEvent) => void; getContextMenu?: (item: T) => Promise; + depth: number; }; const HOVER_CLOSED_FOLDER_DELAY = 800; -export function TreeItem({ +function TreeItem_({ treeId, node, ItemInner, @@ -45,17 +43,34 @@ export function TreeItem({ onClick, getEditOptions, className, + depth, }: TreeItemProps) { - const ref = useRef(null); + const ref = useRef(null); const draggableRef = useRef(null); const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id })); const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id })); - const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id })); const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id })); const [editing, setEditing] = useState(false); const [isDropHover, setIsDropHover] = useState(false); const startedHoverTimeout = useRef(undefined); + const isAncestorCollapsedAtom = useMemo( + () => + selectAtom( + collapsedFamily(treeId), + (collapsed) => { + const next = (n: TreeNode) => { + if (n.parent == null) return false; + if (collapsed[n.parent.item.id]) return true; + return next(n.parent); + }; + return next(node); + }, + (a, b) => a === b, // re-render only when boolean flips + ), + [node, treeId], + ); + const [showContextMenu, setShowContextMenu] = useState<{ items: DropdownItem[]; x: number; @@ -160,7 +175,7 @@ export function TreeItem({ }); const handleContextMenu = useCallback( - async (e: MouseEvent) => { + async (e: MouseEvent) => { if (getContextMenu == null) return; e.preventDefault(); @@ -197,77 +212,107 @@ export function TreeItem({ [setDraggableRef, setDroppableRef], ); + if (useAtomValue(isAncestorCollapsedAtom)) return null; + return ( -
- {showContextMenu && ( - - )} - {node.children != null ? ( - - ) : ( - - )} - + ) : ( + // Make the grid happy + )} + + +
+ ); } + +export const TreeItem = memo( + TreeItem_, + ({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => { + const nonEqualKeys = []; + for (const key of Object.keys(prevProps)) { + if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) { + nonEqualKeys.push(key); + } + } + if (nonEqualKeys.length > 0) { + return false; + } + return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item); + }, +) as typeof TreeItem_; diff --git a/src-web/components/core/tree/TreeItemList.tsx b/src-web/components/core/tree/TreeItemList.tsx index 9c161500..d81c37ea 100644 --- a/src-web/components/core/tree/TreeItemList.tsx +++ b/src-web/components/core/tree/TreeItemList.tsx @@ -1,12 +1,8 @@ -import classNames from 'classnames'; -import { useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; import { Fragment, memo } from 'react'; -import { DropMarker } from '../../DropMarker'; -import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms'; -import type { TreeNode } from './common'; -import { equalSubtree } from './common'; +import type { SelectableTreeNode } from './common'; import type { TreeProps } from './Tree'; +import { TreeDropMarker } from './TreeDropMarker'; import type { TreeItemProps } from './TreeItem'; import { TreeItem } from './TreeItem'; @@ -15,81 +11,54 @@ export type TreeItemListProps = Pick< 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' > & Pick, 'onClick' | 'getContextMenu'> & { - node: TreeNode; - depth: number; + nodes: SelectableTreeNode[]; style?: CSSProperties; className?: string; + forceDepth?: number; }; function TreeItemList_({ className, - depth, getContextMenu, getEditOptions, getItemKey, - node, + nodes, onClick, ItemInner, ItemLeftSlot, style, treeId, + forceDepth, }: TreeItemListProps) { - const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id })); - const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id })); - const childList = !isCollapsed && node.children != null && ( -
    0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l', - isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle', - )} - > - {node.children.map(function mapChild(child, i) { - return ( - - - - - ); - })} - -
- ); - - if (depth === 0) { - return childList; - } - return ( -
  • - - {childList} -
  • +
      + + {nodes.map((child, i) => ( + + + + + ))} +
    ); } export const TreeItemList = memo( TreeItemList_, - ({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => { + ( + { nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps }, + { nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps }, + ) => { const nonEqualKeys = []; for (const key of Object.keys(prevProps)) { if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) { @@ -100,32 +69,16 @@ export const TreeItemList = memo( // console.log('TreeItemList: ', nonEqualKeys); return false; } - return equalSubtree(prevNode, nextNode, nextProps.getItemKey); + if (prevNodes.length !== nextNodes.length) return false; + + for (let i = 0; i < prevNodes.length; i++) { + const prev = prevNodes[i]!; + const next = nextNodes[i]!; + if (prevGetItemKey(prev.node.item) !== nextGetItemKey(next.node.item)) { + return false; + } + } + + return true; }, ) as typeof TreeItemList_; - -const TreeDropMarker = memo(function TreeDropMarker({ - className, - treeId, - parent, - index, -}: { - treeId: string; - parent: TreeNode | null; - index: number; - className?: string; -}) { - const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index })); - const isLastItem = parent?.children?.length === index; - const isLastItemHovered = useAtomValue( - isItemHoveredFamily({ - treeId, - parentId: parent?.item.id, - index: parent?.children?.length ?? 0, - }), - ); - - if (!isHovered && !(isLastItem && isLastItemHovered)) return null; - - return ; -}); diff --git a/src-web/components/core/tree/atoms.ts b/src-web/components/core/tree/atoms.ts index 16c42bb9..4a811e1e 100644 --- a/src-web/components/core/tree/atoms.ts +++ b/src-web/components/core/tree/atoms.ts @@ -32,43 +32,40 @@ export const draggingIdsFamily = atomFamily((_treeId: string) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars export const hoveredParentFamily = atomFamily((_treeId: string) => { - return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null }); + return atom<{ + index: number | null; + childIndex: number | null; + parentId: string | null; + parentDepth: number | null; + }>({ + index: null, + childIndex: null, + parentId: null, + parentDepth: null, + }); }); -export const isParentHoveredFamily = atomFamily( - ({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) => - selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is), - (a, b) => a.treeId === b.treeId && a.parentId === b.parentId, +export const isIndexHoveredFamily = atomFamily( + ({ treeId, index }: { treeId: string; index: number}) => + selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is), + (a, b) => a.treeId === b.treeId && a.index === b.index, ); -export const isItemHoveredFamily = atomFamily( - ({ - treeId, - parentId, - index, - }: { - treeId: string; - parentId: string | null | undefined; - index: number | null; - }) => - selectAtom( - hoveredParentFamily(treeId), - (v) => v.parentId === parentId && v.index === index, - Object.is, - ), - (a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index, +export const hoveredParentDepthFamily = atomFamily((treeId: string) => + selectAtom( + hoveredParentFamily(treeId), + (s) => s.parentDepth, + (a, b) => Object.is(a, b) // prevents re-render unless the value changes + ) ); -function kvKey(workspaceId: string | null) { - return ['sidebar_collapsed', workspaceId ?? 'n/a']; -} - export const collapsedFamily = atomFamily((workspaceId: string) => { - return atomWithKVStorage>(kvKey(workspaceId), {}); + const key = ['sidebar_collapsed', workspaceId ?? 'n/a']; + return atomWithKVStorage>(key, {}); }); export const isCollapsedFamily = atomFamily( - ({ treeId, itemId }: { treeId: string; itemId: string }) => + ({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) => atom( // --- getter --- (get) => !!get(collapsedFamily(treeId))[itemId], diff --git a/src-web/components/core/tree/common.ts b/src-web/components/core/tree/common.ts index 2f153c6a..d2056dda 100644 --- a/src-web/components/core/tree/common.ts +++ b/src-web/components/core/tree/common.ts @@ -2,10 +2,11 @@ import type { DragMoveEvent } from '@dnd-kit/core'; import { jotaiStore } from '../../../lib/jotai'; import { selectedIdsFamily } from './atoms'; -export interface TreeNode { +export interface TreeNode { children?: TreeNode[]; item: T; parent: TreeNode | null; + depth: number; } export interface SelectableTreeNode { @@ -41,9 +42,10 @@ export function equalSubtree( } export function hasAncestor(node: TreeNode, ancestorId: string) { - // Check parents recursively if (node.parent == null) return false; if (node.parent.item.id === ancestorId) return true; + + // Check parents recursively return hasAncestor(node.parent, ancestorId); } diff --git a/src-web/components/core/tree/useSelectableItems.ts b/src-web/components/core/tree/useSelectableItems.ts new file mode 100644 index 00000000..87b82bf6 --- /dev/null +++ b/src-web/components/core/tree/useSelectableItems.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import type { SelectableTreeNode, TreeNode } from './common'; + +export function useSelectableItems(root: TreeNode) { + return useMemo(() => { + const selectableItems: SelectableTreeNode[] = []; + + // Put requests and folders into a tree structure + const next = (node: TreeNode, depth: number = 0) => { + if (node.children == null) { + return; + } + + // Recurse to children + let selectableIndex = 0; + for (const child of node.children) { + selectableItems.push({ + node: child, + index: selectableIndex++, + depth, + }); + + next(child, depth + 1); + } + }; + + next(root); + return selectableItems; + }, [root]); +} diff --git a/src-web/hooks/useSidebarItemCollapsed.ts b/src-web/hooks/useSidebarItemCollapsed.ts index ab7ba93e..89fec4c3 100644 --- a/src-web/hooks/useSidebarItemCollapsed.ts +++ b/src-web/hooks/useSidebarItemCollapsed.ts @@ -1,7 +1,5 @@ -import { atom, useAtomValue } from 'jotai'; -import { useCallback } from 'react'; +import { atom } from 'jotai'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; -import { jotaiStore } from '../lib/jotai'; import { activeWorkspaceIdAtom } from './useActiveWorkspace'; function kvKey(workspaceId: string | null) { @@ -12,18 +10,3 @@ export const sidebarCollapsedAtom = atom((get) => { const workspaceId = get(activeWorkspaceIdAtom); return atomWithKVStorage>(kvKey(workspaceId), {}); }); - -export function useSidebarItemCollapsed(itemId: string) { - const map = useAtomValue(useAtomValue(sidebarCollapsedAtom)); - const isCollapsed = map[itemId] === true; - - const toggle = useCallback(() => toggleSidebarItemCollapsed(itemId), [itemId]); - - return [isCollapsed, toggle] as const; -} - -export function toggleSidebarItemCollapsed(itemId: string) { - jotaiStore.set(jotaiStore.get(sidebarCollapsedAtom), (prev) => { - return { ...prev, [itemId]: !prev[itemId] }; - }); -}