Fix: persist folder drag and drop moves

This commit is contained in:
MangoPig
2026-06-24 17:59:43 +01:00
parent a92e188f84
commit 9ddfa0c3c7
6 changed files with 578 additions and 6 deletions
+204
View File
@@ -45,6 +45,7 @@ var (
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
)
type Service struct {
@@ -203,6 +204,12 @@ type RenameProjectFolderInput struct {
Name string
}
type MoveProjectFolderInput struct {
ProjectID string
FolderID string
ParentFolderID string
}
type CreateProjectFolderResult struct {
ProjectID string `json:"projectId"`
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
@@ -222,6 +229,13 @@ type RenameProjectFolderResult struct {
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderID string `json:"previousFolderId"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type projectHierarchyFolderRow struct {
Path string
ParentPath string
@@ -944,6 +958,14 @@ func (service *Service) RenameProjectTreeFolder(ctx context.Context, input Renam
return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk)
}
func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk)
}
func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
}
func (service *Service) createProjectHierarchyFolder(
ctx context.Context,
input CreateProjectFolderInput,
@@ -1059,6 +1081,50 @@ func (service *Service) renameProjectHierarchyFolder(
}, nil
}
func (service *Service) moveProjectHierarchyFolder(
ctx context.Context,
input MoveProjectFolderInput,
rootPath func(projectSlug string) string,
moveOnDisk func(projectSlug, folderID, parentFolderID string) (string, string, error),
) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectFolderResult{}, err
}
previousFolderID, movedFolderID, err := moveOnDisk(project.Slug, input.FolderID, input.ParentFolderID)
if err != nil {
return MoveProjectFolderResult{}, err
}
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
return MoveProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err)
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found := findProjectHierarchyFolder(folders, movedFolderID)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
}
if previousFolderID != movedFolderID {
if _, found := findProjectHierarchyFolder(folders, previousFolderID); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
}
return MoveProjectFolderResult{
ProjectID: project.ID,
PreviousFolderID: previousFolderID,
MovedFolder: movedFolder,
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
@@ -1358,6 +1424,14 @@ func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, nam
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath)
}
func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath)
}
func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath)
}
func (service *Service) createProjectFolderOnDisk(
projectSlug, parentFolderID, name string,
rootPathBuilder func(projectSlug string) string,
@@ -1550,6 +1624,136 @@ func (service *Service) renameProjectFolderOnDisk(
return folderProjectionPath, renamedProjectionPath, nil
}
func (service *Service) moveProjectFolderOnDisk(
projectSlug, folderID, parentFolderID string,
rootPathBuilder func(projectSlug string) string,
) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/")
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
}
trimmedParentFolderID := strings.TrimSpace(parentFolderID)
parentChildrenProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath))
if trimmedParentFolderID != "" {
parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") {
return "", "", ErrInvalidProjectFolderMove
}
parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(folderDir)
if samePath(currentParentDir, parentDir) {
return folderProjectionPath, folderProjectionPath, nil
}
folderPayload := readJSONFileMap(filepath.Join(folderDir, "folder.json"))
folderName, _ := folderPayload["name"].(string)
if strings.TrimSpace(folderName) == "" {
folderName = fallbackFolderLabel(folderProjectionPath)
}
currentBase := filepath.Base(folderDir)
baseSlug := strings.TrimPrefix(currentBase, "folder-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(folderName)
}
folderSlug := baseSlug
folderDirName := slugDir("folder", folderSlug)
destinationDir := filepath.Join(parentDir, folderDirName)
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderDirName = slugDir("folder", folderSlug)
destinationDir = filepath.Join(parentDir, folderDirName)
}
if err := os.Rename(folderDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project folder: %w", err)
}
folderPayload["name"] = folderName
folderPayload["slug"] = folderSlug
folderPayload["type"] = "folder"
if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), folderPayload); err != nil {
return "", "", fmt.Errorf("write moved project folder.json: %w", err)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName))
return folderProjectionPath, movedProjectionPath, nil
}
func samePath(left, right string) bool {
cleanLeft := filepath.Clean(left)
cleanRight := filepath.Clean(right)
if cleanLeft == cleanRight {
return true
}
leftInfo, leftErr := os.Stat(cleanLeft)
rightInfo, rightErr := os.Stat(cleanRight)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
return false
}
func readJSONFileMap(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return map[string]any{}
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil || payload == nil {
return map[string]any{}
}
return payload
}
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
if len(rows) == 0 {
return nil