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 (
-
+
);
}
+
+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] };
- });
-}