diff --git a/apps/client/package.json b/apps/client/package.json index f85c008e1..62efa2d9c 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -7,9 +7,16 @@ "build": "tsc && vite build", "lint": "eslint .", "preview": "vite preview", - "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0", + "@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.15", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.4", "@casl/react": "^5.0.1", "@docmost/editor-ext": "workspace:*", "@emoji-mart/data": "^1.2.1", @@ -24,6 +31,7 @@ "@mantine/spotlight": "^8.3.18", "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "5.90.17", + "@tanstack/react-virtual": "3.13.24", "alfaaz": "^1.1.0", "axios": "1.16.0", "blueimp-load-image": "^5.16.0", @@ -44,7 +52,6 @@ "mitt": "^3.0.1", "posthog-js": "1.372.2", "react": "^18.3.1", - "react-arborist": "3.4.0", "react-clear-modal": "^2.0.18", "react-dom": "^18.3.1", "react-drawio": "^1.0.7", @@ -59,6 +66,8 @@ "devDependencies": { "@eslint/js": "^9.28.0", "@tanstack/eslint-plugin-query": "^5.94.4", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", "@types/blueimp-load-image": "^5.16.6", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", @@ -72,6 +81,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^15.13.0", + "jsdom": "^25.0.0", "optics-ts": "^2.4.1", "postcss": "^8.5.12", "postcss-preset-mantine": "^1.18.0", @@ -79,6 +89,7 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", - "vite": "8.0.5" + "vite": "8.0.5", + "vitest": "^4.1.6" } } diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 330bded9a..8f6269060 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; @@ -36,7 +35,7 @@ import { usePageQuery, } from "@/features/page/queries/page-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useTranslation } from "react-i18next"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -53,7 +52,6 @@ const MentionList = forwardRef((props, ref) => { const [renderItems, setRenderItems] = useState([]); const { t } = useTranslation(); const [data, setData] = useAtom(treeDataAtom); - const tree = useMemo(() => new SimpleTree(data), [data]); const createPageMutation = useCreatePageMutation(); const emit = useQueryEmit(); const isInCommentContext = props.isInCommentContext ?? false; @@ -220,20 +218,20 @@ const MentionList = forwardRef((props, ref) => { try { createdPage = await createPageMutation.mutateAsync(payload); const parentId = page.id || null; - const data = { + const newNode: SpaceTreeNode = { id: createdPage.id, slugId: createdPage.slugId, name: createdPage.title, position: createdPage.position, spaceId: createdPage.spaceId, parentPageId: createdPage.parentPageId, + hasChildren: false, children: [], - } as any; + }; - const lastIndex = tree.data.length; + const lastIndex = data.length; - tree.create({ parentId, index: lastIndex, data }); - setData(tree.data); + setData(treeModel.insert(data, parentId, newNode, lastIndex)); props.command({ id: uuid7(), @@ -251,7 +249,7 @@ const MentionList = forwardRef((props, ref) => { payload: { parentId, index: lastIndex, - data, + data: newNode, }, }); }, 50); diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 81c25e825..6e481b7aa 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -29,7 +29,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts"; import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { Trans, useTranslation } from "react-i18next"; @@ -134,7 +134,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { pageId: extractPageSlugId(pageSlug), }); const { openDeleteModal } = useDeletePageModal(); - const [tree] = useAtom(treeApiAtom); + const { handleDelete } = useTreeMutation(page?.spaceId ?? ""); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ @@ -183,7 +183,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }; const handleDeletePage = () => { - openDeleteModal({ onConfirm: () => tree?.delete(page.id) }); + openDeleteModal({ onConfirm: () => handleDelete(page.id) }); }; const handleToggleFavorite = () => { diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 89526aa69..1ed704ce1 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -37,7 +37,7 @@ import { validate as isValidUuid } from "uuid"; import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -170,11 +170,8 @@ export function useRestorePageMutation() { onSuccess: async (restoredPage) => { notifications.show({ message: "Page restored successfully" }); - // Add the restored page back to the tree - const treeApi = new SimpleTree(treeData); - // Check if the page already exists in the tree (it shouldn't) - if (!treeApi.find(restoredPage.id)) { + if (!treeModel.find(treeData, restoredPage.id)) { // Create the tree node data with hasChildren from backend const nodeData: SpaceTreeNode = { id: restoredPage.id, @@ -193,24 +190,17 @@ export function useRestorePageMutation() { let index = 0; if (parentId) { - const parentNode = treeApi.find(parentId); + const parentNode = treeModel.find(treeData, parentId); if (parentNode) { index = parentNode.children?.length || 0; } } else { // Root level page - index = treeApi.data.length; + index = treeData.length; } // Add the node to the tree - treeApi.create({ - parentId, - index, - data: nodeData, - }); - - // Update the tree data - setTreeData(treeApi.data); + setTreeData(treeModel.insert(treeData, parentId, nodeData, index)); // Emit websocket event to sync with other users setTimeout(() => { diff --git a/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts new file mode 100644 index 000000000..3dd2d98bc --- /dev/null +++ b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +export type OpenMap = Record; + +export const openTreeNodesAtom = atom({}); diff --git a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts b/apps/client/src/features/page/tree/atoms/tree-api-atom.ts deleted file mode 100644 index f12106f99..000000000 --- a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from "jotai"; -import { TreeApi } from "react-arborist"; -import { SpaceTreeNode } from "../types"; - -export const treeApiAtom = atom | null>(null); diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css new file mode 100644 index 000000000..acd0da016 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css @@ -0,0 +1,26 @@ +.preview { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + color: light-dark( + var(--mantine-color-gray-9), + var(--mantine-color-dark-0) + ); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); + border: 1px solid light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx new file mode 100644 index 000000000..f8a8a88b1 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx @@ -0,0 +1,9 @@ +import styles from './doc-tree-drag-preview.module.css'; + +type Props = { + label: string; +}; + +export function DocTreeDragPreview({ label }: Props) { + return
{label || 'Untitled'}
; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx new file mode 100644 index 000000000..9d6352ab5 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx @@ -0,0 +1,39 @@ +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import styles from '../styles/tree.module.css'; + +type Props = { + instruction: Instruction; + indentPx: number; +}; + +export function DocTreeDropIndicator({ instruction, indentPx }: Props) { + const blocked = instruction.type === 'instruction-blocked'; + const inst = blocked ? instruction.desired : instruction; + + const style = { + ['--drop-line-indent' as never]: `${indentPx}px`, + } as React.CSSProperties; + + if (inst.type === 'reorder-above') { + return ( +
+ ); + } + if (inst.type === 'reorder-below') { + return ( +
+ ); + } + // 'combine' (make-child) is rendered via [data-receiving-drop] on the row itself. + return null; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx new file mode 100644 index 000000000..ab2467a6f --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -0,0 +1,383 @@ +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createRoot } from 'react-dom/client'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { + attachInstruction, + extractInstruction, + type Instruction, + type ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { triggerPostMoveFlash } from '@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash'; +import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region'; + +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { treeModel } from '../model/tree-model'; +import { DocTreeDropIndicator } from './doc-tree-drop-indicator'; +import { DocTreeDragPreview } from './doc-tree-drag-preview'; +import type { RenderRowProps } from './doc-tree'; +import styles from '../styles/tree.module.css'; + +type Props = { + node: TreeNode; + level: number; + isLastSibling: boolean; + openIds: ReadonlySet; + selectedId?: string; + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel: number; + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + readOnly: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + getDragLabel: (node: TreeNode) => string; + contextId: symbol; + registerRowElement: (id: string, el: HTMLElement | null) => void; + // Stable accessor — calling it returns the latest tree. Avoids passing the + // tree itself as a prop (which would break memo and re-run every row's DnD + // useEffect on every mutation). + getRootData: () => TreeNode[]; +}; + +const DRAG_TYPE = 'doc-tree-item'; +const AUTO_EXPAND_MS = 500; + +function DocTreeRowInner(props: Props) { + const { + node, + level, + isLastSibling, + openIds, + selectedId, + renderRow, + indentPerLevel, + onMove, + onToggle, + readOnly, + disableDrag, + disableDrop, + getDragLabel, + contextId, + registerRowElement, + getRootData, + } = props; + + const isOpen = openIds.has(node.id); + // "Has children" includes both already-loaded children AND the consumer's + // own server-side flag (`hasChildren` is a docmost convention on + // SpaceTreeNode / SharedPageTreeNode). The flag lets the chevron and the + // auto-expand timer recognize unloaded subtrees so the consumer's lazy-load + // (via onToggle) can populate them on demand. + const hasLoadedChildren = !!node.children && node.children.length > 0; + const declaredHasChildren = + (node as { hasChildren?: boolean }).hasChildren === true; + const hasChildren = hasLoadedChildren || declaredHasChildren; + const isSelected = selectedId === node.id; + + const rowRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(null); + const autoExpandTimerRef = useRef | null>(null); + + const cancelAutoExpand = useCallback(() => { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } + }, []); + + const toggleOpen = useCallback(() => { + onToggle(node.id, !isOpen); + }, [onToggle, node.id, isOpen]); + + useEffect(() => { + registerRowElement(node.id, rowRef.current); + return () => registerRowElement(node.id, null); + }, [registerRowElement, node.id]); + + // Restore lazy-loaded children when the row mounts open but its children + // aren't loaded (e.g. cross-space page move drops a node into a new tree + // that still has its id in openIds). Calling onToggle(id, true) is + // idempotent for open state and triggers the consumer's lazy-load. + useEffect(() => { + if (isOpen && declaredHasChildren && !hasLoadedChildren) { + onToggle(node.id, true); + } + }, [isOpen, declaredHasChildren, hasLoadedChildren, node.id, onToggle]); + + useEffect(() => { + const el = rowRef.current; + if (!el || readOnly) return; + const dragDisabled = disableDrag?.(node) ?? false; + const dropDisabled = disableDrop?.(node) ?? false; + + const cleanups: Array<() => void> = []; + + if (!dragDisabled) { + cleanups.push( + draggable({ + element: el, + getInitialData: () => ({ + id: node.id, + type: DRAG_TYPE, + uniqueContextId: contextId, + isOpenOnDragStart: isOpen, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }), + render: ({ container }) => { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, + }); + }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + ); + } + + if (!dropDisabled) { + const mode: ItemMode = + isOpen && hasChildren + ? 'expanded' + : isLastSibling + ? 'last-in-group' + : 'standard'; + // Always block 'reparent' (out of scope per spec). + // Block 'reorder-below' when the row is open with children — ambiguous gesture, + // force users to drop into the folder via 'make-child' instead. + const block: Instruction['type'][] = ['reparent']; + if (isOpen && hasChildren) block.push('reorder-below'); + + cleanups.push( + dropTargetForElements({ + element: el, + canDrop: ({ source }) => + source.data.type === DRAG_TYPE && + source.data.uniqueContextId === contextId && + source.data.id !== node.id && + !treeModel.isDescendant( + getRootData(), + source.data.id as string, + node.id, + ), + getData: ({ input, element }) => + attachInstruction( + { id: node.id, type: DRAG_TYPE }, + { + input, + element, + currentLevel: level, + indentPerLevel, + mode, + block, + }, + ), + onDrag: ({ self }) => { + const inst = extractInstruction(self.data); + setInstruction(inst); + // Auto-expand on hover over any collapsed row that has children, + // regardless of the specific instruction type. Reorder-before and + // reorder-after also benefit: once expanded, the user can see the + // children and refine their drop target. + if ( + inst && + hasChildren && + !isOpen && + !autoExpandTimerRef.current + ) { + autoExpandTimerRef.current = setTimeout(() => { + onToggle(node.id, true); + autoExpandTimerRef.current = null; + }, AUTO_EXPAND_MS); + } + }, + onDragLeave: () => { + setInstruction(null); + cancelAutoExpand(); + }, + onDrop: ({ source, self }) => { + setInstruction(null); + cancelAutoExpand(); + const inst = extractInstruction(self.data); + if (!inst || inst.type === 'instruction-blocked') return; + const sourceId = source.data.id as string; + const op: DropOp = + inst.type === 'reorder-above' + ? { kind: 'reorder-before', targetId: node.id } + : inst.type === 'reorder-below' + ? { kind: 'reorder-after', targetId: node.id } + : inst.type === 'make-child' + ? { kind: 'make-child', targetId: node.id } + : null!; + if (!op) return; + onMove(sourceId, op); + triggerPostMoveFlash(el); + const liveTree = getRootData(); + const parentName = + op.kind === 'make-child' + ? getDragLabel(node) + : (() => { + const sib = treeModel.siblingsOf(liveTree, op.targetId); + const parent = sib?.parentId + ? treeModel.find(liveTree, sib.parentId) + : null; + return parent ? getDragLabel(parent) : 'root'; + })(); + const sourceNode = treeModel.find(liveTree, sourceId); + const sourceLabel = sourceNode + ? getDragLabel(sourceNode) + : 'item'; + liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`); + // After a make-child drop, expand this row so the user sees the + // just-dropped child — especially important when the row had no + // children before (chevron just appeared) so the drop would + // otherwise be invisible. + if (op.kind === 'make-child') onToggle(node.id, true); + if (source.data.isOpenOnDragStart) onToggle(sourceId, true); + }, + }), + ); + } + + return combine(...cleanups); + }, [ + node, + level, + isOpen, + hasChildren, + isLastSibling, + readOnly, + disableDrag, + disableDrop, + contextId, + indentPerLevel, + getDragLabel, + onMove, + onToggle, + getRootData, + cancelAutoExpand, + ]); + + useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]); + + const effectiveInst = + instruction?.type === 'instruction-blocked' + ? instruction.desired + : instruction; + const blocked = instruction?.type === 'instruction-blocked'; + const receivingDrop: 'before' | 'after' | 'make-child' | null = (() => { + if (!effectiveInst) return null; + if (effectiveInst.type === 'reorder-above') return 'before'; + if (effectiveInst.type === 'reorder-below') return 'after'; + if (effectiveInst.type === 'make-child') return 'make-child'; + return null; + })(); + + // aria-expanded on the consumer's interactive element is enough; we drop + // aria-controls since the virtualized children no longer live inside a + // dedicated
    with a stable id (children are siblings in the + // flat virtualized list, not a DOM subtree). + const ariaProps = hasChildren ? { 'aria-expanded': isOpen } : {}; + + // The
  • wrapper and recursion are owned by DocTree's + // virtualizer now; this component renders only the row's body. + return ( +
    +
    + {renderRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + isDragging, + isReceivingDrop: receivingDrop, + rowRef, + ariaProps, + toggleOpen, + })} +
    + {instruction && ( + + )} +
    + ); +} + +// Custom memo comparator. The default shallow compare re-renders every row +// when `openIds` (a Set) or `selectedId` (a string) on the parent changes, +// because all rows receive the same reference via {...props} spread. With 1K +// rows that's a perceptible stall on every expand and every navigate. +// +// Resolve openIds / selectedId per-row: only re-render if THIS row's own +// open-state or selected-state actually flipped. Everything else uses +// reference equality (callbacks are useCallback-stable from the parent). +function arePropsEqual( + prev: Props, + next: Props, +): boolean { + if (prev.node !== next.node) return false; + if (prev.level !== next.level) return false; + if (prev.isLastSibling !== next.isLastSibling) return false; + if (prev.readOnly !== next.readOnly) return false; + if (prev.contextId !== next.contextId) return false; + if (prev.indentPerLevel !== next.indentPerLevel) return false; + if (prev.renderRow !== next.renderRow) return false; + if (prev.onMove !== next.onMove) return false; + if (prev.onToggle !== next.onToggle) return false; + if (prev.disableDrag !== next.disableDrag) return false; + if (prev.disableDrop !== next.disableDrop) return false; + if (prev.getDragLabel !== next.getDragLabel) return false; + if (prev.registerRowElement !== next.registerRowElement) return false; + if (prev.getRootData !== next.getRootData) return false; + + const id = next.node.id; + // openIds: only this row's own membership matters. + if (prev.openIds.has(id) !== next.openIds.has(id)) return false; + // selectedId: re-render only the rows whose isSelected actually flipped. + const wasSelected = prev.selectedId === id; + const isSelected = next.selectedId === id; + if (wasSelected !== isSelected) return false; + + return true; +} + +export const DocTreeRow = memo( + DocTreeRowInner, + arePropsEqual, +) as typeof DocTreeRowInner; diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx new file mode 100644 index 000000000..cd8bec270 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -0,0 +1,280 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, + type Ref, +} from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { DocTreeRow } from './doc-tree-row'; +import styles from '../styles/tree.module.css'; + +export type RenderRowProps = { + node: TreeNode; + level: number; + isOpen: boolean; + hasChildren: boolean; + isSelected: boolean; + isDragging: boolean; + isReceivingDrop: 'before' | 'after' | 'make-child' | null; + + rowRef: Ref; + ariaProps: { + 'aria-expanded'?: boolean; + 'aria-controls'?: string; + }; + toggleOpen: () => void; +}; + +export type DocTreeProps = { + data: TreeNode[]; + openIds: ReadonlySet; + selectedId?: string; + + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel?: number; + rowHeight?: number; + emptyState?: ReactNode; + + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + onSelect?: (id: string) => void; + + readOnly?: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + + getDragLabel: (node: TreeNode) => string; + uniqueContextId?: symbol; +}; + +export type DocTreeApi = { + select: ( + id: string, + opts?: { scrollIntoView?: boolean; focus?: boolean }, + ) => void; + scrollTo: (id: string) => void; + focus: (id: string) => void; +}; + +type FlatRow = { + node: TreeNode; + level: number; + isLastSibling: boolean; +}; + +// DFS-walk the tree, emitting only the visible nodes (root nodes always, plus +// the descendants of nodes whose id is in `openIds`). Each emitted row carries +// the precomputed `level` and `isLastSibling` it needs. +function flattenVisible( + data: TreeNode[], + openIds: ReadonlySet, +): FlatRow[] { + const out: FlatRow[] = []; + const walk = (nodes: TreeNode[], level: number) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + out.push({ node, level, isLastSibling: i === nodes.length - 1 }); + if (openIds.has(node.id) && node.children?.length) { + walk(node.children, level + 1); + } + } + }; + walk(data, 0); + return out; +} + +type RowElementMap = Map; + +function DocTreeInner( + props: DocTreeProps, + ref: Ref, +) { + const { + data, + openIds, + selectedId, + renderRow, + indentPerLevel = 16, + rowHeight = 32, + onMove, + onToggle, + onSelect, + readOnly = false, + disableDrag, + disableDrop, + getDragLabel, + uniqueContextId, + emptyState, + } = props; + + const scrollRef = useRef(null); + const rowElementsRef = useRef(new Map()); + const contextId = useMemo( + () => uniqueContextId ?? Symbol('doc-tree'), + [uniqueContextId], + ); + + const registerRowElement = useCallback( + (id: string, el: HTMLElement | null) => { + if (el) rowElementsRef.current.set(id, el); + else rowElementsRef.current.delete(id); + }, + [], + ); + + // Stable live tree accessor — keeps the row useEffect deps stable across + // tree mutations. + const rootDataRef = useRef(data); + rootDataRef.current = data; + const getRootData = useCallback(() => rootDataRef.current, []); + + // Flat visible list drives virtualization. Re-flattens on data or openIds + // change — cheap O(N) walk of the loaded tree. + const flat = useMemo( + () => flattenVisible(data, openIds), + [data, openIds], + ); + + const virtualizer = useVirtualizer({ + count: flat.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan: 10, + }); + + useImperativeHandle( + ref, + (): DocTreeApi => ({ + select: (id, opts) => { + onSelect?.(id); + const idx = flat.findIndex((r) => r.node.id === id); + if (idx >= 0 && opts?.scrollIntoView) { + virtualizer.scrollToIndex(idx, { align: 'auto' }); + } + if (opts?.focus) rowElementsRef.current.get(id)?.focus(); + }, + scrollTo: (id) => { + const idx = flat.findIndex((r) => r.node.id === id); + if (idx >= 0) virtualizer.scrollToIndex(idx, { align: 'auto' }); + }, + focus: (id) => { + rowElementsRef.current.get(id)?.focus(); + }, + }), + [onSelect, flat, virtualizer], + ); + + // Auto-scroll the container during drag so users can target rows currently + // scrolled off-screen. Scoped to drags originating in this DocTree instance + // via uniqueContextId. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + return autoScrollForElements({ + element: el, + canScroll: ({ source }) => + source.data.uniqueContextId === contextId, + }); + }, [contextId]); + + // Scroll the selected row into view when it enters the flat list. If the + // row is already fully visible, leave the user's scroll position alone — + // only scroll when it's off-screen, and when we do, center it for context. + // Deep pages may not be in flat at the moment selectedId changes (ancestors + // still lazy-loading); the effect re-fires once flat contains the row. + // Guarded by a ref so subsequent flat changes don't fight manual scroll. + const lastScrolledIdRef = useRef(undefined); + useEffect(() => { + if (!selectedId) { + lastScrolledIdRef.current = undefined; + return; + } + if (lastScrolledIdRef.current === selectedId) return; + const idx = flat.findIndex((r) => r.node.id === selectedId); + if (idx < 0) return; + + const containerHeight = scrollRef.current?.clientHeight ?? 0; + const scrollOffset = virtualizer.scrollOffset ?? 0; + const item = virtualizer + .getVirtualItems() + .find((v) => v.index === idx); + const isFullyVisible = + !!item && + item.start >= scrollOffset && + item.start + item.size <= scrollOffset + containerHeight; + + if (!isFullyVisible) { + virtualizer.scrollToIndex(idx, { align: 'center' }); + } + lastScrolledIdRef.current = selectedId; + }, [selectedId, flat, virtualizer]); + + if (data.length === 0 && emptyState) { + return
    {emptyState}
    ; + } + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + return ( +
    +
      + {virtualItems.map((virtualItem) => { + const row = flat[virtualItem.index]; + return ( +
    • + +
    • + ); + })} +
    +
    + ); +} + +export const DocTree = forwardRef(DocTreeInner) as ( + props: DocTreeProps & { ref?: Ref }, +) => ReturnType; diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx new file mode 100644 index 000000000..f7cab98bc --- /dev/null +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -0,0 +1,258 @@ +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { ActionIcon, Menu, rem } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconArrowRight, + IconCopy, + IconDotsVertical, + IconFileExport, + IconLink, + IconStar, + IconStarFilled, + IconTrash, +} from "@tabler/icons-react"; + +import ExportModal from "@/components/common/export-modal"; +import MovePageModal from "@/features/page/components/move-page-modal.tsx"; +import CopyPageModal from "@/features/page/components/copy-page-modal.tsx"; +import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { duplicatePage } from "@/features/page/services/page-service.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { getAppUrl } from "@/lib/config.ts"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; +import { + useFavoriteIds, + useAddFavoriteMutation, + useRemoveFavoriteMutation, +} from "@/features/favorite/queries/favorite-query"; + +import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { treeModel } from "@/features/page/tree/model/tree-model"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; +import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; + +export interface NodeMenuProps { + node: SpaceTreeNode; + canEdit: boolean; +} + +export function NodeMenu({ node, canEdit }: NodeMenuProps) { + const { t } = useTranslation(); + const clipboard = useClipboard({ timeout: 500 }); + const { spaceSlug } = useParams(); + const { openDeleteModal } = useDeletePageModal(); + const { handleDelete } = useTreeMutation(node.spaceId); + const [data, setData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); + const [exportOpened, { open: openExportModal, close: closeExportModal }] = + useDisclosure(false); + const [ + movePageModalOpened, + { open: openMovePageModal, close: closeMoveSpaceModal }, + ] = useDisclosure(false); + const [ + copyPageModalOpened, + { open: openCopyPageModal, close: closeCopySpaceModal }, + ] = useDisclosure(false); + const favoriteIds = useFavoriteIds("page", node.spaceId); + const addFavorite = useAddFavoriteMutation(); + const removeFavorite = useRemoveFavoriteMutation(); + const isFavorited = favoriteIds.has(node.id); + + const handleCopyLink = () => { + const pageUrl = + getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name); + clipboard.copy(pageUrl); + notifications.show({ message: t("Link copied") }); + }; + + const handleDuplicatePage = async () => { + try { + const duplicatedPage = await duplicatePage({ pageId: node.id }); + + // figure out parent + insertion index + const siblings = treeModel.siblingsOf(data, node.id); + const parentId = siblings?.parentId ?? null; + const currentIndex = siblings?.index ?? 0; + const newIndex = currentIndex + 1; + + const treeNodeData: SpaceTreeNode = { + id: duplicatedPage.id, + slugId: duplicatedPage.slugId, + name: duplicatedPage.title, + position: duplicatedPage.position, + spaceId: duplicatedPage.spaceId, + parentPageId: duplicatedPage.parentPageId, + icon: duplicatedPage.icon, + hasChildren: duplicatedPage.hasChildren, + canEdit: true, + children: [], + }; + + setData((prev) => + treeModel.insert(prev, parentId, treeNodeData, newIndex), + ); + + setTimeout(() => { + emit({ + operation: "addTreeNode", + spaceId: node.spaceId, + payload: { + parentId, + index: newIndex, + data: treeNodeData, + }, + }); + }, 50); + + notifications.show({ message: t("Page duplicated successfully") }); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message || "An error occurred", + color: "red", + }); + } + }; + + return ( + <> + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleCopyLink(); + }} + > + {t("Copy link")} + + + : + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (isFavorited) { + removeFavorite.mutate({ type: "page", pageId: node.id }); + } else { + addFavorite.mutate({ type: "page", pageId: node.id }); + } + }} + > + {isFavorited ? t("Remove from favorites") : t("Add to favorites")} + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openExportModal(); + }} + > + {t("Export page")} + + + {canEdit && ( + <> + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicatePage(); + }} + > + {t("Duplicate")} + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openMovePageModal(); + }} + > + {t("Move")} + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openCopyPageModal(); + }} + > + {t("Copy to space")} + + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openDeleteModal({ + onConfirm: () => handleDelete(node.id), + }); + }} + > + {t("Move to trash")} + + + )} + + + + + + + + + + ); +} diff --git a/apps/client/src/features/page/tree/components/space-tree-row.tsx b/apps/client/src/features/page/tree/components/space-tree-row.tsx new file mode 100644 index 000000000..4adeccb2c --- /dev/null +++ b/apps/client/src/features/page/tree/components/space-tree-row.tsx @@ -0,0 +1,283 @@ +import { useRef } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, rem } from "@mantine/core"; +import { + IconChevronDown, + IconChevronRight, + IconFileDescription, + IconPlus, + IconPointFilled, +} from "@tabler/icons-react"; + +import EmojiPicker from "@/components/ui/emoji-picker.tsx"; +import { queryClient } from "@/main.tsx"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { getPageById } from "@/features/page/services/page-service.ts"; +import { + useUpdatePageMutation, + fetchAllAncestorChildren, +} from "@/features/page/queries/page-query.ts"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; + +import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { treeModel } from "@/features/page/tree/model/tree-model"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; +import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import type { RenderRowProps } from "./doc-tree"; +import { NodeMenu } from "./space-tree-node-menu"; +import classes from "@/features/page/tree/styles/tree.module.css"; +import { updateTreeNodeIcon } from "@/features/page/tree/utils/utils.ts"; + +type SpaceTreeRowProps = RenderRowProps & { + readOnly: boolean; +}; + +export function SpaceTreeRow({ + node, + isOpen, + hasChildren, + toggleOpen, + rowRef, + ariaProps, + readOnly, +}: SpaceTreeRowProps) { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const updatePageMutation = useUpdatePageMutation(); + const [, setTreeData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); + const timerRef = useRef | null>(null); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + + const canEdit = !readOnly && node.canEdit !== false; + const pageUrl = buildPageUrl(spaceSlug, node.slugId, node.name); + + const prefetchPage = () => { + timerRef.current = setTimeout(async () => { + const page = await queryClient.fetchQuery({ + queryKey: ["pages", node.id], + queryFn: () => getPageById({ pageId: node.id }), + staleTime: 5 * 60 * 1000, + }); + if (page?.slugId) { + queryClient.setQueryData(["pages", page.slugId], page); + } + }, 150); + }; + + const cancelPagePrefetch = () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + + const handleUpdateNodeIcon = (nodeId: string, newIcon: string | null) => { + setTreeData((prev) => + updateTreeNodeIcon(prev, nodeId, newIcon), + ); + }; + + const handleEmojiIconClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleEmojiSelect = (emoji: { native: string }) => { + handleUpdateNodeIcon(node.id, emoji.native); + updatePageMutation + .mutateAsync({ pageId: node.id, icon: emoji.native }) + .then((data) => { + setTimeout(() => { + emit({ + operation: "updateOne", + spaceId: node.spaceId, + entity: ["pages"], + id: node.id, + payload: { icon: emoji.native, parentPageId: data.parentPageId }, + }); + }, 50); + }); + }; + + const handleRemoveEmoji = () => { + handleUpdateNodeIcon(node.id, null); + updatePageMutation.mutateAsync({ pageId: node.id, icon: null }); + + setTimeout(() => { + emit({ + operation: "updateOne", + spaceId: node.spaceId, + entity: ["pages"], + id: node.id, + payload: { icon: null }, + }); + }, 50); + }; + + const handleLoadChildren = async () => { + if (!node.hasChildren) return; + try { + const childrenTree = await fetchAllAncestorChildren({ + pageId: node.id, + spaceId: node.spaceId, + }); + setTreeData((prev) => + treeModel.appendChildren(prev, node.id, childrenTree), + ); + } catch (error) { + console.error("Failed to fetch children:", error); + } + }; + + return ( + } + to={pageUrl} + className={classes.node} + {...ariaProps} + onClick={() => { + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }} + onMouseEnter={prefetchPage} + onMouseLeave={cancelPagePrefetch} + > + + +
    + + } + readOnly={!canEdit} + removeEmojiAction={handleRemoveEmoji} + /> +
    + + {node.name || t("untitled")} + +
    + + + {canEdit && ( + + )} +
    + + ); +} + +interface PageArrowProps { + isOpen: boolean; + hasChildren: boolean; + onToggle: () => void; +} + +function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) { + const { t } = useTranslation(); + + if (!hasChildren) { + return ( + + + + ); + } + + return ( + { + e.preventDefault(); + e.stopPropagation(); + onToggle(); + }} + > + {isOpen ? ( + + ) : ( + + )} + + ); +} + +interface CreateNodeProps { + node: SpaceTreeNode; + isOpen: boolean; + hasChildren: boolean; + onToggle: () => void; + onExpandTree: () => Promise | void; +} + +function CreateNode({ + node, + isOpen, + hasChildren, + onToggle, + onExpandTree, +}: CreateNodeProps) { + const { t } = useTranslation(); + const { handleCreate } = useTreeMutation(node.spaceId); + + async function handleClickCreate() { + if (node.hasChildren && !hasChildren) { + // Expand and lazy-load before creating a child. handleCreate reads the + // latest tree imperatively (via useStore) so we no longer need a + // setTimeout to wait for React to rerun the closure with fresh data. + if (!isOpen) onToggle(); + await onExpandTree(); + } else if (!isOpen) { + onToggle(); + } + handleCreate(node.id); + } + + return ( + { + e.preventDefault(); + e.stopPropagation(); + handleClickCreate(); + }} + > + + + ); +} diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index df5b9a429..c1f163856 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -1,110 +1,47 @@ -import { - NodeApi, - NodeRendererProps, - Tree, - TreeApi, - SimpleTree, -} from "react-arborist"; -import { atom, useAtom } from "jotai"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Text } from "@mantine/core"; import { fetchAllAncestorChildren, useGetRootSidebarPagesQuery, usePageQuery, - useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; -import { useEffect, useRef, useState } from "react"; -import { Link, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; -import { ActionIcon, Box, Menu, rem, Text } from "@mantine/core"; -import { - IconArrowRight, - IconChevronDown, - IconChevronRight, - IconCopy, - IconDotsVertical, - IconFileDescription, - IconFileExport, - IconLink, - IconPlus, - IconPointFilled, - IconStar, - IconStarFilled, - IconTrash, -} from "@tabler/icons-react"; -import { - appendNodeChildrenAtom, - treeDataAtom, -} from "@/features/page/tree/atoms/tree-data-atom.ts"; -import clsx from "clsx"; -import EmojiPicker from "@/components/ui/emoji-picker.tsx"; +import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { - appendNodeChildren, buildTree, buildTreeWithChildren, mergeRootTrees, - updateTreeNodeIcon, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; -import { - getPageBreadcrumbs, - getPageById, - getSidebarPages, -} from "@/features/page/services/page-service.ts"; -import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; -import { queryClient } from "@/main.tsx"; -import { OpenMap } from "react-arborist/dist/main/state/open-slice"; -import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks"; -import { useClipboard } from "@/hooks/use-clipboard"; -import { dfs } from "react-arborist/dist/module/utils"; -import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; -import { buildPageUrl } from "@/features/page/page.utils.ts"; -import { notifications } from "@mantine/notifications"; -import { getAppUrl } from "@/lib/config.ts"; +import { treeModel } from "@/features/page/tree/model/tree-model"; +import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; import { extractPageSlugId } from "@/lib"; -import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; -import { useTranslation } from "react-i18next"; -import ExportModal from "@/components/common/export-modal"; -import MovePageModal from "../../components/move-page-modal.tsx"; -import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; -import CopyPageModal from "../../components/copy-page-modal.tsx"; -import { duplicatePage } from "../../services/page-service.ts"; -import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query"; +import { DocTree } from "./doc-tree"; +import { SpaceTreeRow } from "./space-tree-row"; interface SpaceTreeProps { spaceId: string; readOnly: boolean; } -const openTreeNodesAtom = atom({}); - export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const { t } = useTranslation(); const { pageSlug } = useParams(); - const { data, setData, controllers } = - useTreeMutation>(spaceId); + const [data, setData] = useAtom(treeDataAtom); + const { handleMove } = useTreeMutation(spaceId); const { data: pagesData, hasNextPage, fetchNextPage, isFetching, - } = useGetRootSidebarPagesQuery({ - spaceId, - }); - const [, setTreeApi] = useAtom>(treeApiAtom); - const treeApiRef = useRef>(); - const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); - const rootElement = useRef(); - const [isRootReady, setIsRootReady] = useState(false); - const { ref: sizeRef, width, height } = useElementSize(); - const mergedRef = useMergedRef((element) => { - rootElement.current = element; - if (element && !isRootReady) { - setIsRootReady(true); - } - }, sizeRef); + } = useGetRootSidebarPagesQuery({ spaceId }); + const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const [isDataLoaded, setIsDataLoaded] = useState(false); const spaceIdRef = useRef(spaceId); spaceIdRef.current = spaceId; @@ -123,23 +60,24 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { }, [hasNextPage, fetchNextPage, isFetching, spaceId]); useEffect(() => { - if (pagesData?.pages && !hasNextPage) { - const allItems = pagesData.pages.flatMap((page) => page.items); - const treeData = buildTree(allItems); + if (!pagesData?.pages || hasNextPage) return; - setData((prev) => { - // fresh space; full reset - if (prev.length === 0 || prev[0]?.spaceId !== spaceId) { - setIsDataLoaded(true); - setOpenTreeNodes({}); - return treeData; - } + const allItems = pagesData.pages.flatMap((page) => page.items); + const treeData = buildTree(allItems); - // same space; append only missing roots - setIsDataLoaded(true); - return mergeRootTrees(prev, treeData); - }); - } + setData((prev) => { + // Keep nodes belonging to other spaces — filteredData filters by spaceId + // for rendering, so accumulating is safe. Preserves lazy-loaded children + // and open-state when the user returns to a previously-visited space. + const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId); + const currentSpace = prev.filter((n) => n?.spaceId === spaceId); + const refreshed = + currentSpace.length > 0 + ? mergeRootTrees(currentSpace, treeData) + : treeData; + return [...otherSpaces, ...refreshed]; + }); + setIsDataLoaded(true); }, [pagesData, hasNextPage, spaceId]); useEffect(() => { @@ -148,7 +86,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const fetchData = async () => { if (isDataLoaded && currentPage) { // check if pageId node is present in the tree - const node = dfs(treeApiRef.current?.root, currentPage.id); + const node = treeModel.find(data, currentPage.id); if (node) { // if node is found, no need to traverse its ancestors return; @@ -160,14 +98,12 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { if (spaceIdRef.current !== effectSpaceId) return; - if (ancestors && ancestors?.length > 1) { + if (ancestors && ancestors.length > 1) { let flatTreeItems = [...buildTree(ancestors)]; const fetchAndUpdateChildren = async (ancestor: IPage) => { // we don't want to fetch the children of the opened page - if (ancestor.id === currentPage.id) { - return; - } + if (ancestor.id === currentPage.id) return; const children = await fetchAllAncestorChildren({ pageId: ancestor.id, spaceId: ancestor.spaceId, @@ -185,7 +121,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { fetchAndUpdateChildren(ancestor), ); - // Wait for all fetch operations to complete Promise.all(fetchPromises).then(() => { if (spaceIdRef.current !== effectSpaceId) return; @@ -195,15 +130,24 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const rootChild = ancestorsTree[0]; // attach built ancestors to tree using functional updater - // to avoid stale closure overwriting the current tree data setData((currentData) => - appendNodeChildren(currentData, rootChild.id, rootChild.children), + treeModel.appendChildren( + currentData, + rootChild.id, + rootChild.children ?? [], + ), ); - setTimeout(() => { - // focus on node and open all parents - treeApiRef.current?.select(currentPage.id); - }, 100); + // open all ancestors of the current page. DocTree picks up the + // selectedId change and scrolls the row into view on its own once + // flat contains it. + setOpenTreeNodes((prev) => { + const next = { ...prev }; + for (const a of ancestors) { + if (a.id !== currentPage.id) next[a.id] = true; + } + return next; + }); }); } } @@ -212,556 +156,75 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { fetchData(); }, [isDataLoaded, currentPage?.id]); - useEffect(() => { - if (currentPage?.id) { - setTimeout(() => { - // focus on node and open all parents - treeApiRef.current?.select(currentPage.id, { align: "auto" }); - }, 200); - } else { - treeApiRef.current?.deselectAll(); - } - }, [currentPage?.id]); + const openIds = useMemo( + () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])), + [openTreeNodes], + ); - // Clean up tree API on unmount - useEffect(() => { - return () => { - // @ts-ignore - setTreeApi(null); - }; - }, [setTreeApi]); + const handleToggle = useCallback( + async (id: string, isOpen: boolean) => { + setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })); + if (isOpen) { + const node = treeModel.find(data, id) as SpaceTreeNode | null; + if ( + node?.hasChildren && + (!node.children || node.children.length === 0) + ) { + const fetched = await fetchAllAncestorChildren({ + pageId: id, + spaceId: node.spaceId, + }); + setData((prev) => treeModel.appendChildren(prev, id, fetched)); + } + } + }, + [data, setOpenTreeNodes, setData], + ); - const filteredData = data.filter((node) => node?.spaceId === spaceId); + const filteredData = useMemo( + () => data.filter((node) => node?.spaceId === spaceId), + [data, spaceId], + ); + + // Stable callbacks for DocTree. Without these, every parent render recreates + // the props and tears down every row's draggable/dropTarget subscription, + // defeating memo(DocTreeRow). + const renderRow = useCallback( + (rowProps: Parameters[0]) => ( + + ), + [readOnly], + ); + const disableDragDrop = useCallback( + (n: SpaceTreeNode) => n.canEdit === false, + [], + ); + const getDragLabel = useCallback( + (n: SpaceTreeNode) => n.name || t("untitled"), + [t], + ); return ( -
    +
    {isDataLoaded && filteredData.length === 0 && ( {t("No pages yet")} )} - {isRootReady && rootElement.current && ( - 0 && ( + data={filteredData} - disableDrag={ - readOnly - ? true - : (data) => { - return data.canEdit === false; - } - } - disableDrop={ - readOnly - ? true - : ({ parentNode }) => parentNode?.data?.canEdit === false - } - disableEdit={readOnly ? true : (data) => data.canEdit === false} - {...controllers} - width={width} - height={rootElement.current.clientHeight} - ref={(ref) => { - treeApiRef.current = ref; - if (ref) { - //@ts-ignore - setTreeApi(ref); - } - }} - openByDefault={false} - disableMultiSelection={true} - className={classes.tree} - rowClassName={classes.row} - rowHeight={30} - overscanCount={10} - dndRootElement={rootElement.current} - onToggle={() => { - setOpenTreeNodes(treeApiRef.current?.openState); - }} - initialOpenState={openTreeNodes} - > - {Node} - + openIds={openIds} + selectedId={currentPage?.id} + renderRow={renderRow} + onMove={handleMove} + onToggle={handleToggle} + readOnly={readOnly} + disableDrag={disableDragDrop} + disableDrop={disableDragDrop} + getDragLabel={getDragLabel} + /> )}
    ); } - -function Node({ node, style, dragHandle, tree }: NodeRendererProps) { - const { t } = useTranslation(); - const updatePageMutation = useUpdatePageMutation(); - const [treeData, setTreeData] = useAtom(treeDataAtom); - const [, appendChildren] = useAtom(appendNodeChildrenAtom); - const emit = useQueryEmit(); - const { spaceSlug } = useParams(); - const timerRef = useRef(null); - const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); - const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); - - const prefetchPage = () => { - timerRef.current = setTimeout(async () => { - const page = await queryClient.fetchQuery({ - queryKey: ["pages", node.data.id], - queryFn: () => getPageById({ pageId: node.data.id }), - staleTime: 5 * 60 * 1000, - }); - if (page?.slugId) { - queryClient.setQueryData(["pages", page.slugId], page); - } - }, 150); - }; - - const cancelPagePrefetch = () => { - if (timerRef.current) { - window.clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - - async function handleLoadChildren(node: NodeApi) { - if (!node.data.hasChildren) return; - // in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket - // if (node.data.children && node.data.children.length > 0) { - // return; - // } - - try { - const params: SidebarPagesParams = { - pageId: node.data.id, - spaceId: node.data.spaceId, - }; - - const childrenTree = await fetchAllAncestorChildren(params); - - appendChildren({ - parentId: node.data.id, - children: childrenTree, - }); - } catch (error) { - console.error("Failed to fetch children:", error); - } - } - - const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { - const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon); - setTreeData(updatedTree); - }; - - const handleEmojiIconClick = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleEmojiSelect = (emoji: { native: string }) => { - handleUpdateNodeIcon(node.id, emoji.native); - updatePageMutation - .mutateAsync({ pageId: node.id, icon: emoji.native }) - .then((data) => { - setTimeout(() => { - emit({ - operation: "updateOne", - spaceId: node.data.spaceId, - entity: ["pages"], - id: node.id, - payload: { icon: emoji.native, parentPageId: data.parentPageId }, - }); - }, 50); - }); - }; - - const handleRemoveEmoji = () => { - handleUpdateNodeIcon(node.id, null); - updatePageMutation.mutateAsync({ pageId: node.id, icon: null }); - - setTimeout(() => { - emit({ - operation: "updateOne", - spaceId: node.data.spaceId, - entity: ["pages"], - id: node.id, - payload: { icon: null }, - }); - }, 50); - }; - - if ( - node.willReceiveDrop && - node.isClosed && - (node.children.length > 0 || node.data.hasChildren) - ) { - handleLoadChildren(node); - setTimeout(() => { - if (node.state.willReceiveDrop) { - node.open(); - } - }, 650); - } - - const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name); - - return ( - <> - { - if (mobileSidebarOpened) { - toggleMobileSidebar(); - } - }} - onMouseEnter={prefetchPage} - onMouseLeave={cancelPagePrefetch} - > - handleLoadChildren(node)} /> - -
    - - ) - } - readOnly={ - tree.props.disableEdit === true || node.data.canEdit === false - } - removeEmojiAction={handleRemoveEmoji} - /> -
    - - {node.data.name || t("untitled")} - -
    - - - {tree.props.disableEdit !== true && node.data.canEdit !== false && ( - handleLoadChildren(node)} - /> - )} -
    -
    - - ); -} - -interface CreateNodeProps { - node: NodeApi; - treeApi: TreeApi; - onExpandTree?: () => void; -} - -function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { - const { t } = useTranslation(); - - function handleCreate() { - if (node.data.hasChildren && node.children.length === 0) { - node.toggle(); - onExpandTree(); - - setTimeout(() => { - treeApi?.create({ type: "internal", parentId: node.id, index: 0 }); - }, 500); - } else { - treeApi?.create({ type: "internal", parentId: node.id }); - } - } - - return ( - { - e.preventDefault(); - e.stopPropagation(); - handleCreate(); - }} - > - - - ); -} - -interface NodeMenuProps { - node: NodeApi; - treeApi: TreeApi; - spaceId: string; -} - -function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { - const { t } = useTranslation(); - const clipboard = useClipboard({ timeout: 500 }); - const { spaceSlug } = useParams(); - const { openDeleteModal } = useDeletePageModal(); - const [data, setData] = useAtom(treeDataAtom); - const emit = useQueryEmit(); - const [exportOpened, { open: openExportModal, close: closeExportModal }] = - useDisclosure(false); - const [ - movePageModalOpened, - { open: openMovePageModal, close: closeMoveSpaceModal }, - ] = useDisclosure(false); - const [ - copyPageModalOpened, - { open: openCopyPageModal, close: closeCopySpaceModal }, - ] = useDisclosure(false); - const favoriteIds = useFavoriteIds("page", spaceId); - const addFavorite = useAddFavoriteMutation(); - const removeFavorite = useRemoveFavoriteMutation(); - const isFavorited = favoriteIds.has(node.data.id); - - const handleCopyLink = () => { - const pageUrl = - getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name); - clipboard.copy(pageUrl); - notifications.show({ message: t("Link copied") }); - }; - - const handleDuplicatePage = async () => { - try { - const duplicatedPage = await duplicatePage({ - pageId: node.id, - }); - - // Find the index of the current node - const parentId = - node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__" - ? null - : node.parent?.id; - const siblings = parentId ? node.parent.children : treeApi?.props.data; - const currentIndex = - siblings?.findIndex((sibling) => sibling.id === node.id) || 0; - const newIndex = currentIndex + 1; - - // Add the duplicated page to the tree - const treeNodeData: SpaceTreeNode = { - id: duplicatedPage.id, - slugId: duplicatedPage.slugId, - name: duplicatedPage.title, - position: duplicatedPage.position, - spaceId: duplicatedPage.spaceId, - parentPageId: duplicatedPage.parentPageId, - icon: duplicatedPage.icon, - hasChildren: duplicatedPage.hasChildren, - canEdit: true, - children: [], - }; - - // Update local tree - const simpleTree = new SimpleTree(data); - simpleTree.create({ - parentId, - index: newIndex, - data: treeNodeData, - }); - setData(simpleTree.data); - - // Emit socket event - setTimeout(() => { - emit({ - operation: "addTreeNode", - spaceId: spaceId, - payload: { - parentId, - index: newIndex, - data: treeNodeData, - }, - }); - }, 50); - - notifications.show({ - message: t("Page duplicated successfully"), - }); - } catch (err) { - notifications.show({ - message: err.response?.data.message || "An error occurred", - color: "red", - }); - } - }; - - return ( - <> - - - { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - - - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleCopyLink(); - }} - > - {t("Copy link")} - - - : } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (isFavorited) { - removeFavorite.mutate({ type: "page", pageId: node.data.id }); - } else { - addFavorite.mutate({ type: "page", pageId: node.data.id }); - } - }} - > - {isFavorited ? t("Remove from favorites") : t("Add to favorites")} - - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - openExportModal(); - }} - > - {t("Export page")} - - - {treeApi.props.disableEdit !== true && - node.data.canEdit !== false && ( - <> - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicatePage(); - }} - > - {t("Duplicate")} - - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - openMovePageModal(); - }} - > - {t("Move")} - - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - openCopyPageModal(); - }} - > - {t("Copy to space")} - - - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - openDeleteModal({ onConfirm: () => treeApi?.delete(node) }); - }} - > - {t("Move to trash")} - - - )} - - - - - - - - - - ); -} - -interface PageArrowProps { - node: NodeApi; - onExpandTree?: () => void; -} - -function PageArrow({ node, onExpandTree }: PageArrowProps) { - const { t } = useTranslation(); - - useEffect(() => { - if (node.isOpen) { - onExpandTree(); - } - }, []); - - return ( - { - e.preventDefault(); - e.stopPropagation(); - node.toggle(); - onExpandTree(); - }} - > - {node.isInternal ? ( - node.children && (node.children.length > 0 || node.data.hasChildren) ? ( - node.isOpen ? ( - - ) : ( - - ) - ) : ( - - ) - ) : null} - - ); -} diff --git a/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.test.ts b/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.test.ts new file mode 100644 index 000000000..2f8ee0cb0 --- /dev/null +++ b/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { SpaceTreeNode } from '@/features/page/tree/types'; +import { dropOpToMovePayload } from './drop-op-to-move-payload'; + +vi.mock('fractional-indexing-jittered', () => ({ + generateJitteredKeyBetween: (a: string | null, b: string | null) => + `${a ?? 'START'}|${b ?? 'END'}`, +})); + +const n = (id: string, position: string, children?: SpaceTreeNode[]): SpaceTreeNode => + ({ id, position, children, name: id } as unknown as SpaceTreeNode); + +const tree: SpaceTreeNode[] = [ + n('a', 'A', [n('a1', 'AA'), n('a2', 'AB')]), + n('b', 'B'), +]; + +describe('dropOpToMovePayload', () => { + it('reorder-before computes parentId + position between prev and target', () => { + const p = dropOpToMovePayload(tree, 'a2', { + kind: 'reorder-before', + targetId: 'a1', + }); + expect(p).toEqual({ pageId: 'a2', parentPageId: 'a', position: 'START|AA' }); + }); + + it('reorder-after computes position between target and next', () => { + const p = dropOpToMovePayload(tree, 'a1', { + kind: 'reorder-after', + targetId: 'a2', + }); + expect(p).toEqual({ pageId: 'a1', parentPageId: 'a', position: 'AB|END' }); + }); + + it('make-child appends with position after last child', () => { + const p = dropOpToMovePayload(tree, 'b', { + kind: 'make-child', + targetId: 'a', + }); + expect(p).toEqual({ pageId: 'b', parentPageId: 'a', position: 'AB|END' }); + }); + + it('reorder-before at root: parentPageId is null', () => { + const p = dropOpToMovePayload(tree, 'b', { + kind: 'reorder-before', + targetId: 'a', + }); + expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'START|A' }); + }); + + // Regression: when source is already adjacent to target, the BEFORE-tree + // treats source itself as the target's neighbor and falls back to null, + // producing an unbounded fractional key that overshoots other siblings. + // The fix uses the AFTER-tree, where source occupies its destination slot + // surrounded by its REAL neighbors. + it('reorder-after when source is immediately after target uses post-move neighbors', () => { + const adjacent: SpaceTreeNode[] = [ + n('a', 'A'), + n('b', 'AB'), + n('c', 'B'), + n('d', 'BC'), + ]; + const p = dropOpToMovePayload(adjacent, 'b', { + kind: 'reorder-after', + targetId: 'a', + }); + // After-tree is [a, b, c, d] (no-op shape). Source 'b' at index 1. + // prev = 'A', next = 'B'. Old buggy code returned prev='A', next=null. + expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'A|B' }); + }); + + it('reorder-before when source is immediately before target uses post-move neighbors', () => { + const adjacent: SpaceTreeNode[] = [ + n('a', 'A'), + n('b', 'AB'), + n('c', 'B'), + n('d', 'BC'), + ]; + const p = dropOpToMovePayload(adjacent, 'b', { + kind: 'reorder-before', + targetId: 'c', + }); + // After-tree is [a, b, c, d]. Source 'b' at index 1. + // prev = 'A', next = 'B'. Old buggy code returned prev=null, next='B'. + expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'A|B' }); + }); + + it('make-child when source is already last child of target uses post-move neighbors', () => { + const t: SpaceTreeNode[] = [ + n('p', 'P', [n('x', 'X'), n('y', 'Y')]), + ]; + const p = dropOpToMovePayload(t, 'y', { + kind: 'make-child', + targetId: 'p', + }); + // After-tree: 'y' becomes last child of 'p' → [x, y]. y at index 1. + // prev = 'X', next = null. Old buggy: prev=Y (source's own position), next=null. + expect(p).toEqual({ pageId: 'y', parentPageId: 'p', position: 'X|END' }); + }); +}); diff --git a/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.ts b/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.ts new file mode 100644 index 000000000..7628ceb25 --- /dev/null +++ b/apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.ts @@ -0,0 +1,36 @@ +import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; +import type { SpaceTreeNode } from '@/features/page/tree/types'; +import type { IMovePage } from '@/features/page/types/page.types'; +import type { DropOp } from '@/features/page/tree/model/tree-model.types'; +import { treeModel } from '@/features/page/tree/model/tree-model'; + +export function dropOpToMovePayload( + tree: SpaceTreeNode[], + sourceId: string, + op: DropOp, +): IMovePage { + // Compute the post-move tree so we read source's REAL neighbors at its new + // position. Reading from the before-tree would mean treating source itself + // as a neighbor of the target — wrong when source is adjacent to target. + const { tree: after } = treeModel.move(tree, sourceId, op); + const info = treeModel.siblingsOf(after, sourceId); + if (!info) { + return { + pageId: sourceId, + parentPageId: null, + position: generateJitteredKeyBetween(null, null), + }; + } + + const prev = info.siblings[info.index - 1] as SpaceTreeNode | undefined; + const next = info.siblings[info.index + 1] as SpaceTreeNode | undefined; + + return { + pageId: sourceId, + parentPageId: info.parentId, + position: generateJitteredKeyBetween( + prev?.position ?? null, + next?.position ?? null, + ), + }; +} diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index 162992dde..acdcb0190 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -1,16 +1,15 @@ -import { useMemo } from "react"; -import { - CreateHandler, - DeleteHandler, - MoveHandler, - NodeApi, - RenameHandler, - SimpleTree, -} from "react-arborist"; -import { useAtom } from "jotai"; -import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; -import { IMovePage, IPage } from "@/features/page/types/page.types.ts"; +import { useCallback } from "react"; +import { useAtom, useStore } from "jotai"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; + +import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { treeModel } from "@/features/page/tree/model/tree-model"; +import type { DropOp } from "@/features/page/tree/model/tree-model.types"; +import { dropOpToMovePayload } from "./drop-op-to-move-payload"; +import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; import { useCreatePageMutation, useRemovePageMutation, @@ -18,258 +17,250 @@ import { useUpdatePageMutation, updateCacheOnMovePage, } from "@/features/page/queries/page-query.ts"; -import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; -import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { getSpaceUrl } from "@/lib/config.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; -export function useTreeMutation(spaceId: string) { - const [data, setData] = useAtom(treeDataAtom); - const tree = useMemo(() => new SimpleTree(data), [data]); +export type UseTreeMutation = { + handleMove: (sourceId: string, op: DropOp) => Promise; + handleCreate: (parentId: string | null) => Promise; + handleRename: (id: string, name: string) => Promise; + handleDelete: (id: string) => Promise; +}; + +export function useTreeMutation(spaceId: string): UseTreeMutation { + const { t } = useTranslation(); + const [, setData] = useAtom(treeDataAtom); + // `store` reads the *current* treeDataAtom imperatively in handlers — avoids + // stale-closure issues when the caller updates the tree (e.g. lazy-load + // children) and then immediately invokes a handler. + const store = useStore(); const createPageMutation = useCreatePageMutation(); const updatePageMutation = useUpdatePageMutation(); const removePageMutation = useRemovePageMutation(); const movePageMutation = useMovePageMutation(); const navigate = useNavigate(); - const { spaceSlug } = useParams(); - const { pageSlug } = useParams(); + const { spaceSlug, pageSlug } = useParams(); const emit = useQueryEmit(); - const onCreate: CreateHandler = async ({ parentId, index, type }) => { - const payload: { spaceId: string; parentPageId?: string } = { - spaceId: spaceId, - }; - if (parentId) { - payload.parentPageId = parentId; - } + const handleMove = useCallback( + async (sourceId: string, op: DropOp) => { + const before = store.get(treeDataAtom); + const { tree: after, result } = treeModel.move(before, sourceId, op); + if (after === before) return; - let createdPage: IPage; - try { - createdPage = await createPageMutation.mutateAsync(payload); - } catch (err) { - throw new Error("Failed to create page"); - } + const payload = dropOpToMovePayload(before, sourceId, op); + const source = treeModel.find(before, sourceId) as SpaceTreeNode | null; + if (!source) return; + const oldParentId = source.parentPageId ?? null; - const data = { - id: createdPage.id, - slugId: createdPage.slugId, - name: "", - position: createdPage.position, - spaceId: createdPage.spaceId, - parentPageId: createdPage.parentPageId, - children: [], - } as any; + // optimistic apply with the new position from the payload + let optimistic = treeModel.update(after, sourceId, { + position: payload.position, + parentPageId: payload.parentPageId, + } as Partial); - let lastIndex: number; - if (parentId === null) { - lastIndex = tree.data.length; - } else { - lastIndex = tree.find(parentId).children.length; - } - // to place the newly created node at the bottom - index = lastIndex; - - tree.create({ parentId, index, data }); - setData(tree.data); - - setTimeout(() => { - emit({ - operation: "addTreeNode", - spaceId: spaceId, - payload: { - parentId, - index, - data, - }, - }); - }, 50); - - const pageUrl = buildPageUrl( - spaceSlug, - createdPage.slugId, - createdPage.title - ); - navigate(pageUrl); - return data; - }; - - const onMove: MoveHandler = async (args: { - dragIds: string[]; - dragNodes: NodeApi[]; - parentId: string | null; - parentNode: NodeApi | null; - index: number; - }) => { - const draggedNodeId = args.dragIds[0]; - - tree.move({ - id: draggedNodeId, - parentId: args.parentId, - index: args.index, - }); - - const newDragIndex = tree.find(draggedNodeId)?.childIndex; - - const currentTreeData = args.parentId - ? tree.find(args.parentId).children - : tree.data; - - // if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array - // we have to access the node differently via currentTreeData[args.index]?.data?.position - // this makes it possible to correctly sort children of a parent node that is not the root - - const afterPosition = - // @ts-ignore - currentTreeData[newDragIndex - 1]?.position || - // @ts-ignore - currentTreeData[args.index - 1]?.data?.position || - null; - - const beforePosition = - // @ts-ignore - currentTreeData[newDragIndex + 1]?.position || - // @ts-ignore - currentTreeData[args.index + 1]?.data?.position || - null; - - let newPosition: string; - - if (afterPosition && beforePosition && afterPosition === beforePosition) { - // if after is equal to before, put it next to the after node - newPosition = generateJitteredKeyBetween(afterPosition, null); - } else { - // if both are null then, it is the first index - newPosition = generateJitteredKeyBetween(afterPosition, beforePosition); - } - - // update the node position in tree - tree.update({ - id: draggedNodeId, - changes: { position: newPosition } as any, - }); - - const previousParent = args.dragNodes[0].parent; - if ( - previousParent.id !== args.parentId && - previousParent.id !== "__REACT_ARBORIST_INTERNAL_ROOT__" - ) { - // if the page was moved to another parent, - // check if the previous still has children - // if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly - const childrenCount = previousParent.children.filter( - (child) => child.id !== draggedNodeId - ).length; - if (childrenCount === 0) { - tree.update({ - id: previousParent.id, - changes: { ...previousParent.data, hasChildren: false } as any, - }); + // If the old parent has no children left, mark hasChildren: false so the + // chevron disappears. Without this, the empty parent keeps rendering an + // expand toggle that fetches zero rows on click. + if (oldParentId) { + const oldParent = treeModel.find(optimistic, oldParentId); + if (!oldParent?.children?.length) { + optimistic = treeModel.update(optimistic, oldParentId, { + hasChildren: false, + } as Partial); + } } - } - setData(tree.data); + // For make-child onto a previously-childless target: flip hasChildren on + // so the new parent shows its chevron. + if (op.kind === "make-child") { + optimistic = treeModel.update(optimistic, op.targetId, { + hasChildren: true, + } as Partial); + } - const payload: IMovePage = { - pageId: draggedNodeId, - position: newPosition, - parentPageId: args.parentId, - }; + setData(optimistic); - const draggedNode = args.dragNodes[0]; - const nodeData = draggedNode.data as SpaceTreeNode; - const oldParentId = nodeData.parentPageId ?? null; - const pageData = { - id: nodeData.id, - slugId: nodeData.slugId, - title: nodeData.name, - icon: nodeData.icon, - position: newPosition, - spaceId: nodeData.spaceId, - parentPageId: args.parentId, - hasChildren: nodeData.hasChildren, - }; + try { + await movePageMutation.mutateAsync(payload); + } catch { + setData(before); + notifications.show({ + message: t("Failed to move page"), + color: "red", + }); + return; + } - try { - await movePageMutation.mutateAsync(payload); + const pageData: Partial = { + id: source.id, + slugId: source.slugId, + title: source.name, + icon: source.icon, + position: payload.position, + spaceId: source.spaceId, + parentPageId: payload.parentPageId, + hasChildren: source.hasChildren, + }; - updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData); + updateCacheOnMovePage( + spaceId, + sourceId, + oldParentId, + payload.parentPageId, + pageData, + ); setTimeout(() => { emit({ operation: "moveTreeNode", spaceId: spaceId, payload: { - id: draggedNodeId, - parentId: args.parentId, + id: sourceId, + parentId: payload.parentPageId, oldParentId, - index: args.index, - position: newPosition, + index: result.index, + position: payload.position, pageData, }, }); }, 50); - } catch (error) { - console.error("Error moving page:", error); - } - }; + }, + [setData, store, movePageMutation, spaceId, emit, t], + ); - const onRename: RenameHandler = ({ name, id }) => { - tree.update({ id, changes: { name } as any }); - setData(tree.data); + const handleCreate = useCallback( + async (parentId: string | null) => { + const payload: { spaceId: string; parentPageId?: string } = { spaceId }; + if (parentId) payload.parentPageId = parentId; - try { - updatePageMutation.mutateAsync({ pageId: id, title: name }); - } catch (error) { - console.error("Error updating page title:", error); - } - }; + let createdPage: IPage; + try { + createdPage = await createPageMutation.mutateAsync(payload); + } catch { + throw new Error("Failed to create page"); + } - const isPageInNode = ( - node: { data: SpaceTreeNode; children?: any[] }, - pageSlug: string - ): boolean => { - if (node.data.slugId === pageSlug) { - return true; - } - for (const item of node.children) { - if (item.data.slugId === pageSlug) { - return true; + const newNode: SpaceTreeNode = { + id: createdPage.id, + slugId: createdPage.slugId, + name: "", + position: createdPage.position, + spaceId: createdPage.spaceId, + parentPageId: createdPage.parentPageId, + hasChildren: false, + children: [], + }; + + // Read latest tree at call time. Without this, callers that mutate the + // tree (e.g. lazy-load children on expand) immediately before calling + // handleCreate hit a stale closure and compute lastIndex against the + // pre-load tree, requiring a setTimeout-based wait at the call site. + const current = store.get(treeDataAtom); + let lastIndex: number; + if (parentId === null) { + lastIndex = current.length; } else { - return isPageInNode(item, pageSlug); - } - } - return false; - }; - - const onDelete: DeleteHandler = async (args: { ids: string[] }) => { - try { - await removePageMutation.mutateAsync(args.ids[0]); - - const node = tree.find(args.ids[0]); - if (!node) { - return; + const parent = treeModel.find(current, parentId); + lastIndex = parent?.children?.length ?? 0; } - tree.drop({ id: args.ids[0] }); - setData(tree.data); - - if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) { - navigate(getSpaceUrl(spaceSlug)); - } + setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex)); setTimeout(() => { emit({ - operation: "deleteTreeNode", - spaceId: spaceId, - payload: { node: node.data }, + operation: "addTreeNode", + spaceId, + payload: { + parentId, + index: lastIndex, + data: newNode, + }, }); }, 50); - } catch (error) { - console.error("Failed to delete page:", error); - } - }; - const controllers = { onMove, onRename, onCreate, onDelete }; - return { data, setData, controllers } as const; + const pageUrl = buildPageUrl( + spaceSlug, + createdPage.slugId, + createdPage.title, + ); + navigate(pageUrl); + }, + [spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug], + ); + + const handleRename = useCallback( + async (id: string, name: string) => { + setData((prev) => + treeModel.update(prev, id, { name } as Partial), + ); + try { + await updatePageMutation.mutateAsync({ pageId: id, title: name }); + } catch (error) { + console.error("Error updating page title:", error); + } + }, + [updatePageMutation, setData], + ); + + const handleDelete = useCallback( + async (id: string) => { + const node = treeModel.find( + store.get(treeDataAtom), + id, + ) as SpaceTreeNode | null; + const parentPageId = node?.parentPageId ?? null; + try { + await removePageMutation.mutateAsync(id); + setData((prev) => { + let next = treeModel.remove(prev, id); + // If the parent has no children left, mark hasChildren: false so the + // chevron disappears. Without this, the empty parent keeps rendering an + // expand toggle that fetches zero rows on click. + if (parentPageId) { + const parent = treeModel.find(next, parentPageId); + if (!parent?.children?.length) { + next = treeModel.update(next, parentPageId, { + hasChildren: false, + } as Partial); + } + } + return next; + }); + + if ( + node && + pageSlug && + (node.slugId === pageSlug.split("-")[1] || + isPageInNode(node, pageSlug.split("-")[1])) + ) { + navigate(getSpaceUrl(spaceSlug)); + } + + setTimeout(() => { + if (!node) return; + emit({ + operation: "deleteTreeNode", + spaceId, + payload: { node }, + }); + }, 50); + } catch (error) { + console.error("Failed to delete page:", error); + } + }, + [removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId], + ); + + return { handleMove, handleCreate, handleRename, handleDelete }; +} + +function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean { + if (node.slugId === pageSlug) return true; + if (!node.children) return false; + for (const child of node.children) { + if (isPageInNode(child, pageSlug)) return true; + } + return false; } diff --git a/apps/client/src/features/page/tree/model/tree-model.test.ts b/apps/client/src/features/page/tree/model/tree-model.test.ts new file mode 100644 index 000000000..1c5941e2d --- /dev/null +++ b/apps/client/src/features/page/tree/model/tree-model.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect } from 'vitest'; +import { treeModel } from './tree-model'; +import type { TreeNode } from './tree-model.types'; + +type N = TreeNode<{ name: string }>; + +const fixture: N[] = [ + { + id: 'a', + name: 'A', + children: [ + { id: 'a1', name: 'A1', children: [{ id: 'a1a', name: 'A1a' }] }, + { id: 'a2', name: 'A2' }, + ], + }, + { id: 'b', name: 'B' }, +]; + +describe('treeModel.find', () => { + it('finds a root node', () => { + expect(treeModel.find(fixture, 'a')?.name).toBe('A'); + }); + it('finds a deeply nested node', () => { + expect(treeModel.find(fixture, 'a1a')?.name).toBe('A1a'); + }); + it('returns null for unknown id', () => { + expect(treeModel.find(fixture, 'zzz')).toBeNull(); + }); +}); + +describe('treeModel.path', () => { + it('returns root-to-leaf path for nested id', () => { + const p = treeModel.path(fixture, 'a1a'); + expect(p?.map((n) => n.id)).toEqual(['a', 'a1', 'a1a']); + }); + it('returns [node] for root-level id', () => { + expect(treeModel.path(fixture, 'b')?.map((n) => n.id)).toEqual(['b']); + }); + it('returns null for unknown id', () => { + expect(treeModel.path(fixture, 'zzz')).toBeNull(); + }); +}); + +describe('treeModel.siblingsOf', () => { + it('returns siblings + parent + index for a child', () => { + const info = treeModel.siblingsOf(fixture, 'a2'); + expect(info?.parentId).toBe('a'); + expect(info?.siblings.map((n) => n.id)).toEqual(['a1', 'a2']); + expect(info?.index).toBe(1); + }); + it('returns parentId null + root siblings for a root id', () => { + const info = treeModel.siblingsOf(fixture, 'b'); + expect(info?.parentId).toBeNull(); + expect(info?.siblings.map((n) => n.id)).toEqual(['a', 'b']); + expect(info?.index).toBe(1); + }); + it('returns null for unknown id', () => { + expect(treeModel.siblingsOf(fixture, 'zzz')).toBeNull(); + }); +}); + +describe('treeModel.isDescendant', () => { + it('returns true when descendantId is nested under ancestorId', () => { + expect(treeModel.isDescendant(fixture, 'a', 'a1a')).toBe(true); + }); + it('returns false when ids are siblings', () => { + expect(treeModel.isDescendant(fixture, 'a1', 'a2')).toBe(false); + }); + it('returns false when ancestorId is the same as descendantId', () => { + expect(treeModel.isDescendant(fixture, 'a', 'a')).toBe(false); + }); + it('returns false for unknown ids', () => { + expect(treeModel.isDescendant(fixture, 'zzz', 'a')).toBe(false); + }); +}); + +describe('treeModel.visible', () => { + it('returns only root nodes when no openIds', () => { + const v = treeModel.visible(fixture, new Set()); + expect(v.map((n) => n.id)).toEqual(['a', 'b']); + }); + it('includes children of open ids in DFS order', () => { + const v = treeModel.visible(fixture, new Set(['a'])); + expect(v.map((n) => n.id)).toEqual(['a', 'a1', 'a2', 'b']); + }); + it('recursively descends through chains of open ids', () => { + const v = treeModel.visible(fixture, new Set(['a', 'a1'])); + expect(v.map((n) => n.id)).toEqual(['a', 'a1', 'a1a', 'a2', 'b']); + }); + it('ignores openIds that are not in the tree', () => { + const v = treeModel.visible(fixture, new Set(['ghost'])); + expect(v.map((n) => n.id)).toEqual(['a', 'b']); + }); +}); + +describe('treeModel.insert', () => { + const leaf = (id: string): N => ({ id, name: id.toUpperCase() }); + + it('inserts at end when index is undefined', () => { + const t = treeModel.insert(fixture, 'a', leaf('a3')); + expect(treeModel.siblingsOf(t, 'a3')?.siblings.map((n) => n.id)).toEqual([ + 'a1', 'a2', 'a3', + ]); + }); + it('inserts at index 0', () => { + const t = treeModel.insert(fixture, 'a', leaf('a0'), 0); + expect(treeModel.siblingsOf(t, 'a0')?.siblings.map((n) => n.id)).toEqual([ + 'a0', 'a1', 'a2', + ]); + }); + it('inserts in the middle', () => { + const t = treeModel.insert(fixture, 'a', leaf('a1half'), 1); + expect( + treeModel.siblingsOf(t, 'a1half')?.siblings.map((n) => n.id), + ).toEqual(['a1', 'a1half', 'a2']); + }); + it('inserts at root when parentId is null', () => { + const t = treeModel.insert(fixture, null, leaf('c')); + expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c']); + }); + it('returns same array reference for unknown parentId', () => { + const t = treeModel.insert(fixture, 'ghost', leaf('zz')); + expect(t).toBe(fixture); + }); + it('initializes children array when parent had no children', () => { + const t = treeModel.insert(fixture, 'b', leaf('b1')); + expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['b1']); + }); +}); + +describe('treeModel.remove', () => { + it('removes a leaf', () => { + const t = treeModel.remove(fixture, 'a2'); + expect(treeModel.find(t, 'a2')).toBeNull(); + }); + it('removes a subtree', () => { + const t = treeModel.remove(fixture, 'a1'); + expect(treeModel.find(t, 'a1')).toBeNull(); + expect(treeModel.find(t, 'a1a')).toBeNull(); + }); + it('removes a root node', () => { + const t = treeModel.remove(fixture, 'b'); + expect(t.map((n) => n.id)).toEqual(['a']); + }); + it('returns same array reference for unknown id', () => { + expect(treeModel.remove(fixture, 'ghost')).toBe(fixture); + }); +}); + +describe('treeModel.update', () => { + it('shallow-merges a patch on the matching node', () => { + const t = treeModel.update(fixture, 'a1', { name: 'A1-renamed' }); + expect(treeModel.find(t, 'a1')?.name).toBe('A1-renamed'); + }); + it('returns same array reference for unknown id', () => { + expect(treeModel.update(fixture, 'ghost', { name: 'x' })).toBe(fixture); + }); + it("preserves children when patching parent's own fields", () => { + const t = treeModel.update(fixture, 'a', { name: 'A-renamed' }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a1', 'a2', + ]); + }); + it('preserves reference identity of unrelated subtrees', () => { + const t = treeModel.update(fixture, 'a1', { name: 'X' }); + expect(t[1]).toBe(fixture[1]); + }); +}); + +describe('treeModel.appendChildren', () => { + const kid = (id: string): N => ({ id, name: id }); + + it('appends to existing children', () => { + const t = treeModel.appendChildren(fixture, 'a', [kid('a3'), kid('a4')]); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a1', 'a2', 'a3', 'a4', + ]); + }); + it('initializes children when parent had none', () => { + const t = treeModel.appendChildren(fixture, 'b', [kid('b1')]); + expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['b1']); + }); + it('returns same array reference for unknown parentId', () => { + expect(treeModel.appendChildren(fixture, 'ghost', [kid('zz')])).toBe( + fixture, + ); + }); + + // Regression: lazy-load + auto-expand can race and call appendChildren with + // children that overlap what's already there. React then crashes on duplicate + // keys. Defensive dedup at the model level. + it('dedups against existing children by id', () => { + const t1 = treeModel.appendChildren(fixture, 'a', [ + kid('a3'), + kid('a4'), + ]); + const t2 = treeModel.appendChildren(t1, 'a', [ + kid('a3'), + kid('a4'), + kid('a5'), + ]); + expect(treeModel.find(t2, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a1', 'a2', 'a3', 'a4', 'a5', + ]); + }); + + it('returns same array reference when every child is a duplicate', () => { + const t1 = treeModel.appendChildren(fixture, 'a', [kid('a3')]); + const t2 = treeModel.appendChildren(t1, 'a', [kid('a3')]); + expect(t2).toBe(t1); + }); +}); + +describe('treeModel.place', () => { + it('moves a node to a new parent at a given index', () => { + const t = treeModel.place(fixture, 'a2', { parentId: 'b', index: 0 }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a1']); + expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['a2']); + }); + it('moves a node to root', () => { + const t = treeModel.place(fixture, 'a1', { parentId: null, index: 0 }); + expect(t.map((n) => n.id)).toEqual(['a1', 'a', 'b']); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a2']); + }); + it('reorders within the same parent', () => { + const t = treeModel.place(fixture, 'a2', { parentId: 'a', index: 0 }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a2', 'a1', + ]); + }); + it('returns same array reference for unknown source', () => { + expect( + treeModel.place(fixture, 'ghost', { parentId: 'a', index: 0 }), + ).toBe(fixture); + }); + it('returns same array reference for unknown destination parent', () => { + expect( + treeModel.place(fixture, 'a1', { parentId: 'ghost', index: 0 }), + ).toBe(fixture); + }); +}); + +describe('treeModel.move', () => { + it('reorder-before within same parent: moves source to target index', () => { + const { tree: t, result } = treeModel.move(fixture, 'a2', { + kind: 'reorder-before', + targetId: 'a1', + }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a2', 'a1', + ]); + expect(result).toEqual({ parentId: 'a', index: 0 }); + }); + it('reorder-after within same parent', () => { + const { tree: t, result } = treeModel.move(fixture, 'a1', { + kind: 'reorder-after', + targetId: 'a2', + }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a2', 'a1', + ]); + expect(result).toEqual({ parentId: 'a', index: 1 }); + }); + it('make-child appends at end of target children', () => { + const { tree: t, result } = treeModel.move(fixture, 'b', { + kind: 'make-child', + targetId: 'a', + }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'a1', 'a2', 'b', + ]); + expect(result).toEqual({ parentId: 'a', index: 2 }); + }); + it('make-child initializes children when target had none', () => { + const { tree: t, result } = treeModel.move(fixture, 'a2', { + kind: 'make-child', + targetId: 'b', + }); + expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['a2']); + expect(result).toEqual({ parentId: 'b', index: 0 }); + }); + it('reorder-before across parents', () => { + const { tree: t, result } = treeModel.move(fixture, 'b', { + kind: 'reorder-before', + targetId: 'a1', + }); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([ + 'b', 'a1', 'a2', + ]); + expect(result).toEqual({ parentId: 'a', index: 0 }); + }); + it('reorder-after to root', () => { + const { tree: t, result } = treeModel.move(fixture, 'a1', { + kind: 'reorder-after', + targetId: 'a', + }); + expect(t.map((n) => n.id)).toEqual(['a', 'a1', 'b']); + expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a2']); + expect(result).toEqual({ parentId: null, index: 1 }); + }); + it('no-op when sourceId === targetId', () => { + const out = treeModel.move(fixture, 'a', { + kind: 'make-child', + targetId: 'a', + }); + expect(out.tree).toBe(fixture); + }); + it('no-op when target is descendant of source', () => { + const out = treeModel.move(fixture, 'a', { + kind: 'make-child', + targetId: 'a1a', + }); + expect(out.tree).toBe(fixture); + }); + it('no-op when source is unknown', () => { + const out = treeModel.move(fixture, 'ghost', { + kind: 'reorder-before', + targetId: 'a', + }); + expect(out.tree).toBe(fixture); + }); + it('no-op when target is unknown', () => { + const out = treeModel.move(fixture, 'a1', { + kind: 'reorder-before', + targetId: 'ghost', + }); + expect(out.tree).toBe(fixture); + }); +}); diff --git a/apps/client/src/features/page/tree/model/tree-model.ts b/apps/client/src/features/page/tree/model/tree-model.ts new file mode 100644 index 000000000..71976c50b --- /dev/null +++ b/apps/client/src/features/page/tree/model/tree-model.ts @@ -0,0 +1,222 @@ +import type { TreeNode, SiblingsInfo } from './tree-model.types'; + +function findInternal( + nodes: TreeNode[], + id: string, +): { parents: TreeNode[]; node: TreeNode } | null { + for (const node of nodes) { + if (node.id === id) return { parents: [], node }; + if (node.children) { + const inner = findInternal(node.children, id); + if (inner) return { parents: [node, ...inner.parents], node: inner.node }; + } + } + return null; +} + +export const treeModel = { + find(tree: TreeNode[], id: string): TreeNode | null { + return findInternal(tree, id)?.node ?? null; + }, + + path(tree: TreeNode[], id: string): TreeNode[] | null { + const found = findInternal(tree, id); + if (!found) return null; + return [...found.parents, found.node]; + }, + + siblingsOf( + tree: TreeNode[], + id: string, + ): SiblingsInfo | null { + const found = findInternal(tree, id); + if (!found) return null; + const parent = found.parents[found.parents.length - 1]; + const siblings = parent ? parent.children! : tree; + return { + parentId: parent?.id ?? null, + siblings, + index: siblings.findIndex((n) => n.id === id), + }; + }, + + isDescendant( + tree: TreeNode[], + ancestorId: string, + descendantId: string, + ): boolean { + if (ancestorId === descendantId) return false; + const ancestor = treeModel.find(tree, ancestorId); + if (!ancestor?.children) return false; + return findInternal(ancestor.children, descendantId) !== null; + }, + + visible( + tree: TreeNode[], + openIds: ReadonlySet, + ): TreeNode[] { + const out: TreeNode[] = []; + const walk = (nodes: TreeNode[]) => { + for (const node of nodes) { + out.push(node); + if (openIds.has(node.id) && node.children?.length) walk(node.children); + } + }; + walk(tree); + return out; + }, + + insert( + tree: TreeNode[], + parentId: string | null, + node: TreeNode, + index?: number, + ): TreeNode[] { + if (parentId === null) { + const idx = index ?? tree.length; + return [...tree.slice(0, idx), node, ...tree.slice(idx)]; + } + let touched = false; + const walk = (nodes: TreeNode[]): TreeNode[] => + nodes.map((n) => { + if (n.id === parentId) { + touched = true; + const kids = n.children ?? []; + const idx = index ?? kids.length; + return { + ...n, + children: [...kids.slice(0, idx), node, ...kids.slice(idx)], + }; + } + if (n.children) { + const next = walk(n.children); + if (next !== n.children) return { ...n, children: next }; + } + return n; + }); + const out = walk(tree); + return touched ? out : tree; + }, + + remove(tree: TreeNode[], id: string): TreeNode[] { + let touched = false; + const walk = (nodes: TreeNode[]): TreeNode[] => { + const filtered = nodes.filter((n) => { + if (n.id === id) { + touched = true; + return false; + } + return true; + }); + return filtered.map((n) => { + if (n.children) { + const next = walk(n.children); + if (next !== n.children) return { ...n, children: next }; + } + return n; + }); + }; + const out = walk(tree); + return touched ? out : tree; + }, + + // `patch` excludes `id` (immutable) and `children` (use insert / remove / + // appendChildren for structural changes — otherwise referential identity of + // unrelated subtrees gets blown away). + update( + tree: TreeNode[], + id: string, + patch: Omit, "id" | "children">, + ): TreeNode[] { + let touched = false; + const walk = (nodes: TreeNode[]): TreeNode[] => + nodes.map((n) => { + if (n.id === id) { + touched = true; + return { ...n, ...patch }; + } + if (n.children) { + const next = walk(n.children); + if (next !== n.children) return { ...n, children: next }; + } + return n; + }); + const out = walk(tree); + return touched ? out : tree; + }, + + appendChildren( + tree: TreeNode[], + parentId: string, + children: TreeNode[], + ): TreeNode[] { + let touched = false; + const walk = (nodes: TreeNode[]): TreeNode[] => + nodes.map((n) => { + if (n.id === parentId) { + const existing = n.children ?? []; + // Dedup against existing ids — auto-expand + manual toggle can race + // and produce overlapping fetches; we don't want React to see two + // children with the same key. + const existingIds = new Set(existing.map((c) => c.id)); + const fresh = children.filter((c) => !existingIds.has(c.id)); + if (fresh.length === 0) return n; + touched = true; + return { ...n, children: [...existing, ...fresh] }; + } + if (n.children) { + const next = walk(n.children); + if (next !== n.children) return { ...n, children: next }; + } + return n; + }); + const out = walk(tree); + return touched ? out : tree; + }, + + place( + tree: TreeNode[], + sourceId: string, + to: { parentId: string | null; index: number }, + ): TreeNode[] { + const source = treeModel.find(tree, sourceId); + if (!source) return tree; + if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree; + const removed = treeModel.remove(tree, sourceId); + return treeModel.insert(removed, to.parentId, source, to.index); + }, + + move( + tree: TreeNode[], + sourceId: string, + op: import('./tree-model.types').DropOp, + ): { tree: TreeNode[]; result: import('./tree-model.types').DropResult } { + if (sourceId === op.targetId) return { tree, result: { parentId: null, index: 0 } }; + if (!treeModel.find(tree, sourceId) || !treeModel.find(tree, op.targetId)) { + return { tree, result: { parentId: null, index: 0 } }; + } + if (treeModel.isDescendant(tree, sourceId, op.targetId)) { + return { tree, result: { parentId: null, index: 0 } }; + } + + let parentId: string | null; + let index: number; + + if (op.kind === 'make-child') { + parentId = op.targetId; + const target = treeModel.find(tree, op.targetId)!; + index = target.children?.length ?? 0; + } else { + const info = treeModel.siblingsOf(tree, op.targetId)!; + parentId = info.parentId; + const sourceInfo = treeModel.siblingsOf(tree, sourceId)!; + const sameParent = sourceInfo.parentId === parentId; + const adjust = + sameParent && sourceInfo.index < info.index ? -1 : 0; + index = info.index + adjust + (op.kind === 'reorder-after' ? 1 : 0); + } + + const next = treeModel.place(tree, sourceId, { parentId, index }); + return { tree: next, result: { parentId, index } }; + }, +}; diff --git a/apps/client/src/features/page/tree/model/tree-model.types.ts b/apps/client/src/features/page/tree/model/tree-model.types.ts new file mode 100644 index 000000000..cf484a075 --- /dev/null +++ b/apps/client/src/features/page/tree/model/tree-model.types.ts @@ -0,0 +1,20 @@ +export type TreeNode = T & { + id: string; + children?: TreeNode[]; +}; + +export type DropOp = + | { kind: 'reorder-before'; targetId: string } + | { kind: 'reorder-after'; targetId: string } + | { kind: 'make-child'; targetId: string }; + +export type DropResult = { + parentId: string | null; + index: number; +}; + +export type SiblingsInfo = { + parentId: string | null; + siblings: TreeNode[]; + index: number; +}; diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index 716101e01..e116a1352 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -5,10 +5,11 @@ .treeContainer { height: 100%; min-width: 0; - - > div, > div > .tree { - height: 100% !important; - } + /* DocTree renders a vanilla
      with no internal virtualizer, + so the container must own the scroll. Without this the tree grows past + its parent and the page scrolls instead. */ + overflow-y: auto; + overflow-x: hidden; } .node { @@ -17,76 +18,39 @@ display: flex; align-items: center; height: 100%; - width: 93%; /* not to overlap with scroll bar */ + width: 100%; text-decoration: none; color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); - &:hover { - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); - /*background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));*/ - } + /* Gate hover styles to mouse-capable devices. Touch browsers synthesize + :hover on the first tap (sticky hover) and only fire click on the + second tap, requiring a double-tap to navigate. */ + @media (hover: hover) { + &:hover { + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); + } + &:hover .actions { + opacity: 1; + pointer-events: auto; + } + } .actions { - visibility: hidden; - position: absolute; - height: 100%; - top: 0; - right: 0; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); + display: inline-flex; + flex-shrink: 0; + align-items: center; + margin-left: 4px; + opacity: 0; + pointer-events: none; } - &:hover .actions { - visibility: visible; + &:focus-within .actions { + opacity: 1; + pointer-events: auto; } - } -.node:global(.willReceiveDrop) { - background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7)); -} - -.node:global(.isSelected) { - border-radius: 0; - - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); -/* - color: white; - - // background-color: light-dark( - // var(--mantine-color-gray-0), - // var(--mantine-color-dark-6) - //); - //background: rgb(20, 127, 250, 0.5);*/ -} - -.node:global(.isSelectedStart.isSelectedEnd) { - border-radius: 4px; -} - -.row:focus .node:global(.isSelected) { - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); -} - -.row:focus .node:global(.isFocused) { - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); -} - -.row { - white-space: nowrap; - cursor: pointer; -} - -.row:focus { - outline: none; -} - -.row:focus .node { - /** come back to this **/ - /* background-color: light-dark(var(--mantine-color-red-2), var(--mantine-color-dark-5));*/ -} .icon { margin: 0 rem(10px); @@ -95,8 +59,12 @@ .text { flex: 1; + /* min-width: 0 lets a flex child shrink below its content size — required + for text-overflow: ellipsis on flex items. */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; font-size: rem(14px); font-weight: 500; } @@ -108,3 +76,113 @@ [role="treeitem"] { padding-bottom: 2px; } + +/* Strip the browser's default
        bullet + indent from the DocTree +
          and nested
            nodes. The tree's own indent + is driven by paddingLeft on .rowWrapper. */ +[role="tree"], +[role="tree"] [role="group"] { + list-style: none; + margin: 0; + padding: 0; +} + +/* ---- pragmatic-tree additions ---- */ + +.rowWrapper { + position: relative; + display: flex; + align-items: center; + border-radius: 4px; +} + +.node[data-dragging="true"] { + opacity: 0.4; +} + +.node:focus-visible { + outline: 2px solid light-dark( + var(--mantine-color-blue-5), + var(--mantine-color-blue-4) + ); + outline-offset: -2px; +} + +.node :focus-visible { + outline-offset: -2px; +} + +.node[data-selected="true"] { + background-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-6) + ); +} + +.node[data-selected="true"] .actions { + opacity: 1; + pointer-events: auto; +} + +.node[data-receiving-drop="make-child"] { + background-color: light-dark( + var(--mantine-color-blue-1), + rgba(56, 139, 253, 0.15) + ); + outline: 2px solid light-dark( + var(--mantine-color-blue-5), + var(--mantine-color-blue-7) + ); + outline-offset: -1px; +} + +.node[data-receiving-drop="make-child-blocked"] { + outline-color: light-dark( + var(--mantine-color-red-5), + var(--mantine-color-red-7) + ); +} + +.dropLine { + position: absolute; + left: var(--drop-line-indent, 0); + right: 8px; + height: 2px; + background: light-dark( + var(--mantine-color-blue-5), + var(--mantine-color-blue-4) + ); + pointer-events: none; + z-index: 1; +} + +.dropLine::before { + content: ""; + position: absolute; + left: -4px; + top: -3px; + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 50%; + color: light-dark( + var(--mantine-color-blue-5), + var(--mantine-color-blue-4) + ); + background: var(--mantine-color-body); +} + +.dropLine[data-blocked="true"] { + background: light-dark( + var(--mantine-color-red-5), + var(--mantine-color-red-4) + ); +} + +.dropLine[data-edge="top"] { + top: -1px; +} + +.dropLine[data-edge="bottom"] { + bottom: -1px; +} diff --git a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts new file mode 100644 index 000000000..47882e5e9 --- /dev/null +++ b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const openSharedTreeNodesAtom = atom>({}); diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index 486127b98..1fd19e30f 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -1,14 +1,11 @@ import { ISharedPageTree } from "@/features/share/types/share.types.ts"; -import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; import { buildSharedPageTree, SharedPageTreeNode, } from "@/features/share/utils.ts"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useElementSize, useMergedRef } from "@mantine/hooks"; -import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { Link, useParams } from "react-router-dom"; -import { atom, useAtom } from "jotai/index"; +import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import clsx from "clsx"; @@ -20,154 +17,182 @@ import { } from "@tabler/icons-react"; import { ActionIcon, Box } from "@mantine/core"; import { extractPageSlugId } from "@/lib"; -import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import classes from "@/features/page/tree/styles/tree.module.css"; import styles from "./share.module.css"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; +import { + DocTree, + type DocTreeApi, + type RenderRowProps, +} from "@/features/page/tree/components/doc-tree"; +import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom"; -interface SharedTree { +interface SharedTreeProps { sharedPageTree: ISharedPageTree; } -const openSharedTreeNodesAtom = atom({}); - -export default function SharedTree({ sharedPageTree }: SharedTree) { - const [tree, setTree] = useState< - TreeApi | null | undefined - >(null); - const rootElement = useRef(); - const { ref: sizeRef, width, height } = useElementSize(); - const mergedRef = useMergedRef(rootElement, sizeRef); +export default function SharedTree({ sharedPageTree }: SharedTreeProps) { + const treeRef = useRef(null); const { pageSlug } = useParams(); - const [openTreeNodes, setOpenTreeNodes] = useAtom( - openSharedTreeNodesAtom, - ); + const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom); const currentNodeId = extractPageSlugId(pageSlug); const treeData: SharedPageTreeNode[] = useMemo(() => { - if (!sharedPageTree?.pageTree) return; + if (!sharedPageTree?.pageTree) return [] as SharedPageTreeNode[]; return buildSharedPageTree(sharedPageTree.pageTree); }, [sharedPageTree?.pageTree]); - useEffect(() => { - const parentNodeId = treeData?.[0]?.slugId; - - if (parentNodeId && tree) { - const parentNode = tree.get(parentNodeId); - - setTimeout(() => { - if (parentNode) { - tree.openSiblings(parentNode); - } - }); - - // open direct children of parent node - parentNode?.children.forEach((node) => { - tree.openSiblings(node); - }); - } - }, [treeData, tree]); + const openIds = useMemo( + () => + new Set( + Object.keys(openTreeNodes).filter((k) => openTreeNodes[k]), + ), + [openTreeNodes], + ); useEffect(() => { - if (currentNodeId && tree) { - setTimeout(() => { - // focus on node and open all parents - tree?.select(currentNodeId, { align: "auto" }); - }, 200); - } else { - tree?.deselectAll(); + // Auto-open the first level of the shared tree on initial load. + const root = treeData?.[0]; + if (!root) return; + setOpenTreeNodes((prev) => { + if (prev[root.slugId]) return prev; + const next = { ...prev, [root.slugId]: true }; + for (const child of root.children ?? []) { + next[child.slugId] = true; + } + return next; + }); + }, [treeData, setOpenTreeNodes]); + + useEffect(() => { + if (currentNodeId) { + treeRef.current?.select(currentNodeId, { scrollIntoView: true }); } - }, [currentNodeId, tree]); + }, [currentNodeId, treeData]); + + // Stable callbacks so memo(DocTreeRow) actually saves work — see I2 in the + // post-implementation code review. + const handleToggle = useCallback( + (id: string, isOpen: boolean) => + setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), + [setOpenTreeNodes], + ); + const getDragLabel = useCallback( + (n: SharedPageTreeNode) => n.name || "untitled", + [], + ); if (!sharedPageTree || !sharedPageTree?.pageTree) { return null; } return ( -
            - {rootElement.current && ( - setTree(t)} - openByDefault={false} - disableMultiSelection={true} - className={classes.tree} - rowClassName={classes.row} - rowHeight={30} - overscanCount={10} - dndRootElement={rootElement.current} - onToggle={() => { - setOpenTreeNodes(tree?.openState); - }} - initialOpenState={openTreeNodes} - onClick={(e) => { - if (tree && tree.focusedNode) { - tree.select(tree.focusedNode); - } - }} - > - {Node} - - )} +
            + + readOnly + ref={treeRef} + data={treeData} + openIds={openIds} + selectedId={currentNodeId} + renderRow={SharedTreeRow} + onMove={noopMove} + onToggle={handleToggle} + getDragLabel={getDragLabel} + />
            ); } -function Node({ node, style, tree }: NodeRendererProps) { +// Module-scope noop so it's a stable reference across renders. +const noopMove = () => {}; + +function SharedTreeRow({ + node, + isOpen, + hasChildren, + isSelected, + rowRef, + ariaProps, + toggleOpen, +}: RenderRowProps) { const { shareId } = useParams(); const { t } = useTranslation(); const [, setMobileSidebarState] = useAtom(mobileSidebarAtom); const pageUrl = buildSharedPageUrl({ shareId: shareId, - pageSlugId: node.data.slugId, - pageTitle: node.data.name, + pageSlugId: node.slugId, + pageTitle: node.name, }); return ( - <> - { - setMobileSidebarState(false); - }} - > - -
            - {}} - icon={ - node.data.icon ? ( - node.data.icon - ) : ( - - ) - } - readOnly={true} - removeEmojiAction={() => {}} - /> -
            - {node.data.name || t("untitled")} -
            - + } + {...ariaProps} + data-selected={isSelected || undefined} + className={clsx(classes.node, styles.treeNode)} + component={Link} + to={pageUrl} + onClick={() => { + setMobileSidebarState(false); + }} + > + +
            + {}} + icon={ + node.icon ? ( + node.icon + ) : ( + + ) + } + readOnly={true} + removeEmojiAction={() => {}} + /> +
            + {node.name || t("untitled")} +
            ); } -interface PageArrowProps { - node: NodeApi; +interface SharedPageArrowProps { + isOpen: boolean; + hasChildren: boolean; + onToggle: () => void; } -function PageArrow({ node }: PageArrowProps) { +function SharedPageArrow({ + isOpen, + hasChildren, + onToggle, +}: SharedPageArrowProps) { + if (!hasChildren) { + return ( + + + + ); + } + return ( { e.preventDefault(); e.stopPropagation(); - node.toggle(); + onToggle(); }} > - {node.isInternal ? ( - node.children && (node.children.length > 0 || node.data.hasChildren) ? ( - node.isOpen ? ( - - ) : ( - - ) - ) : ( - - ) - ) : null} + {isOpen ? ( + + ) : ( + + )} ); } diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 0ad0094e5..51e529ba4 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -28,7 +28,7 @@ import { import classes from "./space-sidebar.module.css"; import React from "react"; import { useAtom } from "jotai"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { Link, useLocation, useParams } from "react-router-dom"; import clsx from "clsx"; import { useDisclosure } from "@mantine/hooks"; @@ -56,7 +56,6 @@ import { searchSpotlight } from "@/features/search/constants"; export function SpaceSidebar() { const { t } = useTranslation(); - const [tree] = useAtom(treeApiAtom); const location = useLocation(); const [opened, { open: openSettings, close: closeSettings }] = useDisclosure(false); @@ -68,13 +67,14 @@ export function SpaceSidebar() { const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); + const { handleCreate } = useTreeMutation(space?.id ?? ""); if (!space) { return <>; } function handleCreatePage() { - tree?.create({ parentId: null, type: "internal", index: 0 }); + handleCreate(null); } return ( diff --git a/apps/client/src/features/websocket/use-tree-socket.ts b/apps/client/src/features/websocket/use-tree-socket.ts index df160ec8c..c4cd083b4 100644 --- a/apps/client/src/features/websocket/use-tree-socket.ts +++ b/apps/client/src/features/websocket/use-tree-socket.ts @@ -1,37 +1,27 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { WebSocketEvent } from "@/features/websocket/types"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { useQueryClient } from "@tanstack/react-query"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import localEmitter from "@/lib/local-emitter.ts"; export const useTreeSocket = () => { const [socket] = useAtom(socketAtom); - const [treeData, setTreeData] = useAtom(treeDataAtom); + const [, setTreeData] = useAtom(treeDataAtom); const queryClient = useQueryClient(); - const initialTreeData = useRef(treeData); - - useEffect(() => { - initialTreeData.current = treeData; - }, [treeData]); useEffect(() => { const updateNodeName = (event) => { - const initialData = initialTreeData.current; - const treeApi = new SimpleTree(initialData); - - if (treeApi.find(event?.id)) { - if (event.payload?.title !== undefined) { - treeApi.update({ - id: event.id, - changes: { name: event.payload.title }, - }); - setTreeData(treeApi.data); - } - } + if (event.payload?.title === undefined) return; + setTreeData((prev) => { + if (!treeModel.find(prev, event?.id)) return prev; + return treeModel.update(prev, event.id, { + name: event.payload.title, + } as Partial); + }); }; localEmitter.on("message", updateNodeName); @@ -42,70 +32,110 @@ export const useTreeSocket = () => { useEffect(() => { socket?.on("message", (event: WebSocketEvent) => { - const initialData = initialTreeData.current; - const treeApi = new SimpleTree(initialData); - switch (event.operation) { case "updateOne": if (event.entity[0] === "pages") { - if (treeApi.find(event.id)) { + setTreeData((prev) => { + if (!treeModel.find(prev, event.id)) return prev; + let next = prev; if (event.payload?.title !== undefined) { - treeApi.update({ - id: event.id, - changes: { name: event.payload.title }, - }); + next = treeModel.update(next, event.id, { + name: event.payload.title, + } as Partial); } if (event.payload?.icon !== undefined) { - treeApi.update({ - id: event.id, - changes: { icon: event.payload.icon }, - }); + next = treeModel.update(next, event.id, { + icon: event.payload.icon, + } as Partial); } - setTreeData(treeApi.data); - } + return next; + }); } break; case "addTreeNode": - if (treeApi.find(event.payload.data.id)) return; - - treeApi.create({ - parentId: event.payload.parentId, - index: event.payload.index, - data: event.payload.data, + setTreeData((prev) => { + if (treeModel.find(prev, event.payload.data.id)) return prev; + const newParentId = event.payload.parentId as string | null; + let next = treeModel.insert( + prev, + newParentId, + event.payload.data, + event.payload.index, + ); + // Mirror the emitter: flip new parent's hasChildren to true so + // the chevron renders on the receiver. + if (newParentId) { + next = treeModel.update(next, newParentId, { + hasChildren: true, + } as Partial); + } + return next; }); - setTreeData(treeApi.data); - break; case "moveTreeNode": - // move node - if (treeApi.find(event.payload.id)) { - treeApi.move({ - id: event.payload.id, - parentId: event.payload.parentId, + setTreeData((prev) => { + const sourceBefore = treeModel.find(prev, event.payload.id); + if (!sourceBefore) return prev; + const oldParentId = + (sourceBefore as SpaceTreeNode).parentPageId ?? null; + const newParentId = event.payload.parentId as string | null; + + const placed = treeModel.place(prev, event.payload.id, { + parentId: newParentId, index: event.payload.index, }); + // `place` silently returns the same reference if the destination + // parent isn't loaded on this client. Falling back to removing the + // source keeps the UI consistent (the source will reappear when + // the user expands the new parent and lazy-load fetches it). + if (placed === prev) { + return treeModel.remove(prev, event.payload.id); + } - // update node position - treeApi.update({ - id: event.payload.id, - changes: { - position: event.payload.position, - }, - }); + let next = treeModel.update(placed, event.payload.id, { + position: event.payload.position, + parentPageId: newParentId, + } as Partial); - setTreeData(treeApi.data); - } + // Mirror the emitter's hasChildren bookkeeping so both clients + // converge to the same chevron state. + if (oldParentId) { + const oldParent = treeModel.find(next, oldParentId); + if (!oldParent?.children?.length) { + next = treeModel.update(next, oldParentId, { + hasChildren: false, + } as Partial); + } + } + if (newParentId) { + next = treeModel.update(next, newParentId, { + hasChildren: true, + } as Partial); + } + return next; + }); break; case "deleteTreeNode": - if (treeApi.find(event.payload.node.id)) { - treeApi.drop({ id: event.payload.node.id }); - setTreeData(treeApi.data); - + setTreeData((prev) => { + if (!treeModel.find(prev, event.payload.node.id)) return prev; queryClient.invalidateQueries({ queryKey: ["pages", event.payload.node.slugId].filter(Boolean), }); - } + let next = treeModel.remove(prev, event.payload.node.id); + // Mirror the emitter's hasChildren bookkeeping so both clients + // converge to the same chevron state when the last child is deleted. + const parentPageId = event.payload.node.parentPageId; + if (parentPageId) { + const parent = treeModel.find(next, parentPageId); + if (!parent?.children?.length) { + next = treeModel.update(next, parentPageId, { + hasChildren: false, + } as Partial); + } + } + return next; + }); break; } }); diff --git a/apps/client/vitest.config.ts b/apps/client/vitest.config.ts new file mode 100644 index 000000000..5cde717a1 --- /dev/null +++ b/apps/client/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import * as path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: [], + }, +}); diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 23d41909d..3278f72cb 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -54,7 +54,7 @@ export class WsService { return; } - await this.broadcastToAuthorizedUsers(room, client.data.userId, pageId, data); + await this.broadcastToAuthorizedUsers(room, client.id, pageId, data); } async invalidateSpaceRestrictionCache(spaceId: string): Promise { @@ -115,14 +115,17 @@ export class WsService { private async broadcastToAuthorizedUsers( room: string, - excludeUserId: string | null, + excludeSocketId: string | null, pageId: string, data: any, ): Promise { const sockets = await this.server.in(room).fetchSockets(); - const otherSockets = excludeUserId - ? sockets.filter((s) => s.data.userId !== excludeUserId) + // Exclude only the originating socket, not every socket of the originating + // user. Excluding by userId silently dropped the originator's other tabs + // from receiving restricted-space tree events. + const otherSockets = excludeSocketId + ? sockets.filter((s) => s.id !== excludeSocketId) : sockets; if (otherSockets.length === 0) return; diff --git a/package.json b/package.json index 1c84e4666..1113e735e 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "packageManager": "pnpm@10.4.0", "pnpm": { "patchedDependencies": { - "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch", "scimmy@1.3.5": "patches/scimmy@1.3.5.patch" }, "overrides": { diff --git a/patches/react-arborist@3.4.0.patch b/patches/react-arborist@3.4.0.patch deleted file mode 100644 index 0d8c1ae0f..000000000 --- a/patches/react-arborist@3.4.0.patch +++ /dev/null @@ -1,33 +0,0 @@ -diff --git a/dist/module/components/default-container.js b/dist/module/components/default-container.js -index 47724f59b482454fe3144dbb98bd16d3df6a9c17..2285e35ea0073a773b7b74e22758056fd3514c1a 100644 ---- a/dist/module/components/default-container.js -+++ b/dist/module/components/default-container.js -@@ -34,28 +34,6 @@ export function DefaultContainer() { - return; - } - if (e.key === "Backspace") { -- if (!tree.props.onDelete) -- return; -- const ids = Array.from(tree.selectedIds); -- if (ids.length > 1) { -- let nextFocus = tree.mostRecentNode; -- while (nextFocus && nextFocus.isSelected) { -- nextFocus = nextFocus.nextSibling; -- } -- if (!nextFocus) -- nextFocus = tree.lastNode; -- tree.focus(nextFocus, { scroll: false }); -- tree.delete(Array.from(ids)); -- } -- else { -- const node = tree.focusedNode; -- if (node) { -- const sib = node.nextSibling; -- const parent = node.parent; -- tree.focus(sib || parent, { scroll: false }); -- tree.delete(node); -- } -- } - return; - } - if (e.key === "Tab" && !e.shiftKey) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f54985075..8f6292168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,9 +43,6 @@ overrides: protobufjs: 7.5.5 patchedDependencies: - react-arborist@3.4.0: - hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a - path: patches/react-arborist@3.4.0.patch scimmy@1.3.5: hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673 path: patches/scimmy@1.3.5.patch @@ -250,6 +247,21 @@ importers: apps/client: dependencies: + '@atlaskit/pragmatic-drag-and-drop': + specifier: ^1.8.1 + version: 1.8.1 + '@atlaskit/pragmatic-drag-and-drop-auto-scroll': + specifier: ^2.1.0 + version: 2.1.5 + '@atlaskit/pragmatic-drag-and-drop-flourish': + specifier: ^2.0.15 + version: 2.0.15(react@18.3.1) + '@atlaskit/pragmatic-drag-and-drop-hitbox': + specifier: ^1.1.0 + version: 1.1.0 + '@atlaskit/pragmatic-drag-and-drop-live-region': + specifier: ^1.3.4 + version: 1.3.4 '@casl/react': specifier: ^5.0.1 version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1) @@ -292,6 +304,9 @@ importers: '@tanstack/react-query': specifier: 5.90.17 version: 5.90.17(react@18.3.1) + '@tanstack/react-virtual': + specifier: 3.13.24 + version: 3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) alfaaz: specifier: ^1.1.0 version: 1.1.0 @@ -352,9 +367,6 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 - react-arborist: - specifier: 3.4.0 - version: 3.4.0(patch_hash=419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a)(@types/node@22.19.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-clear-modal: specifier: ^2.0.18 version: 2.0.18(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -392,6 +404,12 @@ importers: '@tanstack/eslint-plugin-query': specifier: ^5.94.4 version: 5.94.4(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@testing-library/jest-dom': + specifier: ^6.6.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/blueimp-load-image': specifier: ^5.16.6 version: 5.16.6 @@ -431,6 +449,9 @@ importers: globals: specifier: ^15.13.0 version: 15.13.0 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 optics-ts: specifier: ^2.4.1 version: 2.4.1 @@ -455,6 +476,9 @@ importers: vite: specifier: 8.0.5 version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.1)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) apps/server: dependencies: @@ -909,6 +933,57 @@ packages: '@asamuzakjp/css-color@2.8.3': resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + '@atlaskit/atlassian-context@0.8.0': + resolution: {integrity: sha512-I4Rpupz2sfHumiLRMHG+emYM0dTSisuL0665lfou6k55jjhrLDDbfH1eBfCZV3qts/xIWnBxoSCaNpV2Vgg9ew==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + + '@atlaskit/browser-apis@0.0.1': + resolution: {integrity: sha512-XKy1lSkezw6o6D4WBuZrpezwwSHhzwMpiGAkVvaLUZF1WJ+nleQaQk3VEZwEOdmUXvIfIJnTbE1QxqsmsehUbQ==} + peerDependencies: + react: ^18.2.0 + + '@atlaskit/css@0.19.4': + resolution: {integrity: sha512-OBacSDD/XBkuu4R2FFMgOoVifSbs7E4LRwqUJFolPurxnsc88/cDrwf9C3obMwDh+64O3a5OyzuUPLVQTfMXYQ==} + peerDependencies: + react: ^18.2.0 + + '@atlaskit/ds-lib@7.0.0': + resolution: {integrity: sha512-Uqywr6YMi6uBpD0BtbRmG8f81fteRT7NBf62J8zzWPSs7u3Jq9G3Oruwm3y+57Dxv6IqfRwrS10WJARlCJdBLw==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + + '@atlaskit/feature-gate-js-client@5.5.11': + resolution: {integrity: sha512-kD+qCh2lIKxXB9IemmKiB+QUI+YKSoDmJ+dTGLehwmav0mcpzpZjrFRc/sHIZ3XefXxob761t8svrakO0hqfPw==} + + '@atlaskit/motion@6.2.2': + resolution: {integrity: sha512-8NmUFbTPDnc2yfRrkVVKCsIrI0AHPk5gU+UyDBSPlUQL6AdWr+zclpii1bAIJjvRHD3aUZwUyt9sa7WBbg2y7A==} + peerDependencies: + react: ^18.2.0 + + '@atlaskit/platform-feature-flags@1.1.3': + resolution: {integrity: sha512-dRn6UvVmMF5+WXnv7ZlxKTV2rVQncI2TgM0KTGSqpHdD9LnogITWC/rFjzUuF/W8MBCEvpouGilukqhq6rASnw==} + + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5': + resolution: {integrity: sha512-InLvVhZAHPBfv3CxuG4AfOQuhNJjaFy69YBfodPMWtRFQNQAKa9Yb3vL9Ho6qsD9qKUBuJa4A5k7QddaXQ4Eyw==} + + '@atlaskit/pragmatic-drag-and-drop-flourish@2.0.15': + resolution: {integrity: sha512-jtrseeLce58/5jXHklwVUo+tzHpGtQyAH63b7AIneyphISFk5gFt9kmTEHaHRd6Q8bfdeOaZjpUbafYVZUAlAQ==} + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': + resolution: {integrity: sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==} + + '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.4': + resolution: {integrity: sha512-HWIbmO4MojXGagbl9lsRTETdLSmkx+FSEzH4n5VK2MDXu/t2sYEK9vq+K9oSrMHh+S6ZkkVchjqGvD94uBlSeg==} + + '@atlaskit/pragmatic-drag-and-drop@1.8.1': + resolution: {integrity: sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==} + + '@atlaskit/tokens@13.0.4': + resolution: {integrity: sha512-DTUMD/NzxtIBb31DZh3xvmcjQPsAafoT109UlcQHcwT20xue9KxiJqAWvh7OSlkI7YrhV11drqbO+p8HugStbg==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1086,6 +1161,10 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} @@ -1688,10 +1767,6 @@ packages: '@babel/regjsgen@0.8.0': resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} - '@babel/runtime@7.26.10': - resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -1785,6 +1860,11 @@ packages: peerDependencies: commander: 11.1.x + '@compiled/react@0.20.0': + resolution: {integrity: sha512-mEJuYGFxIDST1H7CpksyE6a3HRVRQmeDal26O+bCHTEZlPp7iKvs5KD1FOmd2palng+S60dPFFG+UuoZDRILwA==} + peerDependencies: + react: '>=18.0.0' + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2616,6 +2696,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -3896,15 +3979,6 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-dnd/asap@4.0.1': - resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} - - '@react-dnd/invariant@2.0.0': - resolution: {integrity: sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==} - - '@react-dnd/shallowequal@2.0.0': - resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} - '@react-email/render@2.0.8': resolution: {integrity: sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==} engines: {node: '>=20.0.0'} @@ -4264,6 +4338,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@statsig/client-core@3.33.0': + resolution: {integrity: sha512-i0eiAtFkm7Ny38etC5rJ/fkWiAECgyfK23/KIFk8z8LvPW77QmUXJfNeTqFikEOKAsAGU/pxXmchpo8hn9nWBA==} + + '@statsig/js-client@3.33.0': + resolution: {integrity: sha512-jwAjyCI0FQSvoviQqdO2u1pchuG88eUT2/EoCtEV4vda4XilzuX3eUBK+J2nhRZB2u8CLNV7PZoONdy/Tls7yQ==} + '@swc/core-darwin-arm64@1.5.25': resolution: {integrity: sha512-YbD0SBgVJS2DM0vwJTU5m7+wOyCjHPBDMf3nCBJQzFZzOLzK11eRW7SzU2jhJHr9HI9sKcNFfN4lIC2Sj+4inA==} engines: {node: '>=10'} @@ -4339,8 +4419,8 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} - '@swc/types@0.1.25': - resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} '@tabler/icons-react@3.40.0': resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==} @@ -4367,6 +4447,38 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tiptap/core@3.20.4': resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==} peerDependencies: @@ -4653,6 +4765,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4677,6 +4792,9 @@ packages: '@types/bytes@3.1.5': resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -4788,6 +4906,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -4797,6 +4918,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.17.43': resolution: {integrity: sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==} @@ -5172,6 +5296,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5282,10 +5435,6 @@ packages: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} - agent-base@7.1.1: - resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} - engines: {node: '>= 14'} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -5381,6 +5530,13 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -5423,6 +5579,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} @@ -5540,6 +5700,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bind-event-listener@3.0.0: + resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -5655,6 +5818,10 @@ packages: canvas-roundrect-polyfill@0.0.1: resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5953,6 +6120,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -5965,6 +6135,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -6184,15 +6357,6 @@ packages: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -6302,13 +6466,16 @@ packages: dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} - dnd-core@14.0.1: - resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} - doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -6462,6 +6629,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -6592,6 +6762,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -6626,6 +6799,10 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expect@30.2.0: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7000,9 +7177,6 @@ packages: highlightjs-sap-abap@0.3.0: resolution: {integrity: sha512-nSiUvEOCycjtFA3pHaTowrbAAk5+lciBHyoVkDsd6FTRBtW9sT2dt42o2jAKbXjZVUidtacdk+j0Y2xnd233Mw==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.14: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} @@ -7111,6 +7285,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -7607,6 +7785,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -8017,9 +8204,16 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -8083,9 +8277,6 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -8143,6 +8334,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -8221,8 +8416,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - needle@3.3.1: - resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} engines: {node: '>= 4.4.x'} hasBin: true @@ -8380,6 +8575,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ollama@0.6.3: resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==} @@ -8588,20 +8786,20 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.11.0: - resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: pg: '>=8.0' - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-tsquery@8.4.2: resolution: {integrity: sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA==} @@ -8792,6 +8990,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@30.2.0: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8938,6 +9140,9 @@ packages: '@types/react-dom': optional: true + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -8946,12 +9151,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-arborist@3.4.0: - resolution: {integrity: sha512-QI46oRGXJr0oaQfqqVobIiIoqPp5Y5gM69D2A2P7uHVif+X75XWnScR5drC7YDKgJ4CXVaDeFwnYKOWRRfncMg==} - peerDependencies: - react: '>= 16.14' - react-dom: '>= 16.14' - react-clear-modal@2.0.18: resolution: {integrity: sha512-Aiv8Bw5NVm19tlUt3RLV2a1I/ya+UlyEZjREosn5G887nnusnefT+ls4AXkuP8XLn1KOah6DrM5MemV7cXgwWg==} peerDependencies: @@ -8962,24 +9161,6 @@ packages: '@types/react': optional: true - react-dnd-html5-backend@14.1.0: - resolution: {integrity: sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==} - - react-dnd@14.0.5: - resolution: {integrity: sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==} - peerDependencies: - '@types/hoist-non-react-statics': '>= 3.3.1' - '@types/node': '>= 12' - '@types/react': '>= 16' - react: '>= 16.14' - peerDependenciesMeta: - '@types/hoist-non-react-statics': - optional: true - '@types/node': - optional: true - '@types/react': - optional: true - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -9030,6 +9211,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -9098,13 +9282,6 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' - react-window@1.8.10: - resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -9128,6 +9305,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -9136,12 +9317,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - redux@4.2.1: - resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -9160,9 +9335,6 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} @@ -9259,6 +9431,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -9318,8 +9493,8 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} saxes@6.0.0: @@ -9423,6 +9598,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -9490,6 +9668,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -9497,6 +9678,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -9563,6 +9747,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -9674,12 +9862,15 @@ packages: thread-stream@3.0.2: resolution: {integrity: sha512-cBL4xF2A3lSINV4rD5tyqnKH4z/TgWPvT+NaVhJDSwK962oo/Ye7cHSMbDzwcu7tAE1SfU6Q4XtV6Hucmi6Hlw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} - tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -9688,6 +9879,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tlds@1.261.0: resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} hasBin: true @@ -10022,11 +10217,6 @@ packages: '@types/react': optional: true - use-sync-external-store@1.2.2: - resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -10113,6 +10303,47 @@ packages: yaml: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -10250,6 +10481,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@3.1.0: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} @@ -10547,7 +10783,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.3.0 - tinyexec: 1.0.1 + tinyexec: 1.1.2 '@asamuzakjp/css-color@2.8.3': dependencies: @@ -10557,6 +10793,104 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 + '@atlaskit/atlassian-context@0.8.0(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + react: 18.3.1 + + '@atlaskit/browser-apis@0.0.1(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + react: 18.3.1 + + '@atlaskit/css@0.19.4(react@18.3.1)': + dependencies: + '@atlaskit/tokens': 13.0.4(react@18.3.1) + '@babel/runtime': 7.29.2 + '@compiled/react': 0.20.0(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - supports-color + + '@atlaskit/ds-lib@7.0.0(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + bind-event-listener: 3.0.0 + react: 18.3.1 + tiny-invariant: 1.3.3 + + '@atlaskit/feature-gate-js-client@5.5.11(react@18.3.1)': + dependencies: + '@atlaskit/atlassian-context': 0.8.0(react@18.3.1) + '@babel/runtime': 7.29.2 + '@statsig/client-core': 3.33.0 + '@statsig/js-client': 3.33.0 + eventemitter3: 4.0.7 + transitivePeerDependencies: + - react + + '@atlaskit/motion@6.2.2(react@18.3.1)': + dependencies: + '@atlaskit/browser-apis': 0.0.1(react@18.3.1) + '@atlaskit/css': 0.19.4(react@18.3.1) + '@atlaskit/ds-lib': 7.0.0(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.3(react@18.3.1) + '@atlaskit/tokens': 13.0.4(react@18.3.1) + '@babel/runtime': 7.29.2 + '@compiled/react': 0.20.0(react@18.3.1) + bind-event-listener: 3.0.0 + react: 18.3.1 + transitivePeerDependencies: + - supports-color + + '@atlaskit/platform-feature-flags@1.1.3(react@18.3.1)': + dependencies: + '@atlaskit/feature-gate-js-client': 5.5.11(react@18.3.1) + '@babel/runtime': 7.29.2 + transitivePeerDependencies: + - react + + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.8.1 + '@babel/runtime': 7.29.2 + + '@atlaskit/pragmatic-drag-and-drop-flourish@2.0.15(react@18.3.1)': + dependencies: + '@atlaskit/motion': 6.2.2(react@18.3.1) + '@atlaskit/tokens': 13.0.4(react@18.3.1) + '@babel/runtime': 7.29.2 + transitivePeerDependencies: + - react + - supports-color + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.8.1 + '@babel/runtime': 7.29.2 + + '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.4': + dependencies: + '@babel/runtime': 7.29.2 + + '@atlaskit/pragmatic-drag-and-drop@1.8.1': + dependencies: + '@babel/runtime': 7.29.2 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + + '@atlaskit/tokens@13.0.4(react@18.3.1)': + dependencies: + '@atlaskit/ds-lib': 7.0.0(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.3(react@18.3.1) + '@babel/runtime': 7.29.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + bind-event-listener: 3.0.0 + react: 18.3.1 + transitivePeerDependencies: + - supports-color + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -11040,6 +11374,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} '@babel/core@7.28.5': @@ -11775,10 +12115,6 @@ snapshots: '@babel/regjsgen@0.8.0': {} - '@babel/runtime@7.26.10': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.29.2': {} '@babel/template@7.27.2': @@ -11887,6 +12223,11 @@ snapshots: dependencies: commander: 11.1.0 + '@compiled/react@0.20.0(react@18.3.1)': + dependencies: + csstype: 3.2.3 + react: 18.3.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -12748,6 +13089,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -13154,7 +13497,7 @@ snapshots: '@types/xml2js': 0.4.14 '@xmldom/is-dom-node': 1.0.1 '@xmldom/xmldom': 0.8.13 - debug: 4.4.0 + debug: 4.4.3 xml-crypto: 6.1.2 xml-encryption: 3.1.0 xml2js: 0.6.2 @@ -14133,12 +14476,6 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-dnd/asap@4.0.1': {} - - '@react-dnd/invariant@2.0.0': {} - - '@react-dnd/shallowequal@2.0.0': {} - '@react-email/render@2.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: html-to-text: 9.0.5 @@ -14571,6 +14908,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@statsig/client-core@3.33.0': {} + + '@statsig/js-client@3.33.0': + dependencies: + '@statsig/client-core': 3.33.0 + '@swc/core-darwin-arm64@1.5.25': optional: true @@ -14604,7 +14947,7 @@ snapshots: '@swc/core@1.5.25(@swc/helpers@0.5.5)': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.25 + '@swc/types': 0.1.26 optionalDependencies: '@swc/core-darwin-arm64': 1.5.25 '@swc/core-darwin-x64': 1.5.25 @@ -14628,7 +14971,7 @@ snapshots: tslib: 2.8.1 optional: true - '@swc/types@0.1.25': + '@swc/types@0.1.26': dependencies: '@swc/counter': 0.1.3 optional: true @@ -14656,6 +14999,44 @@ snapshots: '@tanstack/query-core': 5.90.17 react: 18.3.1 + '@tanstack/react-virtual@3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.14.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.3 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)': dependencies: '@tiptap/pm': 3.20.4 @@ -14953,6 +15334,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -14987,6 +15370,11 @@ snapshots: '@types/bytes@3.1.5': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 25.5.0 @@ -15122,6 +15510,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 8.56.10 @@ -15134,6 +15524,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.17.43': dependencies: '@types/node': 25.5.0 @@ -15333,7 +15725,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.1 '@types/xml-encryption@1.2.4': dependencies: @@ -15533,6 +15925,47 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -15656,12 +16089,6 @@ snapshots: address@1.2.2: {} - agent-base@7.1.1: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - agent-base@7.1.4: {} ai-sdk-ollama@3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6): @@ -15752,6 +16179,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -15827,6 +16260,8 @@ snapshots: asap@2.0.6: {} + assertion-error@2.0.1: {} + async-lock@1.4.1: {} async-mutex@0.5.0: @@ -15978,6 +16413,8 @@ snapshots: binary-extensions@2.3.0: {} + bind-event-listener@3.0.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -16124,6 +16561,8 @@ snapshots: canvas-roundrect-polyfill@0.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -16440,6 +16879,8 @@ snapshots: css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssstyle@4.2.1: @@ -16449,6 +16890,8 @@ snapshots: csstype@3.1.3: {} + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): dependencies: cose-base: 1.0.3 @@ -16694,10 +17137,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0: - dependencies: - ms: 2.1.3 - debug@4.4.1: dependencies: ms: 2.1.3 @@ -16778,16 +17217,14 @@ snapshots: dingbat-to-unicode@1.0.1: {} - dnd-core@14.0.1: - dependencies: - '@react-dnd/asap': 4.0.1 - '@react-dnd/invariant': 2.0.0 - redux: 4.2.1 - doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -17063,6 +17500,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -17274,6 +17713,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -17304,6 +17747,8 @@ snapshots: exit-x@0.2.2: {} + expect-type@1.3.0: {} + expect@30.2.0: dependencies: '@jest/expect-utils': 30.2.0 @@ -17694,7 +18139,7 @@ snapshots: happy-dom@20.8.9: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.1 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -17744,10 +18189,6 @@ snapshots: highlightjs-sap-abap@0.3.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - hono@4.12.14: {} hookified@1.15.1: {} @@ -17796,7 +18237,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: - agent-base: 7.1.1 + agent-base: 7.1.4 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -17861,6 +18302,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} internal-slot@1.0.7: @@ -18547,6 +18990,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1: + dependencies: + cssstyle: 4.2.1 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.16 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsdom@26.1.0: dependencies: cssstyle: 4.2.1 @@ -18747,7 +19218,7 @@ snapshots: image-size: 0.5.5 make-dir: 2.1.0 mime: 1.6.0 - needle: 3.3.1 + needle: 3.5.0 source-map: 0.6.1 optional: true @@ -18915,10 +19386,16 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -18980,8 +19457,6 @@ snapshots: dependencies: fs-monkey: 1.0.5 - memoize-one@5.2.1: {} - merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -19040,6 +19515,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -19110,10 +19587,10 @@ snapshots: natural-compare@1.4.0: {} - needle@3.3.1: + needle@3.5.0: dependencies: iconv-lite: 0.6.3 - sax: 1.4.4 + sax: 1.6.0 optional: true negotiator@0.6.3: {} @@ -19289,6 +19766,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + ollama@0.6.3: dependencies: whatwg-fetch: 3.6.20 @@ -19504,18 +19983,18 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: + pg-connection-string@2.12.0: optional: true pg-int8@1.0.1: optional: true - pg-pool@3.11.0(pg@8.16.3): + pg-pool@3.13.0(pg@8.16.3): dependencies: pg: 8.16.3 optional: true - pg-protocol@1.11.0: + pg-protocol@1.13.0: optional: true pg-tsquery@8.4.2: {} @@ -19531,9 +20010,9 @@ snapshots: pg@8.16.3: dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.11.0(pg@8.16.3) - pg-protocol: 1.11.0 + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.16.3) + pg-protocol: 1.13.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -19738,6 +20217,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@30.2.0: dependencies: '@jest/schemas': 30.0.5 @@ -19987,6 +20472,8 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + raf-schd@4.0.3: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -19996,20 +20483,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-arborist@3.4.0(patch_hash=419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a)(@types/node@22.19.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dnd: 14.0.5(@types/node@22.19.1)(@types/react@18.3.12)(react@18.3.1) - react-dnd-html5-backend: 14.1.0 - react-dom: 18.3.1(react@18.3.1) - react-window: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - redux: 5.0.1 - use-sync-external-store: 1.2.2(react@18.3.1) - transitivePeerDependencies: - - '@types/hoist-non-react-statics' - - '@types/node' - - '@types/react' - react-clear-modal@2.0.18(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -20017,22 +20490,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-dnd-html5-backend@14.1.0: - dependencies: - dnd-core: 14.0.1 - - react-dnd@14.0.5(@types/node@22.19.1)(@types/react@18.3.12)(react@18.3.1): - dependencies: - '@react-dnd/invariant': 2.0.0 - '@react-dnd/shallowequal': 2.0.0 - dnd-core: 14.0.1 - fast-deep-equal: 3.1.3 - hoist-non-react-statics: 3.3.2 - react: 18.3.1 - optionalDependencies: - '@types/node': 22.19.1 - '@types/react': 18.3.12 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -20100,6 +20557,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-number-format@5.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -20166,13 +20625,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-window@1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.26.10 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -20201,18 +20653,17 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - redux@4.2.1: - dependencies: - '@babel/runtime': 7.29.2 - - redux@5.0.1: {} - reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -20242,8 +20693,6 @@ snapshots: regenerate@1.4.2: {} - regenerator-runtime@0.14.1: {} - regenerator-transform@0.15.2: dependencies: '@babel/runtime': 7.29.2 @@ -20369,6 +20818,8 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.7.1: {} + rrweb-cssom@0.8.0: {} rw@1.3.3: {} @@ -20437,7 +20888,7 @@ snapshots: sax@1.4.1: {} - sax@1.4.4: + sax@1.6.0: optional: true saxes@6.0.0: @@ -20569,6 +21020,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -20653,10 +21106,14 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -20755,6 +21212,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -20865,9 +21326,11 @@ snapshots: dependencies: real-require: 0.2.0 - tinycolor2@1.6.0: {} + tiny-invariant@1.3.3: {} - tinyexec@1.0.1: {} + tinybench@2.9.0: {} + + tinycolor2@1.6.0: {} tinyexec@1.1.2: {} @@ -20876,6 +21339,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tlds@1.261.0: {} tldts-core@6.1.72: {} @@ -21231,10 +21696,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - use-sync-external-store@1.2.2(react@18.3.1): - dependencies: - react: 18.3.1 - use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 @@ -21281,6 +21742,36 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.1)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.1 + happy-dom: 20.8.9 + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -21466,6 +21957,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@3.1.0: dependencies: string-width: 4.2.3