From dafa75911868e43711f3cb1fd9648f7bd8f8729e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:07:31 +0000 Subject: [PATCH 1/7] Initial plan From 10362d3d29ad79e902bd95f0f960bc896f0d09b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:12:24 +0000 Subject: [PATCH 2/7] Implement drag-and-drop sorting for TreeExplorer, CodeExplorer, and TagExplorer Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- .../Code/CodeExplorer/CodeExplorer.tsx | 28 ++- .../Tag/TagExplorer/TagExplorer.tsx | 28 ++- .../components/TreeExplorer/DataTreeView.tsx | 44 +++-- .../components/TreeExplorer/TreeExplorer.tsx | 165 ++++++++++++++---- .../src/components/TreeExplorer/TreeUtils.ts | 47 +++++ frontend/src/hooks/useTreeSortOrder.ts | 94 ++++++++++ 6 files changed, 357 insertions(+), 49 deletions(-) create mode 100644 frontend/src/hooks/useTreeSortOrder.ts diff --git a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx index 800500454..eeedc0d00 100644 --- a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx +++ b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx @@ -1,13 +1,15 @@ import SquareIcon from "@mui/icons-material/Square"; import { Box, BoxProps } from "@mui/material"; import * as React from "react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { CodeRead } from "../../../api/openapi/models/CodeRead.ts"; import { useAppDispatch, useAppSelector } from "../../../plugins/ReduxHooks.ts"; import { AnnoActions } from "../../../views/annotation/annoSlice.ts"; import ExportCodesButton from "../../Export/ExportCodesButton.tsx"; import { ITree } from "../../TreeExplorer/ITree.ts"; import TreeExplorer from "../../TreeExplorer/TreeExplorer.tsx"; +import { flatTree } from "../../TreeExplorer/TreeUtils.ts"; +import { useTreeSortOrder } from "../../../hooks/useTreeSortOrder.ts"; import CodeCreateListItemButton from "../CodeCreateListItemButton.tsx"; import CodeExplorerActionMenu from "./CodeExplorerActionMenu.tsx"; import CodeExplorerNodeRenderer from "./CodeExplorerNodeRenderer.tsx"; @@ -18,7 +20,7 @@ const renderActions = (node: ITree) => state.annotations.selectedCodeId); @@ -28,6 +30,24 @@ function CodeExplorer(props: BoxProps) { // local client state const [codeFilter, setCodeFilter] = useState(""); + // Get all code IDs from the tree + const allCodeIds = useMemo(() => { + if (!codeTree) return []; + return flatTree(codeTree.model).map((code) => code.id); + }, [codeTree]); + + // Get current project ID + const projectId = useMemo(() => { + return allCodes.data?.[0]?.project_id; + }, [allCodes.data]); + + // Use custom sort order hook + const { sortOrder, updateSortOrder } = useTreeSortOrder( + "code-sort-order", + projectId, + allCodeIds + ); + // handle ui events const handleExpandedCodeIdsChange = useCallback( (newCodeIds: string[]) => { @@ -72,6 +92,10 @@ function CodeExplorer(props: BoxProps) { renderActions={renderActions} // components listActions={} + // drag and drop for reordering + draggableItems={true} + sortOrder={sortOrder} + onSortOrderChange={updateSortOrder} /> )} diff --git a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx index 4ef86f9f4..14c3b9596 100644 --- a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx +++ b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx @@ -1,12 +1,14 @@ import LabelIcon from "@mui/icons-material/Label"; import { Box, BoxProps } from "@mui/material"; -import { memo, useCallback, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { TagRead } from "../../../api/openapi/models/TagRead.ts"; import { useAppDispatch, useAppSelector } from "../../../plugins/ReduxHooks.ts"; import { SearchActions } from "../../../views/search/DocumentSearch/searchSlice.ts"; import ExportTagsButton from "../../Export/ExportTagsButton.tsx"; import { ITree } from "../../TreeExplorer/ITree.ts"; import TreeExplorer from "../../TreeExplorer/TreeExplorer.tsx"; +import { flatTree } from "../../TreeExplorer/TreeUtils.ts"; +import { useTreeSortOrder } from "../../../hooks/useTreeSortOrder.ts"; import TagMenuCreateButton from "../TagMenu/TagMenuCreateButton.tsx"; import TagExplorerActionMenu from "./TagExplorerActionMenu.tsx"; import useComputeTagTree from "./useComputeTagTree.ts"; @@ -19,7 +21,7 @@ interface TagExplorerProps { function TagExplorer({ onTagClick, ...props }: TagExplorerProps & BoxProps) { // custom hooks - const { tagTree } = useComputeTagTree(); + const { tagTree, allTags } = useComputeTagTree(); // tag expansion const dispatch = useAppDispatch(); @@ -45,6 +47,24 @@ function TagExplorer({ onTagClick, ...props }: TagExplorerProps & BoxProps) { [onTagClick], ); + // Get all tag IDs from the tree + const allTagIds = useMemo(() => { + if (!tagTree) return []; + return flatTree(tagTree.model).map((tag) => tag.id); + }, [tagTree]); + + // Get current project ID + const projectId = useMemo(() => { + return allTags.data?.[0]?.project_id; + }, [allTags.data]); + + // Use custom sort order hook + const { sortOrder, updateSortOrder } = useTreeSortOrder( + "tag-sort-order", + projectId, + allTagIds + ); + return ( {tagTree && ( @@ -66,6 +86,10 @@ function TagExplorer({ onTagClick, ...props }: TagExplorerProps & BoxProps) { renderActions={renderActions} // components listActions={} + // drag and drop for reordering + draggableItems={true} + sortOrder={sortOrder} + onSortOrderChange={updateSortOrder} /> )} diff --git a/frontend/src/components/TreeExplorer/DataTreeView.tsx b/frontend/src/components/TreeExplorer/DataTreeView.tsx index a43ce4193..9853f15db 100644 --- a/frontend/src/components/TreeExplorer/DataTreeView.tsx +++ b/frontend/src/components/TreeExplorer/DataTreeView.tsx @@ -10,6 +10,7 @@ import AbcIcon from "@mui/icons-material/Abc"; import { Typography } from "@mui/material"; import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; import { useCallback } from "react"; +import { Draggable } from "../DnD/Draggable.tsx"; import Droppable from "../DnD/Droppable.tsx"; import { ITree, NamedObjWithParent } from "./ITree.ts"; @@ -24,6 +25,7 @@ export interface DataTreeViewProps { parentIcon?: React.ElementType; droppable?: boolean | ((node: ITree) => boolean); droppableId?: (node: ITree) => string; + draggable?: boolean; } const defaultNodeRenderer = (node: ITree) => ( @@ -39,6 +41,7 @@ function DataTreeView({ dataIcon, droppable, droppableId, + draggable = false, renderRoot = false, disableRootActions = false, rootIcon = FolderIcon, @@ -52,13 +55,40 @@ function DataTreeView({ // Use rootIcon for the root node if provided and isRoot is true const iconToUse = isRoot ? rootIcon : hasChildren ? parentIcon : dataIcon ? dataIcon : AbcIcon; - const label = ( + const labelContent = ( {renderNode(node)} {renderActions && !(isRoot && disableRootActions) ? renderActions(node) : undefined} ); + + // Wrap with droppable if needed + const droppableWrapped = (typeof droppable === "function" ? droppable(node) : droppable) ? ( + + {labelContent} + + ) : ( + labelContent + ); + + // Wrap with draggable if needed + const label = draggable && !isRoot ? ( + + {droppableWrapped} + + ) : ( + droppableWrapped + ); + return ( ({ expandIcon: ArrowRightIcon, collapseIcon: ArrowDropDownIcon, }} - label={ - (typeof droppable === "function" ? droppable(node) : droppable) ? ( - - {label} - - ) : ( - label - ) - } + label={label} > {hasChildren && {renderTree(node.children!, false)} } ); }); }, - [rootIcon, parentIcon, dataIcon, renderNode, renderActions, disableRootActions, droppable, droppableId], + [rootIcon, parentIcon, dataIcon, renderNode, renderActions, disableRootActions, droppable, droppableId, draggable], ); return ( diff --git a/frontend/src/components/TreeExplorer/TreeExplorer.tsx b/frontend/src/components/TreeExplorer/TreeExplorer.tsx index 3cb68ede4..77d9a90c4 100644 --- a/frontend/src/components/TreeExplorer/TreeExplorer.tsx +++ b/frontend/src/components/TreeExplorer/TreeExplorer.tsx @@ -4,10 +4,11 @@ import Typography from "@mui/material/Typography"; import * as React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Node } from "ts-tree-structure"; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import DataTreeView, { DataTreeViewProps } from "./DataTreeView.tsx"; import { ITree, NamedObjWithParent } from "./ITree.ts"; import TreeDataFilter from "./TreeDataFilter.tsx"; -import { filterTree, flatTree } from "./TreeUtils.ts"; +import { filterTree, flatTree, sortTreeByCustomOrder } from "./TreeUtils.ts"; export interface TreeExplorerProps extends Omit, "data"> { dataTree: Node>; @@ -32,6 +33,10 @@ export interface TreeExplorerProps extends Omit) => boolean); droppableId?: (node: ITree) => string; + // custom sort order + sortOrder?: number[]; + onSortOrderChange?: (newSortOrder: number[]) => void; + draggableItems?: boolean; } function TreeExplorer({ @@ -57,16 +62,34 @@ function TreeExplorer({ disableRootActions = false, droppable, droppableId, + sortOrder, + onSortOrderChange, + draggableItems = false, ...props }: TreeExplorerProps & BoxProps) { + // apply custom sort order if provided + const sortedDataTree = useMemo(() => { + if (!sortOrder || sortOrder.length === 0) { + return dataTree; + } + + // Apply sorting to the tree model + const sortedModel = sortTreeByCustomOrder(dataTree.model, sortOrder); + + // Create a new tree with the sorted model + const newTree = dataTree.clone(); + newTree.model = sortedModel; + return newTree; + }, [dataTree, sortOrder]); + // filter feature const { dataTree: filteredDataTree, nodesToExpand } = useMemo( () => filterTree({ - dataTree: dataTree, + dataTree: sortedDataTree, dataFilter: dataFilter, }), - [dataTree, dataFilter], + [sortedDataTree, dataFilter], ); useEffect(() => { @@ -131,6 +154,104 @@ function TreeExplorer({ [handleCheckboxChange, isChecked, isIndeterminate, renderActions, showCheckboxes], ); + // drag and drop for reordering + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Require 8px movement before drag starts + }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!active || !over || !onSortOrderChange) return; + + const activeData = active.data.current as { type: string; id: number; parentId?: number | null }; + const overData = over.data.current as { type: string; id: number; parentId?: number | null }; + + // Only handle tree-item reordering + if (activeData?.type !== "tree-item" || overData?.type !== "tree-item") return; + + const draggedId = activeData.id; + const targetId = overData.id; + + if (draggedId === targetId) return; + + // Only allow reordering items with the same parent + if (activeData.parentId !== overData.parentId) return; + + // Get all items with the same parent + const flatData = flatTree(sortedDataTree.model); + const siblingIds = flatData + .filter((item) => item.parent_id === activeData.parentId) + .map((item) => item.id); + + // Get current order (either from sortOrder or default by ID) + const currentOrder = sortOrder && sortOrder.length > 0 + ? sortOrder.filter(id => siblingIds.includes(id)) + : siblingIds.sort((a, b) => a - b); + + // Remove dragged item and insert at new position + const draggedIndex = currentOrder.indexOf(draggedId); + const targetIndex = currentOrder.indexOf(targetId); + + if (draggedIndex === -1 || targetIndex === -1) return; + + const newOrder = [...currentOrder]; + newOrder.splice(draggedIndex, 1); + newOrder.splice(targetIndex, 0, draggedId); + + // Merge with other items not in the same parent + const otherIds = flatData + .filter((item) => item.parent_id !== activeData.parentId) + .map((item) => item.id); + + // Combine the reordered siblings with other items + const finalOrder = [...newOrder, ...otherIds]; + + onSortOrderChange(finalOrder); + }, + [sortedDataTree, sortOrder, onSortOrderChange] + ); + + const treeViewContent = ( + { + event.stopPropagation(); + onExpandedItemsChange(itemIds); + }} + // actions + onItemClick={onItemClick} + // renderers + renderActions={wrapppedRenderActions} + renderNode={renderNode} + // root node rendering + renderRoot={renderRoot} + disableRootActions={disableRootActions} + // icons + rootIcon={rootIcon} + parentIcon={parentIcon} + dataIcon={dataIcon} + // dnd + droppable={droppable} + droppableId={droppableId} + draggable={draggableItems} + /> + ); + return ( {toolbarTitle && ( @@ -160,37 +281,13 @@ function TreeExplorer({ {filterActions} )} - { - event.stopPropagation(); - onExpandedItemsChange(itemIds); - }} - // actions - onItemClick={onItemClick} - // renderers - renderActions={wrapppedRenderActions} - renderNode={renderNode} - // root node rendering - renderRoot={renderRoot} - disableRootActions={disableRootActions} - // icons - rootIcon={rootIcon} - parentIcon={parentIcon} - dataIcon={dataIcon} - // dnd - droppable={droppable} - droppableId={droppableId} - /> + {draggableItems ? ( + + {treeViewContent} + + ) : ( + treeViewContent + )} ); } diff --git a/frontend/src/components/TreeExplorer/TreeUtils.ts b/frontend/src/components/TreeExplorer/TreeUtils.ts index aa9953001..c0df61e3d 100644 --- a/frontend/src/components/TreeExplorer/TreeUtils.ts +++ b/frontend/src/components/TreeExplorer/TreeUtils.ts @@ -70,6 +70,53 @@ export function flatTree(tree: ITree | null): T return result; } +/** + * Sorts tree children based on a custom sort order array. + * Items in sortOrder come first (in that order), followed by remaining items by ID. + */ +export function sortTreeByCustomOrder( + tree: ITree, + sortOrder: number[] +): ITree { + const sortChildren = (children: ITree[] | undefined): ITree[] | undefined => { + if (!children || children.length === 0) return children; + + // Create a map for O(1) lookup of sort position + const orderMap = new Map(); + sortOrder.forEach((id, index) => { + orderMap.set(id, index); + }); + + // Sort children: items in sortOrder come first, then by ID + const sorted = [...children].sort((a, b) => { + const orderA = orderMap.get(a.data.id); + const orderB = orderMap.get(b.data.id); + + // Both in sort order - use sort order position + if (orderA !== undefined && orderB !== undefined) { + return orderA - orderB; + } + // Only A in sort order - A comes first + if (orderA !== undefined) return -1; + // Only B in sort order - B comes first + if (orderB !== undefined) return 1; + // Neither in sort order - sort by ID + return a.data.id - b.data.id; + }); + + // Recursively sort children + return sorted.map((child) => ({ + ...child, + children: sortChildren(child.children), + })); + }; + + return { + ...tree, + children: sortChildren(tree.children), + }; +} + export function filterTree({ dataTree, dataFilter }: FilterProps) { const nodesToExpand = new Set(); diff --git a/frontend/src/hooks/useTreeSortOrder.ts b/frontend/src/hooks/useTreeSortOrder.ts new file mode 100644 index 000000000..1d748e692 --- /dev/null +++ b/frontend/src/hooks/useTreeSortOrder.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface TreeSortOrderState { + projectId: number; + sortOrder: number[]; +} + +/** + * Custom hook to manage tree item sort order in localStorage. + * Persists sort order per project and filters out deleted items. + * + * @param storageKey - Unique key for localStorage (e.g., 'code-sort-order', 'tag-sort-order') + * @param projectId - Current project ID + * @param allItemIds - All valid item IDs currently in the tree + * @returns Object with sortOrder array and updateSortOrder function + */ +export function useTreeSortOrder( + storageKey: string, + projectId: number | undefined, + allItemIds: number[] +) { + // Load initial sort order from localStorage + const loadSortOrder = useCallback((): number[] => { + if (!projectId) return []; + + try { + const stored = localStorage.getItem(storageKey); + if (!stored) return []; + + const data: TreeSortOrderState = JSON.parse(stored); + + // Only use stored order if it's for the current project + if (data.projectId !== projectId) return []; + + // Filter out any IDs that no longer exist in allItemIds + const validIds = data.sortOrder.filter(id => allItemIds.includes(id)); + + return validIds; + } catch (error) { + console.error(`Failed to load ${storageKey}:`, error); + return []; + } + }, [storageKey, projectId, allItemIds]); + + const [sortOrder, setSortOrder] = useState(loadSortOrder); + + // Update localStorage when sort order changes + const saveSortOrder = useCallback((order: number[]) => { + if (!projectId) return; + + try { + const data: TreeSortOrderState = { + projectId, + sortOrder: order, + }; + localStorage.setItem(storageKey, JSON.stringify(data)); + } catch (error) { + console.error(`Failed to save ${storageKey}:`, error); + } + }, [storageKey, projectId]); + + // Reload sort order when projectId or allItemIds change + useEffect(() => { + const newOrder = loadSortOrder(); + setSortOrder(newOrder); + }, [loadSortOrder]); + + // Update sort order and persist to localStorage + const updateSortOrder = useCallback((newOrder: number[]) => { + setSortOrder(newOrder); + saveSortOrder(newOrder); + }, [saveSortOrder]); + + // Compute the effective sort order with all items + // Items in sortOrder come first (in that order), followed by remaining items by ID + const effectiveSortOrder = useMemo(() => { + // Get items that are in the custom sort order + const orderedItems = sortOrder.filter(id => allItemIds.includes(id)); + + // Get items that are not in the custom sort order (newly added items) + const unorderedItems = allItemIds.filter(id => !sortOrder.includes(id)); + + // Sort unordered items by ID + unorderedItems.sort((a, b) => a - b); + + // Combine: custom order first, then new items by ID + return [...orderedItems, ...unorderedItems]; + }, [sortOrder, allItemIds]); + + return { + sortOrder: effectiveSortOrder, + updateSortOrder, + }; +} From bd6e7f14bea07981d32f31c65385bc8324b54c1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:14:23 +0000 Subject: [PATCH 3/7] Fix Tree import for sortTreeByCustomOrder Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- frontend/package-lock.json | 132 +++++++----------- .../components/TreeExplorer/TreeExplorer.tsx | 6 +- 2 files changed, 54 insertions(+), 84 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c5b0083d..c8b7a9d39 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -142,6 +142,7 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -642,6 +643,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -685,6 +687,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1517,7 +1520,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1570,6 +1572,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.1.tgz", "integrity": "sha512-mkHLrXMPd5xdI5WD7UOLwNEpdh/i6A7HaRDTXvjDE2/S0N8VmAE+BlvdyvWRMi7ODp2zVqJdP8cF1tgUn+Z0fA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -1735,6 +1738,7 @@ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz", "integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0" }, @@ -1806,6 +1810,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.4.7", @@ -1916,6 +1921,7 @@ "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/private-theming": "^6.4.6", @@ -2000,6 +2006,7 @@ "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.27.3.tgz", "integrity": "sha512-igfKTPC4ZVCmS5j/NXcXBtj/hHseQHzRpCpIB1PMnJGhMdRYXnz8qZz5XhlNBKlzJVXkGu6Uil+obZpCLNj1xg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", @@ -3067,6 +3074,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.2.tgz", "integrity": "sha512-6Sa+BVNJWhAV4QHvIqM73norNeGRWGC3ftN0Ix87cmMvI215I1wyJ44KUTt/9a0V9YimfGcg25AITaYVel71Og==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.67.2" }, @@ -3160,6 +3168,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz", "integrity": "sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3438,6 +3447,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.5.tgz", "integrity": "sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -3884,7 +3894,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3895,7 +3904,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -3942,6 +3950,7 @@ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "*" } @@ -4074,6 +4083,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4202,6 +4212,7 @@ "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.26.0", "@typescript-eslint/types": "8.26.0", @@ -4574,7 +4585,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -4584,29 +4594,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -4617,15 +4623,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4638,7 +4642,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -4648,7 +4651,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -4657,15 +4659,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4682,7 +4682,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -4696,7 +4695,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4709,7 +4707,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -4724,7 +4721,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -4734,15 +4730,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/abs-svg-path": { "version": "0.1.1", @@ -4755,6 +4749,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4794,7 +4789,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -4812,7 +4806,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4828,8 +4821,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/almost-equal": { "version": "1.1.0", @@ -5159,6 +5151,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -5351,7 +5344,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -6096,6 +6088,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6238,7 +6231,8 @@ "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.0", @@ -6597,7 +6591,6 @@ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -6717,8 +6710,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -6931,6 +6923,7 @@ "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7463,8 +7456,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.19.1", @@ -7856,8 +7848,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/global-prefix": { "version": "4.0.0", @@ -9654,7 +9645,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -9669,7 +9659,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9871,7 +9860,6 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" } @@ -12237,8 +12225,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -12832,7 +12819,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12842,7 +12828,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -13604,6 +13589,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -13932,6 +13918,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.1.tgz", "integrity": "sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13961,6 +13948,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -14000,6 +13988,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.3.tgz", "integrity": "sha512-Nhh/+1kZGRINbEHmVu39oynhcap4hWTs/BlU7NnxWj3+l0qi8I1mu67v6mMdEe/ltD8hHvU4FV6PHiCw2VSpMw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -14009,6 +13998,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.38.1.tgz", "integrity": "sha512-4FH/uM1A4PNyrxXbD+RAbAsf0d/mM0D/wAKSVVWK7o0A9Q/oOXJBrw786mBf2Vnrs/Edly6dH6Z2gsb7zWwaUw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -14087,7 +14077,6 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -14097,6 +14086,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14194,6 +14184,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -14230,6 +14221,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14981,6 +14973,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15284,7 +15277,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -15706,7 +15700,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15966,7 +15959,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16003,7 +15995,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -16015,8 +16006,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.1", @@ -16035,7 +16025,6 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -16123,6 +16112,7 @@ "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", @@ -16239,7 +16229,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -16250,7 +16239,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16575,7 +16563,6 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -16585,7 +16572,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -16604,7 +16590,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -16638,8 +16623,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through2": { "version": "2.0.5", @@ -16938,6 +16922,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17473,6 +17458,7 @@ "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", @@ -17561,7 +17547,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -17647,7 +17632,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -17657,7 +17641,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -17671,7 +17654,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -17827,6 +17809,7 @@ "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.2.17.tgz", "integrity": "sha512-9bmO5Vl6E3AH5JRQhJPWOmX4x0dwDZvfM86qd+w38G0j23VSV+ZvrKYBbKc1l44Tnyd7j7TWEuqBpzfOfbc4fw==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.42" }, @@ -17851,6 +17834,7 @@ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -17873,26 +17857,12 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yjs": { "version": "13.6.24", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz", "integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/frontend/src/components/TreeExplorer/TreeExplorer.tsx b/frontend/src/components/TreeExplorer/TreeExplorer.tsx index 77d9a90c4..00c70c568 100644 --- a/frontend/src/components/TreeExplorer/TreeExplorer.tsx +++ b/frontend/src/components/TreeExplorer/TreeExplorer.tsx @@ -3,7 +3,7 @@ import { AppBar, Box, BoxProps, Checkbox, Stack, Toolbar } from "@mui/material"; import Typography from "@mui/material/Typography"; import * as React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Node } from "ts-tree-structure"; +import Tree, { Node } from "ts-tree-structure"; import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import DataTreeView, { DataTreeViewProps } from "./DataTreeView.tsx"; import { ITree, NamedObjWithParent } from "./ITree.ts"; @@ -77,8 +77,8 @@ function TreeExplorer({ const sortedModel = sortTreeByCustomOrder(dataTree.model, sortOrder); // Create a new tree with the sorted model - const newTree = dataTree.clone(); - newTree.model = sortedModel; + const tree = new Tree(); + const newTree = tree.parse>(sortedModel); return newTree; }, [dataTree, sortOrder]); From 3e3a21e9dc8ed06d7403da9b0132538cb0d74d62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:17:34 +0000 Subject: [PATCH 4/7] Address code review feedback: add validation and improve projectId handling Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- .../Code/CodeExplorer/CodeExplorer.tsx | 16 +++++--- .../Tag/TagExplorer/TagExplorer.tsx | 13 +++--- .../components/TreeExplorer/TreeExplorer.tsx | 41 +++++++++++++------ 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx index eeedc0d00..68c23b136 100644 --- a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx +++ b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx @@ -18,7 +18,11 @@ import useComputeCodeTree from "./useComputeCodeTree.ts"; const renderNode = (node: ITree) => ; const renderActions = (node: ITree) => ; -function CodeExplorer(props: BoxProps) { +interface CodeExplorerProps { + projectId?: number; +} + +function CodeExplorer({ projectId, ...props }: CodeExplorerProps & BoxProps) { // custom hooks const { codeTree, allCodes } = useComputeCodeTree(); @@ -36,15 +40,15 @@ function CodeExplorer(props: BoxProps) { return flatTree(codeTree.model).map((code) => code.id); }, [codeTree]); - // Get current project ID - const projectId = useMemo(() => { - return allCodes.data?.[0]?.project_id; - }, [allCodes.data]); + // Use project ID from props or derive from data (fallback) + const effectiveProjectId = useMemo(() => { + return projectId ?? allCodes.data?.[0]?.project_id; + }, [projectId, allCodes.data]); // Use custom sort order hook const { sortOrder, updateSortOrder } = useTreeSortOrder( "code-sort-order", - projectId, + effectiveProjectId, allCodeIds ); diff --git a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx index 14c3b9596..31f921bb3 100644 --- a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx +++ b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx @@ -17,9 +17,10 @@ const renderActions = (node: ITree) => void; + projectId?: number; } -function TagExplorer({ onTagClick, ...props }: TagExplorerProps & BoxProps) { +function TagExplorer({ onTagClick, projectId, ...props }: TagExplorerProps & BoxProps) { // custom hooks const { tagTree, allTags } = useComputeTagTree(); @@ -53,15 +54,15 @@ function TagExplorer({ onTagClick, ...props }: TagExplorerProps & BoxProps) { return flatTree(tagTree.model).map((tag) => tag.id); }, [tagTree]); - // Get current project ID - const projectId = useMemo(() => { - return allTags.data?.[0]?.project_id; - }, [allTags.data]); + // Use project ID from props or derive from data (fallback) + const effectiveProjectId = useMemo(() => { + return projectId ?? allTags.data?.[0]?.project_id; + }, [projectId, allTags.data]); // Use custom sort order hook const { sortOrder, updateSortOrder } = useTreeSortOrder( "tag-sort-order", - projectId, + effectiveProjectId, allTagIds ); diff --git a/frontend/src/components/TreeExplorer/TreeExplorer.tsx b/frontend/src/components/TreeExplorer/TreeExplorer.tsx index 00c70c568..ecacd442b 100644 --- a/frontend/src/components/TreeExplorer/TreeExplorer.tsx +++ b/frontend/src/components/TreeExplorer/TreeExplorer.tsx @@ -169,30 +169,47 @@ function TreeExplorer({ if (!active || !over || !onSortOrderChange) return; - const activeData = active.data.current as { type: string; id: number; parentId?: number | null }; - const overData = over.data.current as { type: string; id: number; parentId?: number | null }; + // Validate drag data structure + const activeData = active.data.current; + const overData = over.data.current; + + if (!activeData || !overData) return; + if (typeof activeData !== "object" || typeof overData !== "object") return; + if (!("type" in activeData) || !("id" in activeData)) return; + if (!("type" in overData) || !("id" in overData)) return; // Only handle tree-item reordering - if (activeData?.type !== "tree-item" || overData?.type !== "tree-item") return; + if (activeData.type !== "tree-item" || overData.type !== "tree-item") return; - const draggedId = activeData.id; - const targetId = overData.id; + const draggedId = activeData.id as number; + const targetId = overData.id as number; + const draggedParentId = ("parentId" in activeData ? activeData.parentId : undefined) as number | null | undefined; + const targetParentId = ("parentId" in overData ? overData.parentId : undefined) as number | null | undefined; if (draggedId === targetId) return; // Only allow reordering items with the same parent - if (activeData.parentId !== overData.parentId) return; + if (draggedParentId !== targetParentId) return; // Get all items with the same parent const flatData = flatTree(sortedDataTree.model); const siblingIds = flatData - .filter((item) => item.parent_id === activeData.parentId) + .filter((item) => item.parent_id === draggedParentId) .map((item) => item.id); - // Get current order (either from sortOrder or default by ID) - const currentOrder = sortOrder && sortOrder.length > 0 - ? sortOrder.filter(id => siblingIds.includes(id)) - : siblingIds.sort((a, b) => a - b); + // Get current order: use sortOrder if available, otherwise sort by ID + // Include all siblings even if they're not in sortOrder yet + let currentOrder: number[]; + if (sortOrder && sortOrder.length > 0) { + // Start with items from sortOrder that are siblings + const orderedSiblings = sortOrder.filter(id => siblingIds.includes(id)); + // Add any siblings not in sortOrder (newly added items) + const newSiblings = siblingIds.filter(id => !sortOrder.includes(id)); + newSiblings.sort((a, b) => a - b); + currentOrder = [...orderedSiblings, ...newSiblings]; + } else { + currentOrder = [...siblingIds].sort((a, b) => a - b); + } // Remove dragged item and insert at new position const draggedIndex = currentOrder.indexOf(draggedId); @@ -206,7 +223,7 @@ function TreeExplorer({ // Merge with other items not in the same parent const otherIds = flatData - .filter((item) => item.parent_id !== activeData.parentId) + .filter((item) => item.parent_id !== draggedParentId) .map((item) => item.id); // Combine the reordered siblings with other items From 216389f00af7518f3aee136e277c66b319439486 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:20:29 +0000 Subject: [PATCH 5/7] Optimize performance: memoize flatData and fix dependency arrays Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- .../src/components/Code/CodeExplorer/CodeExplorer.tsx | 7 +++++-- frontend/src/components/Tag/TagExplorer/TagExplorer.tsx | 7 +++++-- frontend/src/components/TreeExplorer/TreeExplorer.tsx | 8 ++++++-- frontend/src/hooks/useTreeSortOrder.ts | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx index 68c23b136..24ad42189 100644 --- a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx +++ b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx @@ -40,10 +40,13 @@ function CodeExplorer({ projectId, ...props }: CodeExplorerProps & BoxProps) { return flatTree(codeTree.model).map((code) => code.id); }, [codeTree]); + // Extract projectId from data for dependency tracking + const dataProjectId = allCodes.data?.[0]?.project_id; + // Use project ID from props or derive from data (fallback) const effectiveProjectId = useMemo(() => { - return projectId ?? allCodes.data?.[0]?.project_id; - }, [projectId, allCodes.data]); + return projectId ?? dataProjectId; + }, [projectId, dataProjectId]); // Use custom sort order hook const { sortOrder, updateSortOrder } = useTreeSortOrder( diff --git a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx index 31f921bb3..374a86a8c 100644 --- a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx +++ b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx @@ -54,10 +54,13 @@ function TagExplorer({ onTagClick, projectId, ...props }: TagExplorerProps & Box return flatTree(tagTree.model).map((tag) => tag.id); }, [tagTree]); + // Extract projectId from data for dependency tracking + const dataProjectId = allTags.data?.[0]?.project_id; + // Use project ID from props or derive from data (fallback) const effectiveProjectId = useMemo(() => { - return projectId ?? allTags.data?.[0]?.project_id; - }, [projectId, allTags.data]); + return projectId ?? dataProjectId; + }, [projectId, dataProjectId]); // Use custom sort order hook const { sortOrder, updateSortOrder } = useTreeSortOrder( diff --git a/frontend/src/components/TreeExplorer/TreeExplorer.tsx b/frontend/src/components/TreeExplorer/TreeExplorer.tsx index ecacd442b..d6e05189e 100644 --- a/frontend/src/components/TreeExplorer/TreeExplorer.tsx +++ b/frontend/src/components/TreeExplorer/TreeExplorer.tsx @@ -163,6 +163,11 @@ function TreeExplorer({ }) ); + // Memoize flat data to avoid recalculating on every drag + const flatData = useMemo(() => { + return flatTree(sortedDataTree.model); + }, [sortedDataTree]); + const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; @@ -192,7 +197,6 @@ function TreeExplorer({ if (draggedParentId !== targetParentId) return; // Get all items with the same parent - const flatData = flatTree(sortedDataTree.model); const siblingIds = flatData .filter((item) => item.parent_id === draggedParentId) .map((item) => item.id); @@ -231,7 +235,7 @@ function TreeExplorer({ onSortOrderChange(finalOrder); }, - [sortedDataTree, sortOrder, onSortOrderChange] + [flatData, sortOrder, onSortOrderChange] ); const treeViewContent = ( diff --git a/frontend/src/hooks/useTreeSortOrder.ts b/frontend/src/hooks/useTreeSortOrder.ts index 1d748e692..49beb7ad3 100644 --- a/frontend/src/hooks/useTreeSortOrder.ts +++ b/frontend/src/hooks/useTreeSortOrder.ts @@ -63,7 +63,7 @@ export function useTreeSortOrder( useEffect(() => { const newOrder = loadSortOrder(); setSortOrder(newOrder); - }, [loadSortOrder]); + }, [storageKey, projectId, allItemIds]); // eslint-disable-line react-hooks/exhaustive-deps // Update sort order and persist to localStorage const updateSortOrder = useCallback((newOrder: number[]) => { From 7302a239231a4b74554abbb093fec1f49ae2e195 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:23:02 +0000 Subject: [PATCH 6/7] Fix typo and add clarifying comments Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx | 2 ++ frontend/src/components/Tag/TagExplorer/TagExplorer.tsx | 2 ++ frontend/src/components/TreeExplorer/TreeExplorer.tsx | 4 ++-- frontend/src/hooks/useTreeSortOrder.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx index 24ad42189..7b92bfe39 100644 --- a/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx +++ b/frontend/src/components/Code/CodeExplorer/CodeExplorer.tsx @@ -44,6 +44,8 @@ function CodeExplorer({ projectId, ...props }: CodeExplorerProps & BoxProps) { const dataProjectId = allCodes.data?.[0]?.project_id; // Use project ID from props or derive from data (fallback) + // Note: In practice, all codes belong to the same project (enforced by backend) + // Ideally, projectId should be passed as a prop from parent components const effectiveProjectId = useMemo(() => { return projectId ?? dataProjectId; }, [projectId, dataProjectId]); diff --git a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx index 374a86a8c..ac971e955 100644 --- a/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx +++ b/frontend/src/components/Tag/TagExplorer/TagExplorer.tsx @@ -58,6 +58,8 @@ function TagExplorer({ onTagClick, projectId, ...props }: TagExplorerProps & Box const dataProjectId = allTags.data?.[0]?.project_id; // Use project ID from props or derive from data (fallback) + // Note: In practice, all tags belong to the same project (enforced by backend) + // Ideally, projectId should be passed as a prop from parent components const effectiveProjectId = useMemo(() => { return projectId ?? dataProjectId; }, [projectId, dataProjectId]); diff --git a/frontend/src/components/TreeExplorer/TreeExplorer.tsx b/frontend/src/components/TreeExplorer/TreeExplorer.tsx index d6e05189e..b5c2402ef 100644 --- a/frontend/src/components/TreeExplorer/TreeExplorer.tsx +++ b/frontend/src/components/TreeExplorer/TreeExplorer.tsx @@ -137,7 +137,7 @@ function TreeExplorer({ ); // rendering - const wrapppedRenderActions = useCallback( + const wrappedRenderActions = useCallback( (node: ITree) => ( <> {showCheckboxes && ( @@ -257,7 +257,7 @@ function TreeExplorer({ // actions onItemClick={onItemClick} // renderers - renderActions={wrapppedRenderActions} + renderActions={wrappedRenderActions} renderNode={renderNode} // root node rendering renderRoot={renderRoot} diff --git a/frontend/src/hooks/useTreeSortOrder.ts b/frontend/src/hooks/useTreeSortOrder.ts index 49beb7ad3..c920b9867 100644 --- a/frontend/src/hooks/useTreeSortOrder.ts +++ b/frontend/src/hooks/useTreeSortOrder.ts @@ -60,6 +60,7 @@ export function useTreeSortOrder( }, [storageKey, projectId]); // Reload sort order when projectId or allItemIds change + // Note: We directly use dependencies instead of loadSortOrder to avoid effect loops useEffect(() => { const newOrder = loadSortOrder(); setSortOrder(newOrder); From 17f6be2ca2e63448246dea21450de26cedf202c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:46:48 +0000 Subject: [PATCH 7/7] Migrate tree sort order from localStorage to Redux Toolkit Co-authored-by: bigabig <642805+bigabig@users.noreply.github.com> --- .../TreeExplorer/treeSortOrderSlice.ts | 73 ++++++++++++++++ frontend/src/hooks/useTreeSortOrder.ts | 84 ++++++------------- frontend/src/store/store.ts | 2 + 3 files changed, 100 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/TreeExplorer/treeSortOrderSlice.ts diff --git a/frontend/src/components/TreeExplorer/treeSortOrderSlice.ts b/frontend/src/components/TreeExplorer/treeSortOrderSlice.ts new file mode 100644 index 000000000..94cdb0340 --- /dev/null +++ b/frontend/src/components/TreeExplorer/treeSortOrderSlice.ts @@ -0,0 +1,73 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { persistReducer } from "redux-persist"; +import storage from "redux-persist/lib/storage"; +import { RootState } from "../../store/store.ts"; + +/** + * Global slice for managing tree sort orders across all projects. + * Unlike other slices, this one is NOT reset when the project changes, + * allowing sort orders to be preserved per-project. + */ + +interface SortOrderData { + sortOrder: number[]; +} + +interface TreeSortOrderState { + // Store sort orders keyed by: "{projectId}-{storageKey}" + // e.g., "123-code-sort-order" or "456-tag-sort-order" + orders: Record; +} + +const initialState: TreeSortOrderState = { + orders: {}, +}; + +export const treeSortOrderSlice = createSlice({ + name: "treeSortOrder", + initialState, + reducers: { + setTreeSortOrder: ( + state, + action: PayloadAction<{ + projectId: number; + storageKey: string; + sortOrder: number[]; + }> + ) => { + const key = `${action.payload.projectId}-${action.payload.storageKey}`; + state.orders[key] = { + sortOrder: action.payload.sortOrder, + }; + }, + clearTreeSortOrder: ( + state, + action: PayloadAction<{ + projectId: number; + storageKey: string; + }> + ) => { + const key = `${action.payload.projectId}-${action.payload.storageKey}`; + delete state.orders[key]; + }, + }, + // Note: No extraReducers with ProjectActions.changeProject + // This slice should NOT be reset when project changes +}); + +export const TreeSortOrderActions = treeSortOrderSlice.actions; + +// Selector to get sort order for a specific project and storage key +export const selectTreeSortOrder = (projectId: number | undefined, storageKey: string) => (state: RootState) => { + if (!projectId) return []; + const key = `${projectId}-${storageKey}`; + return state.treeSortOrder.orders[key]?.sortOrder || []; +}; + +export default persistReducer( + { + key: "treeSortOrder", + storage, + }, + treeSortOrderSlice.reducer, +); diff --git a/frontend/src/hooks/useTreeSortOrder.ts b/frontend/src/hooks/useTreeSortOrder.ts index c920b9867..6e46dbc78 100644 --- a/frontend/src/hooks/useTreeSortOrder.ts +++ b/frontend/src/hooks/useTreeSortOrder.ts @@ -1,15 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; - -interface TreeSortOrderState { - projectId: number; - sortOrder: number[]; -} +import { useCallback, useMemo } from "react"; +import { useAppDispatch, useAppSelector } from "../plugins/ReduxHooks.ts"; +import { TreeSortOrderActions, selectTreeSortOrder } from "../components/TreeExplorer/treeSortOrderSlice.ts"; /** - * Custom hook to manage tree item sort order in localStorage. + * Custom hook to manage tree item sort order using Redux Toolkit. * Persists sort order per project and filters out deleted items. * - * @param storageKey - Unique key for localStorage (e.g., 'code-sort-order', 'tag-sort-order') + * @param storageKey - Unique key for the sort order (e.g., 'code-sort-order', 'tag-sort-order') * @param projectId - Current project ID * @param allItemIds - All valid item IDs currently in the tree * @returns Object with sortOrder array and updateSortOrder function @@ -19,77 +16,46 @@ export function useTreeSortOrder( projectId: number | undefined, allItemIds: number[] ) { - // Load initial sort order from localStorage - const loadSortOrder = useCallback((): number[] => { - if (!projectId) return []; - - try { - const stored = localStorage.getItem(storageKey); - if (!stored) return []; - - const data: TreeSortOrderState = JSON.parse(stored); - - // Only use stored order if it's for the current project - if (data.projectId !== projectId) return []; - - // Filter out any IDs that no longer exist in allItemIds - const validIds = data.sortOrder.filter(id => allItemIds.includes(id)); - - return validIds; - } catch (error) { - console.error(`Failed to load ${storageKey}:`, error); - return []; - } - }, [storageKey, projectId, allItemIds]); + const dispatch = useAppDispatch(); + + // Get sort order from Redux store + const storedSortOrder = useAppSelector(selectTreeSortOrder(projectId, storageKey)); - const [sortOrder, setSortOrder] = useState(loadSortOrder); + // Filter out any IDs that no longer exist in allItemIds + const validSortOrder = useMemo(() => { + return storedSortOrder.filter(id => allItemIds.includes(id)); + }, [storedSortOrder, allItemIds]); - // Update localStorage when sort order changes - const saveSortOrder = useCallback((order: number[]) => { + // Update sort order and persist to Redux store + const updateSortOrder = useCallback((newOrder: number[]) => { if (!projectId) return; - try { - const data: TreeSortOrderState = { - projectId, - sortOrder: order, - }; - localStorage.setItem(storageKey, JSON.stringify(data)); - } catch (error) { - console.error(`Failed to save ${storageKey}:`, error); - } - }, [storageKey, projectId]); - - // Reload sort order when projectId or allItemIds change - // Note: We directly use dependencies instead of loadSortOrder to avoid effect loops - useEffect(() => { - const newOrder = loadSortOrder(); - setSortOrder(newOrder); - }, [storageKey, projectId, allItemIds]); // eslint-disable-line react-hooks/exhaustive-deps - - // Update sort order and persist to localStorage - const updateSortOrder = useCallback((newOrder: number[]) => { - setSortOrder(newOrder); - saveSortOrder(newOrder); - }, [saveSortOrder]); + dispatch(TreeSortOrderActions.setTreeSortOrder({ + projectId, + storageKey, + sortOrder: newOrder, + })); + }, [dispatch, projectId, storageKey]); // Compute the effective sort order with all items // Items in sortOrder come first (in that order), followed by remaining items by ID const effectiveSortOrder = useMemo(() => { // Get items that are in the custom sort order - const orderedItems = sortOrder.filter(id => allItemIds.includes(id)); + const orderedItems = validSortOrder.filter(id => allItemIds.includes(id)); // Get items that are not in the custom sort order (newly added items) - const unorderedItems = allItemIds.filter(id => !sortOrder.includes(id)); + const unorderedItems = allItemIds.filter(id => !validSortOrder.includes(id)); // Sort unordered items by ID unorderedItems.sort((a, b) => a - b); // Combine: custom order first, then new items by ID return [...orderedItems, ...unorderedItems]; - }, [sortOrder, allItemIds]); + }, [validSortOrder, allItemIds]); return { sortOrder: effectiveSortOrder, updateSortOrder, }; } + diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index c73d8256c..850ee5808 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -7,6 +7,7 @@ import projectReducer from "../components/Project/projectSlice.ts"; import seatFilterReducer from "../components/SentenceAnnotation/SentenceAnnotationTable/seatFilterSlice.ts"; import documentTableFilterReducer from "../components/SourceDocument/SdocTable/documentTableFilterSlice.ts"; import satFilterReducer from "../components/SpanAnnotation/SpanAnnotationTable/satFilterSlice.ts"; +import treeSortOrderReducer from "../components/TreeExplorer/treeSortOrderSlice.ts"; import layoutReducer from "../layouts/layoutSlice.ts"; import tabReducer from "../layouts/TabBar/tabSlice.ts"; import bboxAnnotationAnalysisReducer from "../views/analysis/BBoxAnnotationAnalysis/bboxAnnotationAnalysisSlice.ts"; @@ -33,6 +34,7 @@ export const store = configureStore({ sentenceSearch: sentenceSearchReducer, layout: layoutReducer, project: projectReducer, + treeSortOrder: treeSortOrderReducer, // global slice, not reset on project change // non-persisted reducers tabs: tabReducer, logbook: logbookReducer,