Start extracting Tree component

This commit is contained in:
Gregory Schier
2026-03-08 16:37:25 -07:00
parent 6e11894f79
commit 6534421733
19 changed files with 344 additions and 151 deletions

View File

@@ -15,12 +15,15 @@ import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker'; import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from '@yaakapp-internal/ui'; import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip'; import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { PairEditorHandle } from './core/PairEditor'; import type { PairEditorHandle } from './core/PairEditor';
import { SplitLayout } from './core/SplitLayout'; import { SplitLayout } from './core/SplitLayout';
import { atomFamily } from 'jotai/utils';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import type { TreeNode } from './core/tree/common'; import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree'; import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree'; import { Tree } from './core/tree/Tree';
@@ -28,6 +31,11 @@ import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor'; import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip'; import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
const collapsedFamily = atomFamily((treeId: string) => {
const key = ['env_collapsed', treeId ?? 'n/a'];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
interface Props { interface Props {
initialEnvironmentId: string | null; initialEnvironmentId: string | null;
setRef?: (ref: PairEditorHandle | null) => void; setRef?: (ref: PairEditorHandle | null) => void;
@@ -292,6 +300,13 @@ function EnvironmentEditDialogSidebar({
[setSelectedEnvironmentId], [setSelectedEnvironmentId],
); );
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>['renderContextMenu']>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom); const tree = useAtomValue(treeAtom);
return ( return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle "> <aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
@@ -300,10 +315,12 @@ function EnvironmentEditDialogSidebar({
<Tree <Tree
ref={treeRef} ref={treeRef}
treeId={treeId} treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10" className="px-2 pb-10"
hotkeys={hotkeys} hotkeys={hotkeys}
root={tree} root={tree}
getContextMenu={getContextMenu} getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`} getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner} ItemLeftSlotInner={ItemLeftSlotInner}

View File

@@ -23,7 +23,7 @@ import {
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { atomFamily, selectAtom } from 'jotai/utils';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { 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';
@@ -48,7 +48,7 @@ import { resolvedModelName } from '../lib/resolvedModelName';
import { isSidebarFocused } from '../lib/scopes'; import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams'; import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { ContextMenu, Dropdown } from './core/Dropdown';
import type { FieldDef } from './core/Editor/filter/extension'; import type { FieldDef } from './core/Editor/filter/extension';
import { filter } from './core/Editor/filter/extension'; import { filter } from './core/Editor/filter/extension';
import { evaluate, parseQuery } from './core/Editor/filter/query'; import { evaluate, parseQuery } from './core/Editor/filter/query';
@@ -59,13 +59,19 @@ import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { InputHandle } from './core/Input'; import type { InputHandle } from './core/Input';
import { Input } from './core/Input'; import { Input } from './core/Input';
import { collapsedFamily, isSelectedFamily, selectedIdsFamily } from './core/tree/atoms'; import { isSelectedFamily, selectedIdsFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common'; import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree'; import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree'; import { Tree } from './core/tree/Tree';
import type { TreeItemProps } from './core/tree/TreeItem'; import type { TreeItemProps } from './core/tree/TreeItem';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { GitDropdown } from './git/GitDropdown'; import { GitDropdown } from './git/GitDropdown';
const collapsedFamily = atomFamily((treeId: string) => {
const key = ['sidebar_collapsed', treeId ?? 'n/a'];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
function isSidebarLeafModel(m: AnyModel): boolean { function isSidebarLeafModel(m: AnyModel): boolean {
const modelMap: Record<Exclude<SidebarModel['model'], 'workspace'>, null> = { const modelMap: Record<Exclude<SidebarModel['model'], 'workspace'>, null> = {
@@ -456,6 +462,13 @@ function Sidebar({ className }: { className?: string }) {
[actions], [actions],
); );
const renderContextMenuFn = useCallback<NonNullable<TreeProps<SidebarModel>['renderContextMenu']>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const hotkeys = useMemo<TreeProps<SidebarModel>['hotkeys']>(() => ({ actions }), [actions]); const hotkeys = useMemo<TreeProps<SidebarModel>['hotkeys']>(() => ({ actions }), [actions]);
// Use a language compartment for the filter so we can reconfigure it when the autocompletion changes // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
@@ -575,11 +588,13 @@ function Sidebar({ className }: { className?: string }) {
ref={handleTreeRefInit} ref={handleTreeRefInit}
root={tree} root={tree}
treeId={treeId} treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
hotkeys={hotkeys} hotkeys={hotkeys}
getItemKey={getItemKey} getItemKey={getItemKey}
ItemInner={SidebarInnerItem} ItemInner={SidebarInnerItem}
ItemLeftSlotInner={SidebarLeftSlot} ItemLeftSlotInner={SidebarLeftSlot}
getContextMenu={getContextMenu} getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onActivate={handleActivate} onActivate={handleActivate}
getEditOptions={getEditOptions} getEditOptions={getEditOptions}
className="pl-2 pr-3 pt-2 pb-2" className="pl-2 pr-3 pt-2 pb-2"

View File

@@ -17,9 +17,8 @@ import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle'; import { useToggle } from '../../hooks/useToggle';
import { languageFromContentType } from '../../lib/contentType'; import { languageFromContentType } from '../../lib/contentType';
import { showDialog } from '../../lib/dialog'; import { showDialog } from '../../lib/dialog';
import { computeSideForDragMove } from '../../lib/dnd'; import { computeSideForDragMove, DropMarker } from '@yaakapp-internal/ui';
import { showPrompt } from '../../lib/prompt'; import { showPrompt } from '../../lib/prompt';
import { DropMarker } from '../DropMarker';
import { SelectFile } from '../SelectFile'; import { SelectFile } from '../SelectFile';
import { Button } from './Button'; import { Button } from './Button';
import { Checkbox } from './Checkbox'; import { Checkbox } from './Checkbox';

View File

@@ -22,8 +22,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue'; import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove, DropMarker } from '@yaakapp-internal/ui';
import { DropMarker } from '../../DropMarker';
import { ErrorBoundary } from '../../ErrorBoundary'; import { ErrorBoundary } from '../../ErrorBoundary';
import type { ButtonProps } from '../Button'; import type { ButtonProps } from '../Button';
import { Button } from '../Button'; import { Button } from '../Button';

View File

@@ -24,19 +24,16 @@ import {
import { useKey, useKeyPressEvent } from 'react-use'; import { useKey, useKeyPressEvent } from 'react-use';
import type { HotKeyOptions, HotkeyAction } from '../../../hooks/useHotKey'; import type { HotKeyOptions, HotkeyAction } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey'; import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from '@yaakapp-internal/ui';
import { jotaiStore } from '../../../lib/jotai'; import { useStore } from 'jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { import {
collapsedFamily,
draggingIdsFamily, draggingIdsFamily,
focusIdsFamily, focusIdsFamily,
hoveredParentFamily, hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily, selectedIdsFamily,
} from './atoms'; } from './atoms';
import type { SelectableTreeNode, TreeNode } from './common'; import { type CollapsedAtom, CollapsedAtomContext } from './context';
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from './common';
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common'; import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay'; import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem'; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
@@ -50,8 +47,10 @@ const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
export interface TreeProps<T extends { id: string }> { export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>; root: TreeNode<T>;
treeId: string; treeId: string;
collapsedAtom: CollapsedAtom;
getItemKey: (item: T) => string; getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>; getContextMenu?: (items: T[]) => unknown[] | Promise<unknown[]>;
renderContextMenu?: ContextMenuRenderer;
ItemInner: ComponentType<{ treeId: string; item: T }>; ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>; ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>; ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
@@ -80,12 +79,14 @@ export interface TreeHandle {
function TreeInner<T extends { id: string }>( function TreeInner<T extends { id: string }>(
{ {
className, className,
collapsedAtom,
getContextMenu, getContextMenu,
getEditOptions, getEditOptions,
getItemKey, getItemKey,
hotkeys, hotkeys,
onActivate, onActivate,
onDragEnd, onDragEnd,
renderContextMenu,
ItemInner, ItemInner,
ItemLeftSlotInner, ItemLeftSlotInner,
ItemRightSlot, ItemRightSlot,
@@ -94,10 +95,11 @@ function TreeInner<T extends { id: string }>(
}: TreeProps<T>, }: TreeProps<T>,
ref: Ref<TreeHandle>, ref: Ref<TreeHandle>,
) { ) {
const store = useStore();
const treeRef = useRef<HTMLDivElement>(null); const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root); const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<{ const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[]; items: unknown[];
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
@@ -113,11 +115,11 @@ function TreeInner<T extends { id: string }>(
// Select the first item on first render // Select the first item on first render
// biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render // biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render
useEffect(() => { useEffect(() => {
const ids = jotaiStore.get(selectedIdsFamily(treeId)); const ids = store.get(selectedIdsFamily(treeId));
const fallback = selectableItems[0]; const fallback = selectableItems[0];
if (ids.length === 0 && fallback != null) { if (ids.length === 0 && fallback != null) {
jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]); store.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
jotaiStore.set(focusIdsFamily(treeId), { store.set(focusIdsFamily(treeId), {
anchorId: fallback.node.item.id, anchorId: fallback.node.item.id,
lastId: fallback.node.item.id, lastId: fallback.node.item.id,
}); });
@@ -145,7 +147,7 @@ function TreeInner<T extends { id: string }>(
}, []); }, []);
const ensureTabbableItem = useCallback(() => { const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find( const lastSelectedItem = selectableItems.find(
(i) => i.node.item.id === lastSelectedId && !i.node.hidden, (i) => i.node.item.id === lastSelectedId && !i.node.hidden,
); );
@@ -156,23 +158,23 @@ function TreeInner<T extends { id: string }>(
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden); const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
if (firstItem != null) { if (firstItem != null) {
const id = firstItem.node.item.id; const id = firstItem.node.item.id;
jotaiStore.set(selectedIdsFamily(treeId), [id]); store.set(selectedIdsFamily(treeId), [id]);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
} }
return; return;
} }
const closest = closestVisibleNode(treeId, lastSelectedItem.node); const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
if (closest != null) { if (closest != null) {
const id = closest.item.id; const id = closest.item.id;
jotaiStore.set(selectedIdsFamily(treeId), [id]); store.set(selectedIdsFamily(treeId), [id]);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
} }
}, [selectableItems, treeId]); }, [selectableItems, treeId]);
// Ensure there's always a tabbable item after collapsed state changes // Ensure there's always a tabbable item after collapsed state changes
useEffect(() => { useEffect(() => {
const unsub = jotaiStore.sub(collapsedFamily(treeId), ensureTabbableItem); const unsub = store.sub(collapsedAtom, ensureTabbableItem);
return unsub; return unsub;
}, [ensureTabbableItem, treeId]); }, [ensureTabbableItem, treeId]);
@@ -187,7 +189,7 @@ function TreeInner<T extends { id: string }>(
const setSelected = useCallback( const setSelected = useCallback(
(ids: string[], focus: boolean) => { (ids: string[], focus: boolean) => {
jotaiStore.set(selectedIdsFamily(treeId), ids); store.set(selectedIdsFamily(treeId), ids);
// TODO: Figure out a better way than timeout // TODO: Figure out a better way than timeout
if (!focus) return; if (!focus) return;
setTimeout(tryFocus, 50); setTimeout(tryFocus, 50);
@@ -202,18 +204,18 @@ function TreeInner<T extends { id: string }>(
hasFocus: hasFocus, hasFocus: hasFocus,
renameItem: (id) => treeItemRefs.current[id]?.rename(), renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id, focus) => { selectItem: (id, focus) => {
if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) { if (store.get(selectedIdsFamily(treeId)).includes(id)) {
// Already selected // Already selected
return; return;
} }
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
setSelected([id], focus === true); setSelected([id], focus === true);
}, },
showContextMenu: async () => { showContextMenu: async () => {
if (getContextMenu == null) return; if (getContextMenu == null) return;
const items = getSelectedItems(treeId, selectableItems); const items = getSelectedItems(store, treeId, selectableItems);
const menuItems = await getContextMenu(items); const menuItems = await getContextMenu(items);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null; const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return; if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y }); setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
@@ -227,7 +229,7 @@ function TreeInner<T extends { id: string }>(
const handleGetContextMenu = useMemo(() => { const handleGetContextMenu = useMemo(() => {
if (getContextMenu == null) return; if (getContextMenu == null) return;
return (item: T) => { return (item: T) => {
const items = getSelectedItems(treeId, selectableItems); const items = getSelectedItems(store, treeId, selectableItems);
const isSelected = items.find((i) => i.id === item.id); const isSelected = items.find((i) => i.id === item.id);
if (isSelected) { if (isSelected) {
// If right-clicked an item that was in the multiple-selection, use the entire selection // If right-clicked an item that was in the multiple-selection, use the entire selection
@@ -236,22 +238,22 @@ function TreeInner<T extends { id: string }>(
// If right-clicked an item that was NOT in the multiple-selection, just use that one // If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it // Also update the selection with it
setSelected([item.id], false); setSelected([item.id], false);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id })); store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]); return getContextMenu([item]);
}; };
}, [getContextMenu, selectableItems, setSelected, treeId]); }, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>( const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => { (item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId; const anchorSelectedId = store.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId); const selectedIdsAtom = selectedIdsFamily(treeId);
const selectedIds = jotaiStore.get(selectedIdsAtom); const selectedIds = store.get(selectedIdsAtom);
// Mark the item as the last one selected // Mark the item as the last one selected
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id })); store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) { if (shiftKey) {
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const anchorIndex = validSelectableItems.findIndex( const anchorIndex = validSelectableItems.findIndex(
(i) => i.node.item.id === anchorSelectedId, (i) => i.node.item.id === anchorSelectedId,
); );
@@ -260,7 +262,7 @@ function TreeInner<T extends { id: string }>(
// Nothing was selected yet, so just select this item // Nothing was selected yet, so just select this item
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) { if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true); setSelected([item.id], true);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id })); store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
return; return;
} }
@@ -293,7 +295,7 @@ function TreeInner<T extends { id: string }>(
} else { } else {
// Select single // Select single
setSelected([item.id], true); setSelected([item.id], true);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id })); store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
} }
}, },
[selectableItems, setSelected, treeId], [selectableItems, setSelected, treeId],
@@ -313,8 +315,8 @@ function TreeInner<T extends { id: string }>(
const selectPrevItem = useCallback( const selectPrevItem = useCallback(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index - 1]; const item = validSelectableItems[index - 1];
if (item != null) { if (item != null) {
@@ -326,8 +328,8 @@ function TreeInner<T extends { id: string }>(
const selectNextItem = useCallback( const selectNextItem = useCallback(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index + 1]; const item = validSelectableItems[index + 1];
if (item != null) { if (item != null) {
@@ -339,7 +341,7 @@ function TreeInner<T extends { id: string }>(
const selectParentItem = useCallback( const selectParentItem = useCallback(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null; selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) { if (lastSelectedItem?.parent != null) {
@@ -378,8 +380,8 @@ function TreeInner<T extends { id: string }>(
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = store.get(collapsedAtom);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId); const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if ( if (
@@ -387,7 +389,7 @@ function TreeInner<T extends { id: string }>(
lastSelectedItem?.node.children != null && lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] === true collapsed[lastSelectedItem.node.item.id] === true
) { ) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false); store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: false });
} else { } else {
selectNextItem(e); selectNextItem(e);
} }
@@ -404,8 +406,8 @@ function TreeInner<T extends { id: string }>(
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = store.get(collapsedAtom);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId); const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if ( if (
@@ -413,7 +415,7 @@ function TreeInner<T extends { id: string }>(
lastSelectedItem?.node.children != null && lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] !== true collapsed[lastSelectedItem.node.item.id] !== true
) { ) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true); store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: true });
} else { } else {
selectParentItem(e); selectParentItem(e);
} }
@@ -425,7 +427,7 @@ function TreeInner<T extends { id: string }>(
useKeyPressEvent('Escape', async () => { useKeyPressEvent('Escape', async () => {
if (!treeRef.current?.contains(document.activeElement)) return; if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState(); clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
if (lastSelectedId == null) return; if (lastSelectedId == null) return;
setSelected([lastSelectedId], false); setSelected([lastSelectedId], false);
}); });
@@ -435,7 +437,7 @@ 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), { store.set(hoveredParentFamily(treeId), {
parentId: null, parentId: null,
parentDepth: null, parentDepth: null,
childIndex: null, childIndex: null,
@@ -452,7 +454,7 @@ function TreeInner<T extends { id: string }>(
// Root is anything past the end of the list, so set it to the end // Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id; const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) { if (hoveringRoot) {
jotaiStore.set(hoveredParentFamily(treeId), { store.set(hoveredParentFamily(treeId), {
parentId: root.item.id, parentId: root.item.id,
parentDepth: root.depth, parentDepth: root.depth,
index: selectableItems.length, index: selectableItems.length,
@@ -466,7 +468,7 @@ function TreeInner<T extends { id: string }>(
return; return;
} }
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId)); const draggingItems = store.get(draggingIdsFamily(treeId));
for (const id of draggingItems) { for (const id of draggingItems) {
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null; const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
if (item == null) { if (item == null) {
@@ -499,7 +501,7 @@ function TreeInner<T extends { id: string }>(
const parentDepth = hoveredParent?.depth ?? null; const parentDepth = hoveredParent?.depth ?? null;
const index = hoveredIndex; const index = hoveredIndex;
const childIndex = hoveredChildIndex; const childIndex = hoveredChildIndex;
const existing = jotaiStore.get(hoveredParentFamily(treeId)); const existing = store.get(hoveredParentFamily(treeId));
if ( if (
!( !(
parentId === existing.parentId && parentId === existing.parentId &&
@@ -508,7 +510,7 @@ function TreeInner<T extends { id: string }>(
childIndex === existing.childIndex childIndex === existing.childIndex
) )
) { ) {
jotaiStore.set(hoveredParentFamily(treeId), { store.set(hoveredParentFamily(treeId), {
parentId, parentId,
parentDepth, parentDepth,
index, index,
@@ -521,12 +523,12 @@ function TreeInner<T extends { id: string }>(
const handleDragStart = useCallback( const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) { function handleDragStart(e: DragStartEvent) {
const selectedItems = getSelectedItems(treeId, selectableItems); const selectedItems = getSelectedItems(store, treeId, selectableItems);
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id); const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
// If we started dragging an already-selected item, we'll use that // If we started dragging an already-selected item, we'll use that
if (isDraggingSelectedItem) { if (isDraggingSelectedItem) {
jotaiStore.set( store.set(
draggingIdsFamily(treeId), draggingIdsFamily(treeId),
selectedItems.map((i) => i.id), selectedItems.map((i) => i.id),
); );
@@ -534,7 +536,7 @@ function TreeInner<T extends { id: string }>(
// If we started dragging a non-selected item, only drag that item // If we started dragging a non-selected item, only drag that item
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item; const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
if (activeItem != null) { if (activeItem != null) {
jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]); store.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one // Also update selection to just be this one
handleSelect(activeItem, { handleSelect(activeItem, {
shiftKey: false, shiftKey: false,
@@ -548,13 +550,13 @@ function TreeInner<T extends { id: string }>(
); );
const clearDragState = useCallback(() => { const clearDragState = useCallback(() => {
jotaiStore.set(hoveredParentFamily(treeId), { store.set(hoveredParentFamily(treeId), {
parentId: null, parentId: null,
parentDepth: null, parentDepth: null,
index: null, index: null,
childIndex: null, childIndex: null,
}); });
jotaiStore.set(draggingIdsFamily(treeId), []); store.set(draggingIdsFamily(treeId), []);
}, [treeId]); }, [treeId]);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@@ -564,8 +566,8 @@ function TreeInner<T extends { id: string }>(
index: hoveredIndex, index: hoveredIndex,
parentId: hoveredParentId, parentId: hoveredParentId,
childIndex: hoveredChildIndex, childIndex: hoveredChildIndex,
} = jotaiStore.get(hoveredParentFamily(treeId)); } = store.get(hoveredParentFamily(treeId));
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId)); const draggingItems = store.get(draggingIdsFamily(treeId));
clearDragState(); clearDragState();
// Dropped outside the tree? // Dropped outside the tree?
@@ -624,6 +626,7 @@ function TreeInner<T extends { id: string }>(
> = { > = {
getItemKey, getItemKey,
getContextMenu: handleGetContextMenu, getContextMenu: handleGetContextMenu,
renderContextMenu,
onClick: handleClick, onClick: handleClick,
getEditOptions, getEditOptions,
ItemInner, ItemInner,
@@ -646,15 +649,14 @@ function TreeInner<T extends { id: string }>(
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return ( return (
<> <CollapsedAtomContext.Provider value={collapsedAtom}>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} /> <TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu && ( {showContextMenu &&
<ContextMenu renderContextMenu?.({
items={showContextMenu.items} items: showContextMenu.items,
triggerPosition={showContextMenu} position: showContextMenu,
onClose={handleCloseContextMenu} onClose: handleCloseContextMenu,
/> })}
)}
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={pointerWithin} collisionDetection={pointerWithin}
@@ -707,7 +709,7 @@ function TreeInner<T extends { id: string }>(
getItemKey={getItemKey} getItemKey={getItemKey}
/> />
</DndContext> </DndContext>
</> </CollapsedAtomContext.Provider>
); );
} }
@@ -757,10 +759,11 @@ function TreeHotKey<T extends { id: string }>({
enable, enable,
...options ...options
}: TreeHotKeyProps<T>) { }: TreeHotKeyProps<T>) {
const store = useStore();
useHotKey( useHotKey(
action, action,
() => { () => {
onDone(getSelectedItems(treeId, selectableItems)); onDone(getSelectedItems(store, treeId, selectableItems));
}, },
{ {
...options, ...options,
@@ -802,10 +805,11 @@ function TreeHotKeys<T extends { id: string }>({
} }
function getValidSelectableItems<T extends { id: string }>( function getValidSelectableItems<T extends { id: string }>(
treeId: string, store: JotaiStore,
collapsedAtom: CollapsedAtom,
selectableItems: SelectableTreeNode<T>[], selectableItems: SelectableTreeNode<T>[],
) { ) {
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = store.get(collapsedAtom);
return selectableItems.filter((i) => { return selectableItems.filter((i) => {
if (i.node.hidden) return false; if (i.node.hidden) return false;
let p = i.node.parent; let p = i.node.parent;

View File

@@ -1,9 +1,10 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { memo } from 'react'; import { memo } from 'react';
import { DropMarker } from '../../DropMarker'; import { DropMarker } from '@yaakapp-internal/ui';
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms'; import { hoveredParentDepthFamily, isIndexHoveredFamily } from './atoms';
import type { TreeNode } from './common'; import type { TreeNode } from './common';
import { useIsCollapsed } from './context';
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({ export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className, className,
@@ -16,10 +17,9 @@ export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: strin
node: TreeNode<T> | null; node: TreeNode<T> | null;
className?: string; className?: string;
}) { }) {
const itemId = node?.item.id;
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index })); const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId)); const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId })); const collapsed = useIsCollapsed(node?.item.id);
// Only show if we're hovering over this index // Only show if we're hovering over this index
if (!isHovered) return null; if (!isHovered) return null;

View File

@@ -1,8 +1,7 @@
import type { DragMoveEvent } from '@dnd-kit/core'; import type { DragMoveEvent } from '@dnd-kit/core';
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { import type {
MouseEvent, MouseEvent,
PointerEvent, PointerEvent,
@@ -10,12 +9,11 @@ import type {
KeyboardEvent as ReactKeyboardEvent, KeyboardEvent as ReactKeyboardEvent,
} from 'react'; } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from '@yaakapp-internal/ui';
import { jotaiStore } from '../../../lib/jotai'; import type { ContextMenuRenderer } from './common';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { Icon } from '@yaakapp-internal/ui'; import { Icon } from '@yaakapp-internal/ui';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms'; import { isLastFocusedFamily, isSelectedFamily } from './atoms';
import { useCollapsedAtom, useIsAncestorCollapsed, useIsCollapsed, useSetCollapsed } from './context';
import type { TreeNode } from './common'; import type { TreeNode } from './common';
import { getNodeKey } from './common'; import { getNodeKey } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
@@ -29,12 +27,12 @@ export interface TreeItemClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick< export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>, TreeProps<T>,
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' 'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' | 'renderContextMenu'
> & { > & {
node: TreeNode<T>; node: TreeNode<T>;
className?: string; className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void; onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>; getContextMenu?: (item: T) => unknown[] | Promise<unknown[]>;
depth: number; depth: number;
setRef?: (item: T, n: TreeItemHandle | null) => void; setRef?: (item: T, n: TreeItemHandle | null) => void;
}; };
@@ -56,16 +54,20 @@ function TreeItem_<T extends { id: string }>({
ItemLeftSlotInner, ItemLeftSlotInner,
ItemRightSlot, ItemRightSlot,
getContextMenu, getContextMenu,
renderContextMenu,
onClick, onClick,
getEditOptions, getEditOptions,
className, className,
depth, depth,
setRef, setRef,
}: TreeItemProps<T>) { }: TreeItemProps<T>) {
const store = useStore();
const listItemRef = useRef<HTMLLIElement>(null); const listItemRef = useRef<HTMLLIElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null); const draggableRef = useRef<HTMLButtonElement>(null);
const collapsedAtom = useCollapsedAtom();
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 = useIsCollapsed(node.item.id);
const setCollapsed = useSetCollapsed(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 [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null); const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
@@ -110,19 +112,10 @@ function TreeItem_<T extends { id: string }>({
return ids; return ids;
}, [node]); }, [node]);
const isAncestorCollapsedAtom = useMemo( const isAncestorCollapsed = useIsAncestorCollapsed(ancestorIds);
() =>
selectAtom(
collapsedFamily(treeId),
(collapsed) => ancestorIds.some((id) => collapsed[id]),
(a, b) => a === b,
),
[ancestorIds, treeId],
);
const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom);
const [showContextMenu, setShowContextMenu] = useState<{ const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[]; items: unknown[];
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
@@ -133,8 +126,8 @@ function TreeItem_<T extends { id: string }>({
); );
const toggleCollapsed = useCallback(() => { const toggleCollapsed = useCallback(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev); setCollapsed((prev) => !prev);
}, [node.item.id, treeId]); }, [setCollapsed]);
const handleSubmitNameEdit = useCallback( const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => { async (el: HTMLInputElement) => {
@@ -207,12 +200,13 @@ function TreeItem_<T extends { id: string }>({
const side = computeSideForDragMove(node.item.id, e); const side = computeSideForDragMove(node.item.id, e);
const isFolder = node.children != null; const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0; const hasChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id })); const collapsedMap = store.get(collapsedAtom);
if (isCollapsed && isFolder && hasChildren && side === 'after') { const itemCollapsed = !!collapsedMap[node.item.id];
if (itemCollapsed && isFolder && hasChildren && side === 'after') {
setDropHover('animate'); setDropHover('animate');
clearTimeout(startedHoverTimeout.current); clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => { startedHoverTimeout.current = setTimeout(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false); store.set(collapsedAtom, { ...store.get(collapsedAtom), [node.item.id]: false });
clearDropHover(); clearDropHover();
// Force re-measure everything because all containers below the folder have been pushed down // Force re-measure everything because all containers below the folder have been pushed down
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -306,13 +300,12 @@ function TreeItem_<T extends { id: string }>({
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md', 'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
)} )}
> >
{showContextMenu && ( {showContextMenu &&
<ContextMenu renderContextMenu?.({
items={showContextMenu.items} items: showContextMenu.items,
triggerPosition={showContextMenu} position: showContextMenu,
onClose={handleCloseContextMenu} onClose: handleCloseContextMenu,
/> })}
)}
{node.children != null ? ( {node.children != null ? (
<button <button
type="button" type="button"

View File

@@ -8,7 +8,7 @@ import { TreeItem } from './TreeItem';
export type TreeItemListProps<T extends { id: string }> = Pick< export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>, TreeProps<T>,
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' 'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' | 'renderContextMenu'
> & > &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & { Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
nodes: SelectableTreeNode<T>[]; nodes: SelectableTreeNode<T>[];

View File

@@ -1,6 +1,5 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
import { atomFamily, selectAtom } from 'jotai/utils'; import { atomFamily, selectAtom } from 'jotai/utils';
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export const selectedIdsFamily = atomFamily((_treeId: string) => { export const selectedIdsFamily = atomFamily((_treeId: string) => {
@@ -75,28 +74,3 @@ export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
), ),
); );
export const collapsedFamily = atomFamily((workspaceId: string) => {
const key = ['sidebar_collapsed', workspaceId ?? 'n/a'];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
export const isCollapsedFamily = atomFamily(
({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) =>
atom(
// --- getter ---
(get) => !!get(collapsedFamily(treeId))[itemId],
// --- setter ---
(get, set, next: boolean | ((prev: boolean) => boolean)) => {
const a = collapsedFamily(treeId);
const prevMap = get(a);
const prevValue = !!prevMap[itemId];
const value = typeof next === 'function' ? next(prevValue) : next;
if (value === prevValue) return; // no-op
set(a, { ...prevMap, [itemId]: value });
},
),
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);

View File

@@ -1,5 +1,15 @@
import { jotaiStore } from '../../../lib/jotai'; import type { createStore } from 'jotai';
import { collapsedFamily, selectedIdsFamily } from './atoms'; import type { ReactNode } from 'react';
import type { CollapsedAtom } from './context';
import { selectedIdsFamily } from './atoms';
export type JotaiStore = ReturnType<typeof createStore>;
export type ContextMenuRenderer = (props: {
items: unknown[];
position: { x: number; y: number };
onClose: () => void;
}) => ReactNode;
export interface TreeNode<T extends { id: string }> { export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[]; children?: TreeNode<T>[];
@@ -18,10 +28,11 @@ export interface SelectableTreeNode<T extends { id: string }> {
} }
export function getSelectedItems<T extends { id: string }>( export function getSelectedItems<T extends { id: string }>(
store: JotaiStore,
treeId: string, treeId: string,
selectableItems: SelectableTreeNode<T>[], selectableItems: SelectableTreeNode<T>[],
) { ) {
const selectedItemIds = jotaiStore.get(selectedIdsFamily(treeId)); const selectedItemIds = store.get(selectedIdsFamily(treeId));
return selectableItems return selectableItems
.filter((i) => selectedItemIds.includes(i.node.item.id)) .filter((i) => selectedItemIds.includes(i.node.item.id))
.map((i) => i.node.item); .map((i) => i.node.item);
@@ -60,8 +71,8 @@ export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancesto
return hasAncestor(node.parent, ancestorId); return hasAncestor(node.parent, ancestorId);
} }
export function isVisibleNode<T extends { id: string }>(treeId: string, node: TreeNode<T>) { export function isVisibleNode<T extends { id: string }>(store: JotaiStore, collapsedAtom: CollapsedAtom, node: TreeNode<T>) {
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = store.get(collapsedAtom);
let p = node.parent; let p = node.parent;
while (p) { while (p) {
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
@@ -71,12 +82,13 @@ export function isVisibleNode<T extends { id: string }>(treeId: string, node: Tr
} }
export function closestVisibleNode<T extends { id: string }>( export function closestVisibleNode<T extends { id: string }>(
treeId: string, store: JotaiStore,
collapsedAtom: CollapsedAtom,
node: TreeNode<T>, node: TreeNode<T>,
): TreeNode<T> | null { ): TreeNode<T> | null {
let n: TreeNode<T> | null = node; let n: TreeNode<T> | null = node;
while (n) { while (n) {
if (isVisibleNode(treeId, n) && !n.hidden) return n; if (isVisibleNode(store, collapsedAtom, n) && !n.hidden) return n;
if (n.parent == null) return null; if (n.parent == null) return null;
n = n.parent; n = n.parent;
} }

View File

@@ -0,0 +1,60 @@
import type { WritableAtom } from 'jotai';
import { useAtomValue, useStore } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { createContext, useCallback, useContext, useMemo } from 'react';
type CollapsedMap = Record<string, boolean>;
type SetAction = CollapsedMap | ((prev: CollapsedMap) => CollapsedMap);
export type CollapsedAtom = WritableAtom<CollapsedMap, [SetAction], void>;
export const CollapsedAtomContext = createContext<CollapsedAtom | null>(null);
export function useCollapsedAtom(): CollapsedAtom {
const atom = useContext(CollapsedAtomContext);
if (!atom) throw new Error('CollapsedAtomContext not provided');
return atom;
}
export function useIsCollapsed(itemId: string | undefined) {
const collapsedAtom = useCollapsedAtom();
const derivedAtom = useMemo(
() => selectAtom(collapsedAtom, (map) => !!map[itemId ?? 'n/a'], Object.is),
[collapsedAtom, itemId],
);
return useAtomValue(derivedAtom);
}
export function useSetCollapsed(itemId: string | undefined) {
const collapsedAtom = useCollapsedAtom();
const store = useStore();
return useCallback(
(next: boolean | ((prev: boolean) => boolean)) => {
const key = itemId ?? 'n/a';
const prevMap = store.get(collapsedAtom);
const prevValue = !!prevMap[key];
const value = typeof next === 'function' ? next(prevValue) : next;
if (value === prevValue) return;
store.set(collapsedAtom, { ...prevMap, [key]: value });
},
[collapsedAtom, itemId, store],
);
}
export function useCollapsedMap() {
const collapsedAtom = useCollapsedAtom();
return useAtomValue(collapsedAtom);
}
export function useIsAncestorCollapsed(ancestorIds: string[]) {
const collapsedAtom = useCollapsedAtom();
const derivedAtom = useMemo(
() =>
selectAtom(
collapsedAtom,
(collapsed) => ancestorIds.some((id) => collapsed[id]),
(a, b) => a === b,
),
[collapsedAtom, ancestorIds],
);
return useAtomValue(derivedAtom);
}

View File

@@ -7,11 +7,18 @@ import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./main.css"; import "./main.css";
import { listen, rpc } from "./rpc"; import { listen, rpc } from "./rpc";
import { applyChange, dataAtom, httpExchangesAtom } from "./store"; import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from "./store";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const jotaiStore = createStore(); const jotaiStore = createStore();
// Load initial models from the database
rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) =>
replaceAll(prev, "http_exchange", res.httpExchanges),
);
});
// Subscribe to model change events from the backend // Subscribe to model change events from the backend
listen("model_write", (payload) => { listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) => jotaiStore.set(dataAtom, (prev) =>

View File

@@ -5,7 +5,7 @@ type ProxyModels = {
http_exchange: HttpExchange; http_exchange: HttpExchange;
}; };
export const { dataAtom, applyChange, listAtom, orderedListAtom } = export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore<ProxyModels>(["http_exchange"]); createModelStore<ProxyModels>(["http_exchange"]);
export const httpExchangesAtom = orderedListAtom( export const httpExchangesAtom = orderedListAtom(

View File

@@ -1,5 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModelPayload } from "./gen_models"; import type { HttpExchange, ModelPayload } from "./gen_models";
export type ListModelsRequest = Record<string, never>;
export type ListModelsResponse = { httpExchanges: Array<HttpExchange>, };
export type ProxyStartRequest = { port: number | null, }; export type ProxyStartRequest = { port: number | null, };
@@ -9,4 +13,4 @@ export type ProxyStopRequest = Record<string, never>;
export type RpcEventSchema = { model_write: ModelPayload, }; export type RpcEventSchema = { model_write: ModelPayload, };
export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], }; export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], list_models: [ListModelsRequest, ListModelsResponse], };

View File

@@ -51,6 +51,17 @@ pub struct ProxyStartResponse {
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub struct ProxyStopRequest {} pub struct ProxyStopRequest {}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListModelsRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct ListModelsResponse {
pub http_exchanges: Vec<HttpExchange>,
}
// -- Handlers -- // -- Handlers --
fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result<ProxyStartResponse, RpcError> { fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result<ProxyStartResponse, RpcError> {
@@ -86,6 +97,15 @@ fn proxy_stop(ctx: &ProxyCtx, _req: ProxyStopRequest) -> Result<bool, RpcError>
Ok(handle.take().is_some()) Ok(handle.take().is_some())
} }
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
ctx.db.with_conn(|db| {
Ok(ListModelsResponse {
http_exchanges: db.find_all::<HttpExchange>()
.map_err(|e| RpcError { message: e.to_string() })?,
})
})
}
// -- Event loop -- // -- Event loop --
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) { fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
@@ -193,6 +213,7 @@ define_rpc! {
commands { commands {
proxy_start(ProxyStartRequest) -> ProxyStartResponse, proxy_start(ProxyStartRequest) -> ProxyStartResponse,
proxy_stop(ProxyStopRequest) -> bool, proxy_stop(ProxyStopRequest) -> bool,
list_models(ListModelsRequest) -> ListModelsResponse,
} }
events { events {
model_write(ModelPayload), model_write(ModelPayload),

View File

@@ -71,7 +71,20 @@ export function createModelStore<M extends ModelMap>(
); );
} }
return { dataAtom, applyChange, listAtom, orderedListAtom }; /** Replace all models of a given type. Used for initial hydration. */
function replaceAll<K extends keyof M & string>(
prev: StoreData<M>,
modelType: K,
models: M[K][],
): StoreData<M> {
const bucket = {} as Record<string, M[K]>;
for (const m of models) {
bucket[m.id] = m;
}
return { ...prev, [modelType]: bucket };
}
return { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom };
} }
function shallowEqual<T>(a: T[], b: T[]): boolean { function shallowEqual<T>(a: T[], b: T[]): boolean {

View File

@@ -0,0 +1,34 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo } from 'react';
interface Props {
className?: string;
style?: CSSProperties;
orientation?: 'horizontal' | 'vertical';
}
export const DropMarker = memo(
function DropMarker({ className, style, orientation = 'horizontal' }: Props) {
return (
<div
style={style}
className={classNames(
className,
'absolute pointer-events-none z-50',
orientation === 'horizontal' && 'w-full',
orientation === 'vertical' && 'w-0 top-0 bottom-0',
)}
>
<div
className={classNames(
'absolute bg-primary rounded-full',
orientation === 'horizontal' && 'left-2 right-2 -bottom-[0.1rem] h-[0.2rem]',
orientation === 'vertical' && '-left-[0.1rem] top-0 bottom-0 w-[0.2rem]',
)}
/>
</div>
);
},
() => true,
);

View File

@@ -9,3 +9,5 @@ export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { useDebouncedValue } from "./hooks/useDebouncedValue"; export { useDebouncedValue } from "./hooks/useDebouncedValue";
export { useDebouncedState } from "./hooks/useDebouncedState"; export { useDebouncedState } from "./hooks/useDebouncedState";
export { HEADER_SIZE_MD, HEADER_SIZE_LG, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; export { HEADER_SIZE_MD, HEADER_SIZE_LG, WINDOW_CONTROLS_WIDTH } from "./lib/constants";
export { DropMarker } from "./components/DropMarker";
export { computeSideForDragMove } from "./lib/dnd";

View File

@@ -0,0 +1,39 @@
import type { DragMoveEvent } from '@dnd-kit/core';
export function computeSideForDragMove(
id: string,
e: DragMoveEvent,
orientation: 'vertical' | 'horizontal' = 'vertical',
): 'before' | 'after' | null {
if (e.over == null || e.over.id !== id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
if (orientation === 'horizontal') {
// For horizontal layouts (tabs side-by-side), use left/right logic
const activeLeft =
e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;
const pointerX = activeLeft + e.active.rect.current.initial.width / 2;
const hoverLeft = overRect.left;
const hoverRight = overRect.right;
const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;
return pointerX < hoverMiddleX ? 'before' : 'after'; // 'before' = left, 'after' = right
} else {
// For vertical layouts, use top/bottom logic
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? 'before' : 'after';
}
}