From a5f0c41cba4ac167a5552c30e5045d97c8184e39 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Wed, 24 Jun 2026 17:07:08 +0100 Subject: [PATCH] Feat: add persisted folder rename --- Backend/internal/bootstrap/service.go | 148 ++++++++++ Backend/internal/bootstrap/service_test.go | 99 +++++++ Backend/internal/httpx/api_project_routes.go | 113 ++++++++ Backend/internal/httpx/api_routes.go | 2 + .../shell/ProjectSelector/ProjectSelector.tsx | 190 ++++++++++--- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 258 +++++++++++++----- .../src/components/shell/data/shell.data.ts | 1 + 7 files changed, 701 insertions(+), 110 deletions(-) diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 3da3c7b..8b471d4 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -197,6 +197,12 @@ type DeleteProjectFolderInput struct { FolderID string } +type RenameProjectFolderInput struct { + ProjectID string + FolderID string + Name string +} + type CreateProjectFolderResult struct { ProjectID string `json:"projectId"` CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` @@ -209,6 +215,13 @@ type DeleteProjectFolderResult struct { Folders []ProjectHierarchyFolderRecord `json:"folders"` } +type RenameProjectFolderResult struct { + ProjectID string `json:"projectId"` + PreviousFolderID string `json:"previousFolderId"` + RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + type projectHierarchyFolderRow struct { Path string ParentPath string @@ -917,6 +930,14 @@ func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input Delet return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk) } +func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { + return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk) +} + +func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) { + return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk) +} + func (service *Service) createProjectHierarchyFolder( ctx context.Context, input CreateProjectFolderInput, @@ -990,6 +1011,48 @@ func (service *Service) deleteProjectHierarchyFolder( }, nil } +func (service *Service) renameProjectHierarchyFolder( + ctx context.Context, + input RenameProjectFolderInput, + rootPath func(projectSlug string) string, + renameOnDisk func(projectSlug, folderID, name string) (string, string, error), +) (RenameProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return RenameProjectFolderResult{}, err + } + + previousFolderID, renamedFolderID, err := renameOnDisk(project.Slug, input.FolderID, input.Name) + if err != nil { + return RenameProjectFolderResult{}, err + } + + if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { + return RenameProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return RenameProjectFolderResult{}, err + } + + renamedFolder, found := findProjectHierarchyFolder(folders, renamedFolderID) + if !found { + return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") + } + + if _, found := findProjectHierarchyFolder(folders, previousFolderID); found { + return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") + } + + return RenameProjectFolderResult{ + ProjectID: project.ID, + PreviousFolderID: previousFolderID, + RenamedFolder: renamedFolder, + 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 @@ -1281,6 +1344,14 @@ func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID stri return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath) } +func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { + return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath) +} + +func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) { + return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath) +} + func (service *Service) createProjectFolderOnDisk( projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string, @@ -1396,6 +1467,83 @@ func (service *Service) deleteProjectFolderOnDisk( return folderProjectionPath, nil } +func (service *Service) renameProjectFolderOnDisk( + projectSlug, folderID, 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") + } + + trimmedFolderID := strings.TrimSpace(folderID) + if trimmedFolderID == "" { + return "", "", ErrProjectFolderNotFound + } + + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return "", "", fmt.Errorf("folder name is required") + } + + rootProjectionPath := rootPathBuilder(projectSlug) + folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/") + if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") { + return "", "", ErrProjectFolderNotFound + } + + folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath)) + info, err := os.Stat(folderDir) + if err != nil { + if os.IsNotExist(err) { + return "", "", ErrProjectFolderNotFound + } + return "", "", fmt.Errorf("stat project folder: %w", err) + } + if !info.IsDir() { + return "", "", ErrProjectFolderNotFound + } + + parentDir := filepath.Dir(folderDir) + baseSlug := normalizePOSIXSlug(trimmedName) + folderName := slugDir("folder", baseSlug) + folderSlug := baseSlug + destinationDir := filepath.Join(parentDir, folderName) + + for attempt := 2; ; attempt += 1 { + if destinationDir == folderDir { + break + } + + if _, err := os.Stat(destinationDir); os.IsNotExist(err) { + break + } else if err != nil { + return "", "", fmt.Errorf("stat candidate renamed project folder: %w", err) + } + + folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt) + folderName = slugDir("folder", folderSlug) + destinationDir = filepath.Join(parentDir, folderName) + } + + renamedProjectionPath := filepath.ToSlash(filepath.Join(filepath.Dir(folderProjectionPath), folderName)) + if destinationDir != folderDir { + if err := os.Rename(folderDir, destinationDir); err != nil { + return "", "", fmt.Errorf("rename project folder: %w", err) + } + } + + if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{ + "name": trimmedName, + "slug": folderSlug, + "type": "folder", + }); err != nil { + return "", "", fmt.Errorf("write renamed project folder.json: %w", err) + } + + return folderProjectionPath, renamedProjectionPath, nil +} + func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord { if len(rows) == 0 { return nil diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index 82aa48f..ef93159 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -211,6 +211,105 @@ func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) { } } +func TestRenameProjectHierarchyFolderOnDiskRenamesFolderShape(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, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System") + if err != nil { + t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err) + } + + nestedPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research") + if err != nil { + t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err) + } + + previousPath, renamedPath, err := service.renameProjectHierarchyFolderOnDisk("primary-project", createdPath, "Platform Design") + if err != nil { + t.Fatalf("renameProjectHierarchyFolderOnDisk: %v", err) + } + if previousPath != createdPath { + t.Fatalf("expected previous path %s, got %s", createdPath, previousPath) + } + if renamedPath != "projects/project-primary-project/children/folder-platform-design" { + t.Fatalf("unexpected renamed path: %s", renamedPath) + } + + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(createdPath))); !os.IsNotExist(err) { + t.Fatalf("expected previous folder path to be gone, got err=%v", err) + } + + renamedFolderPath := filepath.Join(rootPath, filepath.FromSlash(renamedPath)) + if _, err := os.Stat(filepath.Join(renamedFolderPath, "children", filepath.Base(nestedPath))); err != nil { + t.Fatalf("expected nested child folder to move with renamed parent: %v", err) + } + + folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, "folder.json")) + if folderPayload["name"] != "Platform Design" { + t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"]) + } + if folderPayload["slug"] != "platform-design" { + t.Fatalf("expected renamed folder slug platform-design, got %#v", folderPayload["slug"]) + } + if folderPayload["type"] != "folder" { + t.Fatalf("expected renamed folder type folder, got %#v", folderPayload["type"]) + } +} + +func TestRenameProjectTreeFolderOnDiskRenamesFolderShape(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, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs") + if err != nil { + t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err) + } + + previousPath, renamedPath, err := service.renameProjectTreeFolderOnDisk("primary-project", createdPath, "Specifications") + if err != nil { + t.Fatalf("renameProjectTreeFolderOnDisk: %v", err) + } + if previousPath != createdPath { + t.Fatalf("expected previous path %s, got %s", createdPath, previousPath) + } + if renamedPath != "projects/project-primary-project/tree/folder-specifications" { + t.Fatalf("unexpected renamed path: %s", renamedPath) + } + + folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(renamedPath), "folder.json")) + if folderPayload["name"] != "Specifications" { + t.Fatalf("expected renamed folder name Specifications, got %#v", folderPayload["name"]) + } + if folderPayload["slug"] != "specifications" { + t.Fatalf("expected renamed folder slug specifications, got %#v", folderPayload["slug"]) + } +} + 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 d82cecc..85cbda6 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -17,6 +17,11 @@ type createProjectFolderRequest struct { ParentFolderID string `json:"parentFolderId"` } +type renameProjectFolderRequest struct { + FolderID string `json:"folderId"` + Name string `json:"name"` +} + type deleteProjectFolderRequest struct { FolderID string `json:"folderId"` } @@ -114,6 +119,48 @@ func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http }) } +func (routes apiRoutes) handleRenameProjectFolder(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 := decodeRenameProjectFolderRequest(w, r) + if !ok { + return + } + + payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.Name = strings.TrimSpace(payload.Name) + if payload.FolderID == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") + return + } + if payload.Name == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") + return + } + + result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ + ProjectID: projectID, + FolderID: payload.FolderID, + Name: payload.Name, + }) + if err != nil { + routes.writeProjectFolderError(w, r, err, "rename") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": result, + "meta": map[string]any{ + "resource": "project-folder-rename", + "persisted": true, + }, + }) +} + func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { @@ -207,6 +254,48 @@ func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r * }) } +func (routes apiRoutes) handleRenameProjectTreeFolder(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 := decodeRenameProjectFolderRequest(w, r) + if !ok { + return + } + + payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.Name = strings.TrimSpace(payload.Name) + if payload.FolderID == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") + return + } + if payload.Name == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") + return + } + + result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ + ProjectID: projectID, + FolderID: payload.FolderID, + Name: payload.Name, + }) + if err != nil { + routes.writeProjectFolderError(w, r, err, "rename") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": result, + "meta": map[string]any{ + "resource": "project-tree-folder-rename", + "persisted": true, + }, + }) +} + func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) { switch { case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): @@ -227,6 +316,30 @@ func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderReques } } +func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) { + var payload renameProjectFolderRequest + + 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 +} + func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) { var payload createProjectFolderRequest diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index ee938b6..66c1f4f 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -36,9 +36,11 @@ 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.Patch("/folders", routes.handleRenameProjectFolder) projectRouter.Delete("/folders", routes.handleDeleteProjectFolder) projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders) projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder) + projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder) projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder) }) diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index 0b264cb..a687fb2 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -57,6 +57,8 @@ type PersistedProjectFolderRecord = { type ProjectFoldersResponse = { data?: { folders?: PersistedProjectFolderRecord[]; + renamedFolder?: PersistedProjectFolderRecord; + previousFolderId?: string; }; error?: string; message?: string; @@ -67,6 +69,11 @@ type PendingProjectFolderDraft = { depth: number; }; +type PendingProjectFolderRename = { + folderId: string; + depth: number; +}; + type ProjectDragTarget = NavTreeDropTarget; type ProjectDragState = { @@ -173,6 +180,11 @@ const ProjectFolderBranch = (props: { onPendingFolderNameChange: (value: string) => void; onSubmitPendingFolder: () => void; onCancelPendingFolder: () => void; + pendingFolderRename: PendingProjectFolderRename | null; + pendingFolderRenameName: string; + onPendingFolderRenameChange: (value: string) => void; + onSubmitPendingFolderRename: () => void; + onCancelPendingFolderRename: () => void; dragState: ProjectDragState | null; isTreeClickSuppressed: () => boolean; }): JSX.Element => ( @@ -197,51 +209,65 @@ const ProjectFolderBranch = (props: { if (node.kind === "folder") { const isCollapsed = (): boolean => props.isFolderCollapsed(node.id); + const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id; return (
  • - } > - - - {node.label} - - {node.meta} - - + 0 || props.pendingFolderDraft?.parentId === node.id)}> @@ -331,6 +362,8 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { ); const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); const [pendingFolderName, setPendingFolderName] = createSignal(""); + const [pendingFolderRename, setPendingFolderRename] = createSignal(null); + const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); const [dragState, setDragState] = createSignal(null); const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); let rootRef: HTMLDivElement | undefined; @@ -382,6 +415,8 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setCollapsedFolderIds([]); setPendingFolderDraft(null); setPendingFolderName(""); + setPendingFolderRename(null); + setPendingFolderRenameName(""); setDragState(null); }; @@ -607,10 +642,19 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); } + setPendingFolderRename(null); + setPendingFolderRenameName(""); setPendingFolderName(""); setPendingFolderDraft({ parentId, depth }); }; + const beginFolderRename = (folderId: string, label: string, depth: number): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename({ folderId, depth }); + setPendingFolderRenameName(label); + }; + const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); @@ -688,11 +732,71 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { } }; + const submitPendingFolderRename = async (): Promise => { + const draft = pendingFolderRename(); + const name = pendingFolderRenameName().trim(); + const projectId = selectedProject().id; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + return; + } + + if (!isUuidString(projectId)) { + cancelPendingFolderRename(); + return; + } + + try { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + folderId: draft.folderId, + name, + }), + }); + + const body = (await response.json()) as ProjectFoldersResponse; + + if (!response.ok) { + throw new Error(body.message || "Failed to rename project folder."); + } + + setPersistedFolders(readPersistedFolders(body)); + setPendingFolderRename(null); + setPendingFolderRenameName(""); + + const previousFolderId = body.data?.previousFolderId; + const renamedFolderId = body.data?.renamedFolder?.id; + if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) { + setCollapsedFolderIds((current) => + current.map((id) => (id === previousFolderId ? renamedFolderId : id)), + ); + } + } catch (error) { + console.error(error); + } + }; + const cancelPendingFolder = (): void => { setPendingFolderDraft(null); setPendingFolderName(""); }; + const cancelPendingFolderRename = (): void => { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + }; + const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => { switch (action.id) { case "new-folder": @@ -715,6 +819,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { void deletePersistedFolder(target.id); } return; + case "rename-folder": + if (target.kind === "folder") { + beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0); + } + return; default: return; } @@ -887,6 +996,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { onPendingFolderNameChange={setPendingFolderName} onSubmitPendingFolder={submitPendingFolder} onCancelPendingFolder={cancelPendingFolder} + pendingFolderRename={pendingFolderRename()} + pendingFolderRenameName={pendingFolderRenameName()} + onPendingFolderRenameChange={setPendingFolderRenameName} + onSubmitPendingFolderRename={submitPendingFolderRename} + onCancelPendingFolderRename={cancelPendingFolderRename} dragState={dragState()} isTreeClickSuppressed={suppressNextTreeClick} /> diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index 502d334..a0e4c1b 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -58,11 +58,18 @@ type PersistedWorkspaceFolderRecord = { type WorkspaceFoldersResponse = { data?: { folders?: PersistedWorkspaceFolderRecord[]; + renamedFolder?: PersistedWorkspaceFolderRecord; + previousFolderId?: string; }; error?: string; message?: string; }; +type PendingWorkspaceFolderRename = { + folderId: string; + depth: number; +}; + const LONG_PRESS_MS = 320; const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id; @@ -194,6 +201,11 @@ const WorkspaceTreeBranch = (props: { onPendingFolderNameChange: (value: string) => void; onSubmitPendingFolder: () => void; onCancelPendingFolder: () => void; + pendingFolderRename: PendingWorkspaceFolderRename | null; + pendingFolderRenameName: string; + onPendingFolderRenameChange: (value: string) => void; + onSubmitPendingFolderRename: () => void; + onCancelPendingFolderRename: () => void; onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; onNodePointerDown: (event: PointerEvent, nodeId: string) => void; @@ -216,6 +228,7 @@ const WorkspaceTreeBranch = (props: { const Icon = getWorkspaceNodeIcon(node); const target = createWorkspaceTreeTarget(node); const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false); + const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id; const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id; const dropIntent = (): WorkspaceDragTarget["intent"] | null => { if (props.dragState?.dropTarget?.targetNodeId !== node.id) { @@ -227,73 +240,86 @@ const WorkspaceTreeBranch = (props: { return (
  • - + 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} + data-slot="workspace-tree-item" + 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; + } + + event.preventDefault(); + props.onOpenContextMenuFromKeyboard(event.currentTarget, target); + }} + > + + + + + {node.label} + + {node.meta} + + + } + > + + 0) || props.pendingFolderDraft?.parentId === node.id)}> { const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); const [pendingFolderName, setPendingFolderName] = createSignal(""); + const [pendingFolderRename, setPendingFolderRename] = createSignal(null); + const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); const [dragState, setDragState] = createSignal(null); const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); let lastSelectedProjectId: string | null = null; @@ -383,6 +416,8 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { setCollapsedFolderIds([]); setPendingFolderDraft(null); setPendingFolderName(""); + setPendingFolderRename(null); + setPendingFolderRenameName(""); setDragState(null); }; const syncWorkspaceTree = (): void => { @@ -530,10 +565,19 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); } + setPendingFolderRename(null); + setPendingFolderRenameName(""); setPendingFolderName(""); setPendingFolderDraft({ parentId, depth }); }; + const beginFolderRename = (folderId: string, label: string, depth: number): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename({ folderId, depth }); + setPendingFolderRenameName(label); + }; + const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); @@ -611,11 +655,71 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { } }; + const submitPendingFolderRename = async (): Promise => { + const draft = pendingFolderRename(); + const name = pendingFolderRenameName().trim(); + const projectId = activeProject()?.id ?? ""; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + return; + } + + if (!projectId || !isUuidString(projectId)) { + cancelPendingFolderRename(); + return; + } + + try { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + folderId: draft.folderId, + name, + }), + }); + + const body = (await response.json()) as WorkspaceFoldersResponse; + + if (!response.ok) { + throw new Error(body.message || "Failed to rename project tree folder."); + } + + setPersistedFolders(readPersistedWorkspaceFolders(body)); + setPendingFolderRename(null); + setPendingFolderRenameName(""); + + const previousFolderId = body.data?.previousFolderId; + const renamedFolderId = body.data?.renamedFolder?.id; + if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) { + setCollapsedFolderIds((current) => + current.map((id) => (id === previousFolderId ? renamedFolderId : id)), + ); + } + } catch (error) { + console.error(error); + } + }; + const cancelPendingFolder = (): void => { setPendingFolderDraft(null); setPendingFolderName(""); }; + const cancelPendingFolderRename = (): void => { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + }; + const handleHeaderActionClick = (actionId: string): void => { switch (actionId) { case "toggle-workspace-folders": @@ -689,6 +793,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { void deletePersistedFolder(target.id); } return; + case "rename-folder": + if (target.kind === "folder") { + beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0); + } + return; default: return; } @@ -815,13 +924,18 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { onPendingFolderNameChange={setPendingFolderName} onSubmitPendingFolder={submitPendingFolder} onCancelPendingFolder={cancelPendingFolder} - onOpenContextMenu={contextMenu.openMenu} - onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement} - onNodePointerDown={handleNodePointerDown} - onNodePointerMove={handleNodePointerMove} - dragState={dragState()} - isTreeClickSuppressed={suppressNextTreeClick} - /> + pendingFolderRename={pendingFolderRename()} + pendingFolderRenameName={pendingFolderRenameName()} + onPendingFolderRenameChange={setPendingFolderRenameName} + onSubmitPendingFolderRename={submitPendingFolderRename} + onCancelPendingFolderRename={cancelPendingFolderRename} + onOpenContextMenu={contextMenu.openMenu} + onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement} + onNodePointerDown={handleNodePointerDown} + onNodePointerMove={handleNodePointerMove} + dragState={dragState()} + isTreeClickSuppressed={suppressNextTreeClick} + /> diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index b9d6c9b..6fda3c0 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -599,6 +599,7 @@ const getProjectCreateActions = (): readonly ProjectContextMenuAction[] => const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] => [ + { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, ] as const;