Sidebar filtering and improvements (#285)

This commit is contained in:
Gregory Schier
2025-10-27 14:10:28 -07:00
committed by GitHub
parent b2766509e3
commit 99a6c38632
15 changed files with 476 additions and 246 deletions

View File

@@ -1,6 +1,7 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
MeasuringStrategy,
PointerSensor,
pointerWithin,
useDroppable,
@@ -24,17 +25,28 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import { isSidebarFocused } from '../../../lib/scopes';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
import {
collapsedFamily,
draggingIdsFamily,
focusIdsFamily,
hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList';
import { useSelectableItems } from './useSelectableItems';
/** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
@@ -93,7 +105,6 @@ function TreeInner<T extends { id: string }>(
y: number;
} | null>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) {
delete treeItemRefs.current[item.id];
@@ -170,16 +181,17 @@ function TreeInner<T extends { id: string }>(
return;
}
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
);
} else if (currIndex < anchorIndex) {
// Selecting up
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1);
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
@@ -217,15 +229,50 @@ function TreeInner<T extends { id: string }>(
[handleSelect, onActivate],
);
const selectPrevItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index - 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectNextItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index + 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectParentItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
useKey(
'ArrowUp',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
if (!isSidebarFocused()) return;
e.preventDefault();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index - 1];
if (item != null) handleSelect(item.node.item, e);
selectPrevItem(e);
},
undefined,
[selectableItems, handleSelect],
@@ -234,12 +281,60 @@ function TreeInner<T extends { id: string }>(
useKey(
'ArrowDown',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
if (!isSidebarFocused()) return;
e.preventDefault();
selectNextItem(e);
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
'ArrowRight',
(e) => {
if (!isSidebarFocused()) return;
e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index + 1];
if (item != null) handleSelect(item.node.item, e);
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] === true
) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);
} else {
selectNextItem(e);
}
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it.
useKey(
'ArrowLeft',
(e) => {
if (!isSidebarFocused()) return;
e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] !== true
) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
} else {
selectParentItem(e);
}
},
undefined,
[selectableItems, handleSelect],
@@ -467,6 +562,7 @@ function TreeInner<T extends { id: string }>(
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove}
autoScroll
>
@@ -608,3 +704,19 @@ function TreeHotKeys<T extends { id: string }>({
</>
);
}
function getValidSelectableItems<T extends { id: string }>(
treeId: string,
selectableItems: SelectableTreeNode<T>[],
) {
const collapsed = jotaiStore.get(collapsedFamily(treeId));
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
let p = i.node.parent;
while (p) {
if (collapsed[p.item.id]) return false;
p = p.parent;
}
return true;
});
}

View File

@@ -21,7 +21,7 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
<div
key={i}
className={classNames(
'w-[1rem] border-r border-r-text-subtlest',
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
)}
/>

View File

@@ -12,10 +12,11 @@ import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
import type { TreeNode } from './common';
import { getNodeKey } from './common';
import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide';
interface OnClickEvent {
export interface TreeItemClickEvent {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
@@ -27,7 +28,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: OnClickEvent) => void;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
@@ -157,8 +158,10 @@ function TreeItem_<T extends { id: string }>({
}
break;
case 'Escape':
e.preventDefault();
setEditing(false);
if (editing) {
e.preventDefault();
setEditing(false);
}
break;
}
},
@@ -253,6 +256,8 @@ function TreeItem_<T extends { id: string }>({
[setDraggableRef, setDroppableRef],
);
if (node.hidden || isAncestorCollapsed) return null;
return (
<li
ref={listItemRef}
@@ -266,7 +271,6 @@ function TreeItem_<T extends { id: string }>({
'tree-item',
'h-sm',
'grid grid-cols-[auto_minmax(0,1fr)]',
isAncestorCollapsed && 'hidden',
editing && 'ring-1 focus-within:ring-focus',
dropHover != null && 'relative z-10 ring-2 ring-primary',
dropHover === 'animate' && 'animate-blinkRing',
@@ -350,6 +354,9 @@ export const TreeItem = memo(
if (nonEqualKeys.length > 0) {
return false;
}
return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
return (
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
);
},
) as typeof TreeItem_;

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react';
import { Fragment, memo } from 'react';
import type { CSSProperties} from 'react';
import { Fragment } from 'react';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
import { TreeDropMarker } from './TreeDropMarker';
@@ -18,7 +18,7 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
};
function TreeItemList_<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>({
className,
getContextMenu,
getEditOptions,
@@ -55,33 +55,3 @@ function TreeItemList_<T extends { id: string }>({
</ul>
);
}
export const TreeItemList = memo(
TreeItemList_,
(
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps },
) => {
const nonEqualKeys = [];
for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
nonEqualKeys.push(key);
}
}
if (nonEqualKeys.length > 0) {
// console.log('TreeItemList: ', nonEqualKeys);
return false;
}
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_;

View File

@@ -4,6 +4,7 @@ import { selectedIdsFamily } from './atoms';
export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[];
item: T;
hidden?: boolean;
parent: TreeNode<T> | null;
depth: number;
}
@@ -27,19 +28,23 @@ export function getSelectedItems<T extends { id: string }>(
export function equalSubtree<T extends { id: string }>(
a: TreeNode<T>,
b: TreeNode<T>,
getKey: (t: T) => string,
getItemKey: (t: T) => string,
): boolean {
if (getKey(a.item) !== getKey(b.item)) return false;
if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
const ak = a.children ?? [];
const bk = b.children ?? [];
if (ak.length !== bk.length) return false;
for (let i = 0; i < ak.length; i++) {
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;
}
return true;
}
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
return getItemKey(a.item) + a.hidden;
}
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true;