diff --git a/src/core/react/components/Navigator/SpaceTree/SpaceTreeView.tsx b/src/core/react/components/Navigator/SpaceTree/SpaceTreeView.tsx index 9f7136e..e2cc956 100644 --- a/src/core/react/components/Navigator/SpaceTree/SpaceTreeView.tsx +++ b/src/core/react/components/Navigator/SpaceTree/SpaceTreeView.tsx @@ -189,6 +189,68 @@ const treeForRoot = ( return tree; }; +/** + * Apply optimistic reordering to the tree for immediate UI feedback + * This mirrors the server-side reordering logic but runs synchronously + */ +const applyOptimisticReorder = ( + tree: TreeNode[], + dragPaths: string[], + activeNode: TreeNode | null, + overId: string, + projected: DragProjection +): TreeNode[] | null => { + if (!activeNode || !projected || dragPaths.length === 0) { + return null; + } + + try { + // Find the indices of dragged items and target + const overIndex = tree.findIndex(({ id }) => id === overId); + if (overIndex === -1) return null; + + const overItem = tree[overIndex]; + + // Calculate target rank based on projection + const parentId = projected.insert ? overId : projected.parentId; + const targetRank = parentId === overItem.id ? -1 : overItem.rank ?? -1; + + // If not sortable or invalid rank, don't apply optimistic update + if (!projected.sortable || targetRank === -1) { + return null; + } + + // Separate items being moved from the rest + const itemsToMove = tree.filter(node => dragPaths.includes(node.path)); + const remainingItems = tree.filter(node => !dragPaths.includes(node.path)); + + // Find insertion point in remaining items + let insertionIndex = remainingItems.findIndex(node => + node.rank !== null && node.rank >= targetRank + ); + + if (insertionIndex === -1) { + insertionIndex = remainingItems.length; + } + + // Insert moved items at the target position + const newTree = [ + ...remainingItems.slice(0, insertionIndex), + ...itemsToMove, + ...remainingItems.slice(insertionIndex) + ]; + + // Recalculate ranks for visual consistency + return newTree.map((node, index) => ({ + ...node, + rank: node.rank !== null ? index : node.rank + })); + } catch (error) { + console.error("Failed to apply optimistic reorder:", error); + return null; + } +}; + const retrieveData = ( superstate: Superstate, activeViewSpaces: PathState[], @@ -613,16 +675,46 @@ export const SpaceTreeComponent = (props: SpaceTreeComponentProps) => { const dragEnded = (e: React.DragEvent, overId: string) => { const modifiers = eventToModifier(e); + + // Validate drop before proceeding + if (!projected) { + resetState(); + return; + } + + // Store current tree state for potential rollback + const previousTree = flattenedTree; + + // Apply optimistic update to UI immediately + const optimisticTree = applyOptimisticReorder( + flattenedTree, + dragPaths, + active, + overId, + projected + ); + + if (optimisticTree) { + setFlattenedTree(optimisticTree); + } + + // Fire async DB write (don't await - it's queued) dropPathsInTree( superstate, dragPaths, active?.id, overId, projected, - flattenedTree, + previousTree, // Use previous tree state for calculations to avoid race conditions activeViewSpaces, modifiers - ); + ).catch((error) => { + // Rollback on error + console.error("Failed to reorder items:", error); + superstate.ui.notify("Failed to save new order"); + setFlattenedTree(previousTree); + }); + resetState(); }; diff --git a/src/core/utils/dnd/dragPath.ts b/src/core/utils/dnd/dragPath.ts index eb6afc1..ab50ce9 100644 --- a/src/core/utils/dnd/dragPath.ts +++ b/src/core/utils/dnd/dragPath.ts @@ -86,7 +86,12 @@ if (!previousItem) return; const previousItemDroppable = previousItem.type != 'file' const insert = activeItem.depth > 0 && overItem.collapsed && previousItemDroppable && (!overItem.sortable || dirDown && yOffset <= 13 || !dirDown && yOffset >= 13) - const sortable = overItem.sortable || previousItemDroppable && !insert && nextItem.sortable + + // Determine if the drop target allows manual reordering + // A drop is sortable if: + // 1. The item we're hovering over is in a manually sorted space, OR + // 2. We're dropping between a folder and a sortable sibling (boundary case) + const sortable = overItem.sortable || (previousItemDroppable && !insert && nextItem?.sortable) const projectedDepth = dragDepth; const maxDepth = activeItem.depth == 0 ? 0 : getMaxDepth( previousItem, dirDown diff --git a/src/core/utils/dnd/dropPath.ts b/src/core/utils/dnd/dropPath.ts index f9673fa..dd65470 100644 --- a/src/core/utils/dnd/dropPath.ts +++ b/src/core/utils/dnd/dropPath.ts @@ -37,10 +37,15 @@ export const dropPathsInTree = async (superstate: Superstate, paths: string[], a const newSpace = flattenedTree.find(({ id }) => id === parentId)?.item.path; const newRank = parentId == overItem.id ? -1 : overItem.rank ?? -1; - - if (!newSpace) return; - dropPathsInSpaceAtIndex(superstate, droppable, newSpace, projected.sortable && newRank, modifier); + + // Only proceed with reordering if the target space supports manual sorting + if (!projected.sortable) { + superstate.ui.notify("This folder is not manually sorted. Change sort order to 'Custom' to reorder items."); + return; + } + + dropPathsInSpaceAtIndex(superstate, droppable, newSpace, newRank, modifier); } }; @@ -57,16 +62,22 @@ export const dropPathInTree = async (superstate: Superstate, path: string, activ const newSpace = projected.depth == 0 && !projected.insert ? null : clonedItems.find(({ id }) => id === parentId)?.item.path; const newRank = parentId == null ? activeSpaces.findIndex(f => f?.path == overItem.id) : parentId == overItem.id ? -1 : overItem.rank ?? -1; + + // Only proceed with reordering if the target space supports manual sorting + if (newSpace && !projected.sortable) { + superstate.ui.notify("This folder is not manually sorted. Change sort order to 'Custom' to reorder items."); + return; + } + if (!active) { - - dropPathInSpaceAtIndex(superstate, path, null, newSpace, projected.sortable && newRank, modifier); + dropPathInSpaceAtIndex(superstate, path, null, newSpace, newRank, modifier); return; } const activeIndex = clonedItems.findIndex(({ id }) => id === active); const activeItem = clonedItems[activeIndex]; const oldSpace = activeItem.parentId == null ? null : clonedItems.find(({ id }) => id === activeItem.parentId)?.item.path; - dropPathInSpaceAtIndex(superstate,activeItem.item.path, oldSpace, newSpace,projected.sortable && newRank, modifier); + dropPathInSpaceAtIndex(superstate,activeItem.item.path, oldSpace, newSpace, newRank, modifier); } };