// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, UnfoldVertical } from "../../../lib/icons"; 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, createProjectTarget, type ProjectItem, type ProjectMenuTarget, } from "../data/shell.data"; import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController"; import styles from "./ProjectSelector.module.scss"; type ProjectSelectorProps = { compact?: boolean; isOpen: boolean; onToggle: () => void; onClose: () => void; }; type ProjectFolderNode = { kind: "folder"; id: string; path: string; label: string; meta?: string; children: ProjectTreeNode[]; }; type ProjectLeafNode = { kind: "project"; item: ProjectItem; }; type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; type PersistedProjectFolderRecord = { id: string; path: string; label: string; children: PersistedProjectFolderRecord[]; }; type ProjectFoldersResponse = { data?: { folders?: PersistedProjectFolderRecord[]; renamedFolder?: PersistedProjectFolderRecord; movedFolder?: PersistedProjectFolderRecord; previousFolderId?: string; previousFolderPath?: string; }; error?: string; message?: string; }; type PendingProjectFolderDraft = { parentId: string | null; depth: number; }; type PendingProjectFolderRename = { folderId: string; depth: number; }; type ProjectDragTarget = NavTreeDropTarget; type ProjectDragState = { draggedNodeId: string; dropTarget: ProjectDragTarget | null; }; const LONG_PRESS_MS = 320; const getProjectTreeNodeId = (node: ProjectTreeNode): string => node.kind === "folder" ? node.id : node.item.id; const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] => folders.map((folder) => ({ kind: "folder", id: folder.id, path: folder.path, label: folder.label, children: buildPersistedFolderNodes(folder.children ?? []), })); const buildProjectTree = ( items: readonly ProjectItem[], folders: readonly PersistedProjectFolderRecord[] = [], ): ProjectTreeNode[] => [ ...items.map((item) => ({ kind: "project" as const, item, })), ...buildPersistedFolderNodes(folders), ]; const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => Array.isArray(body.data?.folders) ? body.data.folders : []; 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: [...children], } : node, }; const ProjectFolderDraftRow = (props: { depth: number; value: string; onInput: (value: string) => void; onSubmit: () => void; onCancel: () => void; }): JSX.Element => { let inputRef: HTMLInputElement | undefined; queueMicrotask(() => inputRef?.focus()); return (
  • props.onInput(event.currentTarget.value)} onBlur={props.onSubmit} onKeyDown={(event): void => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); return; } if (event.key === "Escape") { event.preventDefault(); props.onCancel(); event.currentTarget.blur(); } }} />
  • ); }; const ProjectFolderBranch = (props: { nodes: readonly ProjectTreeNode[]; depth: number; parentId: string | null; selectedProjectId: string; isFolderCollapsed: (folderId: string) => boolean; onToggleFolder: (folderId: string) => void; onSelectProject: (projectId: string) => void; onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void; onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void; onNodePointerDown: (event: PointerEvent, nodeId: string) => void; onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void; pendingFolderDraft: PendingProjectFolderDraft | null; pendingFolderName: string; onPendingFolderNameChange: (value: string) => void; onSubmitPendingFolder: () => void; onCancelPendingFolder: () => void; pendingFolderRename: PendingProjectFolderRename | null; pendingFolderRenameName: string; onPendingFolderRenameChange: (value: string) => void; onSubmitPendingFolderRename: () => void; onCancelPendingFolderRename: () => void; dragState: ProjectDragState | null; isTreeClickSuppressed: () => boolean; }): JSX.Element => ( ); export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const appShellData = useAppShellData(); const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject()); const [drawerTop, setDrawerTop] = createSignal(0); const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); const [persistedFolders, setPersistedFolders] = createSignal([]); const [projectTreeNodes, setProjectTreeNodes] = createSignal( buildProjectTree(appShellData.projectItems(), persistedFolders()), ); const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); const [pendingFolderName, setPendingFolderName] = createSignal(""); const [pendingFolderRename, setPendingFolderRename] = createSignal(null); const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); const [dragState, setDragState] = createSignal(null); const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); let rootRef: HTMLDivElement | undefined; let triggerRef: HTMLButtonElement | undefined; let contextMenuRef: HTMLDivElement | undefined; let longPressTimer: number | undefined; let suppressClickTimer: number | undefined; let lastSelectedProjectId: string | null = null; let latestPersistedFoldersRequest = 0; const contextMenu = createProjectContextMenuController(); const clearLongPressTimer = (): void => { if (longPressTimer !== undefined) { window.clearTimeout(longPressTimer); longPressTimer = undefined; } }; const suppressTreeClickTemporarily = (): void => { setSuppressNextTreeClick(true); if (suppressClickTimer !== undefined) { window.clearTimeout(suppressClickTimer); } suppressClickTimer = window.setTimeout(() => { setSuppressNextTreeClick(false); suppressClickTimer = undefined; }, 80); }; const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); const toggleFolder = (folderId: string): void => { setCollapsedFolderIds((current) => current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], ); }; const syncProjectTree = (): void => { const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders()); const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter)); setProjectTreeNodes(nextTree); setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId))); }; const resetProjectTreeInteractionState = (): void => { setCollapsedFolderIds([]); setPendingFolderDraft(null); setPendingFolderName(""); setPendingFolderRename(null); setPendingFolderRenameName(""); setDragState(null); }; const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter); const expandAllFolders = (): void => { setCollapsedFolderIds([]); }; const collapseAllFolders = (): void => { setCollapsedFolderIds(folderIds()); }; const totalFolderCount = (): number => folderIds().length; const areAllFoldersCollapsed = (): boolean => { const folderCount = totalFolderCount(); return folderCount > 0 && collapsedFolderIds().length >= folderCount; }; const toggleAllFolders = (): void => { if (areAllFoldersCollapsed()) { expandAllFolders(); return; } collapseAllFolders(); }; const loadPersistedFolders = async (projectId: string): Promise => { const requestId = latestPersistedFoldersRequest + 1; latestPersistedFoldersRequest = requestId; if (!projectId) { setPersistedFolders([]); return; } if (!isUuidString(projectId)) { setPersistedFolders([]); return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { headers: { Accept: "application/json", }, }); const body = (await response.json()) as ProjectFoldersResponse; if (requestId !== latestPersistedFoldersRequest) { return; } if (!response.ok) { throw new Error(body.message || "Failed to load project folders."); } setPersistedFolders(readPersistedFolders(body)); } catch (error) { if (requestId !== latestPersistedFoldersRequest) { return; } console.error(error); setPersistedFolders([]); } }; createEffect(() => { setSelectedProject(appShellData.activeProject()); }); createEffect(() => { syncProjectTree(); }); createEffect(() => { const projectId = selectedProject().id; if (lastSelectedProjectId === null) { lastSelectedProjectId = projectId; return; } if (projectId === lastSelectedProjectId) { return; } lastSelectedProjectId = projectId; resetProjectTreeInteractionState(); }); createEffect(() => { const projectId = selectedProject().id; void loadPersistedFolders(projectId); }); onMount(() => { if (triggerRef) { const updateDrawerTop = (): void => { if (!triggerRef) { return; } setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8); }; updateDrawerTop(); const observer = new ResizeObserver(() => { updateDrawerTop(); }); observer.observe(triggerRef); window.addEventListener("resize", updateDrawerTop); onCleanup(() => { observer.disconnect(); window.removeEventListener("resize", updateDrawerTop); }); } const handlePointerDown = (event: PointerEvent): void => { if (!props.isOpen || !rootRef) { return; } const target = event.target; if (target instanceof Node && rootRef.contains(target)) { return; } if (target instanceof Node && contextMenuRef?.contains(target)) { return; } props.onClose(); }; const handlePointerUp = (): void => { clearLongPressTimer(); const nextDragState = dragState(); if (!nextDragState?.dropTarget) { if (nextDragState) { suppressTreeClickTemporarily(); } setDragState(null); return; } suppressTreeClickTemporarily(); const currentNodes = projectTreeNodes(); const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter); const canPersistMove = isUuidString(selectedProject().id); const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null; const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter); const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter); const persistedParentLocation = previewLocation?.parentId ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null; const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null; const previewSiblings = previewLocation?.parentId ? persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.children : [] : previewNodes; const targetIndex = previewLocation ? previewSiblings .slice(0, previewLocation.index) .filter((node) => node.kind === "folder").length : 0; if ( canPersistMove && draggedLocation?.node.kind === "folder" && draggedFolderPath && (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") ) { void movePersistedFolder( draggedFolderPath, persistedParentFolderPath, draggedLocation.node.id, persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, targetIndex, ); } else { setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter), ); } setDragState(null); }; const handleEscape = (event: KeyboardEvent): void => { if (event.key !== "Escape") { return; } clearLongPressTimer(); if (dragState()) { setDragState(null); return; } if (!props.isOpen) { return; } props.onClose(); triggerRef?.focus(); }; document.addEventListener("pointerdown", handlePointerDown); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); window.addEventListener("keydown", handleEscape); onCleanup(() => { clearLongPressTimer(); if (suppressClickTimer !== undefined) { window.clearTimeout(suppressClickTimer); } document.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerUp); window.removeEventListener("keydown", handleEscape); }); }); const toggleOpen = (): void => { if (!props.isOpen) { props.onToggle(); return; } props.onClose(); }; const selectProject = (projectId: string): void => { const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter); if (!location || location.node.kind !== "project") { return; } setSelectedProject({ id: location.node.item.id, name: location.node.item.name }); props.onClose(); }; const beginFolderDraft = (parentId: string | null, depth: number): void => { if (parentId) { setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); } setPendingFolderRename(null); setPendingFolderRenameName(""); setPendingFolderName(""); setPendingFolderDraft({ parentId, depth }); }; const beginFolderRename = (folderId: string, label: string, depth: number): void => { setPendingFolderDraft(null); setPendingFolderName(""); setPendingFolderRename({ folderId, depth }); setPendingFolderRenameName(label); }; const resolveFolderPath = (folderId: string): string | null => { const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter); return location && location.node.kind === "folder" ? location.node.path : null; }; const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); const projectId = selectedProject().id; if (!draft) { return; } if (!name) { setPendingFolderDraft(null); setPendingFolderName(""); return; } if (!isUuidString(projectId)) { cancelPendingFolder(); return; } const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; if (draft.parentId && !parentFolderPath) { cancelPendingFolder(); return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ name, parentFolderId: parentFolderPath, }), }); const body = (await response.json()) as ProjectFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to create project folder."); } setPersistedFolders(readPersistedFolders(body)); setPendingFolderDraft(null); setPendingFolderName(""); } catch (error) { console.error(error); } }; const deletePersistedFolder = async (folderId: string): Promise => { const projectId = selectedProject().id; if (!folderId || !isUuidString(projectId)) { return; } const folderPath = resolveFolderPath(folderId); if (!folderPath) { return; } try { const response = await fetch( `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`, { method: "DELETE", headers: { Accept: "application/json", }, }, ); const body = (await response.json()) as ProjectFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to delete project folder."); } setPersistedFolders(readPersistedFolders(body)); setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); } catch (error) { console.error(error); } }; const movePersistedFolder = async ( folderPath: string, parentFolderPath: string | null, folderNodeId: string, parentNodeId: string | null, targetIndex: number, ): Promise => { const projectId = selectedProject().id; if (!folderPath || !folderNodeId || !isUuidString(projectId)) { return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ folderId: folderPath, folderNodeId, parentFolderId: parentFolderPath, parentNodeId, targetIndex, }), }); const body = (await response.json()) as ProjectFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to move project folder."); } setPersistedFolders(readPersistedFolders(body)); } catch (error) { console.error(error); } }; const submitPendingFolderRename = async (): Promise => { const draft = pendingFolderRename(); const name = pendingFolderRenameName().trim(); const projectId = selectedProject().id; if (!draft) { return; } if (!name) { setPendingFolderRename(null); setPendingFolderRenameName(""); return; } if (!isUuidString(projectId)) { cancelPendingFolderRename(); return; } const folderPath = resolveFolderPath(draft.folderId); if (!folderPath) { cancelPendingFolderRename(); return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ folderId: folderPath, name, }), }); const body = (await response.json()) as ProjectFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to rename project folder."); } setPersistedFolders(readPersistedFolders(body)); setPendingFolderRename(null); setPendingFolderRenameName(""); } catch (error) { console.error(error); } }; const cancelPendingFolder = (): void => { setPendingFolderDraft(null); setPendingFolderName(""); }; const cancelPendingFolderRename = (): void => { setPendingFolderRename(null); setPendingFolderRenameName(""); }; const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => { switch (action.id) { case "new-folder": switch (target.kind) { case "surface": beginFolderDraft(null, 0); return; case "folder": beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); return; case "project": { const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); return; } } return; case "delete-folder": if (target.kind === "folder") { void deletePersistedFolder(target.id); } return; case "rename-folder": if (target.kind === "folder") { beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0); } return; default: return; } }; const handleSurfaceContextMenu = (event: MouseEvent): void => { event.stopPropagation(); contextMenu.openMenu(event, createProjectSurfaceTarget("Projects")); }; const treeControlLabel = (): string => areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"; const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => { if (event.button !== 0 || pendingFolderDraft()) { return; } clearLongPressTimer(); longPressTimer = window.setTimeout(() => { suppressTreeClickTemporarily(); setDragState({ draggedNodeId: nodeId, dropTarget: null }); }, LONG_PRESS_MS); }; const handleNodePointerMove = ( event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode, ): void => { const nextDragState = dragState(); if (!nextDragState) { return; } if (nextDragState.draggedNodeId === getProjectTreeNodeId(node)) { return; } const relativeY = getPointerRelativeY(event); if (relativeY === null) { return; } setDragState({ ...nextDragState, dropTarget: resolveTreeDropTarget({ parentId, index, node, relativeY, adapter: projectTreeAdapter, }), }); }; return (
    <>
    { event.stopPropagation(); contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label)); }} onOpenProjectMenu={(event, item): void => { event.stopPropagation(); contextMenu.openMenu(event, createProjectTarget(item)); }} onNodePointerDown={handleNodePointerDown} onNodePointerMove={handleNodePointerMove} pendingFolderDraft={pendingFolderDraft()} pendingFolderName={pendingFolderName()} onPendingFolderNameChange={setPendingFolderName} onSubmitPendingFolder={submitPendingFolder} onCancelPendingFolder={cancelPendingFolder} pendingFolderRename={pendingFolderRename()} pendingFolderRenameName={pendingFolderRenameName()} onPendingFolderRenameChange={setPendingFolderRenameName} onSubmitPendingFolderRename={submitPendingFolderRename} onCancelPendingFolderRename={cancelPendingFolderRename} dragState={dragState()} isTreeClickSuppressed={suppressNextTreeClick} /> { const state = contextMenu.menuState(); return state ? { x: state.x, y: state.y } : null; })()} menuRef={(element) => { contextMenuRef = element; contextMenu.setMenuRef(element); }} onClose={contextMenu.closeMenu} onSelect={handleContextActionSelect} /> ); };