diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss index 92112c0..aa83f68 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss @@ -9,6 +9,15 @@ justify-items: center; } +.rootDragMode { + user-select: none; + cursor: grabbing; +} + +.rootDragMode .treeItem { + cursor: grabbing; +} + .trigger { width: 100%; min-width: 0; @@ -197,6 +206,43 @@ padding: 0; } +.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; +} + +.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); +} + +.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); +} + .treeItem { width: 100%; min-width: 0; @@ -215,20 +261,43 @@ 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; } .treeItem:hover, .treeItem:focus-visible { - background: var(--color-surface-hover); + 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); } .treeItemFolder { color: var(--color-text); } +.treeItemDragging { + opacity: 0.45; + transform: scale(0.985); + box-shadow: none; +} + +.treeItemDropBefore { + box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); +} + +.treeItemDropAfter { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); +} + +.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); +} + .folderChevron { color: var(--color-text-muted); transition: transform 160ms var(--easing-standard); diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index bc4d5ff..1b5ce4f 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -1,6 +1,6 @@ // Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx -import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"; +import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { ChevronDown, ChevronRight, Folder, LayoutGrid } from "../../../lib/icons"; import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; import { useAppShellData } from "../data/app-shell.context"; @@ -21,38 +21,503 @@ type ProjectSelectorProps = { onClose: () => void; }; +type ProjectFolderNode = { + kind: "folder"; + id: string; + label: string; + meta?: string; + children: ProjectTreeNode[]; +}; + +type ProjectLeafNode = { + kind: "project"; + item: ProjectItem; +}; + +type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; + +type PendingProjectFolderDraft = { + parentId: string | null; + depth: number; +}; + +type ProjectDragTarget = { + parentId: string | null; + index: number; + intent: "before" | "after" | "inside"; + targetNodeId?: string; +}; + +type ProjectDragState = { + draggedNodeId: string; + dropTarget: ProjectDragTarget | null; +}; + +type ProjectNodeLocation = { + parentId: string | null; + index: number; + node: ProjectTreeNode; +}; + +const LONG_PRESS_MS = 320; + +const createProjectFolderId = (): string => `project-folder-${Math.random().toString(36).slice(2, 10)}`; + +const getProjectTreeNodeId = (node: ProjectTreeNode): string => + node.kind === "folder" ? node.id : node.item.id; + +const buildProjectTree = (items: readonly ProjectItem[]): ProjectTreeNode[] => + items.map((item) => ({ + kind: "project", + item, + })); + +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 insertProjectFolderNode = ( + nodes: readonly ProjectTreeNode[], + parentId: string | null, + folder: ProjectFolderNode, +): ProjectTreeNode[] => { + if (parentId === null) { + return [...nodes, folder]; + } + + return nodes.map((node) => { + if (node.kind !== "folder") { + return node; + } + + if (node.id === parentId) { + return { + ...node, + children: [...node.children, folder], + }; + } + + return { + ...node, + children: insertProjectFolderNode(node.children, parentId, folder), + }; + }); +}; + +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 { + ...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); +}; + +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; + dragState: ProjectDragState | null; +}): 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 [projectTreeNodes, setProjectTreeNodes] = createSignal( + buildProjectTree(appShellData.projectItems()), + ); + const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); + const [pendingFolderName, setPendingFolderName] = 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; const contextMenu = createProjectContextMenuController(); - const projectFolders = createMemo(() => { - const sections = new Map(); + const clearLongPressTimer = (): void => { + if (longPressTimer !== undefined) { + window.clearTimeout(longPressTimer); + longPressTimer = undefined; + } + }; - for (const item of appShellData.projectItems()) { - const key = item.parentLabel || item.groupLabel || "Projects"; - const existing = sections.get(key); + const suppressTreeClickTemporarily = (): void => { + setSuppressNextTreeClick(true); - if (existing) { - existing.push(item); - continue; - } - - sections.set(key, [item]); + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); } - return Array.from(sections.entries()).map(([label, items]) => ({ - id: label.toLowerCase().replace(/\s+/g, "-"), - label, - meta: items[0]?.groupLabel && items[0].groupLabel !== label ? items[0].groupLabel : undefined, - items, - })); - }); + suppressClickTimer = window.setTimeout(() => { + setSuppressNextTreeClick(false); + suppressClickTimer = undefined; + }, 80); + }; const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); @@ -66,6 +531,14 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setSelectedProject(appShellData.activeProject()); }); + createEffect(() => { + setProjectTreeNodes(buildProjectTree(appShellData.projectItems())); + setCollapsedFolderIds([]); + setPendingFolderDraft(null); + setPendingFolderName(""); + setDragState(null); + }); + onMount(() => { if (triggerRef) { const updateDrawerTop = (): void => { @@ -109,8 +582,39 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { props.onClose(); }; + const handlePointerUp = (): void => { + clearLongPressTimer(); + + const nextDragState = dragState(); + + if (!nextDragState?.dropTarget) { + if (nextDragState) { + suppressTreeClickTemporarily(); + } + setDragState(null); + return; + } + + suppressTreeClickTemporarily(); + setProjectTreeNodes((current) => + moveProjectTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget), + ); + setDragState(null); + }; + const handleEscape = (event: KeyboardEvent): void => { - if (event.key !== "Escape" || !props.isOpen) { + if (event.key !== "Escape") { + return; + } + + clearLongPressTimer(); + + if (dragState()) { + setDragState(null); + return; + } + + if (!props.isOpen) { return; } @@ -119,10 +623,18 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { }; 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); }); }); @@ -137,18 +649,74 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { }; const selectProject = (projectId: string): void => { - const nextProject = appShellData.projectItems().find((item): boolean => item.id === projectId); + const location = findProjectNodeLocation(projectTreeNodes(), projectId); - if (!nextProject) { + if (!location || location.node.kind !== "project") { return; } - setSelectedProject({ id: nextProject.id, name: nextProject.name }); + setSelectedProject({ id: location.node.item.id, name: location.node.item.name }); props.onClose(); }; - const handleContextActionSelect = (_action: { id: string; label: string }, _target: ProjectMenuTarget): void => { - // Initial implementation keeps the project menu aligned with workspace-menu IA. + const beginFolderDraft = (parentId: string | null, depth: number): void => { + if (parentId) { + setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); + } + + setPendingFolderName(""); + setPendingFolderDraft({ parentId, depth }); + }; + + const submitPendingFolder = (): void => { + const name = pendingFolderName().trim(); + const draft = pendingFolderDraft(); + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderDraft(null); + setPendingFolderName(""); + return; + } + + setProjectTreeNodes((current) => + insertProjectFolderNode(current, draft.parentId, { + kind: "folder", + id: createProjectFolderId(), + label: name, + children: [], + }), + ); + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + const cancelPendingFolder = (): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => { + if (action.id !== "new-folder") { + return; + } + + switch (target.kind) { + case "surface": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findProjectNodeDepth(projectTreeNodes(), target.id) ?? 0) + 1); + return; + case "project": { + const parentId = findProjectNodeLocation(projectTreeNodes(), target.id)?.parentId ?? null; + beginFolderDraft(parentId, parentId ? (findProjectNodeDepth(projectTreeNodes(), parentId) ?? 0) + 1 : 0); + return; + } + } }; const handleSurfaceContextMenu = (event: MouseEvent): void => { @@ -156,12 +724,83 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { contextMenu.openMenu(event, createProjectSurfaceTarget("Projects")); }; + 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 bounds = event.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, + }); + }; + return (
    {
    Projects
    -
      - - {(folder): JSX.Element => { - const isCollapsed = (): boolean => isFolderCollapsed(folder.id); - - return ( -
    • - - - -
        - - {(item): JSX.Element => { - const isSelected = (): boolean => selectedProject().id === item.id; - - 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} + dragState={dragState()} + />
    diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss index 70c248e..f3e9c7d 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss @@ -12,6 +12,15 @@ isolation: isolate; } +.sidebarDragMode { + user-select: none; + cursor: grabbing; +} + +.sidebarDragMode .treeItem { + cursor: grabbing; +} + .header { display: grid; gap: var(--space-3); @@ -123,6 +132,43 @@ padding: 0; } +.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; +} + +.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); +} + +.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); +} + .navItem { width: 100%; min-width: 0; @@ -141,7 +187,7 @@ width: 100%; min-width: 0; display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + 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)); @@ -156,19 +202,51 @@ 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); } .treeItem:hover, .treeItem:focus-visible { - background: var(--color-surface-hover); + 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); } .treeItemFolder { color: var(--color-text); } +.treeItemDragging { + opacity: 0.45; + transform: scale(0.985); + box-shadow: none; +} + +.treeItemDropBefore { + box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); +} + +.treeItemDropAfter { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); +} + +.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); +} + +.folderChevron { + color: var(--color-text-muted); + transition: transform 160ms var(--easing-standard); +} + +.folderChevronOpen { + transform: rotate(90deg); +} + .treeItemActive { border-color: var(--color-border); background: var(--color-surface); diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index 55a7db8..f51aa2c 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,7 +1,7 @@ // Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx -import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; -import { ChevronLeft, ChevronRight } from "../../../lib/icons"; +import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; +import { ChevronLeft, ChevronRight, Folder } from "../../../lib/icons"; import { useAppShellData } from "../data/app-shell.context"; import { ProjectSelector } from "../ProjectSelector/ProjectSelector"; import { @@ -26,12 +26,276 @@ type WorkspaceSidebarProps = { onToggleRailCollapse: () => void; }; +type PendingWorkspaceFolderDraft = { + parentId: string | null; + depth: number; +}; + +type WorkspaceDragTarget = { + parentId: string | null; + index: number; + intent: "before" | "after" | "inside"; + targetNodeId?: string; +}; + +type WorkspaceDragState = { + draggedNodeId: string; + dropTarget: WorkspaceDragTarget | null; +}; + +type WorkspaceNodeLocation = { + parentId: string | null; + index: number; + node: WorkspaceTreeNode; +}; + +const LONG_PRESS_MS = 320; + +const createWorkspaceFolderId = (): string => `folder-${Math.random().toString(36).slice(2, 10)}`; + +const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id; + +const insertWorkspaceFolderNode = ( + nodes: readonly WorkspaceTreeNode[], + parentId: string | null, + folder: WorkspaceTreeNode, +): readonly WorkspaceTreeNode[] => { + if (parentId === null) { + return [...nodes, folder]; + } + + return nodes.map((node) => { + if (node.kind !== "folder") { + return node; + } + + if (node.id === parentId) { + return { + ...node, + children: [...(node.children ?? []), folder], + }; + } + + return { + ...node, + children: node.children ? insertWorkspaceFolderNode(node.children, parentId, folder) : node.children, + }; + }); +}; + +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 { + ...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); +}; + +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; + 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 Icon = props.item.icon; const target = createWorkspaceStaticTarget(props.item); @@ -75,18 +339,41 @@ const WorkspaceHomeEntry = (props: { 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; onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; }): JSX.Element => { const depth = () => props.depth ?? 0; + const parentId = () => props.parentId ?? null; return (
      + +
    • +
      +
    • +
      - {(node): JSX.Element => { + {(node, indexAccessor): JSX.Element => { const Icon = getWorkspaceNodeIcon(node); const target = createWorkspaceTreeTarget(node); + const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false); + const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id; + const dropIntent = (): WorkspaceDragTarget["intent"] | null => { + if (props.dragState?.dropTarget?.targetNodeId !== node.id) { + return null; + } + + return props.dragState.dropTarget.intent; + }; return (
    • @@ -96,8 +383,13 @@ const WorkspaceTreeBranch = (props: { [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()) }} + aria-expanded={node.kind === "folder" ? !isCollapsed() : undefined} aria-current={node.active ? "page" : undefined} aria-label={node.label} title={node.label} @@ -105,10 +397,28 @@ const WorkspaceTreeBranch = (props: { data-kind={node.kind} data-item-type={node.kind === "item" ? node.itemType : undefined} data-active={node.active ? "true" : "false"} + onClick={(): void => { + if (props.dragState || props.isTreeClickSuppressed()) { + return; + } + + if (node.kind !== "folder") { + return; + } + + props.onToggleFolder(node.id); + }} onContextMenu={(event): void => { event.stopPropagation(); props.onOpenContextMenu(event, target); }} + onPointerDown={(event): void => props.onNodePointerDown(event, node.id)} + onPointerMove={(event): void => + props.onNodePointerMove(event, parentId(), indexAccessor(), node) + } + onPointerEnter={(event): void => + props.onNodePointerMove(event, parentId(), indexAccessor(), node) + } onKeyDown={(event): void => { if (!isContextMenuKeyboardTrigger(event)) { return; @@ -118,6 +428,16 @@ const WorkspaceTreeBranch = (props: { props.onOpenContextMenuFromKeyboard(event.currentTarget, target); }} > + + + {node.label} @@ -125,18 +445,40 @@ const WorkspaceTreeBranch = (props: { - + 0) || props.pendingFolderDraft?.parentId === node.id)}>
    • ); }}
      + + + +
    ); }; @@ -144,23 +486,209 @@ const WorkspaceTreeBranch = (props: { export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { const appShellData = useAppShellData(); const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false); + const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(appShellData.workspaceTree()); + const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); + const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); + const [pendingFolderName, setPendingFolderName] = createSignal(""); + const [dragState, setDragState] = createSignal(null); + const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); const contextMenu = createWorkspaceContextMenuController(); + let longPressTimer: number | undefined; + let suppressClickTimer: number | undefined; const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail"); - const sidebarContextMenuTarget = createMemo(() => createWorkspaceSurfaceTarget(appShellData.activeProject())); - const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null); - const contextMenuPosition = createMemo(() => { - const state = contextMenu.menuState(); + const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject()); + 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 clearLongPressTimer = (): void => { + if (longPressTimer !== undefined) { + window.clearTimeout(longPressTimer); + longPressTimer = undefined; + } + }; + const suppressTreeClickTemporarily = (): void => { + setSuppressNextTreeClick(true); - return state - ? { - x: state.x, - y: state.y, - } - : null; + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); + } + + suppressClickTimer = window.setTimeout(() => { + setSuppressNextTreeClick(false); + suppressClickTimer = undefined; + }, 80); + }; + + createEffect(() => { + setWorkspaceTreeNodes(appShellData.workspaceTree()); + setCollapsedFolderIds([]); + setPendingFolderDraft(null); + setPendingFolderName(""); + setDragState(null); }); - const handleContextActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => { - // Initial implementation only establishes the menu IA and placement. + onMount(() => { + const handlePointerUp = (): void => { + clearLongPressTimer(); + + const nextDragState = dragState(); + + if (!nextDragState?.dropTarget) { + if (nextDragState) { + suppressTreeClickTemporarily(); + } + setDragState(null); + return; + } + + suppressTreeClickTemporarily(); + setWorkspaceTreeNodes((current) => + moveWorkspaceTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget), + ); + 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)); + } + + setPendingFolderName(""); + setPendingFolderDraft({ parentId, depth }); + }; + + const submitPendingFolder = (): void => { + const name = pendingFolderName().trim(); + const draft = pendingFolderDraft(); + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderDraft(null); + setPendingFolderName(""); + return; + } + + setWorkspaceTreeNodes((current) => + insertWorkspaceFolderNode(current, draft.parentId, { + id: createWorkspaceFolderId(), + label: name, + kind: "folder", + icon: Folder, + children: [], + }), + ); + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + const cancelPendingFolder = (): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + 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 bounds = event.currentTarget.getBoundingClientRect(); + const relativeY = bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height; + let nextTarget: WorkspaceDragTarget; + + 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.id, + }; + } + + setDragState({ ...nextDragState, dropTarget: nextTarget }); + }; + + const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { + if (action.id !== "new-folder") { + return; + } + + switch (target.kind) { + case "workspace": + case "home": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findWorkspaceFolderDepth(workspaceTreeNodes(), target.id) ?? 0) + 1); + return; + case "settings": + case "item": + return; + } }; return ( @@ -169,12 +697,13 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { classList={{ [styles.sidebar]: true, [styles.sidebarCollapsed]: props.collapsed, + [styles.sidebarDragMode]: !!dragState(), }} aria-label="Left workspace sidebar" data-ui="workspace-sidebar" data-collapsed={props.collapsed ? "true" : "false"} onContextMenu={(event): void => { - contextMenu.openMenu(event, sidebarContextMenuTarget()); + contextMenu.openMenu(event, sidebarContextMenuTarget); }} >
    {
    - +
    { + const state = contextMenu.menuState(); + return state + ? { + x: state.x, + y: state.y, + } + : null; + })()} menuRef={contextMenu.setMenuRef} onClose={contextMenu.closeMenu} onSelect={handleContextActionSelect}