Flattened the sidebar tree

This commit is contained in:
Gregory Schier
2025-10-17 15:07:02 -07:00
parent 6e9b1db196
commit c34ea20406
14 changed files with 418 additions and 370 deletions
+4 -1
View File
@@ -1,14 +1,17 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties} from 'react';
import React, { memo } from 'react'; import React, { memo } from 'react';
interface Props { interface Props {
className?: string; className?: string;
style?: CSSProperties;
} }
export const DropMarker = memo( export const DropMarker = memo(
function DropMarker({ className }: Props) { function DropMarker({ className, style }: Props) {
return ( return (
<div <div
style={style}
className={classNames( className={classNames(
className, className,
'relative w-full h-0 overflow-visible pointer-events-none', 'relative w-full h-0 overflow-visible pointer-events-none',
+22 -12
View File
@@ -18,7 +18,7 @@ import {
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace'; import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings'; import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar'; import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
@@ -71,7 +71,13 @@ function getItemKey(item: Model) {
].join('::'); ].join('::');
} }
function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) { const SidebarLeftSlot = memo(function SidebarLeftSlot({
treeId,
item,
}: {
treeId: string;
item: Model;
}) {
if (item.model === 'folder') { if (item.model === 'folder') {
return <Icon icon="folder" />; return <Icon icon="folder" />;
} else if (item.model === 'workspace') { } 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( const response = useAtomValue(
useMemo( useMemo(
() => () =>
@@ -119,7 +125,7 @@ function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
)} )}
</div> </div>
); );
} });
function NewSidebar({ className }: { className?: string }) { function NewSidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden(); const [hidden, setHidden] = useSidebarHidden();
@@ -292,7 +298,7 @@ const sidebarTreeAtom = atom((get) => {
} }
// Put requests and folders into a tree structure // Put requests and folders into a tree structure
const next = (node: TreeNode<Model>): TreeNode<Model> => { const next = (node: TreeNode<Model>, depth: number): TreeNode<Model> => {
const childItems = childrenMap[node.item.id] ?? []; const childItems = childrenMap[node.item.id] ?? [];
// Recurse to children // Recurse to children
@@ -301,18 +307,22 @@ const sidebarTreeAtom = atom((get) => {
node.children = node.children ?? []; node.children = node.children ?? [];
for (const item of childItems) { for (const item of childItems) {
treeParentMap[item.id] = node; treeParentMap[item.id] = node;
node.children.push(next({ item, parent: node })); node.children.push(next({ item, parent: node, depth }, depth + 1));
} }
} }
return node; return node;
}; };
return next({ return next(
item: activeWorkspace, {
children: [], item: activeWorkspace,
parent: null, children: [],
}); parent: null,
depth: 0,
},
1,
);
}); });
const actions = { const actions = {
+4 -3
View File
@@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
import { settingsAtom } from '@yaakapp-internal/models'; import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { memo } from 'react';
interface Props { interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest; request: HttpRequest | GrpcRequest | WebsocketRequest;
@@ -23,7 +24,7 @@ const methodNames: Record<string, string> = {
websocket: 'WS', websocket: 'WS',
}; };
export function HttpMethodTag({ request, className, short }: Props) { export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const method = const method =
request.model === 'http_request' && request.bodyType === 'graphql' request.model === 'http_request' && request.bodyType === 'graphql'
@@ -42,9 +43,9 @@ export function HttpMethodTag({ request, className, short }: Props) {
short={short} short={short}
/> />
); );
} });
export function HttpMethodTagRaw({ function HttpMethodTagRaw({
className, className,
method, method,
colored, colored,
+4 -1
View File
@@ -1,7 +1,7 @@
import type { Color } from '@yaakapp-internal/plugins'; import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import * as lucide from 'lucide-react'; import * as lucide from 'lucide-react';
import type { HTMLAttributes } from 'react'; import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react'; import { memo } from 'react';
const icons = { const icons = {
@@ -127,6 +127,7 @@ const icons = {
export interface IconProps { export interface IconProps {
icon: keyof typeof icons; icon: keyof typeof icons;
className?: string; className?: string;
style?: CSSProperties;
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
spin?: boolean; spin?: boolean;
title?: string; title?: string;
@@ -138,12 +139,14 @@ export const Icon = memo(function Icon({
color = 'default', color = 'default',
spin, spin,
size = 'md', size = 'md',
style,
className, className,
title, title,
}: IconProps) { }: IconProps) {
const Component = icons[icon] ?? icons._unknown; const Component = icons[icon] ?? icons._unknown;
return ( return (
<Component <Component
style={style}
title={title} title={title}
className={classNames( className={classNames(
className, className,
+101 -129
View File
@@ -9,31 +9,27 @@ import {
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { type } from '@tauri-apps/plugin-os'; import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react'; import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
import { import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use'; import { useKey, useKeyPressEvent } from 'react-use';
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey'; import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey'; import { useHotKey } from '../../../hooks/useHotKey';
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps } from '../Dropdown'; import type { ContextMenuProps } from '../Dropdown';
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms'; import {
collapsedFamily,
draggingIdsFamily,
focusIdsFamily,
hoveredParentFamily,
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common'; import type { SelectableTreeNode, TreeNode } from './common';
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common'; import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay'; import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemProps } from './TreeItem'; import type { TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList'; import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList'; import { TreeItemList } from './TreeItemList';
import { useSelectableItems } from './useSelectableItems';
export interface TreeProps<T extends { id: string }> { export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>; root: TreeNode<T>;
@@ -75,8 +71,7 @@ function TreeInner<T extends { id: string }>(
ref: Ref<TreeHandle>, ref: Ref<TreeHandle>,
) { ) {
const treeRef = useRef<HTMLDivElement>(null); const treeRef = useRef<HTMLDivElement>(null);
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey); const selectableItems = useSelectableItems(root);
const [isFocused, setIsFocused] = useState<boolean>(false);
const tryFocus = useCallback(() => { const tryFocus = useCallback(() => {
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus(); treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
@@ -228,7 +223,12 @@ function TreeInner<T extends { id: string }>(
const over = e.over; const over = e.over;
if (!over) { if (!over) {
// Clear the drop indicator when hovering outside the tree // 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; return;
} }
@@ -242,39 +242,59 @@ function TreeInner<T extends { id: string }>(
if (hoveringRoot) { if (hoveringRoot) {
jotaiStore.set(hoveredParentFamily(treeId), { jotaiStore.set(hoveredParentFamily(treeId), {
parentId: root.item.id, parentId: root.item.id,
index: root.children?.length ?? 0, parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
}); });
return; return;
} }
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null; const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (node == null) { if (selectableItem == null) {
return; return;
} }
const node = selectableItem.node;
const side = computeSideForDragMove(node, e); const side = computeSideForDragMove(node, e);
const item = node.item; const item = node.item;
let hoveredParent = treeParentMap[item.id] ?? null; let hoveredParent = node.parent;
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99; const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = hoveredParent?.children?.[dragIndex] ?? null; const hovered = selectableItems[dragIndex]?.node ?? null;
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); 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; const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) { if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
// Move into the folder if it's open and we're moving below it // 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; hoveredParent = hovered;
hoveredIndex = 0; hoveredChildIndex = 0;
} }
jotaiStore.set(hoveredParentFamily(treeId), { const parentId = hoveredParent?.item.id ?? null;
parentId: hoveredParent?.item.id ?? null, const parentDepth = hoveredParent?.depth ?? null;
index: hoveredIndex, 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( const handleDragStart = useCallback(
@@ -299,46 +319,57 @@ function TreeInner<T extends { id: string }>(
); );
const clearDragState = useCallback(() => { 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), []); jotaiStore.set(draggingIdsFamily(treeId), []);
}, [treeId]); }, [treeId]);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
function handleDragEnd(e: DragEndEvent) { function handleDragEnd(e: DragEndEvent) {
// Get this from the store so our callback doesn't change all the time // 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)); const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
clearDragState(); clearDragState();
// Dropped outside the tree? // Dropped outside the tree?
if (e.over == null) return; if (e.over == null) {
return;
}
const hoveredParent = const hoveredParentS =
hovered.parentId == root.item.id hoveredParentId === root.item.id
? root ? { node: root, depth: 0, index: 0 }
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node; : (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
const hoveredParent = hoveredParentS?.node ?? null;
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return; if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
return;
// Optional tiny guard: don't drop into itself }
if (draggingItems.some((id) => id === hovered.parentId)) return;
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems) // Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
const draggedNodes: TreeNode<T>[] = draggingItems const draggedNodes: TreeNode<T>[] = draggingItems
.map((id) => { .map((id) => {
const parent = treeParentMap[id]; return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
return idx >= 0 ? parent!.children![idx]! : null;
}) })
.filter((n) => n != null) .filter((n) => n != null)
// Filter out invalid drags (dragging into descendant) // 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 // Work on a local copy of target children
const nextChildren = [...(hoveredParent.children ?? [])]; const nextChildren = [...(hoveredParent.children ?? [])];
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex // 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) { for (const node of draggedNodes) {
const i = nextChildren.findIndex((n) => n.item.id === node.item.id); const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
if (i !== -1) { if (i !== -1) {
@@ -355,14 +386,13 @@ function TreeInner<T extends { id: string }>(
insertAt, insertAt,
}); });
}, },
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap], [treeId, clearDragState, selectableItems, root, onDragEnd],
); );
const treeItemListProps: Omit< const treeItemListProps: Omit<
TreeItemListProps<T>, TreeItemListProps<T>,
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex' 'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
> = { > = {
depth: 0,
getItemKey, getItemKey,
getContextMenu: handleGetContextMenu, getContextMenu: handleGetContextMenu,
onClick: handleClick, onClick: handleClick,
@@ -371,14 +401,6 @@ function TreeInner<T extends { id: string }>(
ItemLeftSlot, ItemLeftSlot,
}; };
const handleFocus = useCallback(function handleFocus() {
setIsFocused(true);
}, []);
const handleBlur = useCallback(function handleBlur() {
setIsFocused(false);
}, []);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return ( return (
@@ -396,30 +418,37 @@ function TreeInner<T extends { id: string }>(
> >
<div <div
ref={treeRef} ref={treeRef}
onFocus={handleFocus}
onBlur={handleBlur}
className={classNames( className={classNames(
className, className,
'outline-none h-full', 'outline-none h-full',
'overflow-y-auto overflow-x-hidden', 'overflow-y-auto overflow-x-hidden',
'grid grid-rows-[auto_1fr]', 'grid grid-rows-[auto_1fr]',
' [&_.tree-item.selected]:text-text',
isFocused
? '[&_.tree-item.selected]:bg-surface-active'
: '[&_.tree-item.selected]:bg-surface-highlight',
)} )}
> >
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} /> <div
className={classNames(
'[&_.tree-item-inner]:bg-surface',
'[&_.tree-item-selectable.selected]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
// Round the items, but only if the ends of the selection
'[&_.tree-item]:rounded-md',
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
)}
>
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
</div>
{/* Assign root ID so we can reuse our same move/end logic */} {/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList id={root.item.id} /> <DropRegionAfterList id={root.item.id} />
<TreeDragOverlay
treeId={treeId}
root={root}
selectableItems={selectableItems}
ItemInner={ItemInner}
getItemKey={getItemKey}
/>
</div> </div>
<TreeDragOverlay
treeId={treeId}
selectableItems={selectableItems}
ItemInner={ItemInner}
getItemKey={getItemKey}
/>
</DndContext> </DndContext>
</> </>
); );
@@ -447,63 +476,6 @@ function DropRegionAfterList({ id }: { id: string }) {
return <div ref={setNodeRef} />; return <div ref={setNodeRef} />;
} }
function useTreeParentMap<T extends { id: string }>(
root: TreeNode<T>,
getItemKey: (item: T) => string,
) {
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
return compute(root, collapsedMap);
});
const prevRoot = useRef<TreeNode<T> | 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<T extends { id: string }>(
root: TreeNode<T>,
collapsedMap: Record<string, boolean>,
) {
const treeParentMap: Record<string, TreeNode<T>> = {};
const selectableItems: SelectableTreeNode<T>[] = [];
// Put requests and folders into a tree structure
const next = (node: TreeNode<T>, 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<T extends { id: string }> extends HotKeyOptions { interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
action: HotkeyAction; action: HotkeyAction;
selectableItems: SelectableTreeNode<T>[]; selectableItems: SelectableTreeNode<T>[];
@@ -1,20 +1,18 @@
import { DragOverlay } from '@dnd-kit/core'; import { DragOverlay } from '@dnd-kit/core';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { draggingIdsFamily } from './atoms'; import { draggingIdsFamily } from './atoms';
import type { SelectableTreeNode, TreeNode } from './common'; import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
import { TreeItemList } from './TreeItemList'; import { TreeItemList } from './TreeItemList';
export function TreeDragOverlay<T extends { id: string }>({ export function TreeDragOverlay<T extends { id: string }>({
treeId, treeId,
root,
selectableItems, selectableItems,
getItemKey, getItemKey,
ItemInner, ItemInner,
ItemLeftSlot, ItemLeftSlot,
}: { }: {
treeId: string; treeId: string;
root: TreeNode<T>;
selectableItems: SelectableTreeNode<T>[]; selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) { } & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId)); const draggingItems = useAtomValue(draggingIdsFamily(treeId));
@@ -22,22 +20,11 @@ export function TreeDragOverlay<T extends { id: string }>({
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>
<TreeItemList <TreeItemList
treeId={treeId + '.dragging'} treeId={treeId + '.dragging'}
node={{ nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
item: { ...root.item, id: `${root.item.id}_dragging` },
parent: null,
children: draggingItems
.map((id) => {
const child = selectableItems.find((i2) => {
return i2.node.item.id === id;
})?.node;
return child == null ? null : { ...child, children: undefined };
})
.filter((c) => c != null),
}}
getItemKey={getItemKey} getItemKey={getItemKey}
ItemInner={ItemInner} ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot} ItemLeftSlot={ItemLeftSlot}
depth={0} forceDepth={0}
/> />
</DragOverlay> </DragOverlay>
); );
@@ -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 (
<div style={{ paddingLeft: `${parentDepth}rem` }}>
<DropMarker className={classNames(className)} />
</div>
);
});
@@ -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 (
<div className="flex">
{Array.from({ length: depth }).map((_, i) => (
<div
key={i}
className={classNames(
'w-[1rem] border-r border-r-text-subtlest',
parentDepth !== i + 1 && 'opacity-30',
)}
/>
))}
</div>
);
});
+119 -74
View File
@@ -2,21 +2,18 @@ import type { DragMoveEvent } from '@dnd-kit/core';
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { MouseEvent, PointerEvent } from 'react'; 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 { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown'; import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
isCollapsedFamily,
isLastFocusedFamily,
isParentHoveredFamily,
isSelectedFamily,
} from './atoms';
import type { TreeNode } from './common'; import type { TreeNode } from './common';
import { computeSideForDragMove } from './common'; import { computeSideForDragMove } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide';
interface OnClickEvent { interface OnClickEvent {
shiftKey: boolean; shiftKey: boolean;
@@ -26,17 +23,18 @@ interface OnClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick< export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>, TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
> & { > & {
node: TreeNode<T>; node: TreeNode<T>;
className?: string; className?: string;
onClick?: (item: T, e: OnClickEvent) => void; onClick?: (item: T, e: OnClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>; getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
depth: number;
}; };
const HOVER_CLOSED_FOLDER_DELAY = 800; const HOVER_CLOSED_FOLDER_DELAY = 800;
export function TreeItem<T extends { id: string }>({ function TreeItem_<T extends { id: string }>({
treeId, treeId,
node, node,
ItemInner, ItemInner,
@@ -45,17 +43,34 @@ export function TreeItem<T extends { id: string }>({
onClick, onClick,
getEditOptions, getEditOptions,
className, className,
depth,
}: TreeItemProps<T>) { }: TreeItemProps<T>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLLIElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null); const draggableRef = useRef<HTMLButtonElement>(null);
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id })); const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
const isCollapsed = useAtomValue(isCollapsedFamily({ 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 isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
const [isDropHover, setIsDropHover] = useState<boolean>(false); const [isDropHover, setIsDropHover] = useState<boolean>(false);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined); const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
const isAncestorCollapsedAtom = useMemo(
() =>
selectAtom(
collapsedFamily(treeId),
(collapsed) => {
const next = (n: TreeNode<T>) => {
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<{ const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[]; items: DropdownItem[];
x: number; x: number;
@@ -160,7 +175,7 @@ export function TreeItem<T extends { id: string }>({
}); });
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
async (e: MouseEvent<HTMLDivElement>) => { async (e: MouseEvent<HTMLElement>) => {
if (getContextMenu == null) return; if (getContextMenu == null) return;
e.preventDefault(); e.preventDefault();
@@ -197,77 +212,107 @@ export function TreeItem<T extends { id: string }>({
[setDraggableRef, setDroppableRef], [setDraggableRef, setDroppableRef],
); );
if (useAtomValue(isAncestorCollapsedAtom)) return null;
return ( return (
<div <li
ref={ref} ref={ref}
role="treeitem"
aria-level={depth + 1}
aria-expanded={node.children == null ? undefined : !isCollapsed}
aria-selected={isSelected}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={classNames( className={classNames(
className, className,
'tree-item', 'tree-item',
'h-sm',
'grid grid-cols-[auto_minmax(0,1fr)]',
isSelected && 'selected', isSelected && 'selected',
'text-text-subtle',
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
editing && 'ring-1 focus-within:ring-focus',
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
)} )}
> >
{showContextMenu && ( <TreeIndentGuide treeId={treeId} depth={depth} />
<ContextMenu <div
items={showContextMenu.items} className={classNames(
triggerPosition={showContextMenu} 'tree-item-selectable',
onClose={handleCloseContextMenu} 'text-text-subtle',
/> isSelected && 'selected',
)} 'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
{node.children != null ? ( editing && 'ring-1 focus-within:ring-focus',
<button isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
tabIndex={-1}
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
onClick={toggleCollapsed}
>
<Icon
icon="chevron_right"
className={classNames(
'transition-transform text-text-subtlest',
'ml-auto !h-[1rem] !w-[1rem]',
node.children.length == 0 && 'opacity-0',
!isCollapsed && 'rotate-90',
isHoveredAsParent && '!text-text',
)}
/>
</button>
) : (
<span />
)}
<button
ref={handleSetDraggableRef}
onPointerDown={handlePointerDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
{...attributes}
{...listeners}
tabIndex={isLastSelected ? 0 : -1}
>
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
return (
<input
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown}
/>
);
})()
) : (
<ItemInner treeId={treeId} item={node.item} />
)} )}
</button> >
</div> {showContextMenu && (
<ContextMenu
items={showContextMenu.items}
triggerPosition={showContextMenu}
onClose={handleCloseContextMenu}
/>
)}
{node.children != null ? (
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
<Icon
icon="chevron_right"
className={classNames(
'transition-transform text-text-subtlest',
'ml-auto',
'w-[1rem] h-[1rem]',
// node.children.length == 0 && 'opacity-0',
!isCollapsed && 'rotate-90',
// isHoveredAsParent && '!text-text',
)}
/>
</button>
) : (
<span aria-hidden /> // Make the grid happy
)}
<button
ref={handleSetDraggableRef}
onPointerDown={handlePointerDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
{...attributes}
{...listeners}
tabIndex={isLastSelected ? 0 : -1}
>
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
return (
<input
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown}
/>
);
})()
) : (
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
</div>
</li>
); );
} }
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_;
+40 -87
View File
@@ -1,12 +1,8 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { Fragment, memo } from 'react'; import { Fragment, memo } from 'react';
import { DropMarker } from '../../DropMarker'; import type { SelectableTreeNode } from './common';
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
import type { TreeNode } from './common';
import { equalSubtree } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
import { TreeDropMarker } from './TreeDropMarker';
import type { TreeItemProps } from './TreeItem'; import type { TreeItemProps } from './TreeItem';
import { TreeItem } from './TreeItem'; import { TreeItem } from './TreeItem';
@@ -15,81 +11,54 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
> & > &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & { Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
node: TreeNode<T>; nodes: SelectableTreeNode<T>[];
depth: number;
style?: CSSProperties; style?: CSSProperties;
className?: string; className?: string;
forceDepth?: number;
}; };
function TreeItemList_<T extends { id: string }>({ function TreeItemList_<T extends { id: string }>({
className, className,
depth,
getContextMenu, getContextMenu,
getEditOptions, getEditOptions,
getItemKey, getItemKey,
node, nodes,
onClick, onClick,
ItemInner, ItemInner,
ItemLeftSlot, ItemLeftSlot,
style, style,
treeId, treeId,
forceDepth,
}: TreeItemListProps<T>) { }: TreeItemListProps<T>) {
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
const childList = !isCollapsed && node.children != null && (
<ul
style={style}
className={classNames(
className,
depth > 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 (
<Fragment key={getItemKey(child.item)}>
<TreeDropMarker treeId={treeId} parent={node} index={i} />
<TreeItemList
treeId={treeId}
node={child}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
depth={depth + 1}
getItemKey={getItemKey}
getContextMenu={getContextMenu}
/>
</Fragment>
);
})}
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
</ul>
);
if (depth === 0) {
return childList;
}
return ( return (
<li> <ul role="tree" style={style} className={className}>
<TreeItem <TreeDropMarker itemId={null} treeId={treeId} index={0} />
treeId={treeId} {nodes.map((child, i) => (
node={node} <Fragment key={getItemKey(child.node.item)}>
getContextMenu={getContextMenu} <TreeItem
ItemInner={ItemInner} treeId={treeId}
ItemLeftSlot={ItemLeftSlot} node={child.node}
onClick={onClick} ItemInner={ItemInner}
getEditOptions={getEditOptions} ItemLeftSlot={ItemLeftSlot}
/> onClick={onClick}
{childList} getEditOptions={getEditOptions}
</li> getContextMenu={getContextMenu}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
/>
<TreeDropMarker itemId={child.node.item.id} treeId={treeId} index={i+1} />
</Fragment>
))}
</ul>
); );
} }
export const TreeItemList = memo( export const TreeItemList = memo(
TreeItemList_, TreeItemList_,
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => { (
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps },
) => {
const nonEqualKeys = []; const nonEqualKeys = [];
for (const key of Object.keys(prevProps)) { for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) { 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); // console.log('TreeItemList: ', nonEqualKeys);
return false; 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_; ) as typeof TreeItemList_;
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className,
treeId,
parent,
index,
}: {
treeId: string;
parent: TreeNode<T> | 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 <DropMarker className={classNames(className)} />;
});
+24 -27
View File
@@ -32,43 +32,40 @@ export const draggingIdsFamily = atomFamily((_treeId: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export const hoveredParentFamily = atomFamily((_treeId: string) => { 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( export const isIndexHoveredFamily = atomFamily(
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) => ({ treeId, index }: { treeId: string; index: number}) =>
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is), selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId, (a, b) => a.treeId === b.treeId && a.index === b.index,
); );
export const isItemHoveredFamily = atomFamily( export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
({ selectAtom(
treeId, hoveredParentFamily(treeId),
parentId, (s) => s.parentDepth,
index, (a, b) => Object.is(a, b) // prevents re-render unless the value changes
}: { )
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,
); );
function kvKey(workspaceId: string | null) {
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
}
export const collapsedFamily = atomFamily((workspaceId: string) => { export const collapsedFamily = atomFamily((workspaceId: string) => {
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {}); const key = ['sidebar_collapsed', workspaceId ?? 'n/a'];
return atomWithKVStorage<Record<string, boolean>>(key, {});
}); });
export const isCollapsedFamily = atomFamily( export const isCollapsedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) => ({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) =>
atom( atom(
// --- getter --- // --- getter ---
(get) => !!get(collapsedFamily(treeId))[itemId], (get) => !!get(collapsedFamily(treeId))[itemId],
+4 -2
View File
@@ -2,10 +2,11 @@ import type { DragMoveEvent } from '@dnd-kit/core';
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from '../../../lib/jotai';
import { selectedIdsFamily } from './atoms'; import { selectedIdsFamily } from './atoms';
export interface TreeNode<T extends { id: string }> { export interface TreeNode<T extends { id: string } > {
children?: TreeNode<T>[]; children?: TreeNode<T>[];
item: T; item: T;
parent: TreeNode<T> | null; parent: TreeNode<T> | null;
depth: number;
} }
export interface SelectableTreeNode<T extends { id: string }> { export interface SelectableTreeNode<T extends { id: string }> {
@@ -41,9 +42,10 @@ export function equalSubtree<T extends { id: string }>(
} }
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) { export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
// Check parents recursively
if (node.parent == null) return false; if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true; if (node.parent.item.id === ancestorId) return true;
// Check parents recursively
return hasAncestor(node.parent, ancestorId); return hasAncestor(node.parent, ancestorId);
} }
@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { SelectableTreeNode, TreeNode } from './common';
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
return useMemo(() => {
const selectableItems: SelectableTreeNode<T>[] = [];
// Put requests and folders into a tree structure
const next = (node: TreeNode<T>, 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]);
}
+1 -18
View File
@@ -1,7 +1,5 @@
import { atom, useAtomValue } from 'jotai'; import { atom } from 'jotai';
import { useCallback } from 'react';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { jotaiStore } from '../lib/jotai';
import { activeWorkspaceIdAtom } from './useActiveWorkspace'; import { activeWorkspaceIdAtom } from './useActiveWorkspace';
function kvKey(workspaceId: string | null) { function kvKey(workspaceId: string | null) {
@@ -12,18 +10,3 @@ export const sidebarCollapsedAtom = atom((get) => {
const workspaceId = get(activeWorkspaceIdAtom); const workspaceId = get(activeWorkspaceIdAtom);
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {}); return atomWithKVStorage<Record<string, boolean>>(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] };
});
}