From 5758074f6f84032ff5230da6d10fac1158f38e31 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Wed, 24 Jun 2026 01:28:50 +0100 Subject: [PATCH] Feat: persist project tree hierarchy --- Backend/internal/bootstrap/service.go | 63 +++- Backend/internal/bootstrap/service_test.go | 50 +++ Backend/internal/httpx/api_project_routes.go | 62 ++++ Backend/internal/httpx/api_routes.go | 2 + Backend/internal/posixproj/projector.go | 4 +- Backend/internal/posixproj/projector_test.go | 8 +- .../WorkspaceSidebar.module.scss | 79 ++++- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 330 +++++++++++++----- .../src/components/shell/data/shell.data.ts | 2 + 9 files changed, 489 insertions(+), 111 deletions(-) diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 5949b50..b745250 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -841,19 +841,34 @@ func (service *Service) loadProjectByID(ctx context.Context, projectID string) ( } func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { + return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectHierarchyRootPath) +} + +func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { + return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath) +} + +func (service *Service) getProjectHierarchyFoldersByRootPath( + ctx context.Context, + projectID string, + rootPath func(projectSlug string) string, +) ([]ProjectHierarchyFolderRecord, error) { project, err := service.loadProjectByID(ctx, projectID) if err != nil { return nil, err } + rootParentPath := rootPath(project.Slug) + rows, err := service.db.Pool.Query(ctx, ` SELECT path, COALESCE(parent_path, ''), COALESCE(resource_name, '') FROM posix_nodes WHERE node_kind = 'directory'::posix_node_kind AND logical_type = 'hierarchy_folder' AND project_slug = $1 + AND path LIKE $2 ORDER BY depth ASC, path ASC; - `, project.Slug) + `, project.Slug, rootParentPath+"/%") if err != nil { return nil, err } @@ -872,16 +887,29 @@ func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectI return nil, err } - return buildProjectHierarchyFolderTree(folderRows, projectHierarchyRootPath(project.Slug)), nil + return buildProjectHierarchyFolderTree(folderRows, rootParentPath), nil } func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { + return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk) +} + +func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { + return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk) +} + +func (service *Service) createProjectHierarchyFolder( + ctx context.Context, + input CreateProjectFolderInput, + rootPath func(projectSlug string) string, + createOnDisk func(projectSlug, parentFolderID, name string) (string, string, error), +) (CreateProjectFolderResult, error) { project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) if err != nil { return CreateProjectFolderResult{}, err } - createdPath, _, err := service.createProjectHierarchyFolderOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name) + createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name) if err != nil { return CreateProjectFolderResult{}, err } @@ -890,7 +918,7 @@ func (service *Service) CreateProjectFolder(ctx context.Context, input CreatePro return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) } - folders, err := service.GetProjectHierarchyFolders(ctx, project.ID) + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) if err != nil { return CreateProjectFolderResult{}, err } @@ -1183,8 +1211,19 @@ func (service *Service) ensureBootstrapPOSIXSkeleton( } func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { - rootPath := strings.TrimSpace(service.posixRoot) - if rootPath == "" { + return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath) +} + +func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { + return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath) +} + +func (service *Service) createProjectFolderOnDisk( + projectSlug, parentFolderID, name string, + rootPathBuilder func(projectSlug string) string, +) (string, string, error) { + posixRoot := strings.TrimSpace(service.posixRoot) + if posixRoot == "" { return "", "", fmt.Errorf("POSIX root is not configured") } @@ -1193,14 +1232,12 @@ func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFo return "", "", fmt.Errorf("folder name is required") } - projectRoot := filepath.Join(rootPath, "projects", slugDir("project", projectSlug)) - childrenRoot := filepath.Join(projectRoot, "children") - parentDir := childrenRoot - containerProjectionPath := projectHierarchyRootPath(projectSlug) + containerProjectionPath := rootPathBuilder(projectSlug) + parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) if strings.TrimSpace(parentFolderID) != "" { containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children")) - parentDir = filepath.Join(rootPath, filepath.FromSlash(containerProjectionPath)) + parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath)) info, err := os.Stat(parentDir) if err != nil { if os.IsNotExist(err) { @@ -1324,6 +1361,10 @@ func projectHierarchyRootPath(projectSlug string) string { return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children")) } +func projectTreeRootPath(projectSlug string) string { + return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree")) +} + func normalizePOSIXSlug(value string) string { trimmed := strings.TrimSpace(strings.ToLower(value)) if trimmed == "" { diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index 0df15ec..82aa48f 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -161,6 +161,56 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing } } +func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) { + rootPath := filepath.Join(t.TempDir(), "POSIX") + service := NewService(nil, rootPath) + + err := service.ensureBootstrapPOSIXSkeleton( + InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true}, + AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"}, + namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"}, + namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"}, + namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"}, + namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"}, + ) + if err != nil { + t.Fatalf("ensure bootstrap POSIX skeleton: %v", err) + } + + createdPath, createdSlug, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs") + if err != nil { + t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err) + } + if createdPath != "projects/project-primary-project/tree/folder-docs" { + t.Fatalf("unexpected created path: %s", createdPath) + } + if createdSlug != "docs" { + t.Fatalf("unexpected created slug: %s", createdSlug) + } + + createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs") + for _, path := range []string{ + filepath.Join(createdFolderPath, "folder.json"), + filepath.Join(createdFolderPath, "acl.json"), + filepath.Join(createdFolderPath, "children"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected path to exist %s: %v", path, err) + } + } + + nestedPath, nestedSlug, err := service.createProjectTreeFolderOnDisk("primary-project", createdPath, "Research") + if err != nil { + t.Fatalf("createProjectTreeFolderOnDisk nested folder: %v", err) + } + if nestedPath != "projects/project-primary-project/tree/folder-docs/children/folder-research" { + t.Fatalf("unexpected nested path: %s", nestedPath) + } + if nestedSlug != "research" { + t.Fatalf("unexpected nested slug: %s", nestedSlug) + } +} + func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) { rows := []projectHierarchyFolderRow{ {Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"}, diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go index 5652947..ba1acb1 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -79,6 +79,68 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http }) } +func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) { + projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) + if projectID == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") + return + } + + folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID) + if err != nil { + routes.writeProjectFolderError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "projectId": projectID, + "folders": folders, + }, + "meta": map[string]any{ + "resource": "project-tree-folders", + }, + }) +} + +func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) { + projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) + if projectID == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") + return + } + + payload, ok := decodeProjectFolderRequest(w, r) + if !ok { + return + } + + payload.Name = strings.TrimSpace(payload.Name) + payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) + if payload.Name == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") + return + } + + result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ + ProjectID: projectID, + ParentFolderID: payload.ParentFolderID, + Name: payload.Name, + }) + if err != nil { + routes.writeProjectFolderError(w, r, err) + return + } + + WriteJSON(w, http.StatusCreated, map[string]any{ + "data": result, + "meta": map[string]any{ + "resource": "project-tree-folder-create", + "persisted": true, + }, + }) +} + func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index b9e98e6..ea5ab50 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -36,6 +36,8 @@ func (routes apiRoutes) Register(router chi.Router) { apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) { projectRouter.Get("/folders", routes.handleProjectFolders) projectRouter.Post("/folders", routes.handleCreateProjectFolder) + projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders) + projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder) }) if routes.cfg.Config.IsDevelopment() { diff --git a/Backend/internal/posixproj/projector.go b/Backend/internal/posixproj/projector.go index 6a9e0b9..a2dbfb2 100644 --- a/Backend/internal/posixproj/projector.go +++ b/Backend/internal/posixproj/projector.go @@ -411,7 +411,7 @@ func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) { return "hierarchy_folder", "" } if hasTreeAncestor && strings.HasPrefix(name, "folder-") { - return "folder", "" + return "hierarchy_folder", "" } if hasTreeAncestor && strings.HasPrefix(name, "item-") { return "item", "" @@ -425,7 +425,7 @@ func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) { return "item", fileRole } if strings.HasPrefix(parentName, "folder-") { - return "folder", fileRole + return "hierarchy_folder", fileRole } } return "project", fileRole diff --git a/Backend/internal/posixproj/projector_test.go b/Backend/internal/posixproj/projector_test.go index a2ee812..45f3601 100644 --- a/Backend/internal/posixproj/projector_test.go +++ b/Backend/internal/posixproj/projector_test.go @@ -16,6 +16,7 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) { mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "children")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "tree")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree")) + mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "children")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap")) mustMkdirAll(t, filepath.Join(root, "users", "personals")) @@ -185,10 +186,15 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) { } treeFolder := index["projects/project-primary-project/tree/folder-docs"] - if treeFolder.LogicalType != "folder" || treeFolder.ProjectSlug != "primary-project" { + if treeFolder.LogicalType != "hierarchy_folder" || treeFolder.ProjectSlug != "primary-project" { t.Fatalf("unexpected tree folder node: %#v", treeFolder) } + treeFolderACL := index["projects/project-primary-project/tree/folder-docs/folder.json"] + if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" { + t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL) + } + treeItem := index["projects/project-primary-project/tree/folder-docs/item-roadmap/item.json"] if treeItem.LogicalType != "item" || treeItem.FileRole != "item" { t.Fatalf("unexpected tree item classification: %#v", treeItem) diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss index f3e9c7d..4d8526a 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss @@ -187,30 +187,53 @@ width: 100%; min-width: 0; display: grid; + position: relative; + isolation: isolate; grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); min-height: calc(var(--control-size-lg) - var(--space-2)); padding: var(--space-2) var(--space-3); padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); - border: 1px solid transparent; - border-radius: var(--radius-lg); + border: 0; + border-radius: 0; background: transparent; color: var(--color-text-muted); text-align: left; transition: - background 160ms var(--easing-standard), color 160ms var(--easing-standard), - border-color 160ms var(--easing-standard), box-shadow 160ms var(--easing-standard), transform 180ms var(--easing-standard); + + &::after { + content: ""; + position: absolute; + inset: 0; + border: 1px solid transparent; + border-radius: var(--radius-lg); + background: transparent; + transition: + background 160ms var(--easing-standard), + border-color 160ms var(--easing-standard), + box-shadow 160ms var(--easing-standard); + pointer-events: none; + z-index: 0; + } + + > * { + position: relative; + z-index: 1; + } } .treeItem:hover, .treeItem:focus-visible { - background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); color: var(--color-text); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + + &::after { + background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + } } .treeItemFolder { @@ -224,18 +247,43 @@ } .treeItemDropBefore { - box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + &::before { + content: ""; + position: absolute; + left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); + right: var(--space-3); + top: calc((var(--space-1) * -0.5) - 1px); + height: 2px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + pointer-events: none; + z-index: 2; + } } .treeItemDropAfter { - box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + &::before { + content: ""; + position: absolute; + left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); + right: var(--space-3); + bottom: calc((var(--space-1) * -0.5) - 1px); + height: 2px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent); + pointer-events: none; + z-index: 2; + } } .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); + + &::after { + border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); + background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface)); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + } } .folderChevron { @@ -248,10 +296,13 @@ } .treeItemActive { - border-color: var(--color-border); - background: var(--color-surface); color: var(--color-text); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + + &::after { + border-color: var(--color-border); + background: var(--color-surface); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); + } } .navItemActive { diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index f51aa2c..4df8410 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,7 +1,8 @@ // Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; -import { ChevronLeft, ChevronRight, Folder } from "../../../lib/icons"; +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 { @@ -49,40 +50,53 @@ type WorkspaceNodeLocation = { node: WorkspaceTreeNode; }; -const LONG_PRESS_MS = 320; +type PersistedWorkspaceFolderRecord = { + id: string; + label: string; + children?: PersistedWorkspaceFolderRecord[]; +}; -const createWorkspaceFolderId = (): string => `folder-${Math.random().toString(36).slice(2, 10)}`; +type WorkspaceFoldersResponse = { + data?: { + folders?: PersistedWorkspaceFolderRecord[]; + }; + error?: string; + message?: string; +}; + +const LONG_PRESS_MS = 320; 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]; +const buildPersistedWorkspaceFolderNodes = ( + folders: readonly PersistedWorkspaceFolderRecord[], +): WorkspaceTreeNode[] => + folders.map((folder) => ({ + id: folder.id, + label: folder.label, + kind: "folder", + icon: Folder, + children: buildPersistedWorkspaceFolderNodes(folder.children ?? []), + })); + +const collectWorkspaceFolderIds = (nodes: readonly WorkspaceTreeNode[]): string[] => { + const folderIds: string[] = []; + + for (const node of nodes) { + if (node.kind !== "folder") { + continue; + } + + folderIds.push(node.id); + folderIds.push(...collectWorkspaceFolderIds(node.children ?? [])); } - return 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, - }; - }); + return folderIds; }; +const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] => + Array.isArray(body.data?.folders) ? body.data.folders : []; + const findWorkspaceFolderDepth = (nodes: readonly WorkspaceTreeNode[], folderId: string, depth = 0): number | null => { for (const node of nodes) { if (node.kind !== "folder") { @@ -292,10 +306,6 @@ 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); @@ -350,17 +360,21 @@ const WorkspaceTreeBranch = (props: { onCancelPendingFolder: () => 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 (