diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 346b388..5949b50 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "unicode" "github.com/jackc/pgx/v5" @@ -42,6 +43,8 @@ const ( var ( ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed") + ErrProjectNotFound = errors.New("project not found") + ErrProjectFolderNotFound = errors.New("project folder not found") ) type Service struct { @@ -177,6 +180,30 @@ type namedRecord struct { Slug string `json:"slug"` } +type ProjectHierarchyFolderRecord struct { + ID string `json:"id"` + Label string `json:"label"` + Children []ProjectHierarchyFolderRecord `json:"children"` +} + +type CreateProjectFolderInput struct { + ProjectID string + ParentFolderID string + Name string +} + +type CreateProjectFolderResult struct { + ProjectID string `json:"projectId"` + CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + +type projectHierarchyFolderRow struct { + Path string + ParentPath string + Label string +} + func NewService(db *database.DB, posixRoot string) *Service { return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)} } @@ -794,6 +821,92 @@ func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, erro return records, rows.Err() } +func (service *Service) loadProjectByID(ctx context.Context, projectID string) (*ProjectRecord, error) { + var record ProjectRecord + err := service.db.Pool.QueryRow(ctx, ` + SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug + FROM projects + WHERE id = $1::uuid + LIMIT 1; + `, projectID).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + + return nil, err + } + + return &record, nil +} + +func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) { + project, err := service.loadProjectByID(ctx, projectID) + if err != nil { + return nil, err + } + + 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 + ORDER BY depth ASC, path ASC; + `, project.Slug) + if err != nil { + return nil, err + } + defer rows.Close() + + var folderRows []projectHierarchyFolderRow + for rows.Next() { + var row projectHierarchyFolderRow + if err := rows.Scan(&row.Path, &row.ParentPath, &row.Label); err != nil { + return nil, err + } + folderRows = append(folderRows, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return buildProjectHierarchyFolderTree(folderRows, projectHierarchyRootPath(project.Slug)), nil +} + +func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (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) + if err != nil { + return CreateProjectFolderResult{}, err + } + + if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { + return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + } + + folders, err := service.GetProjectHierarchyFolders(ctx, project.ID) + if err != nil { + return CreateProjectFolderResult{}, err + } + + createdFolder, ok := findProjectHierarchyFolder(folders, createdPath) + if !ok { + return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection") + } + + return CreateProjectFolderResult{ + ProjectID: project.ID, + CreatedFolder: createdFolder, + Folders: folders, + }, nil +} + func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) { rows, err := service.db.Pool.Query(ctx, ` SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text @@ -1069,6 +1182,194 @@ func (service *Service) ensureBootstrapPOSIXSkeleton( return nil } +func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) { + rootPath := strings.TrimSpace(service.posixRoot) + if rootPath == "" { + return "", "", fmt.Errorf("POSIX root is not configured") + } + + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + 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) + + if strings.TrimSpace(parentFolderID) != "" { + containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children")) + parentDir = filepath.Join(rootPath, filepath.FromSlash(containerProjectionPath)) + info, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat parent project folder: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectFolderNotFound + } + } + + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return "", "", fmt.Errorf("create parent project folder path: %w", err) + } + + baseSlug := normalizePOSIXSlug(trimmedName) + folderName := slugDir("folder", baseSlug) + folderDir := filepath.Join(parentDir, folderName) + folderSlug := baseSlug + + for attempt := 2; ; attempt += 1 { + if _, err := os.Stat(folderDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate project folder: %w", err) + } + + folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + folderName = slugDir("folder", folderSlug) + folderDir = filepath.Join(parentDir, folderName) + } + + if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil { + return "", "", fmt.Errorf("create project hierarchy folder: %w", err) + } + + if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{ + "name": trimmedName, + "slug": folderSlug, + "type": "folder", + }); err != nil { + return "", "", fmt.Errorf("write project folder.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(folderDir, "acl.json"), map[string]any{ + "version": 1, + "inherits": true, + "rules": []any{}, + }); err != nil { + return "", "", fmt.Errorf("write project acl.json: %w", err) + } + + return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil +} + +func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord { + if len(rows) == 0 { + return nil + } + + nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows)) + childrenByParent := make(map[string][]string) + + for _, row := range rows { + label := strings.TrimSpace(row.Label) + if label == "" { + label = fallbackFolderLabel(row.Path) + } + nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ + ID: row.Path, + Label: label, + Children: []ProjectHierarchyFolderRecord{}, + } + childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path) + } + + var build func(parentPath string) []ProjectHierarchyFolderRecord + build = func(parentPath string) []ProjectHierarchyFolderRecord { + childPaths := childrenByParent[parentPath] + if len(childPaths) == 0 { + return nil + } + + folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths)) + for _, childPath := range childPaths { + node := nodesByPath[childPath] + if node == nil { + continue + } + + folder := ProjectHierarchyFolderRecord{ + ID: node.ID, + Label: node.Label, + Children: build(filepath.ToSlash(filepath.Join(childPath, "children"))), + } + folders = append(folders, folder) + } + + return folders + } + + return build(rootParentPath) +} + +func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) { + for _, folder := range folders { + if folder.ID == folderID { + return folder, true + } + + if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok { + return child, true + } + } + + return ProjectHierarchyFolderRecord{}, false +} + +func projectHierarchyRootPath(projectSlug string) string { + return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children")) +} + +func normalizePOSIXSlug(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "untitled" + } + + var builder strings.Builder + lastDash := false + for _, r := range trimmed { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || unicode.IsSpace(r): + if !lastDash && builder.Len() > 0 { + builder.WriteByte('-') + lastDash = true + } + } + } + + slug := strings.Trim(builder.String(), "-") + if slug == "" { + return "untitled" + } + + return slug +} + +func fallbackFolderLabel(path string) string { + base := filepath.Base(filepath.FromSlash(path)) + trimmed := strings.TrimPrefix(base, "folder-") + parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' }) + for index, part := range parts { + if part == "" { + continue + } + parts[index] = strings.ToUpper(part[:1]) + part[1:] + } + label := strings.Join(parts, " ") + if label == "" { + return base + } + return label +} + func slugDir(prefix, slug string) string { trimmedSlug := strings.TrimSpace(slug) if trimmedSlug == "" { diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index 3d880e1..0df15ec 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -103,6 +103,83 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) { } } +func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(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.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System") + if err != nil { + t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err) + } + if createdPath != "projects/project-primary-project/children/folder-design-system" { + t.Fatalf("unexpected created path: %s", createdPath) + } + if createdSlug != "design-system" { + t.Fatalf("unexpected created slug: %s", createdSlug) + } + + createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "children", "folder-design-system") + 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) + } + } + + folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(createdFolderPath, "folder.json")) + if folderPayload["name"] != "Design System" { + t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"]) + } + if folderPayload["slug"] != "design-system" { + t.Fatalf("expected folder slug design-system, got %#v", folderPayload["slug"]) + } + + nestedPath, nestedSlug, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research") + if err != nil { + t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err) + } + if nestedPath != "projects/project-primary-project/children/folder-design-system/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"}, + {Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"}, + {Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"}, + } + + folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project")) + if len(folders) != 2 { + t.Fatalf("expected 2 top-level folders, got %d", len(folders)) + } + if folders[0].Label != "Design" || folders[1].Label != "Ops" { + t.Fatalf("unexpected top-level folder labels: %#v", folders) + } + if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" { + t.Fatalf("unexpected nested folder structure: %#v", folders[0].Children) + } +} + func readJSONFileForTest[T any](t *testing.T, path string) T { t.Helper() diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go new file mode 100644 index 0000000..5652947 --- /dev/null +++ b/Backend/internal/httpx/api_project_routes.go @@ -0,0 +1,118 @@ +package httpx + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + bootstrapservice "moku-backend/internal/bootstrap" + + "github.com/go-chi/chi/v5" +) + +type createProjectFolderRequest struct { + Name string `json:"name"` + ParentFolderID string `json:"parentFolderId"` +} + +func (routes apiRoutes) handleProjectFolders(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().GetProjectHierarchyFolders(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-folders", + }, + }) +} + +func (routes apiRoutes) handleCreateProjectFolder(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().CreateProjectFolder(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-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): + WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) + default: + routes.cfg.Logger.Error("persist project folder", "error", err, "path", r.URL.Path) + message := "Failed to persist project folder." + if routes.cfg.Config.IsDevelopment() { + message = message + " " + err.Error() + } + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_persist_failed", message) + } +} + +func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) { + var payload createProjectFolderRequest + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + if errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.") + return payload, false + } + + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.") + return payload, false + } + + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") + return payload, false + } + + return payload, true +} diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index 65271d5..b9e98e6 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) { apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/workspaces", routes.handleWorkspaces) + apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) { + projectRouter.Get("/folders", routes.handleProjectFolders) + projectRouter.Post("/folders", routes.handleCreateProjectFolder) + }) if routes.cfg.Config.IsDevelopment() { apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset) diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss index aa83f68..7215dbb 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss @@ -192,13 +192,61 @@ .treeSectionLabel { @include text-caption; - margin: 0 0 var(--space-2); + margin: 0; padding: 0 var(--space-3); color: var(--color-text-subtle); text-transform: uppercase; letter-spacing: 0.04em; } +.treeSectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-2); + padding-right: var(--space-1); +} + +.treeControls { + display: inline-flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; + justify-content: flex-end; +} + +.treeControlButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(var(--control-size-md) - var(--space-1)); + height: calc(var(--control-size-md) - var(--space-1)); + @include text-caption; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-border) 46%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-surface) 95%, transparent); + color: var(--color-text-muted); + transition: + border-color 160ms var(--easing-standard), + background 160ms var(--easing-standard), + color 160ms var(--easing-standard); +} + +.treeControlButton:hover, +.treeControlButton:focus-visible { + border-color: color-mix(in srgb, var(--color-border-strong) 56%, transparent); + background: var(--color-surface-hover); + color: var(--color-text); + outline: none; +} + +.treeControlButton:disabled { + opacity: 0.45; + cursor: default; +} + .treeList { list-style: none; display: grid; diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index 1b5ce4f..b938b88 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -1,9 +1,10 @@ // Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; -import { ChevronDown, ChevronRight, Folder, LayoutGrid } from "../../../lib/icons"; +import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, UnfoldVertical } from "../../../lib/icons"; import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; import { useAppShellData } from "../data/app-shell.context"; +import { resolveAPIBase } from "../../../lib/api"; import { createProjectFolderTarget, createProjectSurfaceTarget, @@ -36,6 +37,20 @@ type ProjectLeafNode = { type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; +type PersistedProjectFolderRecord = { + id: string; + label: string; + children: PersistedProjectFolderRecord[]; +}; + +type ProjectFoldersResponse = { + data?: { + folders?: PersistedProjectFolderRecord[]; + }; + error?: string; + message?: string; +}; + type PendingProjectFolderDraft = { parentId: string | null; depth: number; @@ -61,17 +76,46 @@ type ProjectNodeLocation = { 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 buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] => + folders.map((folder) => ({ + kind: "folder", + id: folder.id, + label: folder.label, + children: buildPersistedFolderNodes(folder.children ?? []), })); +const buildProjectTree = ( + items: readonly ProjectItem[], + folders: readonly PersistedProjectFolderRecord[] = [], +): ProjectTreeNode[] => [ + ...items.map((item) => ({ + kind: "project" as const, + item, + })), + ...buildPersistedFolderNodes(folders), +]; + +const collectProjectFolderIds = (nodes: readonly ProjectTreeNode[]): string[] => { + const ids: string[] = []; + + for (const node of nodes) { + if (node.kind !== "folder") { + continue; + } + + ids.push(node.id); + ids.push(...collectProjectFolderIds(node.children)); + } + + return ids; +}; + +const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => + Array.isArray(body.data?.folders) ? body.data.folders : []; + const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => { if (node.kind === "project") { return { @@ -89,34 +133,6 @@ const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => { }; }; -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, @@ -336,6 +352,7 @@ const ProjectFolderBranch = (props: { onSubmitPendingFolder: () => void; onCancelPendingFolder: () => void; dragState: ProjectDragState | null; + isTreeClickSuppressed: () => boolean; }): JSX.Element => (