diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss index 7215dbb..cc8419a 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss @@ -1,3 +1,5 @@ +@use "../shared/tree-nav" as treeNav; + .root { display: grid; --project-drawer-gap: var(--space-3); @@ -191,12 +193,9 @@ } .treeSectionLabel { - @include text-caption; + @include treeNav.section-label; margin: 0; padding: 0 var(--space-3); - color: var(--color-text-subtle); - text-transform: uppercase; - letter-spacing: 0.04em; } .treeSectionHeader { @@ -248,133 +247,72 @@ } .treeList { - list-style: none; - display: grid; - gap: var(--space-1); - padding: 0; + @include treeNav.tree-list; } .treeEmptySlot { - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border-radius: var(--radius-lg); - border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent); - opacity: 0.35; + @include treeNav.empty-slot; } .treeInputRow { - width: 100%; - min-width: 0; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: var(--space-2); - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding: var(--space-2) var(--space-3); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-surface) 94%, transparent); + @include treeNav.input-row; } .treeInput { - width: 100%; - min-width: 0; - border: 0; - background: transparent; - color: var(--color-text); - font: inherit; - outline: none; -} - -.treeInput::placeholder { - color: var(--color-text-muted); + @include treeNav.input; } .treeItem { - width: 100%; - min-width: 0; - display: grid; - grid-template-columns: auto auto minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding: var(--space-2) var(--space-3); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border: 1px solid transparent; - border-radius: var(--radius-lg); - background: transparent; - color: var(--color-text-muted); - transition: - background 160ms var(--easing-standard), - color 160ms var(--easing-standard), - border-color 160ms var(--easing-standard), - box-shadow 160ms var(--easing-standard), - transform 180ms var(--easing-standard); - text-align: left; + @include treeNav.item; } .treeItem:hover, .treeItem:focus-visible { - background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); - color: var(--color-text); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + @include treeNav.item-hover; } .treeItemFolder { - color: var(--color-text); + @include treeNav.item-folder; } .treeItemDragging { - opacity: 0.45; - transform: scale(0.985); - box-shadow: none; + @include treeNav.item-dragging; } .treeItemDropBefore { - box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + @include treeNav.item-drop-before; } .treeItemDropAfter { - box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + @include treeNav.item-drop-after; } .treeItemDropInside { - border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); - background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface)); - color: var(--color-text); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + @include treeNav.item-drop-inside; } .folderChevron { - color: var(--color-text-muted); - transition: transform 160ms var(--easing-standard); + @include treeNav.folder-chevron; } .folderChevronOpen { - transform: rotate(90deg); + @include treeNav.folder-chevron-open; } .treeItemActive { - border-color: var(--color-border); - background: var(--color-surface); - color: var(--color-text); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + @include treeNav.item-active; } .icon { - color: inherit; - opacity: 0.85; + @include treeNav.icon; } .label { - @include text-label; - min-width: 0; + @include treeNav.label; } .itemMeta { - @include text-caption; - color: var(--color-text-muted); + @include treeNav.item-meta; } @media (max-width: 720px) { diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index b938b88..3d24279 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -5,6 +5,17 @@ import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, UnfoldVert import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; import { useAppShellData } from "../data/app-shell.context"; import { resolveAPIBase } from "../../../lib/api"; +import { + collectBranchNodeIds, + findTreeNodeDepth, + findTreeNodeLocation, + getPointerRelativeY, + isUuidString, + moveTreeNode, + resolveTreeDropTarget, + type NavTreeAdapter, + type NavTreeDropTarget, +} from "../shared/navTreeDnd"; import { createProjectFolderTarget, createProjectSurfaceTarget, @@ -56,24 +67,13 @@ type PendingProjectFolderDraft = { depth: number; }; -type ProjectDragTarget = { - parentId: string | null; - index: number; - intent: "before" | "after" | "inside"; - targetNodeId?: string; -}; +type ProjectDragTarget = NavTreeDropTarget; type ProjectDragState = { draggedNodeId: string; dropTarget: ProjectDragTarget | null; }; -type ProjectNodeLocation = { - parentId: string | null; - index: number; - node: ProjectTreeNode; -}; - const LONG_PRESS_MS = 320; const getProjectTreeNodeId = (node: ProjectTreeNode): string => @@ -98,198 +98,20 @@ const buildProjectTree = ( ...buildPersistedFolderNodes(folders), ]; -const collectProjectFolderIds = (nodes: readonly ProjectTreeNode[]): string[] => { - const ids: string[] = []; - - for (const node of nodes) { - if (node.kind !== "folder") { - continue; - } - - ids.push(node.id); - ids.push(...collectProjectFolderIds(node.children)); - } - - return ids; -}; - const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => Array.isArray(body.data?.folders) ? body.data.folders : []; -const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => { - if (node.kind === "project") { - return { - kind: "project", - item: { ...node.item }, - }; - } - - return { - kind: "folder", - id: node.id, - label: node.label, - meta: node.meta, - children: node.children.map(cloneProjectTreeNode), - }; -}; - -const findProjectNodeLocation = ( - nodes: readonly ProjectTreeNode[], - nodeId: string, - parentId: string | null = null, -): ProjectNodeLocation | null => { - for (let index = 0; index < nodes.length; index += 1) { - const node = nodes[index]; - - if (getProjectTreeNodeId(node) === nodeId) { - return { parentId, index, node }; - } - - if (node.kind === "folder") { - const nestedLocation = findProjectNodeLocation(node.children, nodeId, node.id); - - if (nestedLocation) { - return nestedLocation; - } - } - } - - return null; -}; - -const findProjectNodeDepth = (nodes: readonly ProjectTreeNode[], nodeId: string, depth = 0): number | null => { - for (const node of nodes) { - if (getProjectTreeNodeId(node) === nodeId) { - return depth; - } - - if (node.kind === "folder") { - const nestedDepth = findProjectNodeDepth(node.children, nodeId, depth + 1); - - if (nestedDepth !== null) { - return nestedDepth; - } - } - } - - return null; -}; - -const projectTreeContainsNode = (nodes: readonly ProjectTreeNode[], nodeId: string): boolean => { - for (const node of nodes) { - if (getProjectTreeNodeId(node) === nodeId) { - return true; - } - - if (node.kind === "folder" && projectTreeContainsNode(node.children, nodeId)) { - return true; - } - } - - return false; -}; - -const removeProjectTreeNode = ( - nodes: readonly ProjectTreeNode[], - nodeId: string, -): { nodes: ProjectTreeNode[]; removed: ProjectTreeNode | null } => { - const nextNodes: ProjectTreeNode[] = []; - let removed: ProjectTreeNode | null = null; - - for (const node of nodes) { - if (getProjectTreeNodeId(node) === nodeId) { - removed = node; - continue; - } - - if (node.kind === "folder") { - const result = removeProjectTreeNode(node.children, nodeId); - - if (result.removed) { - removed = result.removed; - nextNodes.push({ - ...node, - children: result.nodes, - }); - continue; - } - } - - nextNodes.push(node); - } - - return { nodes: nextNodes, removed }; -}; - -const insertProjectTreeNode = ( - nodes: readonly ProjectTreeNode[], - parentId: string | null, - index: number, - nodeToInsert: ProjectTreeNode, -): ProjectTreeNode[] => { - 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 projectTreeAdapter: NavTreeAdapter = { + getNodeId: getProjectTreeNodeId, + isBranchNode: (node) => node.kind === "folder", + getChildren: (node) => (node.kind === "folder" ? node.children : []), + withChildren: (node, children) => + node.kind === "folder" + ? { ...node, - children: nextChildren, - }; - } - - return { - ...node, - children: insertProjectTreeNode(node.children, parentId, index, nodeToInsert), - }; - }); -}; - -const moveProjectTreeNode = ( - nodes: readonly ProjectTreeNode[], - draggedNodeId: string, - dropTarget: ProjectDragTarget, -): ProjectTreeNode[] => { - const location = findProjectNodeLocation(nodes, draggedNodeId); - - if (!location) { - return [...nodes]; - } - - if ( - location.node.kind === "folder" && - dropTarget.parentId !== null && - (projectTreeContainsNode(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 = removeProjectTreeNode(nodes, draggedNodeId); - - if (!removalResult.removed) { - return [...nodes]; - } - - return insertProjectTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed); + children: [...children], + } + : node, }; const ProjectFolderDraftRow = (props: { @@ -385,18 +207,18 @@ const ProjectFolderBranch = (props: { [styles.treeItemFolder]: true, [styles.treeItemDragging]: isDraggedNode(), [styles.treeItemDropBefore]: dropIntent() === "before", - [styles.treeItemDropAfter]: dropIntent() === "after", - [styles.treeItemDropInside]: dropIntent() === "inside", - }} - style={{ "--tree-depth": String(props.depth) }} - aria-expanded={!isCollapsed()} - onClick={() => { - if (props.dragState || props.isTreeClickSuppressed()) { - return; - } + [styles.treeItemDropAfter]: dropIntent() === "after", + [styles.treeItemDropInside]: dropIntent() === "inside", + }} + style={{ "--tree-depth": String(props.depth) }} + aria-expanded={!isCollapsed()} + onClick={() => { + if (props.dragState || props.isTreeClickSuppressed()) { + return; + } - props.onToggleFolder(node.id); - }} + props.onToggleFolder(node.id); + }} onContextMenu={(event): void => props.onOpenFolderMenu(event, node)} onPointerDown={(event): void => props.onNodePointerDown(event, node.id)} onPointerMove={(event): void => @@ -550,7 +372,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const syncProjectTree = (): void => { const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders()); - const availableFolderIds = new Set(collectProjectFolderIds(nextTree)); + const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter)); setProjectTreeNodes(nextTree); setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId))); @@ -563,7 +385,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setDragState(null); }; - const folderIds = (): string[] => collectProjectFolderIds(projectTreeNodes()); + const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter); const expandAllFolders = (): void => { setCollapsedFolderIds([]); @@ -599,6 +421,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } + if (!isUuidString(projectId)) { + setPersistedFolders([]); + return; + } + try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { headers: { @@ -713,7 +540,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { suppressTreeClickTemporarily(); setProjectTreeNodes((current) => - moveProjectTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget), + moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter), ); setDragState(null); }; @@ -765,7 +592,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { }; const selectProject = (projectId: string): void => { - const location = findProjectNodeLocation(projectTreeNodes(), projectId); + const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter); if (!location || location.node.kind !== "project") { return; @@ -787,6 +614,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); + const projectId = selectedProject().id; if (!draft) { return; @@ -798,8 +626,13 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } + if (!isUuidString(projectId)) { + cancelPendingFolder(); + return; + } + try { - const response = await fetch(`${resolveAPIBase()}/projects/${selectedProject().id}/folders`, { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { method: "POST", headers: { Accept: "application/json", @@ -835,16 +668,16 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } - switch (target.kind) { + switch (target.kind) { case "surface": beginFolderDraft(null, 0); return; case "folder": - beginFolderDraft(target.id, (findProjectNodeDepth(projectTreeNodes(), target.id) ?? 0) + 1); + beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); return; case "project": { - const parentId = findProjectNodeLocation(projectTreeNodes(), target.id)?.parentId ?? null; - beginFolderDraft(parentId, parentId ? (findProjectNodeDepth(projectTreeNodes(), parentId) ?? 0) + 1 : 0); + const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; + beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); return; } } @@ -886,50 +719,20 @@ export const ProjectSelector = (props: ProjectSelectorProps): 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: ProjectDragTarget; - - if (node.kind === "folder") { - if (relativeY < 0.28) { - nextTarget = { - parentId, - index, - intent: "before", - targetNodeId: node.id, - }; - } else if (relativeY > 0.72) { - 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 = { - parentId, - index: relativeY < 0.5 ? index : index + 1, - intent: relativeY < 0.5 ? "before" : "after", - targetNodeId: node.item.id, - }; - } - setDragState({ ...nextDragState, - dropTarget: nextTarget, + dropTarget: resolveTreeDropTarget({ + parentId, + index, + node, + relativeY, + adapter: projectTreeAdapter, + }), }); }; diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss index 4d8526a..f5a9b0e 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss @@ -1,3 +1,5 @@ +@use "../shared/tree-nav" as treeNav; + .sidebar { --sidebar-nav-item-min-height: var(--control-size-lg); position: relative; @@ -117,56 +119,25 @@ } .treeSectionLabel { - @include text-caption; + @include treeNav.section-label; margin: var(--space-3) 0 var(--space-2); padding: 0 var(--space-3); - color: var(--color-text-subtle); - text-transform: uppercase; - letter-spacing: 0.04em; } .treeList { - list-style: none; - display: grid; - gap: var(--space-1); - padding: 0; + @include treeNav.tree-list; } .treeEmptySlot { - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border-radius: var(--radius-lg); - border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent); - opacity: 0.35; + @include treeNav.empty-slot; } .treeInputRow { - width: 100%; - min-width: 0; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: var(--space-2); - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding: var(--space-2) var(--space-3); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-surface) 94%, transparent); + @include treeNav.input-row; } .treeInput { - width: 100%; - min-width: 0; - border: 0; - background: transparent; - color: var(--color-text); - font: inherit; - outline: none; -} - -.treeInput::placeholder { - color: var(--color-text-muted); + @include treeNav.input; } .navItem { @@ -184,125 +155,44 @@ } .treeItem { - width: 100%; - min-width: 0; - display: grid; - position: relative; - isolation: isolate; - grid-template-columns: auto auto minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); - min-height: calc(var(--control-size-lg) - var(--space-2)); - padding: var(--space-2) var(--space-3); - padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border: 0; - border-radius: 0; - background: transparent; - color: var(--color-text-muted); - text-align: left; - transition: - color 160ms var(--easing-standard), - box-shadow 160ms var(--easing-standard), - transform 180ms var(--easing-standard); - - &::after { - content: ""; - position: absolute; - inset: 0; - border: 1px solid transparent; - border-radius: var(--radius-lg); - background: transparent; - transition: - background 160ms var(--easing-standard), - border-color 160ms var(--easing-standard), - box-shadow 160ms var(--easing-standard); - pointer-events: none; - z-index: 0; - } - - > * { - position: relative; - z-index: 1; - } + @include treeNav.item; } .treeItem:hover, .treeItem:focus-visible { - color: var(--color-text); - - &::after { - background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); - } + @include treeNav.item-hover; } .treeItemFolder { - color: var(--color-text); + @include treeNav.item-folder; } .treeItemDragging { - opacity: 0.45; - transform: scale(0.985); - box-shadow: none; + @include treeNav.item-dragging; } .treeItemDropBefore { - &::before { - content: ""; - position: absolute; - left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - right: var(--space-3); - top: calc((var(--space-1) * -0.5) - 1px); - height: 2px; - border-radius: 999px; - background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent); - pointer-events: none; - z-index: 2; - } + @include treeNav.item-drop-before; } .treeItemDropAfter { - &::before { - content: ""; - position: absolute; - left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - right: var(--space-3); - bottom: calc((var(--space-1) * -0.5) - 1px); - height: 2px; - border-radius: 999px; - background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent); - pointer-events: none; - z-index: 2; - } + @include treeNav.item-drop-after; } .treeItemDropInside { - color: var(--color-text); - - &::after { - border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); - background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface)); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); - } + @include treeNav.item-drop-inside; } .folderChevron { - color: var(--color-text-muted); - transition: transform 160ms var(--easing-standard); + @include treeNav.folder-chevron; } .folderChevronOpen { - transform: rotate(90deg); + @include treeNav.folder-chevron-open; } .treeItemActive { - color: var(--color-text); - - &::after { - border-color: var(--color-border); - background: var(--color-surface); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); - } + @include treeNav.item-active; } .navItemActive { @@ -313,18 +203,15 @@ } .icon { - color: inherit; - opacity: 0.85; + @include treeNav.icon; } .label { - @include text-label; - min-width: 0; + @include treeNav.label; } .itemMeta { - @include text-caption; - color: var(--color-text-muted); + @include treeNav.item-meta; } .sidebarCollapsed { diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index 4df8410..ac77305 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -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 = { + 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: {