Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions src/core/react/components/Navigator/SpaceTree/SpaceTreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -613,16 +675,46 @@ export const SpaceTreeComponent = (props: SpaceTreeComponentProps) => {

const dragEnded = (e: React.DragEvent<HTMLDivElement>, 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();
};

Expand Down
7 changes: 6 additions & 1 deletion src/core/utils/dnd/dragPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions src/core/utils/dnd/dropPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};

Expand All @@ -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);
}
};

Expand Down