Refactor: share nav drag and drop logic

This commit is contained in:
MangoPig
2026-06-24 02:11:02 +01:00
parent 69af324b1b
commit 212dd1c435
6 changed files with 626 additions and 712 deletions
@@ -5,6 +5,16 @@ import { resolveAPIBase } from "../../../lib/api";
import { ChevronLeft, ChevronRight, Folder, ListCollapse, UnfoldVertical } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import {
collectBranchNodeIds,
findTreeNodeDepth,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
type NavTreeAdapter,
type NavTreeDropTarget,
} from "../shared/navTreeDnd";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
@@ -32,24 +42,13 @@ type PendingWorkspaceFolderDraft = {
depth: number;
};
type WorkspaceDragTarget = {
parentId: string | null;
index: number;
intent: "before" | "after" | "inside";
targetNodeId?: string;
};
type WorkspaceDragTarget = NavTreeDropTarget;
type WorkspaceDragState = {
draggedNodeId: string;
dropTarget: WorkspaceDragTarget | null;
};
type WorkspaceNodeLocation = {
parentId: string | null;
index: number;
node: WorkspaceTreeNode;
};
type PersistedWorkspaceFolderRecord = {
id: string;
label: string;
@@ -79,183 +78,20 @@ const buildPersistedWorkspaceFolderNodes = (
children: buildPersistedWorkspaceFolderNodes(folder.children ?? []),
}));
const collectWorkspaceFolderIds = (nodes: readonly WorkspaceTreeNode[]): string[] => {
const folderIds: string[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
folderIds.push(node.id);
folderIds.push(...collectWorkspaceFolderIds(node.children ?? []));
}
return folderIds;
};
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const findWorkspaceFolderDepth = (nodes: readonly WorkspaceTreeNode[], folderId: string, depth = 0): number | null => {
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
if (node.id === folderId) {
return depth;
}
const nestedDepth = node.children ? findWorkspaceFolderDepth(node.children, folderId, depth + 1) : null;
if (nestedDepth !== null) {
return nestedDepth;
}
}
return null;
};
const findWorkspaceNodeLocation = (
nodes: readonly WorkspaceTreeNode[],
nodeId: string,
parentId: string | null = null,
): WorkspaceNodeLocation | null => {
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.id === nodeId) {
return { parentId, index, node };
}
if (node.kind === "folder" && node.children) {
const nestedLocation = findWorkspaceNodeLocation(node.children, nodeId, node.id);
if (nestedLocation) {
return nestedLocation;
}
}
}
return null;
};
const workspaceTreeContainsNode = (nodes: readonly WorkspaceTreeNode[], nodeId: string): boolean => {
for (const node of nodes) {
if (node.id === nodeId) {
return true;
}
if (node.kind === "folder" && node.children && workspaceTreeContainsNode(node.children, nodeId)) {
return true;
}
}
return false;
};
const removeWorkspaceTreeNode = (
nodes: readonly WorkspaceTreeNode[],
nodeId: string,
): { nodes: WorkspaceTreeNode[]; removed: WorkspaceTreeNode | null } => {
const nextNodes: WorkspaceTreeNode[] = [];
let removed: WorkspaceTreeNode | null = null;
for (const node of nodes) {
if (node.id === nodeId) {
removed = node;
continue;
}
if (node.kind === "folder" && node.children) {
const result = removeWorkspaceTreeNode(node.children, nodeId);
if (result.removed) {
removed = result.removed;
nextNodes.push({
...node,
children: result.nodes,
});
continue;
}
}
nextNodes.push(node);
}
return { nodes: nextNodes, removed };
};
const insertWorkspaceTreeNode = (
nodes: readonly WorkspaceTreeNode[],
parentId: string | null,
index: number,
nodeToInsert: WorkspaceTreeNode,
): WorkspaceTreeNode[] => {
if (parentId === null) {
const nextNodes = [...nodes];
nextNodes.splice(Math.max(0, Math.min(index, nextNodes.length)), 0, nodeToInsert);
return nextNodes;
}
return nodes.map((node) => {
if (node.kind !== "folder") {
return node;
}
if (node.id === parentId) {
const nextChildren = [...(node.children ?? [])];
nextChildren.splice(Math.max(0, Math.min(index, nextChildren.length)), 0, nodeToInsert);
return {
const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: nextChildren,
};
}
return {
...node,
children: node.children ? insertWorkspaceTreeNode(node.children, parentId, index, nodeToInsert) : node.children,
};
});
};
const moveWorkspaceTreeNode = (
nodes: readonly WorkspaceTreeNode[],
draggedNodeId: string,
dropTarget: WorkspaceDragTarget,
): WorkspaceTreeNode[] => {
const location = findWorkspaceNodeLocation(nodes, draggedNodeId);
if (!location) {
return [...nodes];
}
if (
location.node.kind === "folder" &&
dropTarget.parentId !== null &&
((location.node.children && workspaceTreeContainsNode(location.node.children, dropTarget.parentId)) ||
dropTarget.parentId === location.node.id)
) {
return [...nodes];
}
let normalizedIndex = dropTarget.index;
if (dropTarget.parentId === location.parentId && dropTarget.index > location.index) {
normalizedIndex -= 1;
}
if (dropTarget.parentId === location.parentId && normalizedIndex === location.index) {
return [...nodes];
}
const removalResult = removeWorkspaceTreeNode(nodes, draggedNodeId);
if (!removalResult.removed) {
return [...nodes];
}
return insertWorkspaceTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed);
children: [...children],
}
: node,
};
const FolderDraftRow = (props: {
@@ -394,12 +230,12 @@ const WorkspaceTreeBranch = (props: {
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(depth()) }}
@@ -469,15 +305,15 @@ const WorkspaceTreeBranch = (props: {
pendingFolderDraft={props.pendingFolderDraft}
pendingFolderName={props.pendingFolderName}
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
@@ -516,7 +352,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject());
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const folderIds = (): string[] => collectWorkspaceFolderIds(workspaceTreeNodes());
const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const count = totalFolderCount();
@@ -553,7 +389,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const nextTree = activeProject()?.id
? buildPersistedWorkspaceFolderNodes(persistedFolders())
: appShellData.workspaceTree();
const availableFolderIds = new Set(collectWorkspaceFolderIds(nextTree));
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, workspaceTreeAdapter));
setWorkspaceTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
@@ -567,6 +403,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
return;
}
if (!isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
headers: {
@@ -652,7 +493,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
suppressTreeClickTemporarily();
setWorkspaceTreeNodes((current) =>
moveWorkspaceTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget),
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
);
setDragState(null);
};
@@ -708,7 +549,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
return;
}
if (!projectId) {
if (!projectId || !isUuidString(projectId)) {
cancelPendingFolder();
return;
}
@@ -780,42 +621,21 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
return;
}
const currentTarget = event.currentTarget;
if (!(currentTarget instanceof HTMLElement)) {
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
const bounds = currentTarget.getBoundingClientRect();
const relativeY = bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height;
let nextTarget: WorkspaceDragTarget;
if (node.kind === "folder") {
const isFirstSibling = index === 0;
const beforeThreshold = isFirstSibling ? 0.42 : 0.28;
const afterThreshold = 0.72;
if (relativeY < beforeThreshold) {
nextTarget = { parentId, index, intent: "before", targetNodeId: node.id };
} else if (relativeY > afterThreshold) {
nextTarget = { parentId, index: index + 1, intent: "after", targetNodeId: node.id };
} else {
nextTarget = {
parentId: node.id,
index: (node.children ?? []).length,
intent: "inside",
targetNodeId: node.id,
};
}
} else {
nextTarget = {
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index: relativeY < 0.5 ? index : index + 1,
intent: relativeY < 0.5 ? "before" : "after",
targetNodeId: node.id,
};
}
setDragState({ ...nextDragState, dropTarget: nextTarget });
index,
node,
relativeY,
adapter: workspaceTreeAdapter,
}),
});
};
const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
@@ -823,13 +643,13 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
return;
}
switch (target.kind) {
switch (target.kind) {
case "workspace":
case "home":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findWorkspaceFolderDepth(workspaceTreeNodes(), target.id) ?? 0) + 1);
beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1);
return;
case "settings":
case "item":