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 => (
+
+
+ -
+
+
+
+
+
+ {(node, indexAccessor): JSX.Element => {
+ const nodeId = (): string => getProjectTreeNodeId(node);
+ const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
+ const dropIntent = (): ProjectDragTarget["intent"] | null => {
+ if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
+ return null;
+ }
+
+ return props.dragState.dropTarget.intent;
+ };
+
+ if (node.kind === "folder") {
+ const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
+
+ return (
+ -
+
+
+ 0 || props.pendingFolderDraft?.parentId === node.id)}>
+
+
+
+ );
+ }
+
+ return (
+ -
+
+
+ );
+ }}
+
+
+
+
+
+
+);
+
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}