// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; 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, findTreeNodeLocation, getPointerRelativeY, isUuidString, moveTreeNode, resolveTreeDropTarget, type NavTreeAdapter, type NavTreeDropTarget, } from "../shared/navTreeDnd"; import { createWorkspaceStaticTarget, createWorkspaceSurfaceTarget, createWorkspaceTreeTarget, getWorkspaceNodeIcon, workspaceSidebarHeaderActions, workspaceStaticItems, type WorkspaceContextMenuAction, type WorkspaceContextMenuTarget, type WorkspaceStaticItem, type WorkspaceTreeNode, } from "../data/shell.data"; import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu"; import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController"; import styles from "./WorkspaceSidebar.module.scss"; type WorkspaceSidebarProps = { collapsed: boolean; railCollapsed: boolean; onToggleRailCollapse: () => void; }; type PendingWorkspaceFolderDraft = { parentId: string | null; depth: number; }; type WorkspaceDragTarget = NavTreeDropTarget; type WorkspaceDragState = { draggedNodeId: string; dropTarget: WorkspaceDragTarget | null; }; type PersistedWorkspaceFolderRecord = { id: string; label: string; children?: PersistedWorkspaceFolderRecord[]; }; type WorkspaceFoldersResponse = { data?: { folders?: PersistedWorkspaceFolderRecord[]; renamedFolder?: PersistedWorkspaceFolderRecord; movedFolder?: PersistedWorkspaceFolderRecord; previousFolderId?: string; }; error?: string; message?: string; }; type PendingWorkspaceFolderRename = { folderId: string; depth: number; }; const LONG_PRESS_MS = 320; const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id; const buildPersistedWorkspaceFolderNodes = ( folders: readonly PersistedWorkspaceFolderRecord[], ): WorkspaceTreeNode[] => folders.map((folder) => ({ id: folder.id, label: folder.label, kind: "folder", icon: Folder, children: buildPersistedWorkspaceFolderNodes(folder.children ?? []), })); const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] => Array.isArray(body.data?.folders) ? body.data.folders : []; 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: [...children], } : node, }; const FolderDraftRow = (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 isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"); const WorkspaceHomeEntry = (props: { item: WorkspaceStaticItem; onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; }): JSX.Element => { const Icon = props.item.icon; const target = createWorkspaceStaticTarget(props.item); return (
  • ); }; const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; parentId?: string | null; depth?: number; isFolderCollapsed: (folderId: string) => boolean; onToggleFolder: (folderId: string) => void; pendingFolderDraft: PendingWorkspaceFolderDraft | null; pendingFolderName: string; onPendingFolderNameChange: (value: string) => void; onSubmitPendingFolder: () => void; onCancelPendingFolder: () => void; pendingFolderRename: PendingWorkspaceFolderRename | null; pendingFolderRenameName: string; onPendingFolderRenameChange: (value: string) => void; onSubmitPendingFolderRename: () => void; onCancelPendingFolderRename: () => void; onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; onNodePointerDown: (event: PointerEvent, nodeId: string) => void; onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void; dragState: WorkspaceDragState | null; isTreeClickSuppressed: () => boolean; }): JSX.Element => { const depth = () => props.depth ?? 0; const parentId = () => props.parentId ?? null; return ( ); }; export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { const appShellData = useAppShellData(); const activeProject = () => appShellData.activeProject(); const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false); const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(appShellData.workspaceTree()); const [persistedFolders, setPersistedFolders] = createSignal([]); const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); 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 lastSelectedProjectId: string | null = null; let latestPersistedFoldersRequest = 0; const contextMenu = createWorkspaceContextMenuController(); let longPressTimer: number | undefined; let suppressClickTimer: number | undefined; 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[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter); const totalFolderCount = (): number => folderIds().length; const areAllFoldersCollapsed = (): boolean => { const count = totalFolderCount(); return count > 0 && collapsedFolderIds().length >= count; }; const workspaceFolderToggleLabel = (): string => areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"; const toggleFolder = (folderId: string): void => { setCollapsedFolderIds((current) => current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], ); }; const expandAllFolders = (): void => { setCollapsedFolderIds([]); }; const collapseAllFolders = (): void => { setCollapsedFolderIds(folderIds()); }; const toggleAllFolders = (): void => { if (areAllFoldersCollapsed()) { expandAllFolders(); return; } collapseAllFolders(); }; const resetWorkspaceTreeInteractionState = (): void => { setCollapsedFolderIds([]); setPendingFolderDraft(null); setPendingFolderName(""); setPendingFolderRename(null); setPendingFolderRenameName(""); setDragState(null); }; const syncWorkspaceTree = (): void => { const nextTree = activeProject()?.id ? buildPersistedWorkspaceFolderNodes(persistedFolders()) : appShellData.workspaceTree(); const availableFolderIds = new Set(collectBranchNodeIds(nextTree, workspaceTreeAdapter)); setWorkspaceTreeNodes(nextTree); setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id))); }; 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}/tree/folders`, { headers: { Accept: "application/json", }, }); const body = (await response.json()) as WorkspaceFoldersResponse; if (requestId !== latestPersistedFoldersRequest) { return; } if (!response.ok) { throw new Error(body.message || "Failed to load project tree folders."); } setPersistedFolders(readPersistedWorkspaceFolders(body)); } catch (error) { if (requestId !== latestPersistedFoldersRequest) { return; } console.error(error); setPersistedFolders([]); } }; 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); }; createEffect(() => { syncWorkspaceTree(); }); createEffect(() => { const projectId = activeProject()?.id ?? null; if (lastSelectedProjectId === null) { lastSelectedProjectId = projectId; return; } if (projectId === lastSelectedProjectId) { return; } lastSelectedProjectId = projectId; resetWorkspaceTreeInteractionState(); }); createEffect(() => { void loadPersistedFolders(activeProject()?.id ?? ""); }); onMount(() => { const handlePointerUp = (): void => { clearLongPressTimer(); const nextDragState = dragState(); if (!nextDragState?.dropTarget) { if (nextDragState) { suppressTreeClickTemporarily(); } setDragState(null); return; } suppressTreeClickTemporarily(); const currentNodes = workspaceTreeNodes(); const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); const persistedParentId = nextDragState.dropTarget.parentId; const canPersistMove = isUuidString(activeProject()?.id ?? ""); const persistedParentLocation = persistedParentId ? findTreeNodeLocation(currentNodes, persistedParentId, workspaceTreeAdapter) : null; if ( canPersistMove && draggedLocation?.node.kind === "folder" && (persistedParentId === null || persistedParentLocation?.node.kind === "folder") ) { void movePersistedFolder(draggedLocation.node.id, persistedParentId); } else { setWorkspaceTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter), ); } setDragState(null); }; const handleEscape = (event: KeyboardEvent): void => { if (event.key !== "Escape") { return; } clearLongPressTimer(); if (dragState()) { setDragState(null); } }; window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); window.addEventListener("keydown", handleEscape); onCleanup(() => { clearLongPressTimer(); if (suppressClickTimer !== undefined) { window.clearTimeout(suppressClickTimer); } window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerUp); window.removeEventListener("keydown", handleEscape); }); }); 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 submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); const projectId = activeProject()?.id ?? ""; if (!draft) { return; } if (!name) { setPendingFolderDraft(null); setPendingFolderName(""); return; } if (!projectId || !isUuidString(projectId)) { cancelPendingFolder(); return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ name, parentFolderId: draft.parentId, }), }); const body = (await response.json()) as WorkspaceFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to create project tree folder."); } setPersistedFolders(readPersistedWorkspaceFolders(body)); setPendingFolderDraft(null); setPendingFolderName(""); } catch (error) { console.error(error); } }; const deletePersistedFolder = async (folderId: string): Promise => { const projectId = activeProject()?.id ?? ""; if (!folderId || !projectId || !isUuidString(projectId)) { return; } try { const response = await fetch( `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, { method: "DELETE", headers: { Accept: "application/json", }, }, ); const body = (await response.json()) as WorkspaceFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to delete project tree folder."); } setPersistedFolders(readPersistedWorkspaceFolders(body)); setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); } catch (error) { console.error(error); } }; const movePersistedFolder = async (folderId: string, parentFolderId: string | null): Promise => { const projectId = activeProject()?.id ?? ""; if (!folderId || !projectId || !isUuidString(projectId)) { return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders/move`, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ folderId, parentFolderId, }), }); const body = (await response.json()) as WorkspaceFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to move project tree folder."); } setPersistedFolders(readPersistedWorkspaceFolders(body)); const previousFolderId = body.data?.previousFolderId; const movedFolderId = body.data?.movedFolder?.id; if (previousFolderId && movedFolderId && previousFolderId !== movedFolderId) { setCollapsedFolderIds((current) => current.map((id) => (id === previousFolderId ? movedFolderId : id)), ); } } catch (error) { console.error(error); } }; const submitPendingFolderRename = async (): Promise => { const draft = pendingFolderRename(); const name = pendingFolderRenameName().trim(); const projectId = activeProject()?.id ?? ""; if (!draft) { return; } if (!name) { setPendingFolderRename(null); setPendingFolderRenameName(""); return; } if (!projectId || !isUuidString(projectId)) { cancelPendingFolderRename(); return; } try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ folderId: draft.folderId, name, }), }); const body = (await response.json()) as WorkspaceFoldersResponse; if (!response.ok) { throw new Error(body.message || "Failed to rename project tree folder."); } setPersistedFolders(readPersistedWorkspaceFolders(body)); setPendingFolderRename(null); setPendingFolderRenameName(""); const previousFolderId = body.data?.previousFolderId; const renamedFolderId = body.data?.renamedFolder?.id; if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) { setCollapsedFolderIds((current) => current.map((id) => (id === previousFolderId ? renamedFolderId : id)), ); } } catch (error) { console.error(error); } }; const cancelPendingFolder = (): void => { setPendingFolderDraft(null); setPendingFolderName(""); }; const cancelPendingFolderRename = (): void => { setPendingFolderRename(null); setPendingFolderRenameName(""); }; const handleHeaderActionClick = (actionId: string): void => { switch (actionId) { case "toggle-workspace-folders": toggleAllFolders(); return; case "search-workspace": default: return; } }; 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: WorkspaceTreeNode, ): void => { const nextDragState = dragState(); if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) { return; } const relativeY = getPointerRelativeY(event); if (relativeY === null) { return; } setDragState({ ...nextDragState, dropTarget: resolveTreeDropTarget({ parentId, index, node, relativeY, adapter: workspaceTreeAdapter, }), }); }; const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { switch (action.id) { case "new-folder": switch (target.kind) { case "workspace": case "home": beginFolderDraft(null, 0); return; case "folder": beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1); return; case "settings": case "item": 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(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0); } return; default: return; } }; return ( <> { const state = contextMenu.menuState(); return state ? { x: state.x, y: state.y, } : null; })()} menuRef={contextMenu.setMenuRef} onClose={contextMenu.closeMenu} onSelect={handleContextActionSelect} /> ); };