From c64a7b8d446c1a3e0afae390858f0b4f60c89ce6 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Wed, 24 Jun 2026 16:15:30 +0100 Subject: [PATCH] Fix: add persisted project folder deletion --- Backend/internal/bootstrap/service.go | 102 ++++++++++++++++++ Backend/internal/httpx/api_project_routes.go | 88 +++++++++++++-- Backend/internal/httpx/api_routes.go | 2 + .../shell/ProjectSelector/ProjectSelector.tsx | 63 ++++++++--- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 61 ++++++++--- .../src/components/shell/data/shell.data.ts | 9 ++ 6 files changed, 292 insertions(+), 33 deletions(-) diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index b745250..3da3c7b 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -192,12 +192,23 @@ type CreateProjectFolderInput struct { Name string } +type DeleteProjectFolderInput struct { + ProjectID string + FolderID string +} + type CreateProjectFolderResult struct { ProjectID string `json:"projectId"` CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` Folders []ProjectHierarchyFolderRecord `json:"folders"` } +type DeleteProjectFolderResult struct { + ProjectID string `json:"projectId"` + DeletedFolderID string `json:"deletedFolderId"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` +} + type projectHierarchyFolderRow struct { Path string ParentPath string @@ -898,6 +909,14 @@ func (service *Service) CreateProjectTreeFolder(ctx context.Context, input Creat return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk) } +func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { + return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk) +} + +func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) { + return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk) +} + func (service *Service) createProjectHierarchyFolder( ctx context.Context, input CreateProjectFolderInput, @@ -935,6 +954,42 @@ func (service *Service) createProjectHierarchyFolder( }, nil } +func (service *Service) deleteProjectHierarchyFolder( + ctx context.Context, + input DeleteProjectFolderInput, + rootPath func(projectSlug string) string, + deleteOnDisk func(projectSlug, folderID string) (string, error), +) (DeleteProjectFolderResult, error) { + project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + deletedFolderID, err := deleteOnDisk(project.Slug, input.FolderID) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { + return DeleteProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + } + + folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) + if err != nil { + return DeleteProjectFolderResult{}, err + } + + if _, found := findProjectHierarchyFolder(folders, deletedFolderID); found { + return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection") + } + + return DeleteProjectFolderResult{ + ProjectID: project.ID, + DeletedFolderID: deletedFolderID, + 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 @@ -1218,6 +1273,14 @@ func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderI return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath) } +func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) { + return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath) +} + +func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) { + return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath) +} + func (service *Service) createProjectFolderOnDisk( projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string, @@ -1294,6 +1357,45 @@ func (service *Service) createProjectFolderOnDisk( return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil } +func (service *Service) deleteProjectFolderOnDisk( + projectSlug, folderID string, + rootPathBuilder func(projectSlug 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 + } + + 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 + } + + if err := os.RemoveAll(folderDir); err != nil { + return "", fmt.Errorf("delete project folder: %w", err) + } + + return folderProjectionPath, nil +} + func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord { if len(rows) == 0 { return nil diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go index ba1acb1..d82cecc 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -17,6 +17,10 @@ type createProjectFolderRequest struct { ParentFolderID string `json:"parentFolderId"` } +type deleteProjectFolderRequest struct { + FolderID string `json:"folderId"` +} + func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { @@ -26,7 +30,7 @@ func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Requ folders, err := routes.bootstrapService().GetProjectHierarchyFolders(r.Context(), projectID) if err != nil { - routes.writeProjectFolderError(w, r, err) + routes.writeProjectFolderError(w, r, err, "load") return } @@ -66,7 +70,7 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http Name: payload.Name, }) if err != nil { - routes.writeProjectFolderError(w, r, err) + routes.writeProjectFolderError(w, r, err, "persist") return } @@ -79,6 +83,37 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http }) } +func (routes apiRoutes) handleDeleteProjectFolder(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 := decodeDeleteProjectFolderRequest(r) + if strings.TrimSpace(payload.FolderID) == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") + return + } + + result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ + ProjectID: projectID, + FolderID: payload.FolderID, + }) + if err != nil { + routes.writeProjectFolderError(w, r, err, "delete") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": result, + "meta": map[string]any{ + "resource": "project-folder-delete", + "persisted": true, + }, + }) +} + func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { @@ -88,7 +123,7 @@ func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http. folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID) if err != nil { - routes.writeProjectFolderError(w, r, err) + routes.writeProjectFolderError(w, r, err, "load") return } @@ -128,7 +163,7 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r * Name: payload.Name, }) if err != nil { - routes.writeProjectFolderError(w, r, err) + routes.writeProjectFolderError(w, r, err, "persist") return } @@ -141,17 +176,54 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r * }) } -func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error) { +func (routes apiRoutes) handleDeleteProjectTreeFolder(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 := decodeDeleteProjectFolderRequest(r) + if strings.TrimSpace(payload.FolderID) == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") + return + } + + result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ + ProjectID: projectID, + FolderID: payload.FolderID, + }) + if err != nil { + routes.writeProjectFolderError(w, r, err, "delete") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": result, + "meta": map[string]any{ + "resource": "project-tree-folder-delete", + "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): 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." + routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path) + message := "Failed to " + operation + " project folder." if routes.cfg.Config.IsDevelopment() { message = message + " " + err.Error() } - WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_persist_failed", message) + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message) + } +} + +func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { + return deleteProjectFolderRequest{ + FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), } } diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index ea5ab50..ee938b6 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -36,8 +36,10 @@ 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.Delete("/folders", routes.handleDeleteProjectFolder) projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders) projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder) + projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder) }) if routes.cfg.Config.IsDevelopment() { diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index 3d24279..0b264cb 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -658,28 +658,65 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { } }; + const deletePersistedFolder = async (folderId: string): Promise => { + const projectId = selectedProject().id; + if (!folderId || !isUuidString(projectId)) { + return; + } + + try { + const response = await fetch( + `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, + { + method: "DELETE", + headers: { + Accept: "application/json", + }, + }, + ); + + const body = (await response.json()) as ProjectFoldersResponse; + + if (!response.ok) { + throw new Error(body.message || "Failed to delete project folder."); + } + + setPersistedFolders(readPersistedFolders(body)); + setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); + } catch (error) { + console.error(error); + } + }; + 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); + switch (action.id) { + case "new-folder": + switch (target.kind) { + case "surface": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); + return; + case "project": { + const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; + beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); + return; + } + } return; - case "folder": - beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); + case "delete-folder": + if (target.kind === "folder") { + void deletePersistedFolder(target.id); + } return; - case "project": { - const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; - beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); + default: return; - } } }; diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index ac77305..502d334 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -581,6 +581,36 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { } }; + const deletePersistedFolder = async (folderId: string): Promise => { + const projectId = activeProject()?.id ?? ""; + if (!folderId || !projectId || !isUuidString(projectId)) { + return; + } + + try { + const response = await fetch( + `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, + { + method: "DELETE", + headers: { + Accept: "application/json", + }, + }, + ); + + const body = (await response.json()) as WorkspaceFoldersResponse; + + if (!response.ok) { + throw new Error(body.message || "Failed to delete project tree folder."); + } + + setPersistedFolders(readPersistedWorkspaceFolders(body)); + setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); + } catch (error) { + console.error(error); + } + }; + const cancelPendingFolder = (): void => { setPendingFolderDraft(null); setPendingFolderName(""); @@ -639,20 +669,27 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { }; const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { - if (action.id !== "new-folder") { - return; - } - - switch (target.kind) { - case "workspace": - case "home": - beginFolderDraft(null, 0); + switch (action.id) { + case "new-folder": + switch (target.kind) { + case "workspace": + case "home": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1); + return; + case "settings": + case "item": + return; + } return; - case "folder": - beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1); + case "delete-folder": + if (target.kind === "folder") { + void deletePersistedFolder(target.id); + } return; - case "settings": - case "item": + default: return; } }; diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 94f8740..b9d6c9b 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -597,6 +597,11 @@ const getProjectCreateActions = (): readonly ProjectContextMenuAction[] => { id: "new-folder", label: "New folder" }, ] as const; +const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] => + [ + { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ] as const; + export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({ id: "project-surface", label, @@ -643,6 +648,10 @@ export const getProjectContextMenuSections = (target: ProjectMenuTarget): readon id: "create", items: createActions, }, + { + id: "organize", + items: getProjectFolderDangerActions(), + }, ] as const; case "project": return [