diff --git a/Backend/internal/bootstrap/bootstrap_helpers.go b/Backend/internal/bootstrap/bootstrap_helpers.go
new file mode 100644
index 0000000..dddf4f9
--- /dev/null
+++ b/Backend/internal/bootstrap/bootstrap_helpers.go
@@ -0,0 +1,81 @@
+// Path: Backend/internal/bootstrap/bootstrap_helpers.go
+
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+
+ "moku-backend/internal/database"
+)
+
+func NewService(db *database.DB, posixRoot string) *Service {
+ return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
+}
+
+func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
+ var record namedRecord
+ if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
+ return namedRecord{}, err
+ }
+
+ return record, nil
+}
+
+func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
+ _, err := tx.Exec(ctx, `
+ INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
+ VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
+ ON CONFLICT (organization_id, slug) DO UPDATE
+ SET
+ name = EXCLUDED.name,
+ kind = EXCLUDED.kind,
+ created_by_user_id = EXCLUDED.created_by_user_id,
+ department_id = EXCLUDED.department_id,
+ team_id = EXCLUDED.team_id,
+ project_id = EXCLUDED.project_id,
+ updated_at = NOW();
+ `, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
+
+ return err
+}
+
+func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string {
+ trimmedInstallationName := strings.TrimSpace(installationName)
+ trimmedHost := strings.TrimSpace(host)
+ trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
+
+ if trimmedInstallationName != "" {
+ return trimmedInstallationName
+ }
+
+ if strings.EqualFold(mode, defaultInstallationMode) {
+ if trimmedAdminDisplayName != "" {
+ return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
+ }
+
+ return defaultPersonalDisplayName
+ }
+
+ if trimmedHost != "" {
+ return trimmedHost
+ }
+
+ return defaultOrganizationName
+}
+
+func personalHomeTitle(displayName string) string {
+ trimmedDisplayName := strings.TrimSpace(displayName)
+ if trimmedDisplayName == "" {
+ return "Home"
+ }
+
+ if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
+ return fmt.Sprintf("%s' Home", trimmedDisplayName)
+ }
+
+ return fmt.Sprintf("%s's Home", trimmedDisplayName)
+}
diff --git a/Backend/internal/bootstrap/bootstrap_types.go b/Backend/internal/bootstrap/bootstrap_types.go
new file mode 100644
index 0000000..fea221c
--- /dev/null
+++ b/Backend/internal/bootstrap/bootstrap_types.go
@@ -0,0 +1,309 @@
+// Path: Backend/internal/bootstrap/bootstrap_types.go
+
+package bootstrap
+
+import (
+ "errors"
+
+ "moku-backend/internal/database"
+)
+
+const (
+ primaryOrganizationSlug = "primary-organization"
+ primaryDepartmentSlug = "primary-department"
+ primaryTeamSlug = "primary-team"
+ primaryProjectSlug = "primary-project"
+ organizationWorkspaceSlug = "organization-home"
+ departmentWorkspaceSlug = "department-home"
+ teamWorkspaceSlug = "team-home"
+ projectWorkspaceSlug = "project-home"
+ defaultInstallationHost = "localhost"
+ defaultInstallationMode = "personal"
+ defaultInstallationAccess = "local"
+ defaultInstallationProtocol = "http"
+ defaultOrganizationName = "Moku"
+ defaultPersonalServerSuffix = "Personal"
+ defaultPersonalDisplayName = "Personal"
+ bootstrapWorkspaceKindOrg = "organization"
+ bootstrapWorkspaceKindDept = "department"
+ bootstrapWorkspaceKindTeam = "team"
+ bootstrapWorkspaceKindProject = "project"
+ projectFolderOrderRootKey = "__root__"
+ projectFolderOrderHierarchy = "hierarchy"
+ projectFolderOrderTree = "tree"
+)
+
+var (
+ ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
+ ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
+ ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
+ ErrProjectNotFound = errors.New("project not found")
+ ErrProjectFolderNotFound = errors.New("project folder not found")
+ ErrProjectItemNotFound = errors.New("project item not found")
+ ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
+ ErrInvalidProjectItemMove = errors.New("invalid project item move")
+)
+
+type Service struct {
+ db *database.DB
+ posixRoot string
+}
+
+type SaveInstanceInput struct {
+ Protocol string
+ Access string
+ Host string
+}
+
+type SaveModeInput struct {
+ Mode string
+ Name string
+}
+
+type SaveAdminInput struct {
+ DisplayName string
+ Email string
+ Password string
+}
+
+type SaveStructureInput struct {
+ OrganizationName string
+ DepartmentName string
+ TeamName string
+ ProjectName string
+}
+
+type InstallationRecord struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Mode string `json:"mode"`
+ Access string `json:"access"`
+ Protocol string `json:"protocol"`
+ Host string `json:"host"`
+ IsBootstrapped bool `json:"isBootstrapped"`
+ MaterializationStatus string `json:"materializationStatus"`
+ MaterializationError *string `json:"materializationError,omitempty"`
+}
+
+type AdminRecord struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ IsInstanceAdmin bool `json:"isInstanceAdmin"`
+ HomeTitle string `json:"homeTitle"`
+}
+
+type OrganizationRecord struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type DepartmentRecord struct {
+ ID string `json:"id"`
+ OrganizationID string `json:"organizationId"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type TeamRecord struct {
+ ID string `json:"id"`
+ OrganizationID string `json:"organizationId"`
+ DepartmentID *string `json:"departmentId,omitempty"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type ProjectRecord struct {
+ ID string `json:"id"`
+ OrganizationID string `json:"organizationId"`
+ DepartmentID *string `json:"departmentId,omitempty"`
+ TeamID *string `json:"teamId,omitempty"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type WorkspaceRecord struct {
+ ID string `json:"id"`
+ OrganizationID string `json:"organizationId"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Kind string `json:"kind"`
+ DepartmentID *string `json:"departmentId,omitempty"`
+ TeamID *string `json:"teamId,omitempty"`
+ ProjectID *string `json:"projectId,omitempty"`
+}
+
+type StructureRecord struct {
+ Installation InstallationRecord `json:"installation"`
+ Organization namedRecord `json:"organization"`
+ Department namedRecord `json:"department"`
+ Team namedRecord `json:"team"`
+ Project namedRecord `json:"project"`
+ Admin AdminSummary `json:"admin"`
+}
+
+type AdminSummary struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+}
+
+type BootstrapStructureState struct {
+ Organization *OrganizationRecord `json:"organization,omitempty"`
+ Department *DepartmentRecord `json:"department,omitempty"`
+ Team *TeamRecord `json:"team,omitempty"`
+ Project *ProjectRecord `json:"project,omitempty"`
+ Workspaces []WorkspaceRecord `json:"workspaces"`
+}
+
+type BootstrapState struct {
+ Installation *InstallationRecord `json:"installation,omitempty"`
+ Admin *AdminRecord `json:"admin,omitempty"`
+ Structure BootstrapStructureState `json:"structure"`
+}
+
+type AppShellState struct {
+ Installation *InstallationRecord `json:"installation,omitempty"`
+ Admin *AdminRecord `json:"admin,omitempty"`
+ Organizations []OrganizationRecord `json:"organizations"`
+ Departments []DepartmentRecord `json:"departments"`
+ Teams []TeamRecord `json:"teams"`
+ Projects []ProjectRecord `json:"projects"`
+ Workspaces []WorkspaceRecord `json:"workspaces"`
+}
+
+type namedRecord struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type ProjectHierarchyFolderRecord struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Label string `json:"label"`
+ Children []ProjectHierarchyFolderRecord `json:"children"`
+}
+
+type ProjectTreeNodeRecord struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Label string `json:"label"`
+ Kind string `json:"kind"`
+ ItemType string `json:"itemType,omitempty"`
+ Children []ProjectTreeNodeRecord `json:"children,omitempty"`
+}
+
+type CreateProjectFolderInput struct {
+ ProjectID string
+ ParentFolderPath string
+ Name string
+}
+
+type DeleteProjectFolderInput struct {
+ ProjectID string
+ FolderPath string
+}
+
+type RenameProjectFolderInput struct {
+ ProjectID string
+ FolderPath string
+ Name string
+}
+
+type MoveProjectFolderInput struct {
+ ProjectID string
+ FolderPath string
+ FolderStableID string
+ ParentFolderPath string
+ ParentStableID string
+ TargetIndex int
+}
+
+type CreateProjectItemInput struct {
+ ProjectID string
+ ParentFolderPath string
+ Name string
+ ItemType string
+}
+
+type DeleteProjectItemInput struct {
+ ProjectID string
+ ItemPath string
+}
+
+type MoveProjectItemInput struct {
+ ProjectID string
+ ItemPath string
+ ItemStableID string
+ ParentFolderPath string
+ ParentStableID string
+ TargetIndex int
+}
+
+type CreateProjectFolderResult struct {
+ ProjectID string `json:"projectId"`
+ CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
+ Folders []ProjectHierarchyFolderRecord `json:"folders"`
+}
+
+type DeleteProjectFolderResult struct {
+ ProjectID string `json:"projectId"`
+ DeletedFolderStableID string `json:"deletedFolderId"`
+ DeletedFolderPath string `json:"deletedFolderPath"`
+ Folders []ProjectHierarchyFolderRecord `json:"folders"`
+}
+
+type RenameProjectFolderResult struct {
+ ProjectID string `json:"projectId"`
+ PreviousFolderStableID string `json:"previousFolderId"`
+ PreviousFolderPath string `json:"previousFolderPath"`
+ RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
+ Folders []ProjectHierarchyFolderRecord `json:"folders"`
+}
+
+type MoveProjectFolderResult struct {
+ ProjectID string `json:"projectId"`
+ PreviousFolderStableID string `json:"previousFolderId"`
+ PreviousFolderPath string `json:"previousFolderPath"`
+ MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
+ Folders []ProjectHierarchyFolderRecord `json:"folders"`
+}
+
+type CreateProjectItemResult struct {
+ ProjectID string `json:"projectId"`
+ CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
+ Nodes []ProjectTreeNodeRecord `json:"nodes"`
+}
+
+type DeleteProjectItemResult struct {
+ ProjectID string `json:"projectId"`
+ DeletedItemStableID string `json:"deletedItemId"`
+ DeletedItemPath string `json:"deletedItemPath"`
+ Nodes []ProjectTreeNodeRecord `json:"nodes"`
+}
+
+type MoveProjectItemResult struct {
+ ProjectID string `json:"projectId"`
+ PreviousItemStableID string `json:"previousItemId"`
+ PreviousItemPath string `json:"previousItemPath"`
+ MovedItem ProjectTreeNodeRecord `json:"movedItem"`
+ Nodes []ProjectTreeNodeRecord `json:"nodes"`
+}
+
+type projectHierarchyFolderRow struct {
+ ID string
+ Path string
+ ParentPath string
+ Label string
+}
+
+type projectTreeNodeRow struct {
+ ID string
+ Path string
+ ParentPath string
+ Label string
+ Kind string
+ ItemType string
+}
diff --git a/Backend/internal/bootstrap/project_disk.go b/Backend/internal/bootstrap/project_disk.go
new file mode 100644
index 0000000..60fedbf
--- /dev/null
+++ b/Backend/internal/bootstrap/project_disk.go
@@ -0,0 +1,48 @@
+// Path: Backend/internal/bootstrap/project_disk.go
+
+package bootstrap
+
+import (
+ "path/filepath"
+)
+
+func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
+ 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) 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) 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) 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 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"))
+}
diff --git a/Backend/internal/bootstrap/project_disk_bootstrap.go b/Backend/internal/bootstrap/project_disk_bootstrap.go
new file mode 100644
index 0000000..716bcf4
--- /dev/null
+++ b/Backend/internal/bootstrap/project_disk_bootstrap.go
@@ -0,0 +1,196 @@
+// Path: Backend/internal/bootstrap/project_disk_bootstrap.go
+
+package bootstrap
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (service *Service) ensureBootstrapPOSIXSkeleton(
+ installation InstallationRecord,
+ admin AdminSummary,
+ organization namedRecord,
+ department namedRecord,
+ team namedRecord,
+ project namedRecord,
+) error {
+ rootPath := strings.TrimSpace(service.posixRoot)
+ if rootPath == "" {
+ return nil
+ }
+
+ if err := os.MkdirAll(rootPath, 0o755); err != nil {
+ return fmt.Errorf("create POSIX root: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(rootPath, "settings.json"), map[string]any{
+ "installation": map[string]any{
+ "id": installation.ID,
+ "name": installation.Name,
+ "mode": installation.Mode,
+ "access": installation.Access,
+ "protocol": installation.Protocol,
+ "host": installation.Host,
+ "isBootstrapped": installation.IsBootstrapped,
+ },
+ "organization": map[string]any{
+ "id": organization.ID,
+ "name": organization.Name,
+ "slug": organization.Slug,
+ },
+ }); err != nil {
+ return fmt.Errorf("write tenant settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{
+ "version": 1,
+ "type": "tenant-layout",
+ "home": map[string]any{
+ "defaultProjectSlug": project.Slug,
+ },
+ }); err != nil {
+ return fmt.Errorf("write tenant layout.json: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil {
+ return fmt.Errorf("create catalog packs root: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil {
+ return fmt.Errorf("create catalog standalone root: %w", err)
+ }
+
+ departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug))
+ teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug))
+ projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug))
+ usersPath := filepath.Join(rootPath, "users")
+ personalName := strings.TrimSpace(admin.DisplayName)
+ if personalName == "" {
+ personalName = defaultPersonalDisplayName
+ }
+ personalSlug := normalizePOSIXSlug(personalName)
+ personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug))
+
+ for _, dirPath := range []string{
+ departmentPath,
+ teamPath,
+ projectPath,
+ filepath.Join(projectPath, "children"),
+ filepath.Join(projectPath, "tree"),
+ filepath.Join(usersPath, "personals"),
+ personalHomePath,
+ filepath.Join(personalHomePath, "tree"),
+ } {
+ if err := os.MkdirAll(dirPath, 0o755); err != nil {
+ return fmt.Errorf("create POSIX directory %s: %w", dirPath, err)
+ }
+ }
+
+ if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{
+ "id": department.ID,
+ "name": department.Name,
+ "slug": department.Slug,
+ "type": "department",
+ }); err != nil {
+ return fmt.Errorf("write department settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{
+ "owners": []map[string]string{{
+ "id": admin.ID,
+ "email": admin.Email,
+ "displayName": admin.DisplayName,
+ }},
+ }); err != nil {
+ return fmt.Errorf("write department users.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{
+ "id": team.ID,
+ "name": team.Name,
+ "slug": team.Slug,
+ "type": "team",
+ }); err != nil {
+ return fmt.Errorf("write team settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{
+ "owners": []map[string]string{{
+ "id": admin.ID,
+ "email": admin.Email,
+ "displayName": admin.DisplayName,
+ }},
+ }); err != nil {
+ return fmt.Errorf("write team users.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{
+ "id": project.ID,
+ "name": project.Name,
+ "slug": project.Slug,
+ "type": "project",
+ }); err != nil {
+ return fmt.Errorf("write project settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{
+ "type": "project-home",
+ "title": project.Name,
+ }); err != nil {
+ return fmt.Errorf("write project home.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(projectPath, "acl.json"), map[string]any{
+ "version": 1,
+ "inherits": true,
+ "rules": []any{},
+ }); err != nil {
+ return fmt.Errorf("write project acl.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{
+ "primaryAdminId": admin.ID,
+ }); err != nil {
+ return fmt.Errorf("write users settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{
+ "admins": []map[string]string{{
+ "id": admin.ID,
+ "email": admin.Email,
+ "displayName": admin.DisplayName,
+ }},
+ }); err != nil {
+ return fmt.Errorf("write users data.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(personalHomePath, "settings.json"), map[string]any{
+ "type": "personal",
+ "name": personalName,
+ "slug": personalSlug,
+ }); err != nil {
+ return fmt.Errorf("write personal settings.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(personalHomePath, "layout.json"), map[string]any{
+ "version": 1,
+ "type": "personal-layout",
+ "home": map[string]any{
+ "defaultProjectSlug": project.Slug,
+ },
+ }); err != nil {
+ return fmt.Errorf("write personal layout.json: %w", err)
+ }
+
+ if err := writeJSONFile(filepath.Join(personalHomePath, "home.json"), map[string]any{
+ "type": "personal-home",
+ "title": personalHomeTitle(personalName),
+ }); err != nil {
+ return fmt.Errorf("write personal home.json: %w", err)
+ }
+
+ return nil
+}
diff --git a/Backend/internal/bootstrap/project_disk_folders.go b/Backend/internal/bootstrap/project_disk_folders.go
new file mode 100644
index 0000000..4059051
--- /dev/null
+++ b/Backend/internal/bootstrap/project_disk_folders.go
@@ -0,0 +1,252 @@
+// Path: Backend/internal/bootstrap/project_disk_folders.go
+
+package bootstrap
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+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")
+ }
+ trimmedName := strings.TrimSpace(name)
+ if trimmedName == "" {
+ return "", "", fmt.Errorf("folder name is required")
+ }
+ 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(posixRoot, 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)
+ }
+ folderID := uuid.NewString()
+ if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{"id": folderID, "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 (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 (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)
+ }
+ }
+ folderPayload := readJSONFileMap(filepath.Join(destinationDir, "folder.json"))
+ folderMetadataID, _ := folderPayload["id"].(string)
+ if strings.TrimSpace(folderMetadataID) == "" {
+ folderMetadataID = uuid.NewString()
+ }
+ if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{"id": folderMetadataID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
+ return "", "", fmt.Errorf("write renamed project folder.json: %w", err)
+ }
+ 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"))
+ folderMetadataID, _ := folderPayload["id"].(string)
+ if strings.TrimSpace(folderMetadataID) == "" {
+ folderMetadataID = uuid.NewString()
+ }
+ 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["id"] = folderMetadataID
+ 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
+}
diff --git a/Backend/internal/bootstrap/project_disk_helpers.go b/Backend/internal/bootstrap/project_disk_helpers.go
new file mode 100644
index 0000000..80571a9
--- /dev/null
+++ b/Backend/internal/bootstrap/project_disk_helpers.go
@@ -0,0 +1,138 @@
+// Path: Backend/internal/bootstrap/project_disk_helpers.go
+
+package bootstrap
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+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 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 != "" {
+ parts[index] = strings.ToUpper(part[:1]) + part[1:]
+ }
+ }
+ label := strings.Join(parts, " ")
+ if label == "" {
+ return base
+ }
+ return label
+}
+
+func fallbackItemLabel(path string) string {
+ base := filepath.Base(filepath.FromSlash(path))
+ trimmed := strings.TrimPrefix(base, "item-")
+ parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
+ for index, part := range parts {
+ if part != "" {
+ parts[index] = strings.ToUpper(part[:1]) + part[1:]
+ }
+ }
+ label := strings.Join(parts, " ")
+ if label == "" {
+ return base
+ }
+ return label
+}
+
+func normalizeProjectTreeItemType(itemType string) string {
+ switch strings.TrimSpace(strings.ToLower(itemType)) {
+ case "", "board", "core.board", "core.board.kanban", "kanban":
+ return "core.board.kanban"
+ case "core.doc", "doc", "document":
+ return "core.doc"
+ case "core.board.list", "list", "list-board":
+ return "core.board.list"
+ default:
+ return strings.TrimSpace(itemType)
+ }
+}
+
+func defaultProjectTreeItemSchema(itemType string) map[string]any {
+ return map[string]any{"type": "object", "itemType": normalizeProjectTreeItemType(itemType)}
+}
+
+func defaultProjectTreeItemData(itemType, name string) map[string]any {
+ return map[string]any{"title": strings.TrimSpace(name), "itemType": normalizeProjectTreeItemType(itemType)}
+}
+
+func slugDir(prefix, slug string) string {
+ trimmedSlug := strings.TrimSpace(slug)
+ if trimmedSlug == "" {
+ return prefix
+ }
+ return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
+}
+
+func writeJSONFile(path string, payload any) error {
+ parentDir := filepath.Dir(path)
+ if err := os.MkdirAll(parentDir, 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ return os.WriteFile(path, data, 0o644)
+}
+
+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
+}
diff --git a/Backend/internal/bootstrap/project_disk_items.go b/Backend/internal/bootstrap/project_disk_items.go
new file mode 100644
index 0000000..f31682b
--- /dev/null
+++ b/Backend/internal/bootstrap/project_disk_items.go
@@ -0,0 +1,189 @@
+// Path: Backend/internal/bootstrap/project_disk_items.go
+
+package bootstrap
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+func (service *Service) createProjectTreeItemOnDisk(projectSlug, parentFolderPath, name, itemType string) (string, error) {
+ posixRoot := strings.TrimSpace(service.posixRoot)
+ if posixRoot == "" {
+ return "", fmt.Errorf("POSIX root is not configured")
+ }
+ trimmedName := strings.TrimSpace(name)
+ if trimmedName == "" {
+ return "", fmt.Errorf("item name is required")
+ }
+ canonicalItemType := normalizeProjectTreeItemType(itemType)
+ containerProjectionPath := projectTreeRootPath(projectSlug)
+ parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
+ if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
+ containerProjectionPath = trimmedParentFolderPath
+ parentDir = filepath.Join(posixRoot, 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 item path: %w", err)
+ }
+ baseSlug := normalizePOSIXSlug(trimmedName)
+ itemDirName := slugDir("item", baseSlug)
+ itemDir := filepath.Join(parentDir, itemDirName)
+ itemSlug := baseSlug
+ for attempt := 2; ; attempt += 1 {
+ if _, err := os.Stat(itemDir); os.IsNotExist(err) {
+ break
+ } else if err != nil {
+ return "", fmt.Errorf("stat candidate project item: %w", err)
+ }
+ itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
+ itemDirName = slugDir("item", itemSlug)
+ itemDir = filepath.Join(parentDir, itemDirName)
+ }
+ if err := os.MkdirAll(itemDir, 0o755); err != nil {
+ return "", fmt.Errorf("create project item: %w", err)
+ }
+ itemID := uuid.NewString()
+ if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{"id": itemID, "name": trimmedName, "slug": itemSlug, "type": canonicalItemType}); err != nil {
+ return "", fmt.Errorf("write project item.json: %w", err)
+ }
+ if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
+ return "", fmt.Errorf("write project schema.json: %w", err)
+ }
+ if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
+ return "", fmt.Errorf("write project data.json: %w", err)
+ }
+ return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
+}
+
+func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
+ posixRoot := strings.TrimSpace(service.posixRoot)
+ if posixRoot == "" {
+ return "", fmt.Errorf("POSIX root is not configured")
+ }
+ rootProjectionPath := projectTreeRootPath(projectSlug)
+ itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
+ if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
+ return "", ErrProjectItemNotFound
+ }
+ itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
+ info, err := os.Stat(itemDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", ErrProjectItemNotFound
+ }
+ return "", fmt.Errorf("stat project item: %w", err)
+ }
+ if !info.IsDir() {
+ return "", ErrProjectItemNotFound
+ }
+ if err := os.RemoveAll(itemDir); err != nil {
+ return "", fmt.Errorf("delete project item: %w", err)
+ }
+ return itemProjectionPath, nil
+}
+
+func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
+ posixRoot := strings.TrimSpace(service.posixRoot)
+ if posixRoot == "" {
+ return "", "", fmt.Errorf("POSIX root is not configured")
+ }
+ rootProjectionPath := projectTreeRootPath(projectSlug)
+ itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
+ if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
+ return "", "", ErrProjectItemNotFound
+ }
+ itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
+ info, err := os.Stat(itemDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", "", ErrProjectItemNotFound
+ }
+ return "", "", fmt.Errorf("stat project item: %w", err)
+ }
+ if !info.IsDir() {
+ return "", "", ErrProjectItemNotFound
+ }
+ trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
+ parentProjectionPath := rootProjectionPath
+ parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
+ if trimmedParentFolderPath != "" {
+ parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
+ if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
+ return "", "", ErrProjectFolderNotFound
+ }
+ parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
+ }
+ parentInfo, err := os.Stat(parentDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", "", ErrProjectFolderNotFound
+ }
+ return "", "", fmt.Errorf("stat project item parent: %w", err)
+ }
+ if !parentInfo.IsDir() {
+ return "", "", ErrProjectFolderNotFound
+ }
+ currentParentDir := filepath.Dir(itemDir)
+ currentBase := filepath.Base(itemDir)
+ itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json"))
+ itemID, _ := itemPayload["id"].(string)
+ if strings.TrimSpace(itemID) == "" {
+ itemID = uuid.NewString()
+ }
+ itemName, _ := itemPayload["name"].(string)
+ if strings.TrimSpace(itemName) == "" {
+ itemName = fallbackItemLabel(itemProjectionPath)
+ }
+ itemType, _ := itemPayload["type"].(string)
+ canonicalItemType := normalizeProjectTreeItemType(itemType)
+ baseSlug := strings.TrimPrefix(currentBase, "item-")
+ if strings.TrimSpace(baseSlug) == "" {
+ baseSlug = normalizePOSIXSlug(itemName)
+ }
+ itemSlug := baseSlug
+ itemDirName := slugDir("item", itemSlug)
+ destinationDir := filepath.Join(parentDir, itemDirName)
+ for attempt := 2; ; attempt += 1 {
+ if samePath(destinationDir, itemDir) {
+ break
+ }
+ if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
+ break
+ } else if err != nil {
+ return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
+ }
+ itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
+ itemDirName = slugDir("item", itemSlug)
+ destinationDir = filepath.Join(parentDir, itemDirName)
+ }
+ movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
+ if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
+ return itemProjectionPath, itemProjectionPath, nil
+ }
+ if err := os.Rename(itemDir, destinationDir); err != nil {
+ return "", "", fmt.Errorf("move project item: %w", err)
+ }
+ itemPayload["id"] = itemID
+ itemPayload["name"] = itemName
+ itemPayload["slug"] = itemSlug
+ itemPayload["type"] = canonicalItemType
+ if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil {
+ return "", "", fmt.Errorf("write moved project item.json: %w", err)
+ }
+ return itemProjectionPath, movedProjectionPath, nil
+}
diff --git a/Backend/internal/bootstrap/project_mutations.go b/Backend/internal/bootstrap/project_mutations.go
new file mode 100644
index 0000000..15730a3
--- /dev/null
+++ b/Backend/internal/bootstrap/project_mutations.go
@@ -0,0 +1,491 @@
+// Path: Backend/internal/bootstrap/project_mutations.go
+
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+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) 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) 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) 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) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
+ return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
+}
+
+func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
+ return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
+}
+
+func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
+ return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
+}
+
+func (service *Service) createProjectHierarchyFolder(
+ ctx context.Context,
+ input CreateProjectFolderInput,
+ rootPath func(projectSlug string) string,
+ createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
+) (CreateProjectFolderResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ parentOrderID := ""
+ trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
+ if trimmedParentFolderPath != "" {
+ parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
+ if !found {
+ return CreateProjectFolderResult{}, ErrProjectFolderNotFound
+ }
+ parentOrderID = parentFolder.ID
+ }
+
+ createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
+ if err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
+ if !ok {
+ return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
+ insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectFolderResult{}, err
+ }
+
+ createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
+ if !ok {
+ return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
+ }
+
+ return CreateProjectFolderResult{ProjectID: project.ID, CreatedFolder: createdFolder, Folders: folders}, nil
+}
+
+func (service *Service) deleteProjectHierarchyFolder(
+ ctx context.Context,
+ input DeleteProjectFolderInput,
+ rootPath func(projectSlug string) string,
+ deleteOnDisk func(projectSlug, folderPath string) (string, error),
+) (DeleteProjectFolderResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
+ if !found {
+ return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
+ }
+
+ deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
+ if err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
+ return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ removeFolderOrder(folderOrder, deletedFolder.ID)
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectFolderResult{}, err
+ }
+
+ return DeleteProjectFolderResult{ProjectID: project.ID, DeletedFolderStableID: deletedFolder.ID, DeletedFolderPath: deletedFolderPath, Folders: folders}, nil
+}
+
+func (service *Service) renameProjectHierarchyFolder(
+ ctx context.Context,
+ input RenameProjectFolderInput,
+ rootPath func(projectSlug string) string,
+ renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
+) (RenameProjectFolderResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return RenameProjectFolderResult{}, err
+ }
+
+ previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
+ if err != nil {
+ return RenameProjectFolderResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return RenameProjectFolderResult{}, err
+ }
+
+ folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return RenameProjectFolderResult{}, err
+ }
+
+ renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
+ if !found {
+ return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
+ }
+
+ if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
+ return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
+ }
+
+ return RenameProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: renamedFolder.ID, PreviousFolderPath: previousFolderPath, RenamedFolder: renamedFolder, Folders: folders}, nil
+}
+
+func (service *Service) moveProjectHierarchyFolder(
+ ctx context.Context,
+ input MoveProjectFolderInput,
+ rootPath func(projectSlug string) string,
+ moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
+) (MoveProjectFolderResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
+ if !found {
+ return MoveProjectFolderResult{}, ErrProjectFolderNotFound
+ }
+
+ movedFolderStableID := currentFolder.ID
+ providedFolderStableID := strings.TrimSpace(input.FolderStableID)
+ if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
+ return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
+ }
+
+ parentOrderID := ""
+ trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
+ providedParentStableID := strings.TrimSpace(input.ParentStableID)
+ if trimmedParentFolderPath != "" {
+ parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
+ if !found {
+ return MoveProjectFolderResult{}, ErrProjectFolderNotFound
+ }
+ parentOrderID = parentFolder.ID
+ if providedParentStableID != "" && providedParentStableID != parentOrderID {
+ return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
+ }
+ } else if providedParentStableID != "" {
+ return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
+ }
+
+ previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
+ if err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
+ if !found {
+ return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
+ }
+
+ if previousFolderPath != movedFolderPath {
+ if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
+ return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
+ }
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
+ removeFolderOrderReference(folderOrder, movedFolderStableID)
+ removeFolderOrderReference(folderOrder, movedFolder.ID)
+ insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectFolderResult{}, err
+ }
+
+ movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
+ if !found {
+ return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
+ }
+
+ return MoveProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: movedFolder.ID, PreviousFolderPath: previousFolderPath, MovedFolder: movedFolder, Folders: folders}, nil
+}
+
+func (service *Service) createProjectTreeItem(ctx context.Context, input CreateProjectItemInput, rootPath func(projectSlug string) string) (CreateProjectItemResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ parentOrderID := ""
+ trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
+ if trimmedParentFolderPath != "" {
+ parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
+ if !found {
+ return CreateProjectItemResult{}, ErrProjectFolderNotFound
+ }
+ parentOrderID = parentFolder.ID
+ }
+
+ createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
+ if err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
+ if !ok || createdItem.Kind != "item" {
+ return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
+ insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return CreateProjectItemResult{}, err
+ }
+
+ createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
+ if !ok || createdItem.Kind != "item" {
+ return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
+ }
+
+ return CreateProjectItemResult{ProjectID: project.ID, CreatedItem: createdItem, Nodes: nodes}, nil
+}
+
+func (service *Service) deleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput, rootPath func(projectSlug string) string) (DeleteProjectItemResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
+ if !found || deletedItem.Kind != "item" {
+ return DeleteProjectItemResult{}, ErrProjectItemNotFound
+ }
+
+ deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
+ if err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
+ return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ removeFolderOrderReference(folderOrder, deletedItem.ID)
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return DeleteProjectItemResult{}, err
+ }
+
+ return DeleteProjectItemResult{ProjectID: project.ID, DeletedItemStableID: deletedItem.ID, DeletedItemPath: deletedItemPath, Nodes: nodes}, nil
+}
+
+func (service *Service) moveProjectTreeItem(ctx context.Context, input MoveProjectItemInput, rootPath func(projectSlug string) string) (MoveProjectItemResult, error) {
+ project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
+ if err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
+ if !found || currentItem.Kind != "item" {
+ return MoveProjectItemResult{}, ErrProjectItemNotFound
+ }
+
+ movedItemStableID := currentItem.ID
+ providedItemStableID := strings.TrimSpace(input.ItemStableID)
+ if providedItemStableID != "" && providedItemStableID != movedItemStableID {
+ return MoveProjectItemResult{}, ErrInvalidProjectItemMove
+ }
+
+ parentOrderID := ""
+ trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
+ providedParentStableID := strings.TrimSpace(input.ParentStableID)
+ if trimmedParentFolderPath != "" {
+ parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
+ if !found {
+ return MoveProjectItemResult{}, ErrProjectFolderNotFound
+ }
+ parentOrderID = parentFolder.ID
+ if providedParentStableID != "" && providedParentStableID != parentOrderID {
+ return MoveProjectItemResult{}, ErrInvalidProjectItemMove
+ }
+ } else if providedParentStableID != "" {
+ return MoveProjectItemResult{}, ErrInvalidProjectItemMove
+ }
+
+ previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
+ if err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ if err := service.rebuildProjection(ctx); err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
+ if !found || movedItem.Kind != "item" {
+ return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
+ }
+
+ if previousItemPath != movedItemPath {
+ if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
+ return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
+ }
+ }
+
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
+ seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
+ removeFolderOrderReference(folderOrder, movedItemStableID)
+ removeFolderOrderReference(folderOrder, movedItem.ID)
+ insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
+ if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
+ if err != nil {
+ return MoveProjectItemResult{}, err
+ }
+
+ movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
+ if !found || movedItem.Kind != "item" {
+ return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
+ }
+
+ return MoveProjectItemResult{ProjectID: project.ID, PreviousItemStableID: movedItem.ID, PreviousItemPath: previousItemPath, MovedItem: movedItem, Nodes: nodes}, nil
+}
diff --git a/Backend/internal/bootstrap/project_order.go b/Backend/internal/bootstrap/project_order.go
new file mode 100644
index 0000000..3fa13ae
--- /dev/null
+++ b/Backend/internal/bootstrap/project_order.go
@@ -0,0 +1,329 @@
+// Path: Backend/internal/bootstrap/project_order.go
+
+package bootstrap
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
+ settingsPath := service.projectSettingsPath(projectSlug)
+ settingsPayload := readJSONFileMap(settingsPath)
+ folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
+ if folderOrderPayload == nil { return map[string][]string{} }
+ scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
+ if scopePayload == nil { return map[string][]string{} }
+ byParentPayload, _ := scopePayload["byParent"].(map[string]any)
+ if byParentPayload == nil { return map[string][]string{} }
+ order := make(map[string][]string, len(byParentPayload))
+ for key, raw := range byParentPayload {
+ for _, id := range stringSliceValue(raw) {
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" || slicesContains(order[key], trimmedID) { continue }
+ order[key] = append(order[key], trimmedID)
+ }
+ }
+ return order
+}
+
+func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
+ settingsPath := service.projectSettingsPath(projectSlug)
+ settingsPayload := readJSONFileMap(settingsPath)
+ if settingsPayload == nil { settingsPayload = map[string]any{} }
+ folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
+ if folderOrderPayload == nil { folderOrderPayload = map[string]any{} }
+ scopeKey := projectFolderOrderScope(rootProjectionPath)
+ scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
+ if scopePayload == nil { scopePayload = map[string]any{} }
+ byParentPayload := map[string]any{}
+ for key, ids := range folderOrder {
+ if len(ids) == 0 { continue }
+ copied := make([]string, 0, len(ids))
+ for _, id := range ids {
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" || slicesContains(copied, trimmedID) { continue }
+ copied = append(copied, trimmedID)
+ }
+ if len(copied) > 0 { byParentPayload[key] = copied }
+ }
+ scopePayload["byParent"] = byParentPayload
+ folderOrderPayload[scopeKey] = scopePayload
+ settingsPayload["folderOrder"] = folderOrderPayload
+ if err := writeJSONFile(settingsPath, settingsPayload); err != nil { return fmt.Errorf("write project settings.json: %w", err) }
+ return nil
+}
+
+func (service *Service) projectSettingsPath(projectSlug string) string {
+ return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json")
+}
+
+func projectFolderOrderScope(rootProjectionPath string) string {
+ if strings.HasSuffix(rootProjectionPath, "/tree") { return projectFolderOrderTree }
+ return projectFolderOrderHierarchy
+}
+
+func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
+ return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
+}
+
+func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
+ if len(folders) == 0 { return folders }
+ nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
+ copy(nextFolders, folders)
+ for index := range nextFolders { nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder) }
+ orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
+ if len(orderIDs) == 0 { return nextFolders }
+ rankByID := make(map[string]int, len(orderIDs))
+ for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } }
+ sort.SliceStable(nextFolders, func(left, right int) bool {
+ leftRank, leftOrdered := rankByID[nextFolders[left].ID]
+ rightRank, rightOrdered := rankByID[nextFolders[right].ID]
+ if leftOrdered && rightOrdered { return leftRank < rightRank }
+ if leftOrdered != rightOrdered { return leftOrdered }
+ return false
+ })
+ return nextFolders
+}
+
+func removeFolderOrder(folderOrder map[string][]string, folderID string) {
+ removeFolderOrderReference(folderOrder, folderID)
+ trimmedFolderID := strings.TrimSpace(folderID)
+ if trimmedFolderID == "" { return }
+ delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
+}
+
+func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
+ trimmedFolderID := strings.TrimSpace(folderID)
+ if trimmedFolderID == "" { return }
+ for key, ids := range folderOrder {
+ nextIDs := ids[:0]
+ for _, id := range ids {
+ if strings.TrimSpace(id) == trimmedFolderID { continue }
+ nextIDs = append(nextIDs, id)
+ }
+ if len(nextIDs) == 0 { delete(folderOrder, key); continue }
+ folderOrder[key] = append([]string(nil), nextIDs...)
+ }
+}
+
+func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
+ trimmedFolderID := strings.TrimSpace(folderID)
+ if trimmedFolderID == "" { return }
+ removeFolderOrderReference(folderOrder, trimmedFolderID)
+ parentKey := projectFolderOrderParentKey(parentID)
+ children := append([]string(nil), folderOrder[parentKey]...)
+ if index < 0 { index = 0 }
+ if index > len(children) { index = len(children) }
+ children = slicesInsert(children, index, trimmedFolderID)
+ folderOrder[parentKey] = children
+}
+
+func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
+ children := folders
+ trimmedParentID := strings.TrimSpace(parentID)
+ if trimmedParentID != "" {
+ parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
+ if !found { return }
+ children = parent.Children
+ }
+ parentKey := projectFolderOrderParentKey(trimmedParentID)
+ seeded := make([]string, 0, len(children))
+ for _, child := range children {
+ childID := strings.TrimSpace(child.ID)
+ if childID == "" || slicesContains(seeded, childID) { continue }
+ seeded = append(seeded, childID)
+ }
+ if len(seeded) == 0 { delete(folderOrder, parentKey); return }
+ folderOrder[parentKey] = seeded
+}
+
+func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
+ return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
+}
+
+func projectFolderOrderParentKey(parentID string) string {
+ trimmedParentID := strings.TrimSpace(parentID)
+ if trimmedParentID == "" { return projectFolderOrderRootKey }
+ return trimmedParentID
+}
+
+func stringSliceValue(value any) []string {
+ items, ok := value.([]any)
+ if !ok {
+ if typed, ok := value.([]string); ok { return typed }
+ return nil
+ }
+ result := make([]string, 0, len(items))
+ for _, item := range items { if text, ok := item.(string); ok { result = append(result, text) } }
+ return result
+}
+
+func slicesContains(values []string, value string) bool {
+ for _, existing := range values { if existing == value { return true } }
+ return false
+}
+
+func slicesInsert(values []string, index int, value string) []string {
+ values = append(values, "")
+ copy(values[index+1:], values[index:])
+ values[index] = value
+ return values
+}
+
+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 {
+ folderID := strings.TrimSpace(row.ID)
+ if folderID == "" { folderID = row.Path }
+ label := strings.TrimSpace(row.Label)
+ if label == "" { label = fallbackFolderLabel(row.Path) }
+ nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: 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, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))}
+ folders = append(folders, folder)
+ }
+ return folders
+ }
+ return build(rootParentPath)
+}
+
+func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
+ if len(rows) == 0 { return nil }
+ nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
+ childrenByParent := make(map[string][]string)
+ for _, row := range rows {
+ nodeKind := normalizeProjectTreeNodeKind(row.Kind)
+ nodeID := strings.TrimSpace(row.ID)
+ if nodeID == "" { nodeID = row.Path }
+ label := strings.TrimSpace(row.Label)
+ if label == "" { if nodeKind == "item" { label = fallbackItemLabel(row.Path) } else { label = fallbackFolderLabel(row.Path) } }
+ nodesByPath[row.Path] = &ProjectTreeNodeRecord{ID: nodeID, Path: row.Path, Label: label, Kind: nodeKind, ItemType: normalizeProjectTreeItemType(row.ItemType), Children: []ProjectTreeNodeRecord{}}
+ parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath)
+ childrenByParent[parentKey] = append(childrenByParent[parentKey], row.Path)
+ }
+ var build func(parentPath string) []ProjectTreeNodeRecord
+ build = func(parentPath string) []ProjectTreeNodeRecord {
+ childPaths := childrenByParent[parentPath]
+ if len(childPaths) == 0 { return nil }
+ nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
+ for _, childPath := range childPaths {
+ node := nodesByPath[childPath]
+ if node == nil { continue }
+ nextNode := ProjectTreeNodeRecord{ID: node.ID, Path: node.Path, Label: node.Label, Kind: node.Kind, ItemType: node.ItemType}
+ if node.Kind == "folder" { nextNode.Children = build(node.Path) }
+ nodes = append(nodes, nextNode)
+ }
+ return nodes
+ }
+ return build(rootParentPath)
+}
+
+func normalizeProjectTreeParentPath(kind, parentPath string) string {
+ if kind == "folder" && strings.HasSuffix(parentPath, "/children") { return filepath.ToSlash(filepath.Dir(parentPath)) }
+ return parentPath
+}
+
+func normalizeProjectTreeNodeKind(kind string) string {
+ switch strings.TrimSpace(kind) {
+ case "item":
+ return "item"
+ case "folder", "hierarchy_folder":
+ return "folder"
+ default:
+ return "folder"
+ }
+}
+
+func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
+ return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
+}
+
+func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
+ if len(nodes) == 0 { return nodes }
+ nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
+ copy(nextNodes, nodes)
+ for index := range nextNodes { if nextNodes[index].Kind == "folder" { nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder) } }
+ orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
+ if len(orderIDs) == 0 { return nextNodes }
+ rankByID := make(map[string]int, len(orderIDs))
+ for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } }
+ sort.SliceStable(nextNodes, func(left, right int) bool {
+ leftRank, leftOrdered := rankByID[nextNodes[left].ID]
+ rightRank, rightOrdered := rankByID[nextNodes[right].ID]
+ if leftOrdered && rightOrdered { return leftRank < rightRank }
+ if leftOrdered != rightOrdered { return leftOrdered }
+ return false
+ })
+ return nextNodes
+}
+
+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 findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
+ for _, folder := range folders {
+ if folder.Path == folderPath { return folder, true }
+ if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok { return child, true }
+ }
+ return ProjectHierarchyFolderRecord{}, false
+}
+
+func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
+ for _, node := range nodes {
+ if node.ID == nodeID { return node, true }
+ if child, ok := findProjectTreeNode(node.Children, nodeID); ok { return child, true }
+ }
+ return ProjectTreeNodeRecord{}, false
+}
+
+func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
+ for _, node := range nodes {
+ if node.Path == nodePath { return node, true }
+ if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok { return child, true }
+ }
+ return ProjectTreeNodeRecord{}, false
+}
+
+func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
+ node, ok := findProjectTreeNodeByPath(nodes, folderPath)
+ if !ok || node.Kind != "folder" { return ProjectTreeNodeRecord{}, false }
+ return node, true
+}
+
+func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
+ children := nodes
+ trimmedParentID := strings.TrimSpace(parentID)
+ if trimmedParentID != "" {
+ parent, found := findProjectTreeNode(nodes, trimmedParentID)
+ if !found || parent.Kind != "folder" { return }
+ children = parent.Children
+ }
+ parentKey := projectFolderOrderParentKey(parentID)
+ if len(folderOrder[parentKey]) > 0 { return }
+ orderedIDs := make([]string, 0, len(children))
+ for _, child := range children {
+ trimmedChildID := strings.TrimSpace(child.ID)
+ if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) { continue }
+ orderedIDs = append(orderedIDs, trimmedChildID)
+ }
+ if len(orderedIDs) > 0 { folderOrder[parentKey] = orderedIDs }
+}
diff --git a/Backend/internal/bootstrap/project_queries.go b/Backend/internal/bootstrap/project_queries.go
new file mode 100644
index 0000000..dd0afd4
--- /dev/null
+++ b/Backend/internal/bootstrap/project_queries.go
@@ -0,0 +1,347 @@
+// Path: Backend/internal/bootstrap/project_queries.go
+
+package bootstrap
+
+import (
+ "context"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
+ var record OrganizationRecord
+ err := service.db.Pool.QueryRow(ctx, `
+ SELECT id::text, name, slug
+ FROM organizations
+ ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
+ LIMIT 1;
+ `, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return &record, nil
+}
+
+func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
+ var record DepartmentRecord
+ err := service.db.Pool.QueryRow(ctx, `
+ SELECT id::text, organization_id::text, name, slug
+ FROM departments
+ ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
+ LIMIT 1;
+ `, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return &record, nil
+}
+
+func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
+ var record TeamRecord
+ err := service.db.Pool.QueryRow(ctx, `
+ SELECT id::text, organization_id::text, department_id::text, name, slug
+ FROM teams
+ ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
+ LIMIT 1;
+ `, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return &record, nil
+}
+
+func (service *Service) loadPrimaryProject(ctx context.Context) (*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
+ ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
+ LIMIT 1;
+ `, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return &record, nil
+}
+
+func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
+ rows, err := service.db.Pool.Query(ctx, `
+ SELECT id::text, name, slug
+ FROM organizations
+ ORDER BY created_at ASC;
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []OrganizationRecord
+ for rows.Next() {
+ var record OrganizationRecord
+ if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
+ return nil, err
+ }
+
+ records = append(records, record)
+ }
+
+ return records, rows.Err()
+}
+
+func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
+ rows, err := service.db.Pool.Query(ctx, `
+ SELECT id::text, organization_id::text, name, slug
+ FROM departments
+ ORDER BY created_at ASC;
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []DepartmentRecord
+ for rows.Next() {
+ var record DepartmentRecord
+ if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
+ return nil, err
+ }
+
+ records = append(records, record)
+ }
+
+ return records, rows.Err()
+}
+
+func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
+ rows, err := service.db.Pool.Query(ctx, `
+ SELECT id::text, organization_id::text, department_id::text, name, slug
+ FROM teams
+ ORDER BY created_at ASC;
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []TeamRecord
+ for rows.Next() {
+ var record TeamRecord
+ if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
+ return nil, err
+ }
+
+ records = append(records, record)
+ }
+
+ return records, rows.Err()
+}
+
+func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
+ rows, err := service.db.Pool.Query(ctx, `
+ SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
+ FROM projects
+ ORDER BY created_at ASC;
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []ProjectRecord
+ for rows.Next() {
+ var record ProjectRecord
+ if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
+ return nil, err
+ }
+
+ records = append(records, record)
+ }
+
+ 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) {
+ 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) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
+ return service.getProjectTreeNodesByRootPath(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
+ COALESCE(folder_meta.resource_id, ''),
+ directories.path,
+ COALESCE(directories.parent_path, ''),
+ COALESCE(folder_meta.resource_name, directories.resource_name, '')
+ FROM posix_nodes AS directories
+ LEFT JOIN posix_nodes AS folder_meta
+ ON folder_meta.path = directories.path || '/folder.json'
+ AND folder_meta.node_kind = 'file'::posix_node_kind
+ WHERE directories.node_kind = 'directory'::posix_node_kind
+ AND directories.logical_type = 'hierarchy_folder'
+ AND directories.project_slug = $1
+ AND directories.path LIKE $2
+ ORDER BY directories.depth ASC, directories.path ASC;
+ `, project.Slug, rootParentPath+"/%")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var folderRows []projectHierarchyFolderRow
+ for rows.Next() {
+ var row projectHierarchyFolderRow
+ if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil {
+ return nil, err
+ }
+ folderRows = append(folderRows, row)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
+
+ return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
+}
+
+func (service *Service) getProjectTreeNodesByRootPath(
+ ctx context.Context,
+ projectID string,
+ rootPath func(projectSlug string) string,
+) ([]ProjectTreeNodeRecord, 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
+ COALESCE(node_meta.resource_id, ''),
+ directories.path,
+ COALESCE(directories.parent_path, ''),
+ COALESCE(node_meta.resource_name, directories.resource_name, ''),
+ directories.logical_type,
+ COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
+ FROM posix_nodes AS directories
+ LEFT JOIN posix_nodes AS node_meta
+ ON node_meta.path = directories.path || CASE
+ WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json'
+ WHEN directories.logical_type = 'item' THEN '/item.json'
+ ELSE ''
+ END
+ AND node_meta.node_kind = 'file'::posix_node_kind
+ WHERE directories.node_kind = 'directory'::posix_node_kind
+ AND directories.logical_type IN ('hierarchy_folder', 'item')
+ AND directories.project_slug = $1
+ AND directories.path LIKE $2
+ ORDER BY directories.depth ASC, directories.path ASC;
+ `, project.Slug, rootParentPath+"/%")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var nodeRows []projectTreeNodeRow
+ for rows.Next() {
+ var row projectTreeNodeRow
+ if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
+ return nil, err
+ }
+ nodeRows = append(nodeRows, row)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
+ folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
+
+ return applyProjectTreeNodeOrdering(nodes, folderOrder), 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
+ FROM workspaces
+ ORDER BY created_at ASC;
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []WorkspaceRecord
+ for rows.Next() {
+ var record WorkspaceRecord
+ if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
+ return nil, err
+ }
+
+ records = append(records, record)
+ }
+
+ return records, rows.Err()
+}
diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go
index 1cbad9b..96e3db4 100644
--- a/Backend/internal/bootstrap/service.go
+++ b/Backend/internal/bootstrap/service.go
@@ -4,325 +4,12 @@ package bootstrap
import (
"context"
- "encoding/json"
"errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
"strings"
- "unicode"
- "github.com/google/uuid"
"github.com/jackc/pgx/v5"
-
- "moku-backend/internal/database"
)
-const (
- primaryOrganizationSlug = "primary-organization"
- primaryDepartmentSlug = "primary-department"
- primaryTeamSlug = "primary-team"
- primaryProjectSlug = "primary-project"
- organizationWorkspaceSlug = "organization-home"
- departmentWorkspaceSlug = "department-home"
- teamWorkspaceSlug = "team-home"
- projectWorkspaceSlug = "project-home"
- defaultInstallationHost = "localhost"
- defaultInstallationMode = "personal"
- defaultInstallationAccess = "local"
- defaultInstallationProtocol = "http"
- defaultOrganizationName = "Moku"
- defaultPersonalServerSuffix = "Personal"
- defaultPersonalDisplayName = "Personal"
- bootstrapWorkspaceKindOrg = "organization"
- bootstrapWorkspaceKindDept = "department"
- bootstrapWorkspaceKindTeam = "team"
- bootstrapWorkspaceKindProject = "project"
- projectFolderOrderRootKey = "__root__"
- projectFolderOrderHierarchy = "hierarchy"
- projectFolderOrderTree = "tree"
-)
-
-var (
- ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
- ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
- ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
- ErrProjectNotFound = errors.New("project not found")
- ErrProjectFolderNotFound = errors.New("project folder not found")
- ErrProjectItemNotFound = errors.New("project item not found")
- ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
- ErrInvalidProjectItemMove = errors.New("invalid project item move")
-)
-
-type Service struct {
- db *database.DB
- posixRoot string
-}
-
-type SaveInstanceInput struct {
- Protocol string
- Access string
- Host string
-}
-
-type SaveModeInput struct {
- Mode string
- Name string
-}
-
-type SaveAdminInput struct {
- DisplayName string
- Email string
- Password string
-}
-
-type SaveStructureInput struct {
- OrganizationName string
- DepartmentName string
- TeamName string
- ProjectName string
-}
-
-type InstallationRecord struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Mode string `json:"mode"`
- Access string `json:"access"`
- Protocol string `json:"protocol"`
- Host string `json:"host"`
- IsBootstrapped bool `json:"isBootstrapped"`
- MaterializationStatus string `json:"materializationStatus"`
- MaterializationError *string `json:"materializationError,omitempty"`
-}
-
-type AdminRecord struct {
- ID string `json:"id"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- IsInstanceAdmin bool `json:"isInstanceAdmin"`
- HomeTitle string `json:"homeTitle"`
-}
-
-type OrganizationRecord struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
-}
-
-type DepartmentRecord struct {
- ID string `json:"id"`
- OrganizationID string `json:"organizationId"`
- Name string `json:"name"`
- Slug string `json:"slug"`
-}
-
-type TeamRecord struct {
- ID string `json:"id"`
- OrganizationID string `json:"organizationId"`
- DepartmentID *string `json:"departmentId,omitempty"`
- Name string `json:"name"`
- Slug string `json:"slug"`
-}
-
-type ProjectRecord struct {
- ID string `json:"id"`
- OrganizationID string `json:"organizationId"`
- DepartmentID *string `json:"departmentId,omitempty"`
- TeamID *string `json:"teamId,omitempty"`
- Name string `json:"name"`
- Slug string `json:"slug"`
-}
-
-type WorkspaceRecord struct {
- ID string `json:"id"`
- OrganizationID string `json:"organizationId"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- DepartmentID *string `json:"departmentId,omitempty"`
- TeamID *string `json:"teamId,omitempty"`
- ProjectID *string `json:"projectId,omitempty"`
-}
-
-type StructureRecord struct {
- Installation InstallationRecord `json:"installation"`
- Organization namedRecord `json:"organization"`
- Department namedRecord `json:"department"`
- Team namedRecord `json:"team"`
- Project namedRecord `json:"project"`
- Admin AdminSummary `json:"admin"`
-}
-
-type AdminSummary struct {
- ID string `json:"id"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
-}
-
-type BootstrapStructureState struct {
- Organization *OrganizationRecord `json:"organization,omitempty"`
- Department *DepartmentRecord `json:"department,omitempty"`
- Team *TeamRecord `json:"team,omitempty"`
- Project *ProjectRecord `json:"project,omitempty"`
- Workspaces []WorkspaceRecord `json:"workspaces"`
-}
-
-type BootstrapState struct {
- Installation *InstallationRecord `json:"installation,omitempty"`
- Admin *AdminRecord `json:"admin,omitempty"`
- Structure BootstrapStructureState `json:"structure"`
-}
-
-type AppShellState struct {
- Installation *InstallationRecord `json:"installation,omitempty"`
- Admin *AdminRecord `json:"admin,omitempty"`
- Organizations []OrganizationRecord `json:"organizations"`
- Departments []DepartmentRecord `json:"departments"`
- Teams []TeamRecord `json:"teams"`
- Projects []ProjectRecord `json:"projects"`
- Workspaces []WorkspaceRecord `json:"workspaces"`
-}
-
-type namedRecord struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
-}
-
-type ProjectHierarchyFolderRecord struct {
- ID string `json:"id"`
- Path string `json:"path"`
- Label string `json:"label"`
- Children []ProjectHierarchyFolderRecord `json:"children"`
-}
-
-type ProjectTreeNodeRecord struct {
- ID string `json:"id"`
- Path string `json:"path"`
- Label string `json:"label"`
- Kind string `json:"kind"`
- ItemType string `json:"itemType,omitempty"`
- Children []ProjectTreeNodeRecord `json:"children,omitempty"`
-}
-
-type CreateProjectFolderInput struct {
- ProjectID string
- ParentFolderPath string
- Name string
-}
-
-type DeleteProjectFolderInput struct {
- ProjectID string
- FolderPath string
-}
-
-type RenameProjectFolderInput struct {
- ProjectID string
- FolderPath string
- Name string
-}
-
-type MoveProjectFolderInput struct {
- ProjectID string
- FolderPath string
- FolderStableID string
- ParentFolderPath string
- ParentStableID string
- TargetIndex int
-}
-
-type CreateProjectItemInput struct {
- ProjectID string
- ParentFolderPath string
- Name string
- ItemType string
-}
-
-type DeleteProjectItemInput struct {
- ProjectID string
- ItemPath string
-}
-
-type MoveProjectItemInput struct {
- ProjectID string
- ItemPath string
- ItemStableID string
- ParentFolderPath string
- ParentStableID string
- TargetIndex int
-}
-
-type CreateProjectFolderResult struct {
- ProjectID string `json:"projectId"`
- CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
- Folders []ProjectHierarchyFolderRecord `json:"folders"`
-}
-
-type DeleteProjectFolderResult struct {
- ProjectID string `json:"projectId"`
- DeletedFolderStableID string `json:"deletedFolderId"`
- DeletedFolderPath string `json:"deletedFolderPath"`
- Folders []ProjectHierarchyFolderRecord `json:"folders"`
-}
-
-type RenameProjectFolderResult struct {
- ProjectID string `json:"projectId"`
- PreviousFolderStableID string `json:"previousFolderId"`
- PreviousFolderPath string `json:"previousFolderPath"`
- RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
- Folders []ProjectHierarchyFolderRecord `json:"folders"`
-}
-
-type MoveProjectFolderResult struct {
- ProjectID string `json:"projectId"`
- PreviousFolderStableID string `json:"previousFolderId"`
- PreviousFolderPath string `json:"previousFolderPath"`
- MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
- Folders []ProjectHierarchyFolderRecord `json:"folders"`
-}
-
-type CreateProjectItemResult struct {
- ProjectID string `json:"projectId"`
- CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
- Nodes []ProjectTreeNodeRecord `json:"nodes"`
-}
-
-type DeleteProjectItemResult struct {
- ProjectID string `json:"projectId"`
- DeletedItemStableID string `json:"deletedItemId"`
- DeletedItemPath string `json:"deletedItemPath"`
- Nodes []ProjectTreeNodeRecord `json:"nodes"`
-}
-
-type MoveProjectItemResult struct {
- ProjectID string `json:"projectId"`
- PreviousItemStableID string `json:"previousItemId"`
- PreviousItemPath string `json:"previousItemPath"`
- MovedItem ProjectTreeNodeRecord `json:"movedItem"`
- Nodes []ProjectTreeNodeRecord `json:"nodes"`
-}
-
-type projectHierarchyFolderRow struct {
- ID string
- Path string
- ParentPath string
- Label string
-}
-
-type projectTreeNodeRow struct {
- ID string
- Path string
- ParentPath string
- Label string
- Kind string
- ItemType string
-}
-
-func NewService(db *database.DB, posixRoot string) *Service {
- return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
-}
-
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
row := service.db.Pool.QueryRow(ctx, `
INSERT INTO installations (singleton, name, mode, access, protocol, host)
@@ -391,9 +78,7 @@ func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (Ad
if err != nil {
return AdminRecord{}, err
}
- defer func() {
- _ = tx.Rollback(ctx)
- }()
+ defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE users
@@ -452,9 +137,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
if err != nil {
return StructureRecord{}, err
}
- defer func() {
- _ = tx.Rollback(ctx)
- }()
+ defer func() { _ = tx.Rollback(ctx) }()
prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx)
if err != nil {
@@ -545,15 +228,12 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil {
return StructureRecord{}, err
}
-
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil {
return StructureRecord{}, err
}
-
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil {
return StructureRecord{}, err
}
-
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil {
return StructureRecord{}, err
}
@@ -586,9 +266,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error {
if err != nil {
return err
}
- defer func() {
- _ = tx.Rollback(ctx)
- }()
+ defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
TRUNCATE TABLE
@@ -637,7 +315,6 @@ func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
-
return nil, err
}
@@ -649,22 +326,18 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta
if err != nil {
return BootstrapStructureState{}, err
}
-
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
-
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
-
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
-
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return BootstrapStructureState{}, err
@@ -678,2382 +351,3 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta
Workspaces: workspaces,
}, nil
}
-func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
- var record OrganizationRecord
- err := service.db.Pool.QueryRow(ctx, `
- SELECT id::text, name, slug
- FROM organizations
- ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
- LIMIT 1;
- `, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
-
- return nil, err
- }
-
- return &record, nil
-}
-
-func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
- var record DepartmentRecord
- err := service.db.Pool.QueryRow(ctx, `
- SELECT id::text, organization_id::text, name, slug
- FROM departments
- ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
- LIMIT 1;
- `, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
-
- return nil, err
- }
-
- return &record, nil
-}
-
-func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
- var record TeamRecord
- err := service.db.Pool.QueryRow(ctx, `
- SELECT id::text, organization_id::text, department_id::text, name, slug
- FROM teams
- ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
- LIMIT 1;
- `, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
-
- return nil, err
- }
-
- return &record, nil
-}
-
-func (service *Service) loadPrimaryProject(ctx context.Context) (*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
- ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
- LIMIT 1;
- `, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
-
- return nil, err
- }
-
- return &record, nil
-}
-
-func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
- rows, err := service.db.Pool.Query(ctx, `
- SELECT id::text, name, slug
- FROM organizations
- ORDER BY created_at ASC;
- `)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var records []OrganizationRecord
- for rows.Next() {
- var record OrganizationRecord
- if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
- return nil, err
- }
-
- records = append(records, record)
- }
-
- return records, rows.Err()
-}
-
-func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
- rows, err := service.db.Pool.Query(ctx, `
- SELECT id::text, organization_id::text, name, slug
- FROM departments
- ORDER BY created_at ASC;
- `)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var records []DepartmentRecord
- for rows.Next() {
- var record DepartmentRecord
- if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
- return nil, err
- }
-
- records = append(records, record)
- }
-
- return records, rows.Err()
-}
-
-func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
- rows, err := service.db.Pool.Query(ctx, `
- SELECT id::text, organization_id::text, department_id::text, name, slug
- FROM teams
- ORDER BY created_at ASC;
- `)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var records []TeamRecord
- for rows.Next() {
- var record TeamRecord
- if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
- return nil, err
- }
-
- records = append(records, record)
- }
-
- return records, rows.Err()
-}
-
-func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
- rows, err := service.db.Pool.Query(ctx, `
- SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
- FROM projects
- ORDER BY created_at ASC;
- `)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var records []ProjectRecord
- for rows.Next() {
- var record ProjectRecord
- if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
- return nil, err
- }
-
- records = append(records, record)
- }
-
- 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) {
- 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) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
- return service.getProjectTreeNodesByRootPath(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
- COALESCE(folder_meta.resource_id, ''),
- directories.path,
- COALESCE(directories.parent_path, ''),
- COALESCE(folder_meta.resource_name, directories.resource_name, '')
- FROM posix_nodes AS directories
- LEFT JOIN posix_nodes AS folder_meta
- ON folder_meta.path = directories.path || '/folder.json'
- AND folder_meta.node_kind = 'file'::posix_node_kind
- WHERE directories.node_kind = 'directory'::posix_node_kind
- AND directories.logical_type = 'hierarchy_folder'
- AND directories.project_slug = $1
- AND directories.path LIKE $2
- ORDER BY directories.depth ASC, directories.path ASC;
- `, project.Slug, rootParentPath+"/%")
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var folderRows []projectHierarchyFolderRow
- for rows.Next() {
- var row projectHierarchyFolderRow
- if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil {
- return nil, err
- }
- folderRows = append(folderRows, row)
- }
-
- if err := rows.Err(); err != nil {
- return nil, err
- }
-
- // The projection gives us an unordered tree snapshot. Sibling order is stored in
- // the project settings file, so the read path has to rebuild the tree first and
- // then apply persisted ordering on top.
- folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
- folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
-
- return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
-}
-
-func (service *Service) getProjectTreeNodesByRootPath(
- ctx context.Context,
- projectID string,
- rootPath func(projectSlug string) string,
-) ([]ProjectTreeNodeRecord, 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
- COALESCE(node_meta.resource_id, ''),
- directories.path,
- COALESCE(directories.parent_path, ''),
- COALESCE(node_meta.resource_name, directories.resource_name, ''),
- directories.logical_type,
- COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
- FROM posix_nodes AS directories
- LEFT JOIN posix_nodes AS node_meta
- ON node_meta.path = directories.path || CASE
- WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json'
- WHEN directories.logical_type = 'item' THEN '/item.json'
- ELSE ''
- END
- AND node_meta.node_kind = 'file'::posix_node_kind
- WHERE directories.node_kind = 'directory'::posix_node_kind
- AND directories.logical_type IN ('hierarchy_folder', 'item')
- AND directories.project_slug = $1
- AND directories.path LIKE $2
- ORDER BY directories.depth ASC, directories.path ASC;
- `, project.Slug, rootParentPath+"/%")
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var nodeRows []projectTreeNodeRow
- for rows.Next() {
- var row projectTreeNodeRow
- if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
- return nil, err
- }
- nodeRows = append(nodeRows, row)
- }
-
- if err := rows.Err(); err != nil {
- return nil, err
- }
-
- nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
- folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
-
- return applyProjectTreeNodeOrdering(nodes, folderOrder), 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) 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) 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) 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) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
- return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
-}
-
-func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
- return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
-}
-
-func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
- return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
-}
-
-func (service *Service) createProjectHierarchyFolder(
- ctx context.Context,
- input CreateProjectFolderInput,
- rootPath func(projectSlug string) string,
- createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
-) (CreateProjectFolderResult, error) {
- // Folder mutations follow the same pattern:
- // 1. validate/resolve against the current ordered tree
- // 2. mutate POSIX on disk
- // 3. rebuild the projection snapshot
- // 4. rewrite sibling ordering metadata
- // 5. re-read the ordered tree that the frontend should trust
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- parentOrderID := ""
- trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
- if trimmedParentFolderPath != "" {
- parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
- if !found {
- return CreateProjectFolderResult{}, ErrProjectFolderNotFound
- }
- parentOrderID = parentFolder.ID
- }
-
- createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
- if err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
- if !ok {
- return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
- insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectFolderResult{}, err
- }
-
- createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
- if !ok {
- return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
- }
-
- return CreateProjectFolderResult{
- ProjectID: project.ID,
- CreatedFolder: createdFolder,
- Folders: folders,
- }, nil
-}
-
-func (service *Service) deleteProjectHierarchyFolder(
- ctx context.Context,
- input DeleteProjectFolderInput,
- rootPath func(projectSlug string) string,
- deleteOnDisk func(projectSlug, folderPath string) (string, error),
-) (DeleteProjectFolderResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
- if !found {
- return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
- }
-
- deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
- if err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
- return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- removeFolderOrder(folderOrder, deletedFolder.ID)
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectFolderResult{}, err
- }
-
- return DeleteProjectFolderResult{
- ProjectID: project.ID,
- DeletedFolderStableID: deletedFolder.ID,
- DeletedFolderPath: deletedFolderPath,
- Folders: folders,
- }, nil
-}
-
-func (service *Service) renameProjectHierarchyFolder(
- ctx context.Context,
- input RenameProjectFolderInput,
- rootPath func(projectSlug string) string,
- renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
-) (RenameProjectFolderResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return RenameProjectFolderResult{}, err
- }
-
- previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
- if err != nil {
- return RenameProjectFolderResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return RenameProjectFolderResult{}, err
- }
-
- folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return RenameProjectFolderResult{}, err
- }
-
- renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
- if !found {
- return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
- }
-
- if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
- return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
- }
-
- return RenameProjectFolderResult{
- ProjectID: project.ID,
- PreviousFolderStableID: renamedFolder.ID,
- PreviousFolderPath: previousFolderPath,
- RenamedFolder: renamedFolder,
- Folders: folders,
- }, nil
-}
-
-func (service *Service) moveProjectHierarchyFolder(
- ctx context.Context,
- input MoveProjectFolderInput,
- rootPath func(projectSlug string) string,
- moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
-) (MoveProjectFolderResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
- if !found {
- return MoveProjectFolderResult{}, ErrProjectFolderNotFound
- }
-
- movedFolderStableID := currentFolder.ID
- providedFolderStableID := strings.TrimSpace(input.FolderStableID)
- if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
- return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
- }
-
- parentOrderID := ""
- trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
- providedParentStableID := strings.TrimSpace(input.ParentStableID)
- if trimmedParentFolderPath != "" {
- parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
- if !found {
- return MoveProjectFolderResult{}, ErrProjectFolderNotFound
- }
- parentOrderID = parentFolder.ID
- if providedParentStableID != "" && providedParentStableID != parentOrderID {
- return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
- }
- } else if providedParentStableID != "" {
- return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
- }
-
- previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
- if err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
- if !found {
- return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
- }
-
- if previousFolderPath != movedFolderPath {
- if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
- return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
- }
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
- removeFolderOrderReference(folderOrder, movedFolderStableID)
- removeFolderOrderReference(folderOrder, movedFolder.ID)
- insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectFolderResult{}, err
- }
-
- movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
- if !found {
- return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
- }
-
- return MoveProjectFolderResult{
- ProjectID: project.ID,
- PreviousFolderStableID: movedFolder.ID,
- PreviousFolderPath: previousFolderPath,
- MovedFolder: movedFolder,
- Folders: folders,
- }, nil
-}
-
-func (service *Service) createProjectTreeItem(
- ctx context.Context,
- input CreateProjectItemInput,
- rootPath func(projectSlug string) string,
-) (CreateProjectItemResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return CreateProjectItemResult{}, err
- }
-
- currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectItemResult{}, err
- }
-
- parentOrderID := ""
- trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
- if trimmedParentFolderPath != "" {
- parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
- if !found {
- return CreateProjectItemResult{}, ErrProjectFolderNotFound
- }
- parentOrderID = parentFolder.ID
- }
-
- createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
- if err != nil {
- return CreateProjectItemResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return CreateProjectItemResult{}, err
- }
-
- nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectItemResult{}, err
- }
-
- createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
- if !ok || createdItem.Kind != "item" {
- return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
- insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return CreateProjectItemResult{}, err
- }
-
- nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return CreateProjectItemResult{}, err
- }
-
- createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
- if !ok || createdItem.Kind != "item" {
- return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
- }
-
- return CreateProjectItemResult{
- ProjectID: project.ID,
- CreatedItem: createdItem,
- Nodes: nodes,
- }, nil
-}
-
-func (service *Service) deleteProjectTreeItem(
- ctx context.Context,
- input DeleteProjectItemInput,
- rootPath func(projectSlug string) string,
-) (DeleteProjectItemResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
- if !found || deletedItem.Kind != "item" {
- return DeleteProjectItemResult{}, ErrProjectItemNotFound
- }
-
- deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
- if err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
- return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- removeFolderOrderReference(folderOrder, deletedItem.ID)
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return DeleteProjectItemResult{}, err
- }
-
- return DeleteProjectItemResult{
- ProjectID: project.ID,
- DeletedItemStableID: deletedItem.ID,
- DeletedItemPath: deletedItemPath,
- Nodes: nodes,
- }, nil
-}
-
-func (service *Service) moveProjectTreeItem(
- ctx context.Context,
- input MoveProjectItemInput,
- rootPath func(projectSlug string) string,
-) (MoveProjectItemResult, error) {
- project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
- if err != nil {
- return MoveProjectItemResult{}, err
- }
-
- currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectItemResult{}, err
- }
-
- currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
- if !found || currentItem.Kind != "item" {
- return MoveProjectItemResult{}, ErrProjectItemNotFound
- }
-
- movedItemStableID := currentItem.ID
- providedItemStableID := strings.TrimSpace(input.ItemStableID)
- if providedItemStableID != "" && providedItemStableID != movedItemStableID {
- return MoveProjectItemResult{}, ErrInvalidProjectItemMove
- }
-
- parentOrderID := ""
- trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
- providedParentStableID := strings.TrimSpace(input.ParentStableID)
- if trimmedParentFolderPath != "" {
- parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
- if !found {
- return MoveProjectItemResult{}, ErrProjectFolderNotFound
- }
- parentOrderID = parentFolder.ID
- if providedParentStableID != "" && providedParentStableID != parentOrderID {
- return MoveProjectItemResult{}, ErrInvalidProjectItemMove
- }
- } else if providedParentStableID != "" {
- return MoveProjectItemResult{}, ErrInvalidProjectItemMove
- }
-
- previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
- if err != nil {
- return MoveProjectItemResult{}, err
- }
-
- if err := service.rebuildProjection(ctx); err != nil {
- return MoveProjectItemResult{}, err
- }
-
- nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectItemResult{}, err
- }
-
- movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
- if !found || movedItem.Kind != "item" {
- return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
- }
-
- if previousItemPath != movedItemPath {
- if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
- return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
- }
- }
-
- folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
- seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
- removeFolderOrderReference(folderOrder, movedItemStableID)
- removeFolderOrderReference(folderOrder, movedItem.ID)
- insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
- if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
- return MoveProjectItemResult{}, err
- }
-
- nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
- if err != nil {
- return MoveProjectItemResult{}, err
- }
-
- movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
- if !found || movedItem.Kind != "item" {
- return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
- }
-
- return MoveProjectItemResult{
- ProjectID: project.ID,
- PreviousItemStableID: movedItem.ID,
- PreviousItemPath: previousItemPath,
- MovedItem: movedItem,
- Nodes: nodes,
- }, 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
- FROM workspaces
- ORDER BY created_at ASC;
- `)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var records []WorkspaceRecord
- for rows.Next() {
- var record WorkspaceRecord
- if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
- return nil, err
- }
-
- records = append(records, record)
- }
-
- return records, rows.Err()
-}
-
-func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
- var record namedRecord
- if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
- return namedRecord{}, err
- }
-
- return record, nil
-}
-
-func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
- _, err := tx.Exec(ctx, `
- INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
- VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
- ON CONFLICT (organization_id, slug) DO UPDATE
- SET
- name = EXCLUDED.name,
- kind = EXCLUDED.kind,
- created_by_user_id = EXCLUDED.created_by_user_id,
- department_id = EXCLUDED.department_id,
- team_id = EXCLUDED.team_id,
- project_id = EXCLUDED.project_id,
- updated_at = NOW();
- `, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
-
- return err
-}
-
-func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string {
- trimmedInstallationName := strings.TrimSpace(installationName)
- trimmedHost := strings.TrimSpace(host)
- trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
-
- if trimmedInstallationName != "" {
- return trimmedInstallationName
- }
-
- if strings.EqualFold(mode, defaultInstallationMode) {
- if trimmedAdminDisplayName != "" {
- return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
- }
-
- return defaultPersonalDisplayName
- }
-
- if trimmedHost != "" {
- return trimmedHost
- }
-
- return defaultOrganizationName
-}
-
-func personalHomeTitle(displayName string) string {
- trimmedDisplayName := strings.TrimSpace(displayName)
- if trimmedDisplayName == "" {
- return "Home"
- }
-
- if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
- return fmt.Sprintf("%s' Home", trimmedDisplayName)
- }
-
- return fmt.Sprintf("%s's Home", trimmedDisplayName)
-}
-
-func (service *Service) ensureBootstrapPOSIXSkeleton(
- installation InstallationRecord,
- admin AdminSummary,
- organization namedRecord,
- department namedRecord,
- team namedRecord,
- project namedRecord,
-) error {
- rootPath := strings.TrimSpace(service.posixRoot)
- if rootPath == "" {
- return nil
- }
-
- if err := os.MkdirAll(rootPath, 0o755); err != nil {
- return fmt.Errorf("create POSIX root: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(rootPath, "settings.json"), map[string]any{
- "installation": map[string]any{
- "id": installation.ID,
- "name": installation.Name,
- "mode": installation.Mode,
- "access": installation.Access,
- "protocol": installation.Protocol,
- "host": installation.Host,
- "isBootstrapped": installation.IsBootstrapped,
- },
- "organization": map[string]any{
- "id": organization.ID,
- "name": organization.Name,
- "slug": organization.Slug,
- },
- }); err != nil {
- return fmt.Errorf("write tenant settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{
- "version": 1,
- "type": "tenant-layout",
- "home": map[string]any{
- "defaultProjectSlug": project.Slug,
- },
- }); err != nil {
- return fmt.Errorf("write tenant layout.json: %w", err)
- }
-
- if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil {
- return fmt.Errorf("create catalog packs root: %w", err)
- }
-
- if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil {
- return fmt.Errorf("create catalog standalone root: %w", err)
- }
-
- departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug))
- teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug))
- projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug))
- usersPath := filepath.Join(rootPath, "users")
- personalName := strings.TrimSpace(admin.DisplayName)
- if personalName == "" {
- personalName = defaultPersonalDisplayName
- }
- personalSlug := normalizePOSIXSlug(personalName)
- personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug))
-
- for _, dirPath := range []string{
- departmentPath,
- teamPath,
- projectPath,
- filepath.Join(projectPath, "children"),
- filepath.Join(projectPath, "tree"),
- filepath.Join(usersPath, "personals"),
- personalHomePath,
- filepath.Join(personalHomePath, "tree"),
- } {
- if err := os.MkdirAll(dirPath, 0o755); err != nil {
- return fmt.Errorf("create POSIX directory %s: %w", dirPath, err)
- }
- }
-
- if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{
- "id": department.ID,
- "name": department.Name,
- "slug": department.Slug,
- "type": "department",
- }); err != nil {
- return fmt.Errorf("write department settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{
- "owners": []map[string]string{{
- "id": admin.ID,
- "email": admin.Email,
- "displayName": admin.DisplayName,
- }},
- }); err != nil {
- return fmt.Errorf("write department users.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{
- "id": team.ID,
- "name": team.Name,
- "slug": team.Slug,
- "type": "team",
- }); err != nil {
- return fmt.Errorf("write team settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{
- "owners": []map[string]string{{
- "id": admin.ID,
- "email": admin.Email,
- "displayName": admin.DisplayName,
- }},
- }); err != nil {
- return fmt.Errorf("write team users.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{
- "id": project.ID,
- "name": project.Name,
- "slug": project.Slug,
- "type": "project",
- }); err != nil {
- return fmt.Errorf("write project settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{
- "type": "project-home",
- "project": project.Slug,
- "widgets": []any{},
- }); err != nil {
- return fmt.Errorf("write project home.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(projectPath, "acl.json"), map[string]any{
- "version": 1,
- "inherits": true,
- "rules": []any{},
- }); err != nil {
- return fmt.Errorf("write project acl.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{
- "primaryAdminId": admin.ID,
- }); err != nil {
- return fmt.Errorf("write users settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{
- "users": []map[string]string{{
- "id": admin.ID,
- "email": admin.Email,
- "displayName": admin.DisplayName,
- }},
- }); err != nil {
- return fmt.Errorf("write users data.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(personalHomePath, "settings.json"), map[string]any{
- "id": admin.ID,
- "name": personalName,
- "slug": personalSlug,
- "type": "personal",
- "ownerUserId": admin.ID,
- "email": admin.Email,
- }); err != nil {
- return fmt.Errorf("write personal settings.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(personalHomePath, "layout.json"), map[string]any{
- "version": 1,
- "type": "personal-layout",
- }); err != nil {
- return fmt.Errorf("write personal layout.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(personalHomePath, "home.json"), map[string]any{
- "type": "personal-home",
- "title": personalHomeTitle(personalName),
- "owner": map[string]any{
- "id": admin.ID,
- "email": admin.Email,
- "displayName": personalName,
- },
- "widgets": []any{},
- }); err != nil {
- return fmt.Errorf("write personal home.json: %w", err)
- }
-
- return nil
-}
-
-func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
- 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) 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) 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) 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,
-) (string, string, error) {
- posixRoot := strings.TrimSpace(service.posixRoot)
- if posixRoot == "" {
- return "", "", fmt.Errorf("POSIX root is not configured")
- }
-
- trimmedName := strings.TrimSpace(name)
- if trimmedName == "" {
- return "", "", fmt.Errorf("folder name is required")
- }
-
- 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(posixRoot, 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)
- }
-
- folderID := uuid.NewString()
-
- if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{
- "id": folderID,
- "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 (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 (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)
- }
- }
-
- folderPayload := readJSONFileMap(filepath.Join(destinationDir, "folder.json"))
- folderMetadataID, _ := folderPayload["id"].(string)
- if strings.TrimSpace(folderMetadataID) == "" {
- folderMetadataID = uuid.NewString()
- }
-
- if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{
- "id": folderMetadataID,
- "name": trimmedName,
- "slug": folderSlug,
- "type": "folder",
- }); err != nil {
- return "", "", fmt.Errorf("write renamed project folder.json: %w", err)
- }
-
- 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"))
- folderMetadataID, _ := folderPayload["id"].(string)
- if strings.TrimSpace(folderMetadataID) == "" {
- folderMetadataID = uuid.NewString()
- }
- 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["id"] = folderMetadataID
- 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 (service *Service) createProjectTreeItemOnDisk(
- projectSlug, parentFolderPath, name, itemType string,
-) (string, error) {
- posixRoot := strings.TrimSpace(service.posixRoot)
- if posixRoot == "" {
- return "", fmt.Errorf("POSIX root is not configured")
- }
-
- trimmedName := strings.TrimSpace(name)
- if trimmedName == "" {
- return "", fmt.Errorf("item name is required")
- }
-
- canonicalItemType := normalizeProjectTreeItemType(itemType)
- containerProjectionPath := projectTreeRootPath(projectSlug)
- parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
-
- if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
- containerProjectionPath = trimmedParentFolderPath
- parentDir = filepath.Join(posixRoot, 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 item path: %w", err)
- }
-
- baseSlug := normalizePOSIXSlug(trimmedName)
- itemDirName := slugDir("item", baseSlug)
- itemDir := filepath.Join(parentDir, itemDirName)
- itemSlug := baseSlug
-
- for attempt := 2; ; attempt += 1 {
- if _, err := os.Stat(itemDir); os.IsNotExist(err) {
- break
- } else if err != nil {
- return "", fmt.Errorf("stat candidate project item: %w", err)
- }
-
- itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
- itemDirName = slugDir("item", itemSlug)
- itemDir = filepath.Join(parentDir, itemDirName)
- }
-
- if err := os.MkdirAll(itemDir, 0o755); err != nil {
- return "", fmt.Errorf("create project item: %w", err)
- }
-
- itemID := uuid.NewString()
- if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{
- "id": itemID,
- "name": trimmedName,
- "slug": itemSlug,
- "type": canonicalItemType,
- }); err != nil {
- return "", fmt.Errorf("write project item.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
- return "", fmt.Errorf("write project schema.json: %w", err)
- }
-
- if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
- return "", fmt.Errorf("write project data.json: %w", err)
- }
-
- return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
-}
-
-func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
- posixRoot := strings.TrimSpace(service.posixRoot)
- if posixRoot == "" {
- return "", fmt.Errorf("POSIX root is not configured")
- }
-
- rootProjectionPath := projectTreeRootPath(projectSlug)
- itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
- if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
- return "", ErrProjectItemNotFound
- }
-
- itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
- info, err := os.Stat(itemDir)
- if err != nil {
- if os.IsNotExist(err) {
- return "", ErrProjectItemNotFound
- }
- return "", fmt.Errorf("stat project item: %w", err)
- }
- if !info.IsDir() {
- return "", ErrProjectItemNotFound
- }
-
- if err := os.RemoveAll(itemDir); err != nil {
- return "", fmt.Errorf("delete project item: %w", err)
- }
-
- return itemProjectionPath, nil
-}
-
-func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
- posixRoot := strings.TrimSpace(service.posixRoot)
- if posixRoot == "" {
- return "", "", fmt.Errorf("POSIX root is not configured")
- }
-
- rootProjectionPath := projectTreeRootPath(projectSlug)
- itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
- if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
- return "", "", ErrProjectItemNotFound
- }
-
- itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
- info, err := os.Stat(itemDir)
- if err != nil {
- if os.IsNotExist(err) {
- return "", "", ErrProjectItemNotFound
- }
- return "", "", fmt.Errorf("stat project item: %w", err)
- }
- if !info.IsDir() {
- return "", "", ErrProjectItemNotFound
- }
-
- trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
- parentProjectionPath := rootProjectionPath
- parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
- if trimmedParentFolderPath != "" {
- parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
- if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
- return "", "", ErrProjectFolderNotFound
- }
- parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
- }
-
- parentInfo, err := os.Stat(parentDir)
- if err != nil {
- if os.IsNotExist(err) {
- return "", "", ErrProjectFolderNotFound
- }
- return "", "", fmt.Errorf("stat project item parent: %w", err)
- }
- if !parentInfo.IsDir() {
- return "", "", ErrProjectFolderNotFound
- }
-
- currentParentDir := filepath.Dir(itemDir)
- currentBase := filepath.Base(itemDir)
- itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json"))
- itemID, _ := itemPayload["id"].(string)
- if strings.TrimSpace(itemID) == "" {
- itemID = uuid.NewString()
- }
- itemName, _ := itemPayload["name"].(string)
- if strings.TrimSpace(itemName) == "" {
- itemName = fallbackItemLabel(itemProjectionPath)
- }
- itemType, _ := itemPayload["type"].(string)
- canonicalItemType := normalizeProjectTreeItemType(itemType)
-
- baseSlug := strings.TrimPrefix(currentBase, "item-")
- if strings.TrimSpace(baseSlug) == "" {
- baseSlug = normalizePOSIXSlug(itemName)
- }
-
- itemSlug := baseSlug
- itemDirName := slugDir("item", itemSlug)
- destinationDir := filepath.Join(parentDir, itemDirName)
- for attempt := 2; ; attempt += 1 {
- if samePath(destinationDir, itemDir) {
- break
- }
- if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
- break
- } else if err != nil {
- return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
- }
-
- itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
- itemDirName = slugDir("item", itemSlug)
- destinationDir = filepath.Join(parentDir, itemDirName)
- }
-
- movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
- if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
- return itemProjectionPath, itemProjectionPath, nil
- }
-
- if err := os.Rename(itemDir, destinationDir); err != nil {
- return "", "", fmt.Errorf("move project item: %w", err)
- }
-
- itemPayload["id"] = itemID
- itemPayload["name"] = itemName
- itemPayload["slug"] = itemSlug
- itemPayload["type"] = canonicalItemType
- if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil {
- return "", "", fmt.Errorf("write moved project item.json: %w", err)
- }
-
- return itemProjectionPath, 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 (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
- settingsPath := service.projectSettingsPath(projectSlug)
- settingsPayload := readJSONFileMap(settingsPath)
- folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
- if folderOrderPayload == nil {
- return map[string][]string{}
- }
-
- scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
- if scopePayload == nil {
- return map[string][]string{}
- }
-
- byParentPayload, _ := scopePayload["byParent"].(map[string]any)
- if byParentPayload == nil {
- return map[string][]string{}
- }
-
- order := make(map[string][]string, len(byParentPayload))
- for key, raw := range byParentPayload {
- for _, id := range stringSliceValue(raw) {
- trimmedID := strings.TrimSpace(id)
- if trimmedID == "" || slicesContains(order[key], trimmedID) {
- continue
- }
- order[key] = append(order[key], trimmedID)
- }
- }
-
- return order
-}
-
-func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
- settingsPath := service.projectSettingsPath(projectSlug)
- settingsPayload := readJSONFileMap(settingsPath)
- if settingsPayload == nil {
- settingsPayload = map[string]any{}
- }
-
- folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
- if folderOrderPayload == nil {
- folderOrderPayload = map[string]any{}
- }
-
- scopeKey := projectFolderOrderScope(rootProjectionPath)
- scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
- if scopePayload == nil {
- scopePayload = map[string]any{}
- }
-
- byParentPayload := map[string]any{}
- for key, ids := range folderOrder {
- if len(ids) == 0 {
- continue
- }
- copied := make([]string, 0, len(ids))
- for _, id := range ids {
- trimmedID := strings.TrimSpace(id)
- if trimmedID == "" || slicesContains(copied, trimmedID) {
- continue
- }
- copied = append(copied, trimmedID)
- }
- if len(copied) > 0 {
- byParentPayload[key] = copied
- }
- }
-
- scopePayload["byParent"] = byParentPayload
- folderOrderPayload[scopeKey] = scopePayload
- settingsPayload["folderOrder"] = folderOrderPayload
-
- if err := writeJSONFile(settingsPath, settingsPayload); err != nil {
- return fmt.Errorf("write project settings.json: %w", err)
- }
-
- return nil
-}
-
-func (service *Service) projectSettingsPath(projectSlug string) string {
- return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json")
-}
-
-func projectFolderOrderScope(rootProjectionPath string) string {
- if strings.HasSuffix(rootProjectionPath, "/tree") {
- return projectFolderOrderTree
- }
-
- return projectFolderOrderHierarchy
-}
-
-func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
- return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
-}
-
-func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
- if len(folders) == 0 {
- return folders
- }
-
- nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
- copy(nextFolders, folders)
- for index := range nextFolders {
- nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder)
- }
-
- orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
- if len(orderIDs) == 0 {
- return nextFolders
- }
-
- rankByID := make(map[string]int, len(orderIDs))
- for index, id := range orderIDs {
- if _, exists := rankByID[id]; !exists {
- rankByID[id] = index
- }
- }
-
- sort.SliceStable(nextFolders, func(left, right int) bool {
- leftRank, leftOrdered := rankByID[nextFolders[left].ID]
- rightRank, rightOrdered := rankByID[nextFolders[right].ID]
- if leftOrdered && rightOrdered {
- return leftRank < rightRank
- }
- if leftOrdered != rightOrdered {
- return leftOrdered
- }
- return false
- })
-
- return nextFolders
-}
-
-func removeFolderOrder(folderOrder map[string][]string, folderID string) {
- removeFolderOrderReference(folderOrder, folderID)
-
- trimmedFolderID := strings.TrimSpace(folderID)
- if trimmedFolderID == "" {
- return
- }
-
- delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
-}
-
-func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
- trimmedFolderID := strings.TrimSpace(folderID)
- if trimmedFolderID == "" {
- return
- }
-
- for key, ids := range folderOrder {
- nextIDs := ids[:0]
- for _, id := range ids {
- if strings.TrimSpace(id) == trimmedFolderID {
- continue
- }
- nextIDs = append(nextIDs, id)
- }
- if len(nextIDs) == 0 {
- delete(folderOrder, key)
- continue
- }
- folderOrder[key] = append([]string(nil), nextIDs...)
- }
-}
-
-func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
- trimmedFolderID := strings.TrimSpace(folderID)
- if trimmedFolderID == "" {
- return
- }
-
- removeFolderOrderReference(folderOrder, trimmedFolderID)
-
- parentKey := projectFolderOrderParentKey(parentID)
- children := append([]string(nil), folderOrder[parentKey]...)
- if index < 0 {
- index = 0
- }
- if index > len(children) {
- index = len(children)
- }
- children = slicesInsert(children, index, trimmedFolderID)
- folderOrder[parentKey] = children
-}
-
-func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
- children := folders
- trimmedParentID := strings.TrimSpace(parentID)
- if trimmedParentID != "" {
- parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
- if !found {
- return
- }
- children = parent.Children
- }
-
- parentKey := projectFolderOrderParentKey(trimmedParentID)
- seeded := make([]string, 0, len(children))
- for _, child := range children {
- childID := strings.TrimSpace(child.ID)
- if childID == "" || slicesContains(seeded, childID) {
- continue
- }
- seeded = append(seeded, childID)
- }
-
- if len(seeded) == 0 {
- delete(folderOrder, parentKey)
- return
- }
-
- folderOrder[parentKey] = seeded
-}
-
-func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
- return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
-}
-
-func projectFolderOrderParentKey(parentID string) string {
- trimmedParentID := strings.TrimSpace(parentID)
- if trimmedParentID == "" {
- return projectFolderOrderRootKey
- }
-
- return trimmedParentID
-}
-
-func stringSliceValue(value any) []string {
- items, ok := value.([]any)
- if !ok {
- if typed, ok := value.([]string); ok {
- return typed
- }
- return nil
- }
-
- result := make([]string, 0, len(items))
- for _, item := range items {
- text, ok := item.(string)
- if ok {
- result = append(result, text)
- }
- }
-
- return result
-}
-
-func slicesContains(values []string, value string) bool {
- for _, existing := range values {
- if existing == value {
- return true
- }
- }
-
- return false
-}
-
-func slicesInsert(values []string, index int, value string) []string {
- values = append(values, "")
- copy(values[index+1:], values[index:])
- values[index] = value
- return values
-}
-
-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
- }
-
- nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows))
- childrenByParent := make(map[string][]string)
-
- for _, row := range rows {
- folderID := strings.TrimSpace(row.ID)
- if folderID == "" {
- folderID = row.Path
- }
- label := strings.TrimSpace(row.Label)
- if label == "" {
- label = fallbackFolderLabel(row.Path)
- }
- nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{
- ID: folderID,
- Path: 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,
- Path: node.Path,
- Label: node.Label,
- Children: build(filepath.ToSlash(filepath.Join(childPath, "children"))),
- }
- folders = append(folders, folder)
- }
-
- return folders
- }
-
- return build(rootParentPath)
-}
-
-func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
- if len(rows) == 0 {
- return nil
- }
-
- nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
- childrenByParent := make(map[string][]string)
-
- for _, row := range rows {
- nodeKind := normalizeProjectTreeNodeKind(row.Kind)
- nodeID := strings.TrimSpace(row.ID)
- if nodeID == "" {
- nodeID = row.Path
- }
- label := strings.TrimSpace(row.Label)
- if label == "" {
- if nodeKind == "item" {
- label = fallbackItemLabel(row.Path)
- } else {
- label = fallbackFolderLabel(row.Path)
- }
- }
-
- nodesByPath[row.Path] = &ProjectTreeNodeRecord{
- ID: nodeID,
- Path: row.Path,
- Label: label,
- Kind: nodeKind,
- ItemType: normalizeProjectTreeItemType(row.ItemType),
- Children: []ProjectTreeNodeRecord{},
- }
- childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)] = append(childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)], row.Path)
- }
-
- var build func(parentPath string) []ProjectTreeNodeRecord
- build = func(parentPath string) []ProjectTreeNodeRecord {
- childPaths := childrenByParent[parentPath]
- if len(childPaths) == 0 {
- return nil
- }
-
- nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
- for _, childPath := range childPaths {
- node := nodesByPath[childPath]
- if node == nil {
- continue
- }
-
- nextNode := ProjectTreeNodeRecord{
- ID: node.ID,
- Path: node.Path,
- Label: node.Label,
- Kind: node.Kind,
- ItemType: node.ItemType,
- }
- if node.Kind == "folder" {
- nextNode.Children = build(node.Path)
- }
- nodes = append(nodes, nextNode)
- }
-
- return nodes
- }
-
- return build(rootParentPath)
-}
-
-func normalizeProjectTreeParentPath(kind, parentPath string) string {
- if kind == "folder" && strings.HasSuffix(parentPath, "/children") {
- return filepath.ToSlash(filepath.Dir(parentPath))
- }
-
- return parentPath
-}
-
-func normalizeProjectTreeNodeKind(kind string) string {
- switch strings.TrimSpace(kind) {
- case "item":
- return "item"
- case "folder", "hierarchy_folder":
- return "folder"
- default:
- return "folder"
- }
-}
-
-func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
- return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
-}
-
-func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
- if len(nodes) == 0 {
- return nodes
- }
-
- nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
- copy(nextNodes, nodes)
- for index := range nextNodes {
- if nextNodes[index].Kind == "folder" {
- nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder)
- }
- }
-
- orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
- if len(orderIDs) == 0 {
- return nextNodes
- }
-
- rankByID := make(map[string]int, len(orderIDs))
- for index, id := range orderIDs {
- if _, exists := rankByID[id]; !exists {
- rankByID[id] = index
- }
- }
-
- sort.SliceStable(nextNodes, func(left, right int) bool {
- leftRank, leftOrdered := rankByID[nextNodes[left].ID]
- rightRank, rightOrdered := rankByID[nextNodes[right].ID]
- if leftOrdered && rightOrdered {
- return leftRank < rightRank
- }
- if leftOrdered != rightOrdered {
- return leftOrdered
- }
- return false
- })
-
- return nextNodes
-}
-
-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 findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
- for _, folder := range folders {
- if folder.Path == folderPath {
- return folder, true
- }
-
- if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok {
- return child, true
- }
- }
-
- return ProjectHierarchyFolderRecord{}, false
-}
-
-func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
- for _, node := range nodes {
- if node.ID == nodeID {
- return node, true
- }
- if child, ok := findProjectTreeNode(node.Children, nodeID); ok {
- return child, true
- }
- }
-
- return ProjectTreeNodeRecord{}, false
-}
-
-func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
- for _, node := range nodes {
- if node.Path == nodePath {
- return node, true
- }
- if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok {
- return child, true
- }
- }
-
- return ProjectTreeNodeRecord{}, false
-}
-
-func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
- node, ok := findProjectTreeNodeByPath(nodes, folderPath)
- if !ok || node.Kind != "folder" {
- return ProjectTreeNodeRecord{}, false
- }
-
- return node, true
-}
-
-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 == "" {
- 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 fallbackItemLabel(path string) string {
- base := filepath.Base(filepath.FromSlash(path))
- trimmed := strings.TrimPrefix(base, "item-")
- 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 normalizeProjectTreeItemType(itemType string) string {
- switch strings.TrimSpace(strings.ToLower(itemType)) {
- case "", "board", "core.board", "core.board.kanban", "kanban":
- return "core.board.kanban"
- case "core.doc", "doc", "document":
- return "core.doc"
- case "core.board.list", "list", "list-board":
- return "core.board.list"
- default:
- return strings.TrimSpace(itemType)
- }
-}
-
-func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
- children := nodes
- trimmedParentID := strings.TrimSpace(parentID)
- if trimmedParentID != "" {
- parent, found := findProjectTreeNode(nodes, trimmedParentID)
- if !found || parent.Kind != "folder" {
- return
- }
- children = parent.Children
- }
-
- parentKey := projectFolderOrderParentKey(parentID)
- if len(folderOrder[parentKey]) > 0 {
- return
- }
-
- orderedIDs := make([]string, 0, len(children))
- for _, child := range children {
- trimmedChildID := strings.TrimSpace(child.ID)
- if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) {
- continue
- }
- orderedIDs = append(orderedIDs, trimmedChildID)
- }
-
- if len(orderedIDs) > 0 {
- folderOrder[parentKey] = orderedIDs
- }
-}
-
-func defaultProjectTreeItemSchema(itemType string) map[string]any {
- return map[string]any{
- "type": "object",
- "itemType": normalizeProjectTreeItemType(itemType),
- }
-}
-
-func defaultProjectTreeItemData(itemType, name string) map[string]any {
- return map[string]any{
- "title": strings.TrimSpace(name),
- "itemType": normalizeProjectTreeItemType(itemType),
- }
-}
-
-func slugDir(prefix, slug string) string {
- trimmedSlug := strings.TrimSpace(slug)
- if trimmedSlug == "" {
- return prefix
- }
-
- return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
-}
-
-func writeJSONFile(path string, payload any) error {
- parentDir := filepath.Dir(path)
- if err := os.MkdirAll(parentDir, 0o755); err != nil {
- return err
- }
-
- data, err := json.MarshalIndent(payload, "", " ")
- if err != nil {
- return err
- }
-
- data = append(data, '\n')
-
- return os.WriteFile(path, data, 0o644)
-}
diff --git a/Backend/internal/httpx/api_project_decode.go b/Backend/internal/httpx/api_project_decode.go
new file mode 100644
index 0000000..b7de3c4
--- /dev/null
+++ b/Backend/internal/httpx/api_project_decode.go
@@ -0,0 +1,127 @@
+// Path: Backend/internal/httpx/api_project_decode.go
+
+package httpx
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+)
+
+type createProjectFolderRequest struct {
+ Name string `json:"name"`
+ ParentFolderPath string `json:"parentFolderId"`
+}
+
+type renameProjectFolderRequest struct {
+ FolderPath string `json:"folderId"`
+ Name string `json:"name"`
+}
+
+type deleteProjectFolderRequest struct {
+ FolderPath string `json:"folderId"`
+}
+
+// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
+// names internally so the move flow is easier to reason about.
+type moveProjectFolderRequest struct {
+ FolderPath string `json:"folderId"`
+ FolderStableID string `json:"folderNodeId"`
+ ParentFolderPath string `json:"parentFolderId"`
+ ParentStableID string `json:"parentNodeId"`
+ TargetIndex int `json:"targetIndex"`
+}
+
+type createProjectItemRequest struct {
+ Name string `json:"name"`
+ ParentFolderPath string `json:"parentFolderId"`
+ ItemType string `json:"itemType"`
+}
+
+type deleteProjectItemRequest struct {
+ ItemPath string `json:"itemId"`
+}
+
+type moveProjectItemRequest struct {
+ ItemPath string `json:"itemId"`
+ ItemStableID string `json:"itemNodeId"`
+ ParentFolderPath string `json:"parentFolderId"`
+ ParentStableID string `json:"parentNodeId"`
+ TargetIndex int `json:"targetIndex"`
+}
+
+func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
+ var payload moveProjectFolderRequest
+ if !decodeJSONObjectBody(w, r, &payload) {
+ return payload, false
+ }
+ return payload, true
+}
+
+func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
+ return deleteProjectFolderRequest{
+ FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
+ }
+}
+
+func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
+ return deleteProjectItemRequest{
+ ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
+ }
+}
+
+func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
+ var payload renameProjectFolderRequest
+ if !decodeJSONObjectBody(w, r, &payload) {
+ return payload, false
+ }
+ return payload, true
+}
+
+func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
+ var payload createProjectFolderRequest
+ if !decodeJSONObjectBody(w, r, &payload) {
+ return payload, false
+ }
+ return payload, true
+}
+
+func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
+ var payload createProjectItemRequest
+ if !decodeJSONObjectBody(w, r, &payload) {
+ return payload, false
+ }
+ return payload, true
+}
+
+func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
+ var payload moveProjectItemRequest
+ if !decodeJSONObjectBody(w, r, &payload) {
+ return payload, false
+ }
+ return payload, true
+}
+
+func decodeJSONObjectBody(w http.ResponseWriter, r *http.Request, target any) bool {
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+
+ if err := decoder.Decode(target); 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 false
+ }
+
+ WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
+ return 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 false
+ }
+
+ return true
+}
diff --git a/Backend/internal/httpx/api_project_errors.go b/Backend/internal/httpx/api_project_errors.go
new file mode 100644
index 0000000..064977e
--- /dev/null
+++ b/Backend/internal/httpx/api_project_errors.go
@@ -0,0 +1,42 @@
+// Path: Backend/internal/httpx/api_project_errors.go
+
+package httpx
+
+import (
+ "errors"
+ "net/http"
+
+ bootstrapservice "moku-backend/internal/bootstrap"
+)
+
+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())
+ case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
+ WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
+ default:
+ 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_"+operation+"_failed", message)
+ }
+}
+
+func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
+ switch {
+ case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
+ WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
+ case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
+ WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
+ default:
+ routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
+ message := "Failed to " + operation + " project tree."
+ if routes.cfg.Config.IsDevelopment() {
+ message = message + " " + err.Error()
+ }
+ WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
+ }
+}
diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go
index b9a5bd3..9bdf48f 100644
--- a/Backend/internal/httpx/api_project_routes.go
+++ b/Backend/internal/httpx/api_project_routes.go
@@ -1,9 +1,8 @@
+// Path: Backend/internal/httpx/api_project_routes.go
+
package httpx
import (
- "encoding/json"
- "errors"
- "io"
"net/http"
"strings"
@@ -12,48 +11,6 @@ import (
"github.com/go-chi/chi/v5"
)
-type createProjectFolderRequest struct {
- Name string `json:"name"`
- ParentFolderPath string `json:"parentFolderId"`
-}
-
-type renameProjectFolderRequest struct {
- FolderPath string `json:"folderId"`
- Name string `json:"name"`
-}
-
-type deleteProjectFolderRequest struct {
- FolderPath string `json:"folderId"`
-}
-
-// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
-// names internally so the move flow is easier to reason about.
-type moveProjectFolderRequest struct {
- FolderPath string `json:"folderId"`
- FolderStableID string `json:"folderNodeId"`
- ParentFolderPath string `json:"parentFolderId"`
- ParentStableID string `json:"parentNodeId"`
- TargetIndex int `json:"targetIndex"`
-}
-
-type createProjectItemRequest struct {
- Name string `json:"name"`
- ParentFolderPath string `json:"parentFolderId"`
- ItemType string `json:"itemType"`
-}
-
-type deleteProjectItemRequest struct {
- ItemPath string `json:"itemId"`
-}
-
-type moveProjectItemRequest struct {
- ItemPath string `json:"itemId"`
- ItemStableID string `json:"itemNodeId"`
- ParentFolderPath string `json:"parentFolderId"`
- ParentStableID string `json:"parentNodeId"`
- TargetIndex int `json:"targetIndex"`
-}
-
func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
@@ -551,167 +508,3 @@ func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http
},
})
}
-
-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())
- case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
- WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
- default:
- 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_"+operation+"_failed", message)
- }
-}
-
-func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
- switch {
- case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
- WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
- case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
- WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
- default:
- routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
- message := "Failed to " + operation + " project tree."
- if routes.cfg.Config.IsDevelopment() {
- message = message + " " + err.Error()
- }
- WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
- }
-}
-
-func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
- var payload moveProjectFolderRequest
-
- 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 decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
- return deleteProjectFolderRequest{
- FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
- }
-}
-
-func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
- return deleteProjectItemRequest{
- ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
- }
-}
-
-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
-
- 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 decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
- var payload createProjectItemRequest
-
- 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 decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
- var payload moveProjectItemRequest
-
- 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/jobs/store.go b/Backend/internal/jobs/store.go
index 6958cd4..c331ebe 100644
--- a/Backend/internal/jobs/store.go
+++ b/Backend/internal/jobs/store.go
@@ -1,3 +1,5 @@
+// Path: Backend/internal/jobs/store.go
+
package jobs
import (
diff --git a/Backend/internal/posixproj/projector.go b/Backend/internal/posixproj/projector.go
index a2dbfb2..0a5c00b 100644
--- a/Backend/internal/posixproj/projector.go
+++ b/Backend/internal/posixproj/projector.go
@@ -1,72 +1,15 @@
+// Path: Backend/internal/posixproj/projector.go
+
package posixproj
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
"fmt"
- "io/fs"
- "os"
- "path/filepath"
"strings"
"moku-backend/internal/database"
)
-const rootProjectionPath = "/"
-
-type Projector struct {
- db *database.DB
- root string
-}
-
-type RebuildSummary struct {
- TotalNodes int
- DirectoryCount int
- FileCount int
-}
-
-type NodeKind string
-
-const (
- NodeKindDirectory NodeKind = "directory"
- NodeKindFile NodeKind = "file"
-)
-
-type Scope struct {
- InstallationID string
- OrganizationID string
- OrganizationSlug string
- DepartmentSlug string
- TeamSlug string
- ProjectSlug string
- PersonalSlug string
-}
-
-type Node struct {
- Path string
- ParentPath *string
- Name string
- Depth int
- NodeKind NodeKind
- LogicalType string
- FileRole string
- ResourceID string
- ResourceName string
- ResourceSlug string
- InstallationID string
- OrganizationID string
- OrganizationSlug string
- DepartmentSlug string
- TeamSlug string
- ProjectSlug string
- PersonalSlug string
- ContentJSON []byte
- SizeBytes int64
- Checksum string
-}
-
func NewProjector(db *database.DB, root string) *Projector {
return &Projector{db: db, root: strings.TrimSpace(root)}
}
@@ -145,9 +88,9 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
node.TeamSlug,
node.ProjectSlug,
node.PersonalSlug,
- node.ContentJSON,
- node.SizeBytes,
- node.Checksum,
+ node.ContentJSON,
+ node.SizeBytes,
+ node.Checksum,
); err != nil {
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
}
@@ -159,395 +102,3 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
return summary, nil
}
-
-func ScanRoot(root string) ([]Node, error) {
- rootPath := strings.TrimSpace(root)
- if rootPath == "" {
- return nil, nil
- }
-
- info, err := os.Stat(rootPath)
- if err != nil {
- return nil, fmt.Errorf("stat POSIX root: %w", err)
- }
- if !info.IsDir() {
- return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
- }
-
- rootScope, err := loadRootScope(rootPath)
- if err != nil {
- return nil, err
- }
-
- nodes := []Node{{
- Path: rootProjectionPath,
- ParentPath: nil,
- Name: filepath.Base(rootPath),
- Depth: 0,
- NodeKind: NodeKindDirectory,
- LogicalType: "tenant_root",
- InstallationID: rootScope.InstallationID,
- OrganizationID: rootScope.OrganizationID,
- OrganizationSlug: rootScope.OrganizationSlug,
- }}
-
- err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if path == rootPath {
- return nil
- }
-
- relPath, err := filepath.Rel(rootPath, path)
- if err != nil {
- return err
- }
- relPath = filepath.ToSlash(relPath)
- if relPath == "." {
- return nil
- }
-
- node, err := buildNode(rootPath, relPath, entry, rootScope)
- if err != nil {
- return err
- }
-
- nodes = append(nodes, node)
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("scan POSIX root: %w", err)
- }
-
- return nodes, nil
-}
-
-func loadRootScope(rootPath string) (Scope, error) {
- settingsPath := filepath.Join(rootPath, "settings.json")
- content, err := os.ReadFile(settingsPath)
- if err != nil {
- if errorsIsNotExist(err) {
- return Scope{}, nil
- }
- return Scope{}, fmt.Errorf("read root settings.json: %w", err)
- }
-
- var payload map[string]any
- if err := json.Unmarshal(content, &payload); err != nil {
- return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
- }
-
- installation, _ := payload["installation"].(map[string]any)
- organization, _ := payload["organization"].(map[string]any)
-
- return Scope{
- InstallationID: stringValue(installation["id"]),
- OrganizationID: stringValue(organization["id"]),
- OrganizationSlug: stringValue(organization["slug"]),
- }, nil
-}
-
-func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
- scope := deriveScope(relPath, rootScope)
- parentPath := projectionParentPath(relPath)
- logicalType, fileRole := classifyPath(relPath, entry.IsDir())
-
- node := Node{
- Path: relPath,
- ParentPath: parentPath,
- Name: entry.Name(),
- Depth: strings.Count(relPath, "/") + 1,
- NodeKind: NodeKindDirectory,
- LogicalType: logicalType,
- FileRole: fileRole,
- InstallationID: scope.InstallationID,
- OrganizationID: scope.OrganizationID,
- OrganizationSlug: scope.OrganizationSlug,
- DepartmentSlug: scope.DepartmentSlug,
- TeamSlug: scope.TeamSlug,
- ProjectSlug: scope.ProjectSlug,
- PersonalSlug: scope.PersonalSlug,
- }
-
- if entry.IsDir() {
- return node, nil
- }
-
- absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
- content, err := os.ReadFile(absPath)
- if err != nil {
- return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
- }
-
- hash := sha256.Sum256(content)
- node.NodeKind = NodeKindFile
- node.SizeBytes = int64(len(content))
- node.Checksum = hex.EncodeToString(hash[:])
-
- if strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
- var payload map[string]any
- if err := json.Unmarshal(content, &payload); err == nil {
- jsonContent, err := json.Marshal(payload)
- if err != nil {
- return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
- }
- node.ContentJSON = jsonContent
- node.ResourceID = stringValue(payload["id"])
- node.ResourceName = stringValue(payload["name"])
- node.ResourceSlug = stringValue(payload["slug"])
- if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
- installation, _ := payload["installation"].(map[string]any)
- organization, _ := payload["organization"].(map[string]any)
- node.ResourceID = stringValue(installation["id"])
- node.ResourceName = stringValue(installation["name"])
- node.InstallationID = stringValue(installation["id"])
- node.OrganizationID = stringValue(organization["id"])
- node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
- }
- if node.ResourceID == "" && fileRole == "users" {
- node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
- }
- }
- }
-
- if node.ResourceSlug == "" {
- node.ResourceSlug = inferredResourceSlug(logicalType, scope)
- }
-
- return node, nil
-}
-
-func deriveScope(relPath string, rootScope Scope) Scope {
- scope := rootScope
- parts := strings.Split(relPath, "/")
- for _, part := range parts {
- switch {
- case strings.HasPrefix(part, "department-"):
- scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
- case strings.HasPrefix(part, "team-"):
- scope.TeamSlug = strings.TrimPrefix(part, "team-")
- case strings.HasPrefix(part, "project-"):
- scope.ProjectSlug = strings.TrimPrefix(part, "project-")
- case strings.HasPrefix(part, "personal-"):
- scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
- }
- }
- return scope
-}
-
-func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
- parts := strings.Split(relPath, "/")
- name := parts[len(parts)-1]
- if !isDir {
- fileRole = strings.TrimSuffix(name, filepath.Ext(name))
- }
-
- hasChildrenAncestor := pathContainsSegment(parts, "children")
- hasTreeAncestor := pathContainsSegment(parts, "tree")
- parentName := ""
- if len(parts) >= 2 {
- parentName = parts[len(parts)-2]
- }
-
- switch {
- case relPath == "settings.json":
- return "tenant", "settings"
- case relPath == "layout.json":
- return "tenant", "layout"
- case len(parts) >= 1 && parts[0] == "catalog":
- if isDir {
- if len(parts) == 1 {
- return "catalog", ""
- }
- if len(parts) >= 2 && parts[1] == "packs" {
- if len(parts) == 2 {
- return "catalog_packs", ""
- }
- if len(parts) == 3 {
- return "catalog_pack", ""
- }
- if len(parts) >= 4 && parts[3] == "entries" {
- return "catalog_pack_entries", ""
- }
- return "catalog_entry", ""
- }
- if len(parts) >= 2 && parts[1] == "standalone" {
- if len(parts) == 2 {
- return "catalog_standalone", ""
- }
- return "catalog_entry", ""
- }
- }
- return "catalog", fileRole
- case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
- if isDir {
- if len(parts) == 2 {
- return "department", ""
- }
- if len(parts) == 3 && parts[2] == "teams" {
- return "department_teams", ""
- }
- if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
- return "team", ""
- }
- }
- if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
- return "team", fileRole
- }
- return "department", fileRole
- case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
- if isDir {
- if strings.HasPrefix(name, "project-") {
- return "project", ""
- }
- if name == "children" {
- return "project_children", ""
- }
- if name == "tree" {
- return "project_tree", ""
- }
- if hasChildrenAncestor && strings.HasPrefix(name, "folder-") {
- return "hierarchy_folder", ""
- }
- if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
- return "hierarchy_folder", ""
- }
- if hasTreeAncestor && strings.HasPrefix(name, "item-") {
- return "item", ""
- }
- }
- if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") {
- return "hierarchy_folder", fileRole
- }
- if hasTreeAncestor {
- if strings.HasPrefix(parentName, "item-") {
- return "item", fileRole
- }
- if strings.HasPrefix(parentName, "folder-") {
- return "hierarchy_folder", fileRole
- }
- }
- return "project", fileRole
- case len(parts) >= 1 && parts[0] == "users":
- if isDir {
- if len(parts) == 1 {
- return "users", ""
- }
- if len(parts) == 2 && parts[1] == "personals" {
- return "personals", ""
- }
- if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
- return "personal", ""
- }
- if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
- if strings.HasPrefix(name, "folder-") {
- return "folder", ""
- }
- if strings.HasPrefix(name, "item-") {
- return "item", ""
- }
- }
- }
- if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
- if strings.Contains(relPath, "/tree/") {
- if strings.HasPrefix(parts[len(parts)-2], "item-") {
- return "item", fileRole
- }
- if strings.HasPrefix(parts[len(parts)-2], "folder-") {
- return "folder", fileRole
- }
- }
- return "personal", fileRole
- }
- return "users", fileRole
- default:
- if isDir {
- return "directory", ""
- }
- return "file", fileRole
- }
-}
-
-func pathContainsSegment(parts []string, target string) bool {
- for _, part := range parts {
- if part == target {
- return true
- }
- }
- return false
-}
-
-func projectionParentPath(relPath string) *string {
- if relPath == "" || relPath == rootProjectionPath {
- return nil
- }
- parent := filepath.ToSlash(filepath.Dir(relPath))
- if parent == "." || parent == "" {
- root := rootProjectionPath
- return &root
- }
- return &parent
-}
-
-func inferredResourceSlug(logicalType string, scope Scope) string {
- switch logicalType {
- case "department":
- return scope.DepartmentSlug
- case "team":
- return scope.TeamSlug
- case "project":
- return scope.ProjectSlug
- case "personal":
- return scope.PersonalSlug
- default:
- return ""
- }
-}
-
-func parentEntityName(logicalType string, scope Scope) string {
- switch logicalType {
- case "department":
- return scope.DepartmentSlug
- case "team":
- return scope.TeamSlug
- case "project":
- return scope.ProjectSlug
- case "personal":
- return scope.PersonalSlug
- default:
- return ""
- }
-}
-
-func stringValue(value any) string {
- stringValue, _ := value.(string)
- return strings.TrimSpace(stringValue)
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- trimmed := strings.TrimSpace(value)
- if trimmed != "" {
- return trimmed
- }
- }
- return ""
-}
-
-func summarizeNodes(nodes []Node) RebuildSummary {
- summary := RebuildSummary{TotalNodes: len(nodes)}
- for _, node := range nodes {
- switch node.NodeKind {
- case NodeKindDirectory:
- summary.DirectoryCount++
- case NodeKindFile:
- summary.FileCount++
- }
- }
- return summary
-}
-
-func errorsIsNotExist(err error) bool {
- return err != nil && os.IsNotExist(err)
-}
diff --git a/Backend/internal/posixproj/projector_classify.go b/Backend/internal/posixproj/projector_classify.go
new file mode 100644
index 0000000..f48e299
--- /dev/null
+++ b/Backend/internal/posixproj/projector_classify.go
@@ -0,0 +1,175 @@
+// Path: Backend/internal/posixproj/projector_classify.go
+
+package posixproj
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+func deriveScope(relPath string, rootScope Scope) Scope {
+ scope := rootScope
+ parts := strings.Split(relPath, "/")
+ for _, part := range parts {
+ switch {
+ case strings.HasPrefix(part, "department-"):
+ scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
+ case strings.HasPrefix(part, "team-"):
+ scope.TeamSlug = strings.TrimPrefix(part, "team-")
+ case strings.HasPrefix(part, "project-"):
+ scope.ProjectSlug = strings.TrimPrefix(part, "project-")
+ case strings.HasPrefix(part, "personal-"):
+ scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
+ }
+ }
+ return scope
+}
+
+func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
+ parts := strings.Split(relPath, "/")
+ name := parts[len(parts)-1]
+ if !isDir {
+ fileRole = strings.TrimSuffix(name, filepath.Ext(name))
+ }
+
+ hasChildrenAncestor := pathContainsSegment(parts, "children")
+ hasTreeAncestor := pathContainsSegment(parts, "tree")
+ parentName := ""
+ if len(parts) >= 2 {
+ parentName = parts[len(parts)-2]
+ }
+
+ switch {
+ case relPath == "settings.json":
+ return "tenant", "settings"
+ case relPath == "layout.json":
+ return "tenant", "layout"
+ case len(parts) >= 1 && parts[0] == "catalog":
+ if isDir {
+ if len(parts) == 1 {
+ return "catalog", ""
+ }
+ if len(parts) >= 2 && parts[1] == "packs" {
+ if len(parts) == 2 { return "catalog_packs", "" }
+ if len(parts) == 3 { return "catalog_pack", "" }
+ if len(parts) >= 4 && parts[3] == "entries" { return "catalog_pack_entries", "" }
+ return "catalog_entry", ""
+ }
+ if len(parts) >= 2 && parts[1] == "standalone" {
+ if len(parts) == 2 { return "catalog_standalone", "" }
+ return "catalog_entry", ""
+ }
+ }
+ return "catalog", fileRole
+ case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
+ if isDir {
+ if len(parts) == 2 { return "department", "" }
+ if len(parts) == 3 && parts[2] == "teams" { return "department_teams", "" }
+ if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", "" }
+ }
+ if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", fileRole }
+ return "department", fileRole
+ case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
+ if isDir {
+ if strings.HasPrefix(name, "project-") { return "project", "" }
+ if name == "children" { return "project_children", "" }
+ if name == "tree" { return "project_tree", "" }
+ if hasChildrenAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
+ if hasTreeAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
+ if hasTreeAncestor && strings.HasPrefix(name, "item-") { return "item", "" }
+ }
+ if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
+ if hasTreeAncestor {
+ if strings.HasPrefix(parentName, "item-") { return "item", fileRole }
+ if strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
+ }
+ return "project", fileRole
+ case len(parts) >= 1 && parts[0] == "users":
+ if isDir {
+ if len(parts) == 1 { return "users", "" }
+ if len(parts) == 2 && parts[1] == "personals" { return "personals", "" }
+ if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { return "personal", "" }
+ if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
+ if strings.HasPrefix(name, "folder-") { return "folder", "" }
+ if strings.HasPrefix(name, "item-") { return "item", "" }
+ }
+ }
+ if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
+ if strings.Contains(relPath, "/tree/") {
+ if strings.HasPrefix(parts[len(parts)-2], "item-") { return "item", fileRole }
+ if strings.HasPrefix(parts[len(parts)-2], "folder-") { return "folder", fileRole }
+ }
+ return "personal", fileRole
+ }
+ return "users", fileRole
+ default:
+ if isDir { return "directory", "" }
+ return "file", fileRole
+ }
+}
+
+func pathContainsSegment(parts []string, target string) bool {
+ for _, part := range parts {
+ if part == target {
+ return true
+ }
+ }
+ return false
+}
+
+func projectionParentPath(relPath string) *string {
+ if relPath == "" || relPath == rootProjectionPath {
+ return nil
+ }
+ parent := filepath.ToSlash(filepath.Dir(relPath))
+ if parent == "." || parent == "" {
+ root := rootProjectionPath
+ return &root
+ }
+ return &parent
+}
+
+func inferredResourceSlug(logicalType string, scope Scope) string {
+ switch logicalType {
+ case "department":
+ return scope.DepartmentSlug
+ case "team":
+ return scope.TeamSlug
+ case "project":
+ return scope.ProjectSlug
+ case "personal":
+ return scope.PersonalSlug
+ default:
+ return ""
+ }
+}
+
+func parentEntityName(logicalType string, scope Scope) string {
+ switch logicalType {
+ case "department":
+ return scope.DepartmentSlug
+ case "team":
+ return scope.TeamSlug
+ case "project":
+ return scope.ProjectSlug
+ case "personal":
+ return scope.PersonalSlug
+ default:
+ return ""
+ }
+}
+
+func stringValue(value any) string {
+ stringValue, _ := value.(string)
+ return strings.TrimSpace(stringValue)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
diff --git a/Backend/internal/posixproj/projector_scan.go b/Backend/internal/posixproj/projector_scan.go
new file mode 100644
index 0000000..d1aa352
--- /dev/null
+++ b/Backend/internal/posixproj/projector_scan.go
@@ -0,0 +1,189 @@
+// Path: Backend/internal/posixproj/projector_scan.go
+
+package posixproj
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func ScanRoot(root string) ([]Node, error) {
+ rootPath := strings.TrimSpace(root)
+ if rootPath == "" {
+ return nil, nil
+ }
+
+ info, err := os.Stat(rootPath)
+ if err != nil {
+ return nil, fmt.Errorf("stat POSIX root: %w", err)
+ }
+ if !info.IsDir() {
+ return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
+ }
+
+ rootScope, err := loadRootScope(rootPath)
+ if err != nil {
+ return nil, err
+ }
+
+ nodes := []Node{{
+ Path: rootProjectionPath,
+ ParentPath: nil,
+ Name: filepath.Base(rootPath),
+ Depth: 0,
+ NodeKind: NodeKindDirectory,
+ LogicalType: "tenant_root",
+ InstallationID: rootScope.InstallationID,
+ OrganizationID: rootScope.OrganizationID,
+ OrganizationSlug: rootScope.OrganizationSlug,
+ }}
+
+ err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if path == rootPath {
+ return nil
+ }
+
+ relPath, err := filepath.Rel(rootPath, path)
+ if err != nil {
+ return err
+ }
+ relPath = filepath.ToSlash(relPath)
+ if relPath == "." {
+ return nil
+ }
+
+ node, err := buildNode(rootPath, relPath, entry, rootScope)
+ if err != nil {
+ return err
+ }
+
+ nodes = append(nodes, node)
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scan POSIX root: %w", err)
+ }
+
+ return nodes, nil
+}
+
+func loadRootScope(rootPath string) (Scope, error) {
+ settingsPath := filepath.Join(rootPath, "settings.json")
+ content, err := os.ReadFile(settingsPath)
+ if err != nil {
+ if errorsIsNotExist(err) {
+ return Scope{}, nil
+ }
+ return Scope{}, fmt.Errorf("read root settings.json: %w", err)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(content, &payload); err != nil {
+ return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
+ }
+
+ installation, _ := payload["installation"].(map[string]any)
+ organization, _ := payload["organization"].(map[string]any)
+
+ return Scope{
+ InstallationID: stringValue(installation["id"]),
+ OrganizationID: stringValue(organization["id"]),
+ OrganizationSlug: stringValue(organization["slug"]),
+ }, nil
+}
+
+func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
+ scope := deriveScope(relPath, rootScope)
+ parentPath := projectionParentPath(relPath)
+ logicalType, fileRole := classifyPath(relPath, entry.IsDir())
+
+ node := Node{
+ Path: relPath,
+ ParentPath: parentPath,
+ Name: entry.Name(),
+ Depth: strings.Count(relPath, "/") + 1,
+ NodeKind: NodeKindDirectory,
+ LogicalType: logicalType,
+ FileRole: fileRole,
+ InstallationID: scope.InstallationID,
+ OrganizationID: scope.OrganizationID,
+ OrganizationSlug: scope.OrganizationSlug,
+ DepartmentSlug: scope.DepartmentSlug,
+ TeamSlug: scope.TeamSlug,
+ ProjectSlug: scope.ProjectSlug,
+ PersonalSlug: scope.PersonalSlug,
+ }
+
+ if entry.IsDir() {
+ return node, nil
+ }
+
+ absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
+ content, err := os.ReadFile(absPath)
+ if err != nil {
+ return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
+ }
+
+ hash := sha256.Sum256(content)
+ node.NodeKind = NodeKindFile
+ node.SizeBytes = int64(len(content))
+ node.Checksum = hex.EncodeToString(hash[:])
+
+ if strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
+ var payload map[string]any
+ if err := json.Unmarshal(content, &payload); err == nil {
+ jsonContent, err := json.Marshal(payload)
+ if err != nil {
+ return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
+ }
+ node.ContentJSON = jsonContent
+ node.ResourceID = stringValue(payload["id"])
+ node.ResourceName = stringValue(payload["name"])
+ node.ResourceSlug = stringValue(payload["slug"])
+ if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
+ installation, _ := payload["installation"].(map[string]any)
+ organization, _ := payload["organization"].(map[string]any)
+ node.ResourceID = stringValue(installation["id"])
+ node.ResourceName = stringValue(installation["name"])
+ node.InstallationID = stringValue(installation["id"])
+ node.OrganizationID = stringValue(organization["id"])
+ node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
+ }
+ if node.ResourceID == "" && fileRole == "users" {
+ node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
+ }
+ }
+ }
+
+ if node.ResourceSlug == "" {
+ node.ResourceSlug = inferredResourceSlug(logicalType, scope)
+ }
+
+ return node, nil
+}
+
+func summarizeNodes(nodes []Node) RebuildSummary {
+ summary := RebuildSummary{TotalNodes: len(nodes)}
+ for _, node := range nodes {
+ switch node.NodeKind {
+ case NodeKindDirectory:
+ summary.DirectoryCount++
+ case NodeKindFile:
+ summary.FileCount++
+ }
+ }
+ return summary
+}
+
+func errorsIsNotExist(err error) bool {
+ return err != nil && os.IsNotExist(err)
+}
diff --git a/Backend/internal/posixproj/projector_test.go b/Backend/internal/posixproj/projector_test.go
index 45f3601..788fd81 100644
--- a/Backend/internal/posixproj/projector_test.go
+++ b/Backend/internal/posixproj/projector_test.go
@@ -1,3 +1,5 @@
+// Path: Backend/internal/posixproj/projector_test.go
+
package posixproj
import (
diff --git a/Backend/internal/posixproj/projector_types.go b/Backend/internal/posixproj/projector_types.go
new file mode 100644
index 0000000..ee5adbb
--- /dev/null
+++ b/Backend/internal/posixproj/projector_types.go
@@ -0,0 +1,58 @@
+// Path: Backend/internal/posixproj/projector_types.go
+
+package posixproj
+
+import "moku-backend/internal/database"
+
+const rootProjectionPath = "/"
+
+type Projector struct {
+ db *database.DB
+ root string
+}
+
+type RebuildSummary struct {
+ TotalNodes int
+ DirectoryCount int
+ FileCount int
+}
+
+type NodeKind string
+
+const (
+ NodeKindDirectory NodeKind = "directory"
+ NodeKindFile NodeKind = "file"
+)
+
+type Scope struct {
+ InstallationID string
+ OrganizationID string
+ OrganizationSlug string
+ DepartmentSlug string
+ TeamSlug string
+ ProjectSlug string
+ PersonalSlug string
+}
+
+type Node struct {
+ Path string
+ ParentPath *string
+ Name string
+ Depth int
+ NodeKind NodeKind
+ LogicalType string
+ FileRole string
+ ResourceID string
+ ResourceName string
+ ResourceSlug string
+ InstallationID string
+ OrganizationID string
+ OrganizationSlug string
+ DepartmentSlug string
+ TeamSlug string
+ ProjectSlug string
+ PersonalSlug string
+ ContentJSON []byte
+ SizeBytes int64
+ Checksum string
+}
diff --git a/Backend/internal/worker/runner.go b/Backend/internal/worker/runner.go
index bfb8afd..9024869 100644
--- a/Backend/internal/worker/runner.go
+++ b/Backend/internal/worker/runner.go
@@ -1,3 +1,5 @@
+// Path: Backend/internal/worker/runner.go
+
package worker
import (
diff --git a/Backend/internal/worker/runner_test.go b/Backend/internal/worker/runner_test.go
index d67c67c..e60ab4c 100644
--- a/Backend/internal/worker/runner_test.go
+++ b/Backend/internal/worker/runner_test.go
@@ -1,3 +1,5 @@
+// Path: Backend/internal/worker/runner_test.go
+
package worker
import (
diff --git a/Frontend/src/app.tsx b/Frontend/src/app.tsx
index 50b94e2..6a0d34c 100644
--- a/Frontend/src/app.tsx
+++ b/Frontend/src/app.tsx
@@ -1,7 +1,7 @@
// Path: Frontend/src/app.tsx
import type { JSX } from "solid-js";
-import { AppShell } from "./components/shell/AppShell/AppShell";
+import { AppShell } from "./components/app-shell/AppShell/AppShell";
import "./styles/main.scss";
import "./styles/user-overrides.scss";
diff --git a/Frontend/src/components/shell/AppShell/AppShell.module.scss b/Frontend/src/components/app-shell/AppShell/AppShell.module.scss
similarity index 100%
rename from Frontend/src/components/shell/AppShell/AppShell.module.scss
rename to Frontend/src/components/app-shell/AppShell/AppShell.module.scss
diff --git a/Frontend/src/components/shell/AppShell/AppShell.tsx b/Frontend/src/components/app-shell/AppShell/AppShell.tsx
similarity index 81%
rename from Frontend/src/components/shell/AppShell/AppShell.tsx
rename to Frontend/src/components/app-shell/AppShell/AppShell.tsx
index 9365125..2e5baa0 100644
--- a/Frontend/src/components/shell/AppShell/AppShell.tsx
+++ b/Frontend/src/components/app-shell/AppShell/AppShell.tsx
@@ -1,17 +1,19 @@
-// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
+// Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
-import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
-import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
+import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
+import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
+import { WorkspaceHome } from "../../workspace-home/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
-import { LeftRail } from "../LeftRail/LeftRail";
+import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
-import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
+import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock";
-import { NotificationsMenu } from "../TopBar/NotificationsMenu";
-import { ProfileMenu } from "../TopBar/ProfileMenu";
-import { TopBar } from "../TopBar/TopBar";
-import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
+import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
+import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
+import { TopBar } from "../../top-bar/TopBar/TopBar";
+import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
+import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null;
@@ -80,6 +82,8 @@ const AppShellContent = (): JSX.Element => {
setActiveMobileWorkspaceView(null);
};
+ const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
+
return (
{
{
- setIsSidebarCollapsed((collapsed) => !collapsed);
- }}
- />
+ <>
+ {
+ setIsSidebarCollapsed((collapsed) => !collapsed);
+ }}
+ />
+
+ >
}
>
@@ -162,6 +170,7 @@ const AppShellContent = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
+
);
};
diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss
rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss
diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
similarity index 95%
rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
index cfb1711..84ef430 100644
--- a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
+++ b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
+// Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.module.scss b/Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss
similarity index 100%
rename from Frontend/src/components/shell/ServerDock/ServerDock.module.scss
rename to Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss
diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.tsx b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
similarity index 95%
rename from Frontend/src/components/shell/ServerDock/ServerDock.tsx
rename to Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
index 580abc7..2cd568e 100644
--- a/Frontend/src/components/shell/ServerDock/ServerDock.tsx
+++ b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
+// Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
diff --git a/Frontend/src/components/app-shell/data/app-shell.builders.ts b/Frontend/src/components/app-shell/data/app-shell.builders.ts
new file mode 100644
index 0000000..f6ecd55
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.builders.ts
@@ -0,0 +1,169 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts
+
+import {
+ activeDepartment as fallbackActiveDepartment,
+ activeProject as fallbackActiveProject,
+ activeServer as fallbackActiveServer,
+ activeUserProfile as fallbackActiveUserProfile,
+ departmentItems as fallbackDepartmentItems,
+ organizationAdminDockActions,
+ personalDockActions,
+ projectItems as fallbackProjectItems,
+ railItems as fallbackRailItems,
+ workspaceTree as fallbackWorkspaceTree,
+ type ActiveDepartment,
+ type ActiveProject,
+ type ActiveServer,
+ type ActiveUserProfile,
+ type DepartmentItem,
+ type ProjectItem,
+ type RailItem,
+ type WorkspaceTreeNode,
+} from "./shell.data";
+import type { AppShellPayload } from "./app-shell.types";
+
+const buildAbbreviation = (name: string, fallback: string): string => {
+ const parts = name
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (parts.length === 0) {
+ return fallback;
+ }
+
+ const abbreviation = parts
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? "")
+ .join("");
+
+ return abbreviation || fallback;
+};
+
+export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
+ if (!payload?.installation || payload.organizations.length === 0) {
+ return fallbackRailItems;
+ }
+
+ const kind = payload.installation.mode === "personal" ? "personal" : "organization";
+ const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
+
+ return payload.organizations.map((organization, index) => ({
+ id: organization.id,
+ label: serverName || organization.name,
+ abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
+ kind,
+ active: index === 0,
+ }));
+};
+
+export const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
+ const installation = payload?.installation;
+ const organization = payload?.organizations[0];
+
+ if (!installation || !organization) {
+ return fallbackActiveServer;
+ }
+
+ const kind = installation.mode === "personal" ? "personal" : "organization";
+ const serverName = installation.name || organization.name || installation.host;
+
+ return {
+ id: installation.id,
+ name: serverName || fallbackActiveServer.name,
+ abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
+ kind,
+ connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
+ subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
+ dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
+ };
+};
+
+export const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
+ if (!payload?.projects.length) {
+ return fallbackProjectItems;
+ }
+
+ return payload.projects.map((project, index) => ({
+ id: project.id,
+ name: project.name,
+ description: project.slug || "Persisted project workspace",
+ groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
+ parentLabel:
+ payload.teams.find((team) => team.id === project.teamId)?.name ||
+ payload.departments.find((department) => department.id === project.departmentId)?.name ||
+ "Shared project",
+ meta: (() => {
+ const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
+
+ return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
+ })(),
+ active: index === 0,
+ }));
+};
+
+export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
+ const firstProject = payload?.projects[0];
+
+ if (!firstProject) {
+ return fallbackActiveProject;
+ }
+
+ return {
+ id: firstProject.id,
+ name: firstProject.name,
+ };
+};
+
+export const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
+ if (!payload?.departments.length) {
+ return fallbackDepartmentItems;
+ }
+
+ return payload.departments.map((department, index) => ({
+ id: department.id,
+ name: department.name,
+ teams: payload.teams.filter((team) => team.departmentId === department.id).map((team) => team.name),
+ active: index === 0,
+ }));
+};
+
+export const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
+ const firstDepartment = payload?.departments[0];
+
+ if (!firstDepartment) {
+ return fallbackActiveDepartment;
+ }
+
+ const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
+
+ return {
+ id: firstDepartment.id,
+ name: firstDepartment.name,
+ teamName: firstTeamName,
+ };
+};
+
+export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
+ if (!payload?.projects.length) {
+ return fallbackWorkspaceTree;
+ }
+
+ return [];
+};
+
+export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
+ if (!payload?.admin) {
+ return fallbackActiveUserProfile;
+ }
+
+ const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
+ const departmentName = payload.departments[0]?.name;
+
+ return {
+ name: payload.admin.displayName,
+ email: payload.admin.email,
+ roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
+ contextLabel: departmentName ? `${organizationName} • ${departmentName}` : organizationName,
+ };
+};
diff --git a/Frontend/src/components/app-shell/data/app-shell.context.tsx b/Frontend/src/components/app-shell/data/app-shell.context.tsx
new file mode 100644
index 0000000..75c1a6c
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.context.tsx
@@ -0,0 +1,97 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx
+
+import {
+ createContext,
+ createMemo,
+ createSignal,
+ onMount,
+ useContext,
+ type JSX,
+} from "solid-js";
+import { resolveAPIBase } from "../../../lib/api";
+import {
+ buildActiveDepartment,
+ buildActiveProject,
+ buildActiveServer,
+ buildActiveUserProfile,
+ buildDepartmentItems,
+ buildProjectItems,
+ buildRailItems,
+ buildWorkspaceTree,
+} from "./app-shell.builders";
+import type { AppShellContextValue, AppShellPayload } from "./app-shell.types";
+import { normalizeAppShellPayload } from "./app-shell.types";
+
+const AppShellContext = createContext();
+
+export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
+ const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
+ const [error, setError] = createSignal("");
+ const [payload, setPayload] = createSignal(null);
+
+ const load = async (): Promise => {
+ setStatus("loading");
+ setError("");
+
+ try {
+ const response = await fetch(`${resolveAPIBase()}/app-shell`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ const body = (await response.json()) as {
+ data?: AppShellPayload;
+ error?: { message?: string } | string;
+ message?: string;
+ };
+ const errorMessage =
+ typeof body.message === "string"
+ ? body.message
+ : typeof body.error === "string"
+ ? body.error
+ : body.error?.message;
+
+ if (!response.ok || !body.data) {
+ throw new Error(errorMessage || "Failed to load app shell state.");
+ }
+
+ setPayload(normalizeAppShellPayload(body.data));
+ setStatus("success");
+ } catch (loadError) {
+ setStatus("error");
+ setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
+ }
+ };
+
+ onMount(() => {
+ void load();
+ });
+
+ const value: AppShellContextValue = {
+ status,
+ error,
+ installation: createMemo(() => payload()?.installation),
+ railItems: createMemo(() => buildRailItems(payload())),
+ activeServer: createMemo(() => buildActiveServer(payload())),
+ activeProject: createMemo(() => buildActiveProject(payload())),
+ activeDepartment: createMemo(() => buildActiveDepartment(payload())),
+ projectItems: createMemo(() => buildProjectItems(payload())),
+ departmentItems: createMemo(() => buildDepartmentItems(payload())),
+ workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
+ activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
+ reload: load,
+ };
+
+ return {props.children};
+};
+
+export const useAppShellData = (): AppShellContextValue => {
+ const context = useContext(AppShellContext);
+
+ if (!context) {
+ throw new Error("useAppShellData must be used within AppShellDataProvider");
+ }
+
+ return context;
+};
diff --git a/Frontend/src/components/app-shell/data/app-shell.types.ts b/Frontend/src/components/app-shell/data/app-shell.types.ts
new file mode 100644
index 0000000..b4103e6
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/app-shell.types.ts
@@ -0,0 +1,130 @@
+// Path: Frontend/src/components/app-shell/data/app-shell.types.ts
+
+import type { Accessor } from "solid-js";
+import type {
+ ActiveDepartment,
+ ActiveProject,
+ ActiveServer,
+ ActiveUserProfile,
+ DepartmentItem,
+ ProjectItem,
+ RailItem,
+ WorkspaceTreeNode,
+} from "./shell.data";
+
+export type AppShellInstallation = {
+ id: string;
+ name: string;
+ mode: "personal" | "organizational" | string;
+ access: string;
+ protocol: string;
+ host: string;
+ isBootstrapped: boolean;
+ materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
+ materializationError?: string;
+};
+
+export type AppShellAdmin = {
+ id: string;
+ email: string;
+ displayName: string;
+ isInstanceAdmin: boolean;
+ homeTitle: string;
+};
+
+export type AppShellOrganization = {
+ id: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellDepartment = {
+ id: string;
+ organizationId: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellTeam = {
+ id: string;
+ organizationId: string;
+ departmentId?: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellProject = {
+ id: string;
+ organizationId: string;
+ departmentId?: string;
+ teamId?: string;
+ name: string;
+ slug: string;
+};
+
+export type AppShellWorkspace = {
+ id: string;
+ organizationId: string;
+ name: string;
+ slug: string;
+ kind: "organization" | "department" | "team" | "project" | string;
+ departmentId?: string;
+ teamId?: string;
+ projectId?: string;
+};
+
+export type AppShellPayload = {
+ installation?: AppShellInstallation;
+ admin?: AppShellAdmin;
+ organizations: AppShellOrganization[];
+ departments: AppShellDepartment[];
+ teams: AppShellTeam[];
+ projects: AppShellProject[];
+ workspaces: AppShellWorkspace[];
+};
+
+export type AppShellContextValue = {
+ status: Accessor<"idle" | "loading" | "success" | "error">;
+ error: Accessor;
+ installation: Accessor;
+ railItems: Accessor;
+ activeServer: Accessor;
+ activeProject: Accessor;
+ activeDepartment: Accessor;
+ projectItems: Accessor;
+ departmentItems: Accessor;
+ workspaceTree: Accessor;
+ activeUserProfile: Accessor;
+ reload: () => Promise;
+};
+
+export const normalizeInstallation = (
+ installation: AppShellInstallation | null | undefined,
+): AppShellInstallation | undefined => {
+ if (!installation) {
+ return undefined;
+ }
+
+ const materializationStatus = installation.materializationStatus?.trim()
+ ? installation.materializationStatus
+ : installation.isBootstrapped
+ ? "succeeded"
+ : "not_started";
+ const materializationError = installation.materializationError?.trim() || undefined;
+
+ return {
+ ...installation,
+ materializationStatus,
+ materializationError,
+ };
+};
+
+export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
+ installation: normalizeInstallation(payload?.installation),
+ admin: payload?.admin,
+ organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
+ departments: Array.isArray(payload?.departments) ? payload.departments : [],
+ teams: Array.isArray(payload?.teams) ? payload.teams : [],
+ projects: Array.isArray(payload?.projects) ? payload.projects : [],
+ workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
+});
diff --git a/Frontend/src/components/app-shell/data/shell.context-menu.ts b/Frontend/src/components/app-shell/data/shell.context-menu.ts
new file mode 100644
index 0000000..48e2c83
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.context-menu.ts
@@ -0,0 +1,174 @@
+// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts
+
+import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry";
+import type {
+ ActiveProject,
+ ProjectContextMenuAction,
+ ProjectContextMenuSection,
+ ProjectItem,
+ ProjectMenuTarget,
+ WorkspaceContextMenuSection,
+ WorkspaceContextMenuTarget,
+ WorkspaceStaticItem,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export { getWorkspaceContextMenuEyebrow } from "./shell.registry";
+
+export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
+ id: `workspace-${workspace.id}`,
+ label: workspace.name,
+ kind: "workspace",
+});
+
+export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
+ id: item.id,
+ label: item.label,
+ kind: item.contextKind,
+});
+
+export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
+ id: node.id,
+ label: node.label,
+ ...(node.kind === "folder"
+ ? { kind: "folder" as const }
+ : {
+ kind: "item" as const,
+ itemType: node.itemType,
+ }),
+});
+
+export const getWorkspaceContextMenuSections = (
+ target: WorkspaceContextMenuTarget,
+): readonly WorkspaceContextMenuSection[] => {
+ const createActions = getWorkspaceCreateActions();
+ const createSubmenuAction = {
+ id: "create",
+ label: "Create",
+ children: createActions,
+ } as const;
+
+ switch (target.kind) {
+ case "workspace":
+ return [
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ {
+ id: "workspace",
+ label: undefined,
+ items: [
+ { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
+ { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
+ ],
+ },
+ ] as const;
+ case "home":
+ return [
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ { id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] },
+ ] as const;
+ case "settings":
+ return [
+ {
+ id: "settings",
+ label: undefined,
+ items: [
+ { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
+ { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
+ ],
+ },
+ ] as const;
+ case "folder":
+ return [
+ { id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] },
+ { id: "create", label: undefined, items: [createSubmenuAction] },
+ {
+ id: "organize",
+ label: undefined,
+ items: [
+ { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
+ { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
+ ],
+ },
+ ] as const;
+ case "item": {
+ const definition = getWorkspaceItemTypeDefinition(target.itemType);
+ const actionPrefix = definition.actionPrefix;
+ const nounLabel = definition.noun;
+
+ return [
+ {
+ id: `${actionPrefix}-primary`,
+ items: [
+ { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
+ { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
+ ],
+ },
+ {
+ id: "organize",
+ label: undefined,
+ items: [
+ { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
+ { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
+ ],
+ },
+ ] as const;
+ }
+ }
+};
+
+const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
+ [
+ { id: "new-project", label: "New project" },
+ { id: "new-folder", label: "New folder" },
+ ] as const;
+
+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;
+
+export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
+ id: "project-surface",
+ label,
+ kind: "surface",
+});
+
+export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
+ id,
+ label,
+ kind: "folder",
+});
+
+export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
+ id: project.id,
+ label: project.name,
+ kind: "project",
+});
+
+export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
+ switch (target.kind) {
+ case "surface":
+ return "Projects";
+ case "folder":
+ return "Folder";
+ case "project":
+ return "Project";
+ }
+};
+
+export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
+ const createActions = getProjectCreateActions();
+
+ switch (target.kind) {
+ case "surface":
+ return [{ id: "create", items: createActions }] as const;
+ case "folder":
+ return [
+ { id: "create", items: createActions },
+ { id: "organize", items: getProjectFolderDangerActions() },
+ ] as const;
+ case "project":
+ return [{ id: "create", items: createActions }] as const;
+ }
+};
diff --git a/Frontend/src/components/app-shell/data/shell.data.ts b/Frontend/src/components/app-shell/data/shell.data.ts
new file mode 100644
index 0000000..7dbbfdb
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.data.ts
@@ -0,0 +1,6 @@
+// Path: Frontend/src/components/app-shell/data/shell.data.ts
+
+export * from "./shell.types";
+export * from "./shell.registry";
+export * from "./shell.scaffold";
+export * from "./shell.context-menu";
diff --git a/Frontend/src/components/app-shell/data/shell.registry.ts b/Frontend/src/components/app-shell/data/shell.registry.ts
new file mode 100644
index 0000000..83d3e4a
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.registry.ts
@@ -0,0 +1,100 @@
+// Path: Frontend/src/components/app-shell/data/shell.registry.ts
+
+import { FileText, LayoutGrid } from "../../../lib/icons";
+import type {
+ ShellIcon,
+ WorkspaceContextMenuAction,
+ WorkspaceContextMenuTarget,
+ WorkspaceItemTypeDefinition,
+ WorkspaceItemTypeId,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
+ {
+ id: "core.doc",
+ label: "Doc",
+ shortLabel: "Doc",
+ icon: FileText,
+ noun: "doc",
+ actionPrefix: "doc",
+ defaultCreateLabel: "New doc",
+ includeInWorkspaceCreate: true,
+ description: "Rich text documents and notes.",
+ },
+ {
+ id: "core.board.kanban",
+ label: "Kanban board",
+ shortLabel: "Board",
+ icon: LayoutGrid,
+ noun: "board",
+ actionPrefix: "board",
+ defaultCreateLabel: "New board",
+ includeInWorkspaceCreate: true,
+ description: "Default board-style workspace item.",
+ },
+ {
+ id: "core.board.list",
+ label: "List board",
+ shortLabel: "Board",
+ icon: LayoutGrid,
+ noun: "board",
+ actionPrefix: "list-board",
+ defaultCreateLabel: "New list board",
+ description: "Alternate first-party board view prepared for the future registry.",
+ },
+] as const;
+
+const workspaceItemTypeMap = new Map(
+ firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
+);
+
+const createUnknownWorkspaceItemTypeDefinition = (
+ itemType: WorkspaceItemTypeId,
+): WorkspaceItemTypeDefinition => ({
+ id: itemType,
+ label: "Item",
+ shortLabel: "Item",
+ icon: FileText,
+ noun: "item",
+ actionPrefix: "item",
+ defaultCreateLabel: "New item",
+ description: "Fallback definition for unknown or future workspace item types.",
+});
+
+export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
+ return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
+};
+
+export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
+ node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
+
+export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
+ { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
+ ...firstPartyWorkspaceItemTypes
+ .filter((definition) => definition.includeInWorkspaceCreate)
+ .map((definition) => ({
+ id: `create-${definition.actionPrefix}`,
+ label: definition.defaultCreateLabel,
+ shortcut:
+ definition.id === "core.board.kanban"
+ ? ({ modifiers: ["alt"], key: "b" } as const)
+ : definition.id === "core.doc"
+ ? ({ modifiers: ["alt"], key: "d" } as const)
+ : undefined,
+ })),
+];
+
+export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ return "Workspace";
+ case "settings":
+ return "Configuration";
+ case "folder":
+ return "Folder";
+ case "item":
+ return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
+ }
+};
diff --git a/Frontend/src/components/app-shell/data/shell.scaffold.ts b/Frontend/src/components/app-shell/data/shell.scaffold.ts
new file mode 100644
index 0000000..7025cde
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.scaffold.ts
@@ -0,0 +1,222 @@
+// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts
+
+import {
+ Bell,
+ CircleHelp,
+ FileText,
+ Folder,
+ Home,
+ Keyboard,
+ LayoutGrid,
+ ListCollapse,
+ LogOut,
+ Repeat,
+ Search,
+ Settings,
+ Shield,
+ User,
+} from "../../../lib/icons";
+import type {
+ ActiveDepartment,
+ ActiveProject,
+ ActiveServer,
+ ActiveUserProfile,
+ DepartmentItem,
+ MobileBottomNavItem,
+ NotificationItem,
+ ProfileMenuSection,
+ ProjectItem,
+ RailItem,
+ ServerDockAction,
+ SidebarHeaderAction,
+ TopBarAction,
+ WorkspaceStaticItem,
+ WorkspaceTreeNode,
+} from "./shell.types";
+
+export const personalDockActions: readonly ServerDockAction[] = [
+ { id: "account", label: "Account", icon: User },
+ { id: "settings", label: "Settings", icon: Settings },
+] as const;
+
+export const organizationAdminDockActions: readonly ServerDockAction[] = [
+ { id: "members", label: "Members", icon: User },
+ { id: "server", label: "Server", icon: Settings },
+] as const;
+
+export const railItems: readonly RailItem[] = [
+ { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
+ { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
+ { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
+] as const;
+
+export const activeServer: ActiveServer = {
+ id: "organization-server",
+ name: "Organization Name",
+ abbreviation: "O",
+ kind: "organization",
+ connectedLabel: "12 connected",
+ dockActions: organizationAdminDockActions,
+};
+
+export const activeProject: ActiveProject = {
+ id: "general",
+ name: "General",
+};
+
+export const activeDepartment: ActiveDepartment = {
+ id: "product",
+ name: "Product",
+ teamName: "Design Systems",
+};
+
+export const projectItems: readonly ProjectItem[] = [
+ {
+ id: "general",
+ name: "General",
+ description: "Default shared project",
+ groupLabel: "Shared space",
+ parentLabel: "Workspace home",
+ meta: "1 workspace",
+ active: true,
+ },
+ {
+ id: "operations",
+ name: "Operations",
+ description: "Cross-team planning and delivery",
+ groupLabel: "Team folders",
+ parentLabel: "Shared Services",
+ meta: "2 workspaces",
+ },
+ {
+ id: "hiring",
+ name: "Hiring",
+ description: "Candidate pipeline and interview loops",
+ groupLabel: "Team folders",
+ parentLabel: "People Ops",
+ meta: "1 workspace",
+ },
+] as const;
+
+export const departmentItems: readonly DepartmentItem[] = [
+ { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
+ { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
+ { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
+] as const;
+
+export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
+ { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
+ { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
+] as const;
+
+export const workspaceTree: readonly WorkspaceTreeNode[] = [
+ {
+ id: "product-workspace",
+ label: "Product",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
+ { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
+ {
+ id: "research-folder",
+ label: "Research",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
+ { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
+ ],
+ },
+ ],
+ },
+ {
+ id: "design-folder",
+ label: "Design",
+ kind: "folder",
+ icon: Folder,
+ children: [
+ { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
+ { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
+ ],
+ },
+ { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
+] as const;
+
+export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
+ { id: "search-workspace", label: "Search workspace", icon: Search },
+ { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
+] as const;
+
+export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
+ { id: "home", label: "Home", icon: Home, active: true },
+ { id: "search", label: "Search", icon: Search },
+ { id: "browse", label: "Browse", icon: Folder },
+] as const;
+
+export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const;
+
+export const notificationItems: readonly NotificationItem[] = [
+ {
+ id: "comment-design-systems",
+ title: "New comment on Design Systems",
+ contextLabel: "Product • Review thread updated",
+ timeLabel: "2m ago",
+ unread: true,
+ },
+ {
+ id: "sprint-platform",
+ title: "Sprint updated in Platform",
+ contextLabel: "Engineering • Scope changed",
+ timeLabel: "15m ago",
+ unread: true,
+ },
+ {
+ id: "member-joined",
+ title: "New member joined Operations",
+ contextLabel: "Organization Name • Access granted",
+ timeLabel: "1h ago",
+ },
+ {
+ id: "daily-summary",
+ title: "Daily summary is ready",
+ contextLabel: "General • 8 updates across boards",
+ timeLabel: "Today, 8:00 AM",
+ },
+] as const;
+
+export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
+
+export const activeUserProfile: ActiveUserProfile = {
+ name: "Demo Account",
+ email: "demo@moku.work",
+ roleLabel: "Founder · Product",
+ contextLabel: "Organization Name • Design Systems",
+};
+
+export const profileMenuSections: readonly ProfileMenuSection[] = [
+ {
+ id: "account",
+ items: [
+ { id: "profile", label: "Profile", icon: User },
+ { id: "account-settings", label: "Account Settings", icon: Settings },
+ { id: "notifications", label: "Notifications", icon: Bell },
+ { id: "security", label: "Security", icon: Shield },
+ ],
+ },
+ {
+ id: "preferences",
+ items: [
+ { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
+ { id: "theme-preferences", label: "Theme Preferences", icon: Settings },
+ { id: "help-support", label: "Help & Support", icon: CircleHelp },
+ ],
+ },
+ {
+ id: "session",
+ items: [
+ { id: "switch-account", label: "Switch Account", icon: Repeat },
+ { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
+ ],
+ },
+] as const;
diff --git a/Frontend/src/components/app-shell/data/shell.types.ts b/Frontend/src/components/app-shell/data/shell.types.ts
new file mode 100644
index 0000000..2fc993e
--- /dev/null
+++ b/Frontend/src/components/app-shell/data/shell.types.ts
@@ -0,0 +1,233 @@
+// Path: Frontend/src/components/app-shell/data/shell.types.ts
+
+import type { Component } from "solid-js";
+
+export type ShellIconProps = {
+ class?: string;
+ size?: number;
+ strokeWidth?: number;
+};
+
+export type ShellIcon = Component;
+
+export type RailItem = {
+ id: string;
+ label: string;
+ abbreviation: string;
+ kind: "personal" | "organization";
+ active?: boolean;
+};
+
+export type ServerDockAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type ActiveServer = {
+ id: string;
+ name: string;
+ abbreviation: string;
+ kind: "personal" | "organization";
+ connectedLabel?: string;
+ subtitle?: string;
+ dockActions: readonly ServerDockAction[];
+};
+
+export type ActiveProject = {
+ id: string;
+ name: string;
+};
+
+export type ActiveDepartment = {
+ id: string;
+ name: string;
+ teamName: string;
+};
+
+export type DepartmentItem = {
+ id: string;
+ name: string;
+ teams: readonly string[];
+ active?: boolean;
+};
+
+export type ProjectItem = {
+ id: string;
+ name: string;
+ description: string;
+ groupLabel?: string;
+ parentLabel?: string;
+ meta?: string;
+ active?: boolean;
+};
+
+export type ProjectMenuTarget =
+ | {
+ id: string;
+ label: string;
+ kind: "surface";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "folder";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "project";
+ };
+
+export type ProjectContextMenuAction = {
+ id: string;
+ label: string;
+ tone?: "default" | "danger";
+ shortcut?: WorkspaceContextMenuShortcut;
+ children?: readonly ProjectContextMenuAction[];
+};
+
+export type ProjectContextMenuSection = {
+ id: string;
+ label?: string;
+ items: readonly ProjectContextMenuAction[];
+};
+
+export type SidebarItem = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ active?: boolean;
+ meta?: string;
+};
+
+export type WorkspaceStaticKind = "workspace" | "home" | "settings";
+
+export type WorkspaceItemTypeId = string;
+
+export type WorkspaceStaticItem = SidebarItem & {
+ contextKind: WorkspaceStaticKind;
+};
+
+export type WorkspaceFolderNode = {
+ id: string;
+ path?: string;
+ label: string;
+ kind: "folder";
+ icon: ShellIcon;
+ active?: boolean;
+ meta?: string;
+ children?: readonly WorkspaceTreeNode[];
+};
+
+export type WorkspaceItemNode = {
+ id: string;
+ path?: string;
+ label: string;
+ kind: "item";
+ itemType: WorkspaceItemTypeId;
+ active?: boolean;
+ meta?: string;
+ children?: undefined;
+};
+
+export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
+
+export type WorkspaceItemTypeDefinition = {
+ id: WorkspaceItemTypeId;
+ label: string;
+ shortLabel: string;
+ icon: ShellIcon;
+ noun: string;
+ actionPrefix: string;
+ defaultCreateLabel: string;
+ includeInWorkspaceCreate?: boolean;
+ description?: string;
+};
+
+export type SidebarHeaderAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type TopBarAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+};
+
+export type MobileBottomNavItem = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ active?: boolean;
+};
+
+export type WorkspaceContextMenuTarget =
+ | {
+ id: string;
+ label: string;
+ kind: WorkspaceStaticKind;
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "folder";
+ }
+ | {
+ id: string;
+ label: string;
+ kind: "item";
+ itemType: WorkspaceItemTypeId;
+ };
+
+export type WorkspaceContextMenuAction = {
+ id: string;
+ label: string;
+ tone?: "default" | "danger";
+ shortcut?: WorkspaceContextMenuShortcut;
+ children?: readonly WorkspaceContextMenuAction[];
+};
+
+export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
+
+export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
+
+export type WorkspaceContextMenuShortcut = {
+ modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
+ key: WorkspaceContextMenuShortcutKey;
+};
+
+export type WorkspaceContextMenuSection = {
+ id: string;
+ label?: string;
+ items: readonly WorkspaceContextMenuAction[];
+};
+
+export type NotificationItem = {
+ id: string;
+ title: string;
+ contextLabel: string;
+ timeLabel: string;
+ unread?: boolean;
+};
+
+export type ProfileMenuAction = {
+ id: string;
+ label: string;
+ icon: ShellIcon;
+ tone?: "default" | "danger";
+};
+
+export type ProfileMenuSection = {
+ id: string;
+ items: readonly ProfileMenuAction[];
+};
+
+export type ActiveUserProfile = {
+ name: string;
+ email: string;
+ roleLabel: string;
+ contextLabel: string;
+};
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
similarity index 98%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
index 80b831f..d2eac7b 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
similarity index 87%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
index e847214..eddfef0 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
@@ -1,8 +1,7 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
-import { resolveAPIBase } from "../../../lib/api";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
@@ -20,7 +19,8 @@ import {
type InstanceForm,
type ModeForm,
type StructureForm,
-} from "./WorkspaceHome.data";
+} from "./BootstrapWizard.data";
+import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
@@ -49,67 +49,7 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({
const materializationPollIntervalMs = 2000;
-const readResponseBody = async (response: Response): Promise => {
- const raw = await response.text();
-
- if (!raw.trim()) {
- return null;
- }
-
- try {
- return JSON.parse(raw);
- } catch {
- return raw;
- }
-};
-
-const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
- const fallback = `Bootstrap ${step} request failed.`;
-
- if (typeof data === "string") {
- const message = data.trim();
- return message || fallback;
- }
-
- if (!data || typeof data !== "object") {
- return fallback;
- }
-
- const record = data as {
- error?: string;
- message?: string;
- requestId?: string;
- };
- const message = typeof record.message === "string" ? record.message.trim() : "";
- const errorCode = typeof record.error === "string" ? record.error.trim() : "";
- const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
-
- if (!message && !errorCode && !requestId) {
- return fallback;
- }
-
- const details: string[] = [];
-
- if (errorCode) {
- details.push(`code: ${errorCode}`);
- }
-
- if (requestId) {
- details.push(`request: ${requestId}`);
- }
-
- if (message && details.length > 0) {
- return `${message} (${details.join(", ")})`;
- }
-
- if (message) {
- return message;
- }
-
- return `${fallback} (${details.join(", ")})`;
-};
-
-export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) => {
+export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
@@ -303,7 +243,6 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
});
});
- const apiBase = (): string => resolveAPIBase();
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
@@ -342,19 +281,7 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
setStepState(step, { status: "submitting", error: "" });
try {
- const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify(payload),
- });
- const data = await readResponseBody(response);
-
- if (!response.ok) {
- throw new Error(readResponseError(step, data));
- }
+ await submitBootstrapStepRequest(step, payload);
setStepState(step, {
status: "success",
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
similarity index 99%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
index 1624c99..756611c 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss
@@ -1,4 +1,4 @@
-/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */
+/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport,
.wizardLayer {
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
similarity index 98%
rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx
rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
index 74f5128..cc79a0e 100644
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
@@ -1,3 +1,5 @@
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
+
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
@@ -11,8 +13,8 @@ import {
type InstanceForm,
type ModeForm,
type StructureForm,
-} from "./WorkspaceHome.data";
-import styles from "./WorkspaceHome.module.scss";
+} from "./BootstrapWizard.data";
+import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx
new file mode 100644
index 0000000..813dfe1
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx
@@ -0,0 +1,107 @@
+import { type JSX } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { useBootstrapWizard } from "./BootstrapWizard.hook";
+import { BootstrapWizardDialog } from "./BootstrapWizardDialog";
+
+export const BootstrapWizard = (): JSX.Element => {
+ const appShellData = useAppShellData();
+ const {
+ instanceForm,
+ setInstanceForm,
+ modeForm,
+ setModeForm,
+ adminForm,
+ setAdminForm,
+ structureForm,
+ setStructureForm,
+ selectedPersona,
+ hasChosenPersona,
+ stepState,
+ isBootstrapStateResolved,
+ isWizardOpen,
+ setIsWizardOpen,
+ setIsFinishingBootstrapFlow,
+ fieldTooltip,
+ materializationState,
+ isMaterializationInFlight,
+ hasMaterializationFailed,
+ showBootstrapFinishingState,
+ materializationStatusLabel,
+ materializationMessage,
+ selectedPersonaIsAvailable,
+ usesCondensedBootstrapFlow,
+ activeWizardSteps,
+ bootstrapNamePlaceholder,
+ bootstrapStepCount,
+ currentStep,
+ currentWizardStepIndex,
+ wizardProgressFillWidth,
+ currentStepState,
+ isFirstStep,
+ canDismissWizard,
+ handleCurrentStepSubmit,
+ applyPersonaSelection,
+ statusLabel,
+ showFieldTooltip,
+ hideFieldTooltip,
+ stepStatusLabel,
+ navigateBack,
+ navigateToVisibleStep,
+ } = useBootstrapWizard(appShellData);
+
+ return (
+ {
+ setIsWizardOpen(false);
+ }}
+ onCloseFinishingState={(): void => {
+ setIsFinishingBootstrapFlow(false);
+ setIsWizardOpen(false);
+ }}
+ onSubmit={handleCurrentStepSubmit}
+ onSelectPersona={applyPersonaSelection}
+ onProtocolChange={(value): void => setInstanceForm("protocol", value)}
+ onAccessChange={(value): void => setInstanceForm("access", value)}
+ onHostChange={(value): void => setInstanceForm("host", value)}
+ onModeNameChange={(value): void => setModeForm("name", value)}
+ onProjectNameChange={(value): void => setStructureForm("projectName", value)}
+ onTeamNameChange={(value): void => setStructureForm("teamName", value)}
+ onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)}
+ onAdminEmailChange={(value): void => setAdminForm("email", value)}
+ onAdminPasswordChange={(value): void => setAdminForm("password", value)}
+ onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
+ onShowTooltip={showFieldTooltip}
+ onHideTooltip={hideFieldTooltip}
+ statusLabel={statusLabel}
+ stepStatusLabel={stepStatusLabel}
+ onNavigateBack={navigateBack}
+ onSelectStep={navigateToVisibleStep}
+ />
+ );
+};
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
new file mode 100644
index 0000000..a94fd8c
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
@@ -0,0 +1,234 @@
+// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
+
+import { Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import {
+ type AdminForm,
+ type BootstrapPersona,
+ bootstrapPersonaDefinitions,
+ type BootstrapStepDefinition,
+ type BootstrapStepKey,
+ type InstanceForm,
+ type ModeForm,
+ type StructureForm,
+} from "./BootstrapWizard.data";
+import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook";
+import {
+ BootstrapAdminStep,
+ BootstrapFinishingState,
+ BootstrapInstanceStep,
+ BootstrapModeStep,
+ BootstrapPersonaStep,
+ BootstrapStructureStep,
+ BootstrapWizardProgress,
+} from "./BootstrapWizard.parts";
+import styles from "./BootstrapWizard.module.scss";
+
+export type BootstrapWizardDialogProps = {
+ isOpen: boolean;
+ currentStep: BootstrapStepDefinition;
+ currentStepState: BootstrapSubmissionState;
+ activeWizardSteps: readonly BootstrapStepDefinition[];
+ currentWizardStepIndex: number;
+ stepState: Record;
+ bootstrapStepCount: number;
+ wizardProgressFillWidth: string;
+ showBootstrapFinishingState: boolean;
+ materializationState: MaterializationState;
+ materializationStatusLabel: string;
+ materializationMessage: string;
+ isMaterializationInFlight: boolean;
+ hasMaterializationFailed: boolean;
+ instanceForm: InstanceForm;
+ modeForm: ModeForm;
+ adminForm: AdminForm;
+ structureForm: StructureForm;
+ selectedPersona: BootstrapPersona;
+ hasChosenPersona: boolean;
+ selectedPersonaIsAvailable: boolean;
+ usesCondensedBootstrapFlow: boolean;
+ bootstrapNamePlaceholder: string;
+ isFirstStep: boolean;
+ canDismissWizard: boolean;
+ fieldTooltip: FieldTooltipState | null;
+ onClose: () => void;
+ onCloseFinishingState: () => void;
+ onSubmit: JSX.EventHandlerUnion;
+ onSelectPersona: (persona: BootstrapPersona) => void;
+ onProtocolChange: (value: InstanceForm["protocol"]) => void;
+ onAccessChange: (value: InstanceForm["access"]) => void;
+ onHostChange: (value: string) => void;
+ onModeNameChange: (value: string) => void;
+ onProjectNameChange: (value: string) => void;
+ onTeamNameChange: (value: string) => void;
+ onAdminDisplayNameChange: (value: string) => void;
+ onAdminEmailChange: (value: string) => void;
+ onAdminPasswordChange: (value: string) => void;
+ onDepartmentNameChange: (value: string) => void;
+ onShowTooltip: (target: HTMLElement, text: string) => void;
+ onHideTooltip: () => void;
+ statusLabel: (state: BootstrapSubmissionState) => string;
+ stepStatusLabel: (step: BootstrapStepDefinition) => string;
+ onNavigateBack: () => void;
+ onSelectStep: (index: number) => void;
+};
+
+export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => (
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.currentStepState.error}
+
+
+
+
+
+
+
+ {(tooltip): JSX.Element => (
+
+ )}
+
+
+
+
+);
diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts
new file mode 100644
index 0000000..2ee6d05
--- /dev/null
+++ b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts
@@ -0,0 +1,81 @@
+import { resolveAPIBase } from "../../../lib/api";
+import type { BootstrapStepKey } from "./BootstrapWizard.data";
+
+const readBootstrapResponseBody = async (response: Response): Promise => {
+ const raw = await response.text();
+
+ if (!raw.trim()) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return raw;
+ }
+};
+
+const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => {
+ const fallback = `Bootstrap ${step} request failed.`;
+
+ if (typeof data === "string") {
+ const message = data.trim();
+ return message || fallback;
+ }
+
+ if (!data || typeof data !== "object") {
+ return fallback;
+ }
+
+ const record = data as {
+ error?: string;
+ message?: string;
+ requestId?: string;
+ };
+ const message = typeof record.message === "string" ? record.message.trim() : "";
+ const errorCode = typeof record.error === "string" ? record.error.trim() : "";
+ const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
+
+ if (!message && !errorCode && !requestId) {
+ return fallback;
+ }
+
+ const details: string[] = [];
+
+ if (errorCode) {
+ details.push(`code: ${errorCode}`);
+ }
+
+ if (requestId) {
+ details.push(`request: ${requestId}`);
+ }
+
+ if (message && details.length > 0) {
+ return `${message} (${details.join(", ")})`;
+ }
+
+ if (message) {
+ return message;
+ }
+
+ return `${fallback} (${details.join(", ")})`;
+};
+
+export const submitBootstrapStepRequest = async (
+ step: BootstrapStepKey,
+ payload: unknown,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await readBootstrapResponseBody(response);
+
+ if (!response.ok) {
+ throw new Error(readBootstrapResponseError(step, data));
+ }
+};
diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
deleted file mode 100644
index 79efa1b..0000000
--- a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
+++ /dev/null
@@ -1,638 +0,0 @@
-import { For, Show, createSignal, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronRight, Plus, X } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { createLongPressGesture } from "../createLongPressGesture";
-import {
- createWorkspaceStaticTarget,
- createWorkspaceSurfaceTarget,
- createWorkspaceTreeTarget,
- getWorkspaceItemTypeDefinition,
- getWorkspaceNodeIcon,
- workspaceStaticItems,
- type SidebarItem,
- type WorkspaceContextMenuAction,
- type WorkspaceContextMenuTarget,
- type WorkspaceItemTypeId,
- type WorkspaceStaticItem,
- type WorkspaceTreeNode,
-} from "../data/shell.data";
-import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
-import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
-import styles from "./MobileWorkspaceBrowser.module.scss";
-
-type MobileWorkspaceBrowserProps = {
- open: boolean;
- onClose: VoidFunction;
-};
-
-type MobileWorkspaceDialogState =
- | {
- kind: "text";
- title: string;
- message: string;
- confirmLabel: string;
- initialValue: string;
- onConfirm: (value: string) => void;
- }
- | {
- kind: "confirm";
- title: string;
- message: string;
- confirmLabel: string;
- tone?: "danger";
- onConfirm: () => void;
- };
-
-type MobileMoveTargetState = {
- kind: "folder" | "item";
- id: string;
- label: string;
-};
-
-type MobileMoveDestination = {
- id: string | null;
- label: string;
- depth: number;
- meta?: string;
-};
-
-const collectMoveDestinations = (
- nodes: readonly WorkspaceTreeNode[],
- movingTarget: MobileMoveTargetState,
- depth = 0,
- ancestorBlocked = false,
-): MobileMoveDestination[] => {
- const destinations: MobileMoveDestination[] = [];
-
- for (const node of nodes) {
- if (node.kind !== "folder") {
- continue;
- }
-
- const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
- if (!ancestorBlocked && !isBlockedFolder) {
- destinations.push({
- id: node.id,
- label: node.label,
- depth,
- meta: "Folder",
- });
- }
-
- destinations.push(
- ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
- );
- }
-
- return destinations;
-};
-
-const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
- for (const node of nodes) {
- if (node.id === nodeId) {
- return node;
- }
-
- if (node.kind !== "folder") {
- continue;
- }
-
- const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
- if (nestedMatch) {
- return nestedMatch;
- }
- }
-
- return null;
-};
-
-const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
-
-const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
- const depth = props.depth ?? 0;
- const Icon = getWorkspaceNodeIcon(props.node);
- const hasChildren = (props.node.children?.length ?? 0) > 0;
-
- return (
-
- );
-};
-
-const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
- const Icon = props.item.icon;
-
- return (
-
- );
-};
-
-const WorkspaceStaticRow = (props: {
- item: WorkspaceStaticItem;
- onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
-}): JSX.Element => {
- const target = createWorkspaceStaticTarget(props.item);
- const longPress = createLongPressGesture({
- onLongPress: () => {
- props.onOpenActionSheet(target);
- },
- });
-
- return (
- {
- event.preventDefault();
- props.onOpenActionSheet(target);
- }}
- {...longPress}
- >
-
-
- );
-};
-
-const WorkspaceTreeBranch = (props: {
- nodes: readonly WorkspaceTreeNode[];
- depth?: number;
- onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
-}): JSX.Element => {
- const depth = props.depth ?? 0;
-
- return (
-
- {(node): JSX.Element => {
- const target = createWorkspaceTreeTarget(node);
- const longPress = createLongPressGesture({
- onLongPress: () => {
- props.onOpenActionSheet(target);
- },
- });
-
- return (
- {
- event.preventDefault();
- props.onOpenActionSheet(target);
- }}
- {...longPress}
- >
-
-
-
-
-
-
- );
- }}
-
- );
-};
-
-export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
- const appShellData = useAppShellData();
- const [actionSheetTarget, setActionSheetTarget] = createSignal(null);
- const [dialogState, setDialogState] = createSignal(null);
- const [dialogValue, setDialogValue] = createSignal("");
- const [moveTarget, setMoveTarget] = createSignal(null);
- const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
- activeProjectId: () => appShellData.activeProject().id,
- fallbackWorkspaceTree: () => appShellData.workspaceTree(),
- });
- const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
-
- const moveDestinations = () => {
- const target = moveTarget();
- if (!target) {
- return [] as MobileMoveDestination[];
- }
-
- return [
- { id: null, label: "Items root", depth: 0, meta: "Root" },
- ...collectMoveDestinations(workspaceTreeNodes(), target),
- ];
- };
-
- const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
- switch (actionId) {
- case "create-doc":
- return "core.doc";
- case "create-board":
- return "core.board.kanban";
- case "create-list-board":
- return "core.board.list";
- default:
- return null;
- }
- };
-
- const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
- const definition = getWorkspaceItemTypeDefinition(itemType);
- void createItem(definition.defaultCreateLabel, itemType, parentId);
- };
-
- const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
- setActionSheetTarget(target);
- };
-
- const closeActionSheet = (): void => {
- setActionSheetTarget(null);
- };
-
- const closeMoveSheet = (): void => {
- setMoveTarget(null);
- };
-
- const openWorkspaceActionSheet = (): void => {
- openActionSheet(workspaceTarget());
- };
-
- const closeDialog = (): void => {
- setDialogState(null);
- setDialogValue("");
- };
-
- const openMoveSheet = (target: MobileMoveTargetState): void => {
- setMoveTarget(target);
- };
-
- const openTextDialog = (config: Omit, "kind">): void => {
- setDialogValue(config.initialValue);
- setDialogState({ kind: "text", ...config });
- };
-
- const openConfirmDialog = (config: Omit, "kind">): void => {
- setDialogValue("");
- setDialogState({ kind: "confirm", ...config });
- };
-
- const submitDialog = (): void => {
- const state = dialogState();
- if (!state) {
- return;
- }
-
- if (state.kind === "text") {
- const value = dialogValue().trim();
- if (!value) {
- return;
- }
-
- closeDialog();
- state.onConfirm(value);
- return;
- }
-
- closeDialog();
- state.onConfirm();
- };
-
- const handleMoveDestinationSelect = (destinationId: string | null): void => {
- const target = moveTarget();
- if (!target) {
- return;
- }
-
- const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
- const targetIndex = destinationNode?.kind === "folder"
- ? destinationNode.children?.length ?? 0
- : destinationId
- ? 0
- : workspaceTreeNodes().length;
-
- closeMoveSheet();
-
- if (target.kind === "folder") {
- void moveFolder(target.id, destinationId, targetIndex);
- return;
- }
-
- void moveItem(target.id, destinationId, targetIndex);
- };
-
- const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
- const createItemType = resolveCreateItemType(action.id);
- if (createItemType) {
- switch (target.kind) {
- case "workspace":
- case "home":
- createPersistedItem(createItemType, null);
- return;
- case "folder":
- createPersistedItem(createItemType, target.id);
- return;
- case "settings":
- case "item":
- return;
- }
- }
-
- switch (action.id) {
- case "new-folder": {
- if (target.kind === "settings" || target.kind === "item") {
- return;
- }
-
- openTextDialog({
- title: "New folder",
- message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
- confirmLabel: "Create",
- initialValue: "Untitled folder",
- onConfirm: (name) => {
- void createFolder(name, target.kind === "folder" ? target.id : null);
- },
- });
- return;
- }
- case "rename-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openTextDialog({
- title: "Rename folder",
- message: `Update the name for "${target.label}".`,
- confirmLabel: "Save",
- initialValue: target.label,
- onConfirm: (name) => {
- if (name === target.label) {
- return;
- }
- void renameFolder(target.id, name);
- },
- });
- return;
- }
- case "move-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openMoveSheet({
- kind: "folder",
- id: target.id,
- label: target.label,
- });
- return;
- }
- case "delete-folder": {
- if (target.kind !== "folder") {
- return;
- }
-
- openConfirmDialog({
- title: "Delete folder?",
- message: `"${target.label}" and everything inside it will be removed.`,
- confirmLabel: "Delete",
- tone: "danger",
- onConfirm: () => {
- void deleteFolder(target.id);
- },
- });
- return;
- }
- case "move-doc":
- case "move-board":
- case "move-list-board": {
- if (target.kind !== "item") {
- return;
- }
-
- openMoveSheet({
- kind: "item",
- id: target.id,
- label: target.label,
- });
- return;
- }
- case "delete-doc":
- case "delete-board":
- case "delete-list-board": {
- if (target.kind !== "item") {
- return;
- }
-
- openConfirmDialog({
- title: "Delete item?",
- message: `"${target.label}" will be removed from the project tree.`,
- confirmLabel: "Delete",
- tone: "danger",
- onConfirm: () => {
- void deleteItem(target.id);
- },
- });
- return;
- }
- default:
- return;
- }
- };
-
- const workspaceLongPress = createLongPressGesture({
- onLongPress: openWorkspaceActionSheet,
- });
-
- return (
-
-
-
-
-
-
-
- Workspace
-
-
- {(item): JSX.Element => }
-
-
-
-
-
-
-
-
-
-
-
- {(state): JSX.Element => (
-
-
-
-
-
-
-
-
-
Destination
-
-
- {(destination): JSX.Element => (
-
- )}
-
-
-
-
-
-
-
-
- )}
-
-
-
- {(state): JSX.Element => (
-
-
-
-
-
-
{state().title}
-
{state().message}
-
-
-
- {
- setDialogValue(event.currentTarget.value);
- }}
- onKeyDown={(event): void => {
- if (event.key === "Enter") {
- event.preventDefault();
- submitDialog();
- }
- }}
- autofocus
- />
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- );
-};
diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
deleted file mode 100644
index 7d5c7ac..0000000
--- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
+++ /dev/null
@@ -1,1216 +0,0 @@
-// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
-
-import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
-import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
-import { useAppShellData } from "../data/app-shell.context";
-import { resolveAPIBase } from "../../../lib/api";
-import {
- collectBranchNodeIds,
- findTreeNodeDepth,
- findTreeNodeLocation,
- getPointerRelativeY,
- isUuidString,
- moveTreeNode,
- resolveTreeDropTarget,
- type NavTreeAdapter,
- type NavTreeDropTarget,
-} from "../shared/navTreeDnd";
-import {
- createProjectFolderTarget,
- createProjectSurfaceTarget,
- createProjectTarget,
- type ProjectItem,
- type ProjectMenuTarget,
-} from "../data/shell.data";
-import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
-import styles from "./ProjectSelector.module.scss";
-
-type ProjectSelectorProps = {
- compact?: boolean;
- isOpen: boolean;
- onToggle: () => void;
- onClose: () => void;
-};
-
-type ProjectFolderNode = {
- kind: "folder";
- id: string;
- path: string;
- label: string;
- meta?: string;
- children: ProjectTreeNode[];
-};
-
-type ProjectLeafNode = {
- kind: "project";
- item: ProjectItem;
-};
-
-type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
-
-type PersistedProjectFolderRecord = {
- id: string;
- path: string;
- label: string;
- children: PersistedProjectFolderRecord[];
-};
-
-type ProjectFoldersResponse = {
- data?: {
- folders?: PersistedProjectFolderRecord[];
- renamedFolder?: PersistedProjectFolderRecord;
- movedFolder?: PersistedProjectFolderRecord;
- previousFolderId?: string;
- previousFolderPath?: string;
- };
- error?: string;
- message?: string;
-};
-
-type PendingProjectFolderDraft = {
- parentId: string | null;
- depth: number;
-};
-
-type PendingProjectFolderRename = {
- folderId: string;
- depth: number;
-};
-
-type ProjectDragTarget = NavTreeDropTarget;
-
-type ProjectDragState = {
- draggedNodeId: string;
- dropTarget: ProjectDragTarget | null;
-};
-
-type DragGhostPosition = {
- x: number;
- y: number;
-};
-
-const LONG_PRESS_MS = 320;
-
-const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
- node.kind === "folder" ? node.id : node.item.id;
-
-const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
- folders.map((folder) => ({
- kind: "folder",
- id: folder.id,
- path: folder.path,
- label: folder.label,
- children: buildPersistedFolderNodes(folder.children ?? []),
- }));
-
-const buildProjectTree = (
- items: readonly ProjectItem[],
- folders: readonly PersistedProjectFolderRecord[] = [],
-): ProjectTreeNode[] => [
- // The selector still presents scaffold project leaves beside persisted folders.
- // Keep that mixed root shape explicit here so the drag/drop logic can account
- // for folder-only ordering when we persist sibling positions.
- ...items.map((item) => ({
- kind: "project" as const,
- item,
- })),
- ...buildPersistedFolderNodes(folders),
-];
-
-const countProjectFolderSiblingsBeforeIndex = (
- siblings: readonly ProjectTreeNode[],
- index: number,
-): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
-
-const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
- Array.isArray(body.data?.folders) ? body.data.folders : [];
-
-const projectTreeAdapter: NavTreeAdapter = {
- getNodeId: getProjectTreeNodeId,
- isBranchNode: (node) => node.kind === "folder",
- getChildren: (node) => (node.kind === "folder" ? node.children : []),
- withChildren: (node, children) =>
- node.kind === "folder"
- ? {
- ...node,
- children: [...children],
- }
- : node,
-};
-
-const ProjectFolderDraftRow = (props: {
- depth: number;
- value: string;
- onInput: (value: string) => void;
- onSubmit: () => void;
- onCancel: () => void;
-}): JSX.Element => {
- let inputRef: HTMLInputElement | undefined;
-
- queueMicrotask(() => inputRef?.focus());
-
- return (
-
-
-
- props.onInput(event.currentTarget.value)}
- onBlur={props.onSubmit}
- onKeyDown={(event): void => {
- if (event.key === "Enter") {
- event.preventDefault();
- event.currentTarget.blur();
- return;
- }
-
- if (event.key === "Escape") {
- event.preventDefault();
- props.onCancel();
- event.currentTarget.blur();
- }
- }}
- />
-
-
- );
-};
-
-const ProjectFolderBranch = (props: {
- nodes: readonly ProjectTreeNode[];
- depth: number;
- parentId: string | null;
- selectedProjectId: string;
- isFolderCollapsed: (folderId: string) => boolean;
- onToggleFolder: (folderId: string) => void;
- onSelectProject: (projectId: string) => void;
- onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void;
- onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void;
- onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
- onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void;
- pendingFolderDraft: PendingProjectFolderDraft | null;
- pendingFolderName: string;
- 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 => (
-
-
- -
-
-
-
-
-
- {(node, indexAccessor): JSX.Element => {
- const nodeId = (): string => getProjectTreeNodeId(node);
- const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
- const dropIntent = (): ProjectDragTarget["intent"] | null => {
- if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
- return null;
- }
-
- return props.dragState.dropTarget.intent;
- };
-
- if (node.kind === "folder") {
- const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
- const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
-
- return (
- -
- {
- if (props.dragState || props.isTreeClickSuppressed()) {
- return;
- }
-
- props.onToggleFolder(node.id);
- }}
- onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
- onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
- onPointerMove={(event): void =>
- props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
- }
- onPointerEnter={(event): void =>
- props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
- }
- >
-
-
- {node.label}
-
- {node.meta}
-
-
- }
- >
-
-
-
- 0 || props.pendingFolderDraft?.parentId === node.id)}>
-
-
-
- );
- }
-
- return (
- -
-
-
- );
- }}
-
-
-
-
-
-
-);
-
-export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
- const appShellData = useAppShellData();
- const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
- const [drawerTop, setDrawerTop] = createSignal(0);
- const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
- const [persistedFolders, setPersistedFolders] = createSignal([]);
- const [projectTreeNodes, setProjectTreeNodes] = createSignal(
- buildProjectTree(appShellData.projectItems(), persistedFolders()),
- );
- const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
- const [pendingFolderName, setPendingFolderName] = createSignal("");
- const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
- const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
- const [dragState, setDragState] = createSignal(null);
- const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
- const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
- let rootRef: HTMLDivElement | undefined;
- let triggerRef: HTMLButtonElement | undefined;
- let contextMenuRef: HTMLDivElement | undefined;
- let longPressTimer: number | undefined;
- let suppressClickTimer: number | undefined;
- let lastSelectedProjectId: string | null = null;
- let latestPersistedFoldersRequest = 0;
- const contextMenu = createProjectContextMenuController();
-
- const clearLongPressTimer = (): void => {
- if (longPressTimer !== undefined) {
- window.clearTimeout(longPressTimer);
- longPressTimer = undefined;
- }
- };
-
- const updateDragGhostPosition = (x: number, y: number): void => {
- setDragGhostPosition({ x: x + 18, y: y + 18 });
- };
-
- const draggedNode = (): ProjectTreeNode | null => {
- const currentDragState = dragState();
- if (!currentDragState) {
- return null;
- }
-
- return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
- };
-
- const draggedNodeMeta = (): string => {
- const node = draggedNode();
- if (!node) {
- return "";
- }
-
- return node.kind === "folder" ? "Folder" : "Project";
- };
-
- const suppressTreeClickTemporarily = (): void => {
- setSuppressNextTreeClick(true);
-
- if (suppressClickTimer !== undefined) {
- window.clearTimeout(suppressClickTimer);
- }
-
- suppressClickTimer = window.setTimeout(() => {
- setSuppressNextTreeClick(false);
- suppressClickTimer = undefined;
- }, 80);
- };
-
- const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
-
- const toggleFolder = (folderId: string): void => {
- setCollapsedFolderIds((current) =>
- current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
- );
- };
-
- const syncProjectTree = (): void => {
- const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
- const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
-
- setProjectTreeNodes(nextTree);
- setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
- };
-
- const resetProjectTreeInteractionState = (): void => {
- setCollapsedFolderIds([]);
- setPendingFolderDraft(null);
- setPendingFolderName("");
- setPendingFolderRename(null);
- setPendingFolderRenameName("");
- setDragState(null);
- };
-
- const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
-
- const expandAllFolders = (): void => {
- setCollapsedFolderIds([]);
- };
-
- const collapseAllFolders = (): void => {
- setCollapsedFolderIds(folderIds());
- };
-
- const totalFolderCount = (): number => folderIds().length;
-
- const areAllFoldersCollapsed = (): boolean => {
- const folderCount = totalFolderCount();
-
- return folderCount > 0 && collapsedFolderIds().length >= folderCount;
- };
-
- const toggleAllFolders = (): void => {
- if (areAllFoldersCollapsed()) {
- expandAllFolders();
- return;
- }
-
- collapseAllFolders();
- };
-
- const loadPersistedFolders = async (projectId: string): Promise => {
- const requestId = latestPersistedFoldersRequest + 1;
- latestPersistedFoldersRequest = requestId;
-
- if (!projectId) {
- setPersistedFolders([]);
- return;
- }
-
- if (!isUuidString(projectId)) {
- setPersistedFolders([]);
- return;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
- headers: {
- Accept: "application/json",
- },
- });
- const body = (await response.json()) as ProjectFoldersResponse;
-
- if (requestId !== latestPersistedFoldersRequest) {
- return;
- }
-
- if (!response.ok) {
- throw new Error(body.message || "Failed to load project folders.");
- }
-
- setPersistedFolders(readPersistedFolders(body));
- } catch (error) {
- if (requestId !== latestPersistedFoldersRequest) {
- return;
- }
-
- console.error(error);
- setPersistedFolders([]);
- }
- };
-
- createEffect(() => {
- setSelectedProject(appShellData.activeProject());
- });
-
- createEffect(() => {
- syncProjectTree();
- });
-
- createEffect(() => {
- const projectId = selectedProject().id;
-
- if (lastSelectedProjectId === null) {
- lastSelectedProjectId = projectId;
- return;
- }
-
- if (projectId === lastSelectedProjectId) {
- return;
- }
-
- lastSelectedProjectId = projectId;
- resetProjectTreeInteractionState();
- });
-
- createEffect(() => {
- const projectId = selectedProject().id;
- void loadPersistedFolders(projectId);
- });
-
- onMount(() => {
- if (triggerRef) {
- const updateDrawerTop = (): void => {
- if (!triggerRef) {
- return;
- }
-
- setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8);
- };
-
- updateDrawerTop();
-
- const observer = new ResizeObserver(() => {
- updateDrawerTop();
- });
-
- observer.observe(triggerRef);
- window.addEventListener("resize", updateDrawerTop);
-
- onCleanup(() => {
- observer.disconnect();
- window.removeEventListener("resize", updateDrawerTop);
- });
- }
-
- const handlePointerDown = (event: PointerEvent): void => {
- if (!props.isOpen || !rootRef) {
- return;
- }
-
- const target = event.target;
-
- if (target instanceof Node && rootRef.contains(target)) {
- return;
- }
-
- if (target instanceof Node && contextMenuRef?.contains(target)) {
- return;
- }
-
- props.onClose();
- };
-
- const handlePointerUp = (): void => {
- clearLongPressTimer();
-
- const nextDragState = dragState();
-
- if (!nextDragState?.dropTarget) {
- if (nextDragState) {
- suppressTreeClickTemporarily();
- }
- setDragState(null);
- return;
- }
-
- suppressTreeClickTemporarily();
-
- const currentNodes = projectTreeNodes();
- const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
- const canPersistMove = isUuidString(selectedProject().id);
- const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
- const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter);
- const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
- const persistedParentLocation = previewLocation?.parentId
- ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter)
- : null;
- const persistedParentFolderPath =
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
- const previewSiblings = previewLocation?.parentId
- ? persistedParentLocation?.node.kind === "folder"
- ? persistedParentLocation.node.children
- : []
- : previewNodes;
- // The preview tree includes project leaves and folders, but the backend only
- // stores sibling order for folders. Persist a folder-only index so the server
- // can reapply the same position against the authoritative ordered tree.
- const targetIndex = previewLocation
- ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
- : 0;
-
- if (
- canPersistMove &&
- draggedLocation?.node.kind === "folder" &&
- draggedFolderPath &&
- (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
- ) {
- void movePersistedFolder(
- draggedFolderPath,
- persistedParentFolderPath,
- draggedLocation.node.id,
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
- targetIndex,
- );
- } else {
- setProjectTreeNodes((current) =>
- moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter),
- );
- }
-
- setDragState(null);
- };
-
- const handleEscape = (event: KeyboardEvent): void => {
- if (event.key !== "Escape") {
- return;
- }
-
- clearLongPressTimer();
-
- if (dragState()) {
- setDragState(null);
- return;
- }
-
- if (!props.isOpen) {
- return;
- }
-
- props.onClose();
- triggerRef?.focus();
- };
-
- const handlePointerMove = (event: PointerEvent): void => {
- if (!dragState()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- };
-
- document.addEventListener("pointerdown", handlePointerDown);
- window.addEventListener("pointerup", handlePointerUp);
- window.addEventListener("pointercancel", handlePointerUp);
- window.addEventListener("pointermove", handlePointerMove);
- window.addEventListener("keydown", handleEscape);
-
- onCleanup(() => {
- clearLongPressTimer();
- if (suppressClickTimer !== undefined) {
- window.clearTimeout(suppressClickTimer);
- }
- document.removeEventListener("pointerdown", handlePointerDown);
- window.removeEventListener("pointerup", handlePointerUp);
- window.removeEventListener("pointercancel", handlePointerUp);
- window.removeEventListener("pointermove", handlePointerMove);
- window.removeEventListener("keydown", handleEscape);
- });
- });
-
- const toggleOpen = (): void => {
- if (!props.isOpen) {
- props.onToggle();
- return;
- }
-
- props.onClose();
- };
-
- const selectProject = (projectId: string): void => {
- const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
-
- if (!location || location.node.kind !== "project") {
- return;
- }
-
- setSelectedProject({ id: location.node.item.id, name: location.node.item.name });
- props.onClose();
- };
-
- const beginFolderDraft = (parentId: string | null, depth: number): void => {
- if (parentId) {
- 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 resolveFolderPath = (folderId: string): string | null => {
- const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
- return location && location.node.kind === "folder" ? location.node.path : null;
- };
-
- const submitPendingFolder = async (): Promise => {
- const name = pendingFolderName().trim();
- const draft = pendingFolderDraft();
- const projectId = selectedProject().id;
-
- if (!draft) {
- return;
- }
-
- if (!name) {
- setPendingFolderDraft(null);
- setPendingFolderName("");
- return;
- }
-
- if (!isUuidString(projectId)) {
- cancelPendingFolder();
- return;
- }
-
- const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
- if (draft.parentId && !parentFolderPath) {
- cancelPendingFolder();
- return;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name,
- parentFolderId: parentFolderPath,
- }),
- });
-
- const body = (await response.json()) as ProjectFoldersResponse;
-
- if (!response.ok) {
- throw new Error(body.message || "Failed to create project folder.");
- }
-
- setPersistedFolders(readPersistedFolders(body));
- setPendingFolderDraft(null);
- setPendingFolderName("");
- } catch (error) {
- console.error(error);
- }
- };
-
- const deletePersistedFolder = async (folderId: string): Promise => {
- const projectId = selectedProject().id;
- if (!folderId || !isUuidString(projectId)) {
- return;
- }
-
- const folderPath = resolveFolderPath(folderId);
- if (!folderPath) {
- return;
- }
-
- try {
- const response = await fetch(
- `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`,
- {
- 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 movePersistedFolder = async (
- folderPath: string,
- parentFolderPath: string | null,
- folderStableId: string,
- parentStableId: string | null,
- targetIndex: number,
- ): Promise => {
- const projectId = selectedProject().id;
- if (!folderPath || !folderStableId || !isUuidString(projectId)) {
- return;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- folderId: folderPath,
- folderNodeId: folderStableId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentStableId,
- targetIndex,
- }),
- });
-
- const body = (await response.json()) as ProjectFoldersResponse;
-
- if (!response.ok) {
- throw new Error(body.message || "Failed to move project folder.");
- }
-
- setPersistedFolders(readPersistedFolders(body));
- } catch (error) {
- console.error(error);
- }
- };
-
- 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;
- }
-
- const folderPath = resolveFolderPath(draft.folderId);
- if (!folderPath) {
- 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: folderPath,
- 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("");
- } 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":
- 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 "delete-folder":
- if (target.kind === "folder") {
- 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;
- }
- };
-
- const handleSurfaceContextMenu = (event: MouseEvent): void => {
- event.stopPropagation();
- contextMenu.openMenu(event, createProjectSurfaceTarget("Projects"));
- };
-
- const openRootCreateMenu = (element: HTMLElement): void => {
- contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects"));
- };
-
- const treeControlLabel = (): string =>
- areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
-
- const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
- if (event.button !== 0 || pendingFolderDraft()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- clearLongPressTimer();
- longPressTimer = window.setTimeout(() => {
- suppressTreeClickTemporarily();
- setDragState({ draggedNodeId: nodeId, dropTarget: null });
- }, LONG_PRESS_MS);
- };
-
- const handleNodePointerMove = (
- event: PointerEvent,
- parentId: string | null,
- index: number,
- node: ProjectTreeNode,
- ): void => {
- const nextDragState = dragState();
-
- if (!nextDragState) {
- return;
- }
-
- if (nextDragState.draggedNodeId === getProjectTreeNodeId(node)) {
- return;
- }
-
- const relativeY = getPointerRelativeY(event);
- if (relativeY === null) {
- return;
- }
-
- setDragState({
- ...nextDragState,
- dropTarget: resolveTreeDropTarget({
- parentId,
- index,
- node,
- relativeY,
- adapter: projectTreeAdapter,
- }),
- });
- };
-
- return (
-
-
-
-
- <>
-
-
-
-
-
-
-
{
- event.stopPropagation();
- contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
- }}
- onOpenProjectMenu={(event, item): void => {
- event.stopPropagation();
- contextMenu.openMenu(event, createProjectTarget(item));
- }}
- onNodePointerDown={handleNodePointerDown}
- onNodePointerMove={handleNodePointerMove}
- pendingFolderDraft={pendingFolderDraft()}
- pendingFolderName={pendingFolderName()}
- onPendingFolderNameChange={setPendingFolderName}
- onSubmitPendingFolder={submitPendingFolder}
- onCancelPendingFolder={cancelPendingFolder}
- pendingFolderRename={pendingFolderRename()}
- pendingFolderRenameName={pendingFolderRenameName()}
- onPendingFolderRenameChange={setPendingFolderRenameName}
- onSubmitPendingFolderRename={submitPendingFolderRename}
- onCancelPendingFolderRename={cancelPendingFolderRename}
- dragState={dragState()}
- isTreeClickSuppressed={suppressNextTreeClick}
- />
-
-
- >
-
-
-
{
- const state = contextMenu.menuState();
- return state ? { x: state.x, y: state.y } : null;
- })()}
- menuRef={(element) => {
- contextMenuRef = element;
- contextMenu.setMenuRef(element);
- }}
- onClose={contextMenu.closeMenu}
- onSelect={handleContextActionSelect}
- />
-
-
- {(node): JSX.Element => {
- const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid;
-
- return (
-
-
-
-
-
-
{node.kind === "folder" ? node.label : node.item.name}
-
{draggedNodeMeta()}
-
-
-
-
- );
- }}
-
-
- );
-};
diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
deleted file mode 100644
index 3e92f5b..0000000
--- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
+++ /dev/null
@@ -1,1017 +0,0 @@
-// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
-
-import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronLeft, ChevronRight, Folder, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
-import {
- collectBranchNodeIds,
- findTreeNodeDepth,
- findTreeNodeLocation,
- getPointerRelativeY,
- isUuidString,
- moveTreeNode,
- resolveTreeDropTarget,
- type NavTreeAdapter,
- type NavTreeDropTarget,
-} from "../shared/navTreeDnd";
-import {
- createWorkspaceStaticTarget,
- createWorkspaceSurfaceTarget,
- createWorkspaceTreeTarget,
- getWorkspaceNodeIcon,
- getWorkspaceItemTypeDefinition,
- workspaceSidebarHeaderActions,
- workspaceStaticItems,
- type WorkspaceContextMenuAction,
- type WorkspaceContextMenuTarget,
- type WorkspaceItemTypeId,
- type WorkspaceStaticItem,
- type WorkspaceTreeNode,
-} from "../data/shell.data";
-import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
-import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
-import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
-import styles from "./WorkspaceSidebar.module.scss";
-
-type WorkspaceSidebarProps = {
- collapsed: boolean;
- railCollapsed: boolean;
- onToggleRailCollapse: () => void;
-};
-
-type PendingWorkspaceFolderDraft = {
- parentId: string | null;
- depth: number;
-};
-
-type WorkspaceDragTarget = NavTreeDropTarget;
-
-type WorkspaceDragState = {
- draggedNodeId: string;
- dropTarget: WorkspaceDragTarget | null;
-};
-
-type DragGhostPosition = {
- x: number;
- y: number;
-};
-
-type PendingWorkspaceFolderRename = {
- folderId: string;
- depth: number;
-};
-
-const LONG_PRESS_MS = 320;
-
-const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
-
-const countWorkspaceFolderSiblingsBeforeIndex = (
- siblings: readonly WorkspaceTreeNode[],
- index: number,
-): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
-
-const workspaceTreeAdapter: NavTreeAdapter = {
- getNodeId: getWorkspaceTreeNodeId,
- isBranchNode: (node) => node.kind === "folder",
- getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
- withChildren: (node, children) =>
- node.kind === "folder"
- ? {
- ...node,
- children: [...children],
- }
- : node,
-};
-
-const FolderDraftRow = (props: {
- depth: number;
- value: string;
- onInput: (value: string) => void;
- onSubmit: () => void;
- onCancel: () => void;
-}): JSX.Element => {
- let inputRef: HTMLInputElement | undefined;
-
- queueMicrotask(() => inputRef?.focus());
-
- return (
-
-
-
- props.onInput(event.currentTarget.value)}
- onBlur={props.onSubmit}
- onKeyDown={(event): void => {
- if (event.key === "Enter") {
- event.preventDefault();
- event.currentTarget.blur();
- return;
- }
-
- if (event.key === "Escape") {
- event.preventDefault();
- props.onCancel();
- event.currentTarget.blur();
- }
- }}
- />
-
-
- );
-};
-
-const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
-
-const WorkspaceHomeEntry = (props: {
- item: WorkspaceStaticItem;
- onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
- onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
-}): JSX.Element => {
- const Icon = props.item.icon;
- const target = createWorkspaceStaticTarget(props.item);
-
- return (
-
-
-
- );
-};
-
-const WorkspaceTreeBranch = (props: {
- nodes: readonly WorkspaceTreeNode[];
- parentId?: string | null;
- depth?: number;
- isFolderCollapsed: (folderId: string) => boolean;
- onToggleFolder: (folderId: string) => void;
- pendingFolderDraft: PendingWorkspaceFolderDraft | null;
- pendingFolderName: string;
- 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;
- 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 (
-
-
- -
-
-
-
-
- {(node, indexAccessor): JSX.Element => {
- 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) {
- return null;
- }
-
- return props.dragState.dropTarget.intent;
- };
-
- return (
- -
- {
- 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)}>
-
-
-
- );
- }}
-
-
-
-
-
-
- );
-};
-
-export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
- const appShellData = useAppShellData();
- const activeProject = () => appShellData.activeProject();
- const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
- 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 [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
- const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
- let lastSelectedProjectId: string | null = null;
- const {
- workspaceTreeNodes,
- setWorkspaceTreeNodes,
- resolveFolderPath,
- resolveItemPath,
- createFolder,
- renameFolder,
- deleteFolder,
- moveFolder,
- createItem,
- deleteItem,
- moveItem,
- } = useWorkspaceTreeData({
- activeProjectId: () => activeProject()?.id ?? "",
- fallbackWorkspaceTree: () => appShellData.workspaceTree(),
- });
- const contextMenu = createWorkspaceContextMenuController();
- let longPressTimer: number | undefined;
- let suppressClickTimer: number | undefined;
- const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
- const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject());
- const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
- const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
- const totalFolderCount = (): number => folderIds().length;
- const areAllFoldersCollapsed = (): boolean => {
- const count = totalFolderCount();
- return count > 0 && collapsedFolderIds().length >= count;
- };
- const workspaceFolderToggleLabel = (): string =>
- areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
- const toggleFolder = (folderId: string): void => {
- setCollapsedFolderIds((current) =>
- current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
- );
- };
- const expandAllFolders = (): void => {
- setCollapsedFolderIds([]);
- };
- const collapseAllFolders = (): void => {
- setCollapsedFolderIds(folderIds());
- };
- const toggleAllFolders = (): void => {
- if (areAllFoldersCollapsed()) {
- expandAllFolders();
- return;
- }
-
- collapseAllFolders();
- };
- const resetWorkspaceTreeInteractionState = (): void => {
- setCollapsedFolderIds([]);
- setPendingFolderDraft(null);
- setPendingFolderName("");
- setPendingFolderRename(null);
- setPendingFolderRenameName("");
- setDragState(null);
- };
- const syncCollapsedFolderIds = (): void => {
- const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter));
- setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
- };
- const updateDragGhostPosition = (x: number, y: number): void => {
- setDragGhostPosition({ x: x + 18, y: y + 18 });
- };
- const draggedNode = (): WorkspaceTreeNode | null => {
- const currentDragState = dragState();
- if (!currentDragState) {
- return null;
- }
-
- return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null;
- };
- const draggedNodeMeta = (): string => {
- const node = draggedNode();
- if (!node) {
- return "";
- }
-
- if (node.kind === "folder") {
- return "Folder";
- }
-
- return getWorkspaceItemTypeDefinition(node.itemType).label;
- };
- const clearLongPressTimer = (): void => {
- if (longPressTimer !== undefined) {
- window.clearTimeout(longPressTimer);
- longPressTimer = undefined;
- }
- };
- const suppressTreeClickTemporarily = (): void => {
- setSuppressNextTreeClick(true);
-
- if (suppressClickTimer !== undefined) {
- window.clearTimeout(suppressClickTimer);
- }
-
- suppressClickTimer = window.setTimeout(() => {
- setSuppressNextTreeClick(false);
- suppressClickTimer = undefined;
- }, 80);
- };
-
- createEffect(() => {
- syncCollapsedFolderIds();
- });
-
- createEffect(() => {
- const projectId = activeProject()?.id ?? null;
-
- if (lastSelectedProjectId === null) {
- lastSelectedProjectId = projectId;
- return;
- }
-
- if (projectId === lastSelectedProjectId) {
- return;
- }
-
- lastSelectedProjectId = projectId;
- resetWorkspaceTreeInteractionState();
- });
-
- onMount(() => {
- const handlePointerMove = (event: PointerEvent): void => {
- if (!dragState()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- };
-
- const handlePointerUp = (): void => {
- clearLongPressTimer();
-
- const nextDragState = dragState();
-
- if (!nextDragState?.dropTarget) {
- if (nextDragState) {
- suppressTreeClickTemporarily();
- }
- setDragState(null);
- return;
- }
-
- suppressTreeClickTemporarily();
-
- const currentNodes = workspaceTreeNodes();
- const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
- const canPersistMove = isUuidString(activeProject()?.id ?? "");
- const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
- const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
- const persistedParentLocation = previewLocation?.parentId
- ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
- : null;
- const previewSiblings = previewLocation?.parentId
- ? persistedParentLocation?.node.kind === "folder"
- ? persistedParentLocation.node.children ?? []
- : []
- : previewNodes;
- // The tree preview can include static/workspace items, but persisted ordering
- // only applies to folder siblings. Convert the preview position into a folder-
- // only index before sending it to the backend move endpoint.
- const targetIndex = previewLocation
- ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
- : 0;
-
- if (
- canPersistMove &&
- draggedLocation?.node.kind === "folder" &&
- (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
- ) {
- void moveFolder(
- draggedLocation.node.id,
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
- targetIndex,
- );
- } else if (
- canPersistMove &&
- draggedLocation?.node.kind === "item" &&
- resolveItemPath(draggedLocation.node.id) &&
- (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
- ) {
- void moveItem(
- draggedLocation.node.id,
- persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
- previewLocation?.index ?? 0,
- );
- } else {
- setWorkspaceTreeNodes((current) =>
- moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
- );
- }
-
- setDragState(null);
- };
-
- const handleEscape = (event: KeyboardEvent): void => {
- if (event.key !== "Escape") {
- return;
- }
-
- clearLongPressTimer();
-
- if (dragState()) {
- setDragState(null);
- }
- };
-
- window.addEventListener("pointerup", handlePointerUp);
- window.addEventListener("pointercancel", handlePointerUp);
- window.addEventListener("pointermove", handlePointerMove);
- window.addEventListener("keydown", handleEscape);
-
- onCleanup(() => {
- clearLongPressTimer();
- if (suppressClickTimer !== undefined) {
- window.clearTimeout(suppressClickTimer);
- }
- window.removeEventListener("pointerup", handlePointerUp);
- window.removeEventListener("pointercancel", handlePointerUp);
- window.removeEventListener("pointermove", handlePointerMove);
- window.removeEventListener("keydown", handleEscape);
- });
- });
-
- const beginFolderDraft = (parentId: string | null, depth: number): void => {
- if (parentId) {
- 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();
- const projectId = activeProject()?.id ?? "";
-
- if (!draft) {
- return;
- }
-
- if (!name) {
- setPendingFolderDraft(null);
- setPendingFolderName("");
- return;
- }
-
- if (!projectId || !isUuidString(projectId)) {
- cancelPendingFolder();
- return;
- }
-
- const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
- if (draft.parentId && !parentFolderPath) {
- cancelPendingFolder();
- return;
- }
-
- const created = await createFolder(name, draft.parentId);
- if (created) {
- setPendingFolderDraft(null);
- setPendingFolderName("");
- }
- };
-
- const deletePersistedFolder = async (folderId: string): Promise => {
- const deleted = await deleteFolder(folderId);
- if (deleted) {
- setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
- }
- };
-
- 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;
- }
-
- const folderPath = resolveFolderPath(draft.folderId);
- if (!folderPath) {
- cancelPendingFolderRename();
- return;
- }
-
- const renamed = await renameFolder(draft.folderId, name);
- if (renamed) {
- setPendingFolderRename(null);
- setPendingFolderRenameName("");
- }
- };
-
- const cancelPendingFolder = (): void => {
- setPendingFolderDraft(null);
- setPendingFolderName("");
- };
-
- const cancelPendingFolderRename = (): void => {
- setPendingFolderRename(null);
- setPendingFolderRenameName("");
- };
-
- const handleHeaderActionClick = (actionId: string): void => {
- switch (actionId) {
- case "toggle-workspace-folders":
- toggleAllFolders();
- return;
- case "search-workspace":
- default:
- return;
- }
- };
-
- const openWorkspaceCreateMenu = (element: HTMLElement): void => {
- contextMenu.openMenuFromElement(element, sidebarContextMenuTarget);
- };
-
- const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
- switch (actionId) {
- case "create-doc":
- return "core.doc";
- case "create-board":
- return "core.board.kanban";
- case "create-list-board":
- return "core.board.list";
- default:
- return null;
- }
- };
-
- const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => {
- const definition = getWorkspaceItemTypeDefinition(itemType);
- await createItem(definition.defaultCreateLabel, itemType, parentId);
- };
-
- const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
- if (event.button !== 0 || pendingFolderDraft()) {
- return;
- }
-
- updateDragGhostPosition(event.clientX, event.clientY);
- clearLongPressTimer();
- longPressTimer = window.setTimeout(() => {
- suppressTreeClickTemporarily();
- setDragState({ draggedNodeId: nodeId, dropTarget: null });
- }, LONG_PRESS_MS);
- };
-
- const handleNodePointerMove = (
- event: PointerEvent,
- parentId: string | null,
- index: number,
- node: WorkspaceTreeNode,
- ): void => {
- const nextDragState = dragState();
-
- if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) {
- return;
- }
-
- const relativeY = getPointerRelativeY(event);
- if (relativeY === null) {
- return;
- }
-
- setDragState({
- ...nextDragState,
- dropTarget: resolveTreeDropTarget({
- parentId,
- index,
- node,
- relativeY,
- adapter: workspaceTreeAdapter,
- }),
- });
- };
-
- const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
- const createItemType = resolveCreateItemType(action.id);
- if (createItemType) {
- switch (target.kind) {
- case "workspace":
- case "home":
- void createPersistedItem(createItemType, null);
- return;
- case "folder":
- void createPersistedItem(createItemType, target.id);
- return;
- case "settings":
- case "item":
- return;
- }
- }
-
- 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 "delete-folder":
- if (target.kind === "folder") {
- void deletePersistedFolder(target.id);
- }
- return;
- case "rename-folder":
- if (target.kind === "folder") {
- beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0);
- }
- return;
- case "delete-doc":
- case "delete-board":
- case "delete-list-board":
- if (target.kind === "item") {
- void deleteItem(target.id);
- }
- return;
- default:
- return;
- }
- };
-
- return (
- <>
-
-
- {
- const state = contextMenu.menuState();
- return state
- ? {
- x: state.x,
- y: state.y,
- }
- : null;
- })()}
- menuRef={contextMenu.setMenuRef}
- onClose={contextMenu.closeMenu}
- onSelect={handleContextActionSelect}
- />
-
-
- {(node): JSX.Element => {
- const GhostIcon = getWorkspaceNodeIcon(node);
-
- return (
-
-
-
-
-
-
{node.label}
-
{draggedNodeMeta()}
-
-
-
-
- );
- }}
-
- >
- );
-};
diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx
deleted file mode 100644
index 66198e7..0000000
--- a/Frontend/src/components/shell/data/app-shell.context.tsx
+++ /dev/null
@@ -1,375 +0,0 @@
-// Path: Frontend/src/components/shell/data/app-shell.context.tsx
-
-import {
- createContext,
- createMemo,
- createSignal,
- onMount,
- useContext,
- type Accessor,
- type JSX,
-} from "solid-js";
-import { Folder } from "../../../lib/icons";
-import { resolveAPIBase } from "../../../lib/api";
-import {
- activeDepartment as fallbackActiveDepartment,
- activeProject as fallbackActiveProject,
- activeServer as fallbackActiveServer,
- activeUserProfile as fallbackActiveUserProfile,
- departmentItems as fallbackDepartmentItems,
- organizationAdminDockActions,
- personalDockActions,
- projectItems as fallbackProjectItems,
- railItems as fallbackRailItems,
- workspaceTree as fallbackWorkspaceTree,
- type ActiveDepartment,
- type ActiveProject,
- type ActiveServer,
- type ActiveUserProfile,
- type DepartmentItem,
- type ProjectItem,
- type RailItem,
- type WorkspaceTreeNode,
-} from "./shell.data";
-
-type AppShellInstallation = {
- id: string;
- name: string;
- mode: "personal" | "organizational" | string;
- access: string;
- protocol: string;
- host: string;
- isBootstrapped: boolean;
- materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
- materializationError?: string;
-};
-
-type AppShellAdmin = {
- id: string;
- email: string;
- displayName: string;
- isInstanceAdmin: boolean;
- homeTitle: string;
-};
-
-type AppShellOrganization = {
- id: string;
- name: string;
- slug: string;
-};
-
-type AppShellDepartment = {
- id: string;
- organizationId: string;
- name: string;
- slug: string;
-};
-
-type AppShellTeam = {
- id: string;
- organizationId: string;
- departmentId?: string;
- name: string;
- slug: string;
-};
-
-type AppShellProject = {
- id: string;
- organizationId: string;
- departmentId?: string;
- teamId?: string;
- name: string;
- slug: string;
-};
-
-type AppShellWorkspace = {
- id: string;
- organizationId: string;
- name: string;
- slug: string;
- kind: "organization" | "department" | "team" | "project" | string;
- departmentId?: string;
- teamId?: string;
- projectId?: string;
-};
-
-type AppShellPayload = {
- installation?: AppShellInstallation;
- admin?: AppShellAdmin;
- organizations: AppShellOrganization[];
- departments: AppShellDepartment[];
- teams: AppShellTeam[];
- projects: AppShellProject[];
- workspaces: AppShellWorkspace[];
-};
-
-const normalizeInstallation = (
- installation: AppShellInstallation | null | undefined,
-): AppShellInstallation | undefined => {
- if (!installation) {
- return undefined;
- }
-
- const materializationStatus = installation.materializationStatus?.trim()
- ? installation.materializationStatus
- : installation.isBootstrapped
- ? "succeeded"
- : "not_started";
- const materializationError = installation.materializationError?.trim() || undefined;
-
- return {
- ...installation,
- materializationStatus,
- materializationError,
- };
-};
-
-const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
- installation: normalizeInstallation(payload?.installation),
- admin: payload?.admin,
- organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
- departments: Array.isArray(payload?.departments) ? payload.departments : [],
- teams: Array.isArray(payload?.teams) ? payload.teams : [],
- projects: Array.isArray(payload?.projects) ? payload.projects : [],
- workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
-});
-
-type AppShellContextValue = {
- status: Accessor<"idle" | "loading" | "success" | "error">;
- error: Accessor;
- installation: Accessor;
- railItems: Accessor;
- activeServer: Accessor;
- activeProject: Accessor;
- activeDepartment: Accessor;
- projectItems: Accessor;
- departmentItems: Accessor;
- workspaceTree: Accessor;
- activeUserProfile: Accessor;
- reload: () => Promise;
-};
-
-const AppShellContext = createContext();
-
-const buildAbbreviation = (name: string, fallback: string): string => {
- const parts = name
- .trim()
- .split(/\s+/)
- .filter(Boolean);
-
- if (parts.length === 0) {
- return fallback;
- }
-
- const abbreviation = parts
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase() ?? "")
- .join("");
-
- return abbreviation || fallback;
-};
-
-const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
- if (!payload?.installation || payload.organizations.length === 0) {
- return fallbackRailItems;
- }
-
- const kind = payload.installation.mode === "personal" ? "personal" : "organization";
- const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
-
- return payload.organizations.map((organization, index) => ({
- id: organization.id,
- label: serverName || organization.name,
- abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
- kind,
- active: index === 0,
- }));
-};
-
-const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
- const installation = payload?.installation;
- const organization = payload?.organizations[0];
-
- if (!installation || !organization) {
- return fallbackActiveServer;
- }
-
- const kind = installation.mode === "personal" ? "personal" : "organization";
- const serverName = installation.name || organization.name || installation.host;
-
- return {
- id: installation.id,
- name: serverName || fallbackActiveServer.name,
- abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
- kind,
- connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
- subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
- dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
- };
-};
-
-const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
- if (!payload?.projects.length) {
- return fallbackProjectItems;
- }
-
- return payload.projects.map((project, index) => ({
- id: project.id,
- name: project.name,
- description: project.slug || "Persisted project workspace",
- groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
- parentLabel:
- payload.teams.find((team) => team.id === project.teamId)?.name ||
- payload.departments.find((department) => department.id === project.departmentId)?.name ||
- "Shared project",
- meta: (() => {
- const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
-
- return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
- })(),
- active: index === 0,
- }));
-};
-
-const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
- const firstProject = payload?.projects[0];
-
- if (!firstProject) {
- return fallbackActiveProject;
- }
-
- return {
- id: firstProject.id,
- name: firstProject.name,
- };
-};
-
-const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
- if (!payload?.departments.length) {
- return fallbackDepartmentItems;
- }
-
- return payload.departments.map((department, index) => ({
- id: department.id,
- name: department.name,
- teams: payload.teams
- .filter((team) => team.departmentId === department.id)
- .map((team) => team.name),
- active: index === 0,
- }));
-};
-
-const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
- const firstDepartment = payload?.departments[0];
-
- if (!firstDepartment) {
- return fallbackActiveDepartment;
- }
-
- const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
-
- return {
- id: firstDepartment.id,
- name: firstDepartment.name,
- teamName: firstTeamName,
- };
-};
-
-const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
- if (!payload?.projects.length) {
- return fallbackWorkspaceTree;
- }
-
- // The workspace tree should represent items inside the current project, not the
- // project container itself. We do not have project-contents hydration yet, so
- // return an empty tree rather than showing the project root as a fake item.
- return [];
-};
-
-const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
- if (!payload?.admin) {
- return fallbackActiveUserProfile;
- }
-
- const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
- const departmentName = payload.departments[0]?.name;
-
- return {
- name: payload.admin.displayName,
- email: payload.admin.email,
- roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
- contextLabel: departmentName ? `${organizationName} • ${departmentName}` : organizationName,
- };
-};
-
-export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
- const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
- const [error, setError] = createSignal("");
- const [payload, setPayload] = createSignal(null);
-
- const load = async (): Promise => {
- setStatus("loading");
- setError("");
-
- try {
- const response = await fetch(`${resolveAPIBase()}/app-shell`, {
- headers: {
- Accept: "application/json",
- },
- });
-
- const body = (await response.json()) as {
- data?: AppShellPayload;
- error?: { message?: string } | string;
- message?: string;
- };
- const errorMessage =
- typeof body.message === "string"
- ? body.message
- : typeof body.error === "string"
- ? body.error
- : body.error?.message;
-
- if (!response.ok || !body.data) {
- throw new Error(errorMessage || "Failed to load app shell state.");
- }
-
- setPayload(normalizeAppShellPayload(body.data));
- setStatus("success");
- } catch (loadError) {
- setStatus("error");
- setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
- }
- };
-
- onMount(() => {
- void load();
- });
-
- const value: AppShellContextValue = {
- status,
- error,
- installation: createMemo(() => payload()?.installation),
- railItems: createMemo(() => buildRailItems(payload())),
- activeServer: createMemo(() => buildActiveServer(payload())),
- activeProject: createMemo(() => buildActiveProject(payload())),
- activeDepartment: createMemo(() => buildActiveDepartment(payload())),
- projectItems: createMemo(() => buildProjectItems(payload())),
- departmentItems: createMemo(() => buildDepartmentItems(payload())),
- workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
- activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
- reload: load,
- };
-
- return {props.children};
-};
-
-export const useAppShellData = (): AppShellContextValue => {
- const context = useContext(AppShellContext);
-
- if (!context) {
- throw new Error("useAppShellData must be used within AppShellDataProvider");
- }
-
- return context;
-};
diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts
deleted file mode 100644
index 0479831..0000000
--- a/Frontend/src/components/shell/data/shell.data.ts
+++ /dev/null
@@ -1,734 +0,0 @@
-// Path: Frontend/src/components/shell/data/shell.data.ts
-
-import type { Component } from "solid-js";
-import {
- Bell,
- CircleHelp,
- FileText,
- Folder,
- Home,
- Keyboard,
- LayoutGrid,
- ListCollapse,
- LogOut,
- Repeat,
- Search,
- Settings,
- Shield,
- User,
-} from "../../../lib/icons";
-
-type ShellIconProps = {
- class?: string;
- size?: number;
- strokeWidth?: number;
-};
-
-export type ShellIcon = Component;
-
-export type RailItem = {
- id: string;
- label: string;
- abbreviation: string;
- kind: "personal" | "organization";
- active?: boolean;
-};
-
-export type ServerDockAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type ActiveServer = {
- id: string;
- name: string;
- abbreviation: string;
- kind: "personal" | "organization";
- connectedLabel?: string;
- subtitle?: string;
- dockActions: readonly ServerDockAction[];
-};
-
-export type ActiveProject = {
- id: string;
- name: string;
-};
-
-export type ActiveDepartment = {
- id: string;
- name: string;
- teamName: string;
-};
-
-export type DepartmentItem = {
- id: string;
- name: string;
- teams: readonly string[];
- active?: boolean;
-};
-
-export type ProjectItem = {
- id: string;
- name: string;
- description: string;
- groupLabel?: string;
- parentLabel?: string;
- meta?: string;
- active?: boolean;
-};
-
-export type ProjectMenuTarget =
- | {
- id: string;
- label: string;
- kind: "surface";
- }
- | {
- id: string;
- label: string;
- kind: "folder";
- }
- | {
- id: string;
- label: string;
- kind: "project";
- };
-
-export type ProjectContextMenuAction = {
- id: string;
- label: string;
- tone?: "default" | "danger";
- shortcut?: WorkspaceContextMenuShortcut;
- children?: readonly ProjectContextMenuAction[];
-};
-
-export type ProjectContextMenuSection = {
- id: string;
- label?: string;
- items: readonly ProjectContextMenuAction[];
-};
-
-export type SidebarItem = {
- id: string;
- label: string;
- icon: ShellIcon;
- active?: boolean;
- meta?: string;
-};
-
-export type WorkspaceStaticKind = "workspace" | "home" | "settings";
-
-// Keep this open-ended so future server-driven or plugin-provided item types do
-// not require a frontend source edit before they can be represented safely.
-export type WorkspaceItemTypeId = string;
-
-export type WorkspaceStaticItem = SidebarItem & {
- contextKind: WorkspaceStaticKind;
-};
-
-export type WorkspaceFolderNode = {
- id: string;
- path?: string;
- label: string;
- kind: "folder";
- icon: ShellIcon;
- active?: boolean;
- meta?: string;
- children?: readonly WorkspaceTreeNode[];
-};
-
-export type WorkspaceItemNode = {
- id: string;
- path?: string;
- label: string;
- kind: "item";
- itemType: WorkspaceItemTypeId;
- active?: boolean;
- meta?: string;
- children?: undefined;
-};
-
-export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
-
-export type WorkspaceItemTypeDefinition = {
- id: WorkspaceItemTypeId;
- label: string;
- shortLabel: string;
- icon: ShellIcon;
- noun: string;
- actionPrefix: string;
- defaultCreateLabel: string;
- includeInWorkspaceCreate?: boolean;
- description?: string;
-};
-
-export type SidebarHeaderAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type TopBarAction = {
- id: string;
- label: string;
- icon: ShellIcon;
-};
-
-export type MobileBottomNavItem = {
- id: string;
- label: string;
- icon: ShellIcon;
- active?: boolean;
-};
-
-export type WorkspaceContextMenuTarget =
- | {
- id: string;
- label: string;
- kind: WorkspaceStaticKind;
- }
- | {
- id: string;
- label: string;
- kind: "folder";
- }
- | {
- id: string;
- label: string;
- kind: "item";
- itemType: WorkspaceItemTypeId;
- };
-
-export type WorkspaceContextMenuAction = {
- id: string;
- label: string;
- tone?: "default" | "danger";
- shortcut?: WorkspaceContextMenuShortcut;
- children?: readonly WorkspaceContextMenuAction[];
-};
-
-export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
-
-export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
-
-export type WorkspaceContextMenuShortcut = {
- modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
- key: WorkspaceContextMenuShortcutKey;
-};
-
-export type WorkspaceContextMenuSection = {
- id: string;
- label?: string;
- items: readonly WorkspaceContextMenuAction[];
-};
-
-export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
- {
- id: "core.doc",
- label: "Doc",
- shortLabel: "Doc",
- icon: FileText,
- noun: "doc",
- actionPrefix: "doc",
- defaultCreateLabel: "New doc",
- includeInWorkspaceCreate: true,
- description: "Rich text documents and notes.",
- },
- {
- id: "core.board.kanban",
- label: "Kanban board",
- shortLabel: "Board",
- icon: LayoutGrid,
- noun: "board",
- actionPrefix: "board",
- defaultCreateLabel: "New board",
- includeInWorkspaceCreate: true,
- description: "Default board-style workspace item.",
- },
- {
- id: "core.board.list",
- label: "List board",
- shortLabel: "Board",
- icon: LayoutGrid,
- noun: "board",
- actionPrefix: "list-board",
- defaultCreateLabel: "New list board",
- description: "Alternate first-party board view prepared for the future registry.",
- },
-] as const;
-
-const workspaceItemTypeMap = new Map(
- firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
-);
-
-const createUnknownWorkspaceItemTypeDefinition = (
- itemType: WorkspaceItemTypeId,
-): WorkspaceItemTypeDefinition => ({
- id: itemType,
- label: "Item",
- shortLabel: "Item",
- icon: FileText,
- noun: "item",
- actionPrefix: "item",
- defaultCreateLabel: "New item",
- description: "Fallback definition for unknown or future workspace item types.",
-});
-
-export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
- return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
-};
-
-export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
- node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
-
-const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
- { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
- ...firstPartyWorkspaceItemTypes
- .filter((definition) => definition.includeInWorkspaceCreate)
- .map((definition) => ({
- id: `create-${definition.actionPrefix}`,
- label: definition.defaultCreateLabel,
- shortcut:
- definition.id === "core.board.kanban"
- ? ({ modifiers: ["alt"], key: "b" } as const)
- : definition.id === "core.doc"
- ? ({ modifiers: ["alt"], key: "d" } as const)
- : undefined,
- })),
-];
-
-export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
- switch (target.kind) {
- case "workspace":
- case "home":
- return "Workspace";
- case "settings":
- return "Configuration";
- case "folder":
- return "Folder";
- case "item":
- return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
- }
-};
-
-export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
- id: `workspace-${workspace.id}`,
- label: workspace.name,
- kind: "workspace",
-});
-
-export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
- id: item.id,
- label: item.label,
- kind: item.contextKind,
-});
-
-export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
- id: node.id,
- label: node.label,
- ...(node.kind === "folder"
- ? { kind: "folder" as const }
- : {
- kind: "item" as const,
- itemType: node.itemType,
- }),
-});
-
-export type NotificationItem = {
- id: string;
- title: string;
- contextLabel: string;
- timeLabel: string;
- unread?: boolean;
-};
-
-export type ProfileMenuAction = {
- id: string;
- label: string;
- icon: ShellIcon;
- tone?: "default" | "danger";
-};
-
-export type ProfileMenuSection = {
- id: string;
- items: readonly ProfileMenuAction[];
-};
-
-export type ActiveUserProfile = {
- name: string;
- email: string;
- roleLabel: string;
- contextLabel: string;
-};
-
-export const personalDockActions: readonly ServerDockAction[] = [
- { id: "account", label: "Account", icon: User },
- { id: "settings", label: "Settings", icon: Settings },
-] as const;
-
-export const organizationAdminDockActions: readonly ServerDockAction[] = [
- { id: "members", label: "Members", icon: User },
- { id: "server", label: "Server", icon: Settings },
-] as const;
-
-// Server shell scaffold data
-export const railItems: readonly RailItem[] = [
- { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
- { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
- { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
-] as const;
-
-export const activeServer: ActiveServer = {
- id: "organization-server",
- name: "Organization Name",
- abbreviation: "O",
- kind: "organization",
- connectedLabel: "12 connected",
- dockActions: organizationAdminDockActions,
-};
-
-// Workspace framing scaffold data
-export const activeProject: ActiveProject = {
- id: "general",
- name: "General",
-};
-
-export const activeDepartment: ActiveDepartment = {
- id: "product",
- name: "Product",
- teamName: "Design Systems",
-};
-
-export const projectItems: readonly ProjectItem[] = [
- {
- id: "general",
- name: "General",
- description: "Default shared project",
- groupLabel: "Shared space",
- parentLabel: "Workspace home",
- meta: "1 workspace",
- active: true,
- },
- {
- id: "operations",
- name: "Operations",
- description: "Cross-team planning and delivery",
- groupLabel: "Team folders",
- parentLabel: "Shared Services",
- meta: "2 workspaces",
- },
- {
- id: "hiring",
- name: "Hiring",
- description: "Candidate pipeline and interview loops",
- groupLabel: "Team folders",
- parentLabel: "People Ops",
- meta: "1 workspace",
- },
-] as const;
-
-export const departmentItems: readonly DepartmentItem[] = [
- { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
- { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
- { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
-] as const;
-
-// Sidebar and topbar scaffold data
-// These static entries stay pinned in both desktop and mobile workspace navigation.
-export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
- { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
- { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
-] as const;
-
-// Freeform workspace tree scaffold: folders are structural, while non-folder
-// nodes already flow through the future-safe itemType registry seam.
-export const workspaceTree: readonly WorkspaceTreeNode[] = [
- {
- id: "product-workspace",
- label: "Product",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
- { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
- {
- id: "research-folder",
- label: "Research",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
- { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
- ],
- },
- ],
- },
- {
- id: "design-folder",
- label: "Design",
- kind: "folder",
- icon: Folder,
- children: [
- { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
- { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
- ],
- },
- { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
-] as const;
-
-export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
- { id: "search-workspace", label: "Search workspace", icon: Search },
- { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
-] as const;
-
-export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
- { id: "home", label: "Home", icon: Home, active: true },
- { id: "search", label: "Search", icon: Search },
- { id: "browse", label: "Browse", icon: Folder },
-] as const;
-
-// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
-// target kinds and action grouping should stay shared across workspace surfaces.
-export const getWorkspaceContextMenuSections = (
- target: WorkspaceContextMenuTarget,
-): readonly WorkspaceContextMenuSection[] => {
- const createActions = getWorkspaceCreateActions();
-
- const createSubmenuAction = {
- id: "create",
- label: "Create",
- children: createActions,
- } as const;
-
- switch (target.kind) {
- case "workspace":
- return [
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "workspace",
- label: undefined,
- items: [
- { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
- { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
- ],
- },
- ] as const;
- case "home":
- return [
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "workspace",
- label: undefined,
- items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
- },
- ] as const;
- case "settings":
- return [
- {
- id: "settings",
- label: undefined,
- items: [
- { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
- { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
- ],
- },
- ] as const;
- case "folder":
- return [
- {
- id: "open",
- items: [
- { id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
- { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
- ],
- },
- {
- id: "create",
- label: undefined,
- items: [createSubmenuAction],
- },
- {
- id: "organize",
- label: undefined,
- items: [
- { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
- { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
- ],
- },
- ] as const;
- case "item": {
- const definition = getWorkspaceItemTypeDefinition(target.itemType);
- const actionPrefix = definition.actionPrefix;
- const nounLabel = definition.noun;
-
- return [
- {
- id: `${actionPrefix}-primary`,
- items: [
- { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
- { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
- ],
- },
- {
- id: "organize",
- label: undefined,
- items: [
- { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
- { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
- ],
- },
- ] as const;
- }
- }
-};
-
-const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
- [
- { id: "new-project", label: "New project" },
- { id: "new-folder", label: "New folder" },
- ] as const;
-
-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;
-
-export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
- id: "project-surface",
- label,
- kind: "surface",
-});
-
-export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
- id,
- label,
- kind: "folder",
-});
-
-export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
- id: project.id,
- label: project.name,
- kind: "project",
-});
-
-export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
- switch (target.kind) {
- case "surface":
- return "Projects";
- case "folder":
- return "Folder";
- case "project":
- return "Project";
- }
-};
-
-export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
- const createActions = getProjectCreateActions();
-
- switch (target.kind) {
- case "surface":
- return [
- {
- id: "create",
- items: createActions,
- },
- ] as const;
- case "folder":
- return [
- {
- id: "create",
- items: createActions,
- },
- {
- id: "organize",
- items: getProjectFolderDangerActions(),
- },
- ] as const;
- case "project":
- return [
- {
- id: "create",
- items: createActions,
- },
- ] as const;
- }
-};
-
-export const topBarActions: readonly TopBarAction[] = [
- { id: "search", label: "Search", icon: Search },
-] as const;
-
-export const notificationItems: readonly NotificationItem[] = [
- {
- id: "comment-design-systems",
- title: "New comment on Design Systems",
- contextLabel: "Product • Review thread updated",
- timeLabel: "2m ago",
- unread: true,
- },
- {
- id: "sprint-platform",
- title: "Sprint updated in Platform",
- contextLabel: "Engineering • Scope changed",
- timeLabel: "15m ago",
- unread: true,
- },
- {
- id: "member-joined",
- title: "New member joined Operations",
- contextLabel: "Organization Name • Access granted",
- timeLabel: "1h ago",
- },
- {
- id: "daily-summary",
- title: "Daily summary is ready",
- contextLabel: "General • 8 updates across boards",
- timeLabel: "Today, 8:00 AM",
- },
-] as const;
-
-export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
-
-export const activeUserProfile: ActiveUserProfile = {
- name: "Demo Account",
- email: "demo@moku.work",
- roleLabel: "Founder · Product",
- contextLabel: "Organization Name • Design Systems",
-};
-
-export const profileMenuSections: readonly ProfileMenuSection[] = [
- {
- id: "account",
- items: [
- { id: "profile", label: "Profile", icon: User },
- { id: "account-settings", label: "Account Settings", icon: Settings },
- { id: "notifications", label: "Notifications", icon: Bell },
- { id: "security", label: "Security", icon: Shield },
- ],
- },
- {
- id: "preferences",
- items: [
- { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
- { id: "theme-preferences", label: "Theme Preferences", icon: Settings },
- { id: "help-support", label: "Help & Support", icon: CircleHelp },
- ],
- },
- {
- id: "session",
- items: [
- { id: "switch-account", label: "Switch Account", icon: Repeat },
- { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
- ],
- },
-] as const;
diff --git a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts b/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts
deleted file mode 100644
index 20057f9..0000000
--- a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
-import { resolveAPIBase } from "../../../lib/api";
-import { Folder } from "../../../lib/icons";
-import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../data/shell.data";
-import { isUuidString } from "./navTreeDnd";
-
-type PersistedWorkspaceTreeNodeRecord = {
- id: string;
- path: string;
- label: string;
- kind: "folder" | "item";
- itemType?: string;
- children?: PersistedWorkspaceTreeNodeRecord[];
-};
-
-type WorkspaceTreeResponse = {
- data?: {
- nodes?: PersistedWorkspaceTreeNodeRecord[];
- };
- error?: string;
- message?: string;
-};
-
-type WorkspaceMutationResponse = {
- error?: string;
- message?: string;
-};
-
-const buildPersistedWorkspaceTreeNodes = (
- nodes: readonly PersistedWorkspaceTreeNodeRecord[],
-): WorkspaceTreeNode[] =>
- nodes.map((node) =>
- node.kind === "folder"
- ? {
- id: node.id,
- path: node.path,
- label: node.label,
- kind: "folder",
- icon: Folder,
- children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
- }
- : {
- id: node.id,
- path: node.path,
- label: node.label,
- kind: "item",
- itemType: node.itemType ?? "core.board.kanban",
- }
- );
-
-const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
- Array.isArray(body.data?.nodes) ? body.data.nodes : [];
-
-const findNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
- for (const node of nodes) {
- if (node.id === nodeId) {
- return node;
- }
-
- if (node.kind !== "folder") {
- continue;
- }
-
- const nestedMatch = findNodeById(node.children ?? [], nodeId);
- if (nestedMatch) {
- return nestedMatch;
- }
- }
-
- return null;
-};
-
-type UseWorkspaceTreeDataOptions = {
- activeProjectId: Accessor;
- fallbackWorkspaceTree: Accessor;
-};
-
-type UseWorkspaceTreeDataResult = {
- workspaceTreeNodes: Accessor;
- setWorkspaceTreeNodes: Setter;
- resolveFolderPath: (folderId: string) => string | null;
- resolveItemPath: (itemId: string) => string | null;
- createFolder: (name: string, parentId: string | null) => Promise;
- renameFolder: (folderId: string, name: string) => Promise;
- deleteFolder: (folderId: string) => Promise;
- moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise;
- createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise;
- deleteItem: (itemId: string) => Promise;
- moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise;
-};
-
-export const useWorkspaceTreeData = (
- options: UseWorkspaceTreeDataOptions,
-): UseWorkspaceTreeDataResult => {
- const [persistedNodes, setPersistedNodes] = createSignal([]);
- const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(
- options.fallbackWorkspaceTree(),
- );
- let latestPersistedTreeRequest = 0;
-
- const syncWorkspaceTree = (): void => {
- const nextTree = options.activeProjectId()
- ? buildPersistedWorkspaceTreeNodes(persistedNodes())
- : options.fallbackWorkspaceTree();
-
- setWorkspaceTreeNodes(nextTree);
- };
-
- const loadPersistedTree = async (projectId: string): Promise => {
- const requestId = latestPersistedTreeRequest + 1;
- latestPersistedTreeRequest = requestId;
-
- if (!projectId || !isUuidString(projectId)) {
- setPersistedNodes([]);
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree`, {
- headers: {
- Accept: "application/json",
- },
- });
- const body = (await response.json()) as WorkspaceTreeResponse;
-
- if (requestId !== latestPersistedTreeRequest) {
- return false;
- }
-
- if (!response.ok) {
- throw new Error(body.message || "Failed to load project tree.");
- }
-
- setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
- return true;
- } catch (error) {
- if (requestId !== latestPersistedTreeRequest) {
- return false;
- }
-
- console.error(error);
- setPersistedNodes([]);
- return false;
- }
- };
-
- const resolveFolderPath = (folderId: string): string | null => {
- const node = findNodeById(workspaceTreeNodes(), folderId);
- return node?.kind === "folder" ? node.path ?? null : null;
- };
-
- const resolveItemPath = (itemId: string): string | null => {
- const node = findNodeById(workspaceTreeNodes(), itemId);
- return node?.kind === "item" ? node.path ?? null : null;
- };
-
- const refreshAfterMutation = async (projectId: string, response: Response): Promise => {
- const body = (await response.json()) as WorkspaceMutationResponse;
- if (!response.ok) {
- throw new Error(body.message || "Failed to persist project tree mutation.");
- }
-
- return loadPersistedTree(projectId);
- };
-
- const createFolder = async (name: string, parentId: string | null): Promise => {
- const projectId = options.activeProjectId();
- if (!projectId || !isUuidString(projectId)) {
- return false;
- }
-
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (parentId && !parentFolderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name,
- parentFolderId: parentFolderPath,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const renameFolder = async (folderId: string, name: string): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- folderId: folderPath,
- name,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const deleteFolder = async (folderId: string): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- try {
- const response = await fetch(
- `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`,
- {
- method: "DELETE",
- headers: {
- Accept: "application/json",
- },
- },
- );
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => {
- const projectId = options.activeProjectId();
- const folderPath = resolveFolderPath(folderId);
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (!projectId || !isUuidString(projectId) || !folderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders/move`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- folderId: folderPath,
- folderNodeId: folderId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentId,
- targetIndex,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const createItem = async (
- name: string,
- itemType: WorkspaceItemTypeId,
- parentId: string | null,
- ): Promise => {
- const projectId = options.activeProjectId();
- if (!projectId || !isUuidString(projectId)) {
- return false;
- }
-
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (parentId && !parentFolderPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items`, {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name,
- itemType,
- parentFolderId: parentFolderPath,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const deleteItem = async (itemId: string): Promise => {
- const projectId = options.activeProjectId();
- const itemPath = resolveItemPath(itemId);
- if (!projectId || !isUuidString(projectId) || !itemPath) {
- return false;
- }
-
- try {
- const response = await fetch(
- `${resolveAPIBase()}/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemPath)}`,
- {
- method: "DELETE",
- headers: {
- Accept: "application/json",
- },
- },
- );
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => {
- const projectId = options.activeProjectId();
- const itemPath = resolveItemPath(itemId);
- const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
- if (!projectId || !isUuidString(projectId) || !itemPath) {
- return false;
- }
-
- try {
- const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items/move`, {
- method: "PATCH",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- itemId: itemPath,
- itemNodeId: itemId,
- parentFolderId: parentFolderPath,
- parentNodeId: parentId,
- targetIndex,
- }),
- });
-
- return refreshAfterMutation(projectId, response);
- } catch (error) {
- console.error(error);
- return false;
- }
- };
-
- createEffect(() => {
- syncWorkspaceTree();
- });
-
- createEffect(() => {
- void loadPersistedTree(options.activeProjectId());
- });
-
- return {
- workspaceTreeNodes,
- setWorkspaceTreeNodes,
- resolveFolderPath,
- resolveItemPath,
- createFolder,
- renameFolder,
- deleteFolder,
- moveFolder,
- createItem,
- deleteItem,
- moveItem,
- };
-};
diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss
similarity index 100%
rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss
rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss
diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
similarity index 96%
rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx
rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
index 43d6fd4..03edcf9 100644
--- a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx
+++ b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx
@@ -1,7 +1,7 @@
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { type DepartmentItem } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { type DepartmentItem } from "../../app-shell/data/shell.data";
import styles from "./DepartmentSelector.module.scss";
export const DepartmentSelector = (): JSX.Element => {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsButton.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
similarity index 93%
rename from Frontend/src/components/shell/TopBar/NotificationsButton.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
index 00c8b41..434cfee 100644
--- a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx
+++ b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons";
-import { unreadNotificationCount } from "../data/shell.data";
+import { unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
similarity index 98%
rename from Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
index 217864b..1003121 100644
--- a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
+++ b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx
@@ -1,6 +1,6 @@
import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons";
-import { notificationItems, unreadNotificationCount } from "../data/shell.data";
+import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = {
diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsNav.module.scss
rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss
diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx
similarity index 100%
rename from Frontend/src/components/shell/TopBar/NotificationsNav.tsx
rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx
diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.module.scss b/Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss
diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
similarity index 94%
rename from Frontend/src/components/shell/TopBar/ProfileMenu.tsx
rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
index 62914c4..3689082 100644
--- a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx
+++ b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx
@@ -1,7 +1,7 @@
import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons";
-import { useAppShellData } from "../data/app-shell.context";
-import { profileMenuSections } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.module.scss b/Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/ThemeToggle.module.scss
rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss
diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
similarity index 87%
rename from Frontend/src/components/shell/TopBar/ThemeToggle.tsx
rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
index 86403c9..bcfd830 100644
--- a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx
+++ b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
@@ -1,7 +1,7 @@
-// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx
+// Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js";
-import type { Theme } from "../../../theme/runtime";
+import type { Theme } from "../../../helper/themeRuntime";
import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss";
diff --git a/Frontend/src/components/shell/TopBar/TopBar.module.scss b/Frontend/src/components/top-bar/TopBar/TopBar.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/TopBar.module.scss
rename to Frontend/src/components/top-bar/TopBar/TopBar.module.scss
diff --git a/Frontend/src/components/shell/TopBar/TopBar.tsx b/Frontend/src/components/top-bar/TopBar/TopBar.tsx
similarity index 90%
rename from Frontend/src/components/shell/TopBar/TopBar.tsx
rename to Frontend/src/components/top-bar/TopBar/TopBar.tsx
index 194b378..34c4806 100644
--- a/Frontend/src/components/shell/TopBar/TopBar.tsx
+++ b/Frontend/src/components/top-bar/TopBar/TopBar.tsx
@@ -1,8 +1,8 @@
-// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
+// Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js";
-import type { Theme } from "../../../theme/runtime";
-import { topBarActions } from "../data/shell.data";
+import type { Theme } from "../../../helper/themeRuntime";
+import { topBarActions } from "../../app-shell/data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle";
diff --git a/Frontend/src/components/shell/TopBar/UserNav.module.scss b/Frontend/src/components/top-bar/TopBar/UserNav.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNav.module.scss
rename to Frontend/src/components/top-bar/TopBar/UserNav.module.scss
diff --git a/Frontend/src/components/shell/TopBar/UserNav.tsx b/Frontend/src/components/top-bar/TopBar/UserNav.tsx
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNav.tsx
rename to Frontend/src/components/top-bar/TopBar/UserNav.tsx
diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.module.scss b/Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss
similarity index 100%
rename from Frontend/src/components/shell/TopBar/UserNavButton.module.scss
rename to Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss
diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.tsx b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
similarity index 93%
rename from Frontend/src/components/shell/TopBar/UserNavButton.tsx
rename to Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
index 6d28932..3c4945a 100644
--- a/Frontend/src/components/shell/TopBar/UserNavButton.tsx
+++ b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
@@ -1,4 +1,4 @@
-// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx
+// Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js";
import { User } from "../../../lib/icons";
diff --git a/Frontend/src/components/shell/TopBar/createDesktopMenuController.ts b/Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts
similarity index 100%
rename from Frontend/src/components/shell/TopBar/createDesktopMenuController.ts
rename to Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss
new file mode 100644
index 0000000..cb1ab20
--- /dev/null
+++ b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss
@@ -0,0 +1,25 @@
+/* Path: Frontend/src/components/workspace-home/WorkspaceHome.module.scss */
+
+.viewport {
+ --workspace-content-max-width: var(--content-width-wide);
+
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: var(--space-4);
+ padding: var(--space-5) var(--space-6);
+}
+
+.title {
+ @include text-display;
+ font-family: var(--font-family-display);
+ max-width: 12ch;
+}
+
+@include respond-down(mobile) {
+ .viewport {
+ gap: var(--space-4);
+ padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
+ }
+}
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome.tsx
new file mode 100644
index 0000000..b1e770b
--- /dev/null
+++ b/Frontend/src/components/workspace-home/WorkspaceHome.tsx
@@ -0,0 +1,15 @@
+// Path: Frontend/src/components/workspace-home/WorkspaceHome.tsx
+
+import { type JSX } from "solid-js";
+import { useAppShellData } from "../app-shell/data/app-shell.context";
+import styles from "./WorkspaceHome.module.scss";
+
+export const WorkspaceHome = (): JSX.Element => {
+ const appShellData = useAppShellData();
+
+ return (
+
+ {appShellData.activeServer().name}
+
+ );
+};
diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
deleted file mode 100644
index 96ec8b9..0000000
--- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
-
-import { Show, createMemo, type JSX } from "solid-js";
-import { Portal } from "solid-js/web";
-import { ChevronLeft, ChevronRight } from "../../../lib/icons";
-import { useAppShellData } from "../../shell/data/app-shell.context";
-import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data";
-import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook";
-import styles from "./WorkspaceHome.module.scss";
-import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts";
-
-type WorkspaceHomeProps = {
- sidebarCollapsed: boolean;
- onToggleSidebarCollapse: () => void;
-};
-
-export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
- const appShellData = useAppShellData();
- const {
- instanceForm,
- setInstanceForm,
- modeForm,
- setModeForm,
- adminForm,
- setAdminForm,
- structureForm,
- setStructureForm,
- selectedPersona,
- hasChosenPersona,
- stepState,
- isBootstrapStateResolved,
- isWizardOpen,
- setIsWizardOpen,
- setIsFinishingBootstrapFlow,
- fieldTooltip,
- materializationState,
- isMaterializationInFlight,
- hasMaterializationFailed,
- showBootstrapFinishingState,
- materializationStatusLabel,
- materializationMessage,
- personaDefinition,
- selectedPersonaIsAvailable,
- usesCondensedBootstrapFlow,
- activeWizardSteps,
- bootstrapNamePlaceholder,
- bootstrapStepCount,
- currentStep,
- currentWizardStepIndex,
- wizardProgressFillWidth,
- currentStepState,
- isFirstStep,
- canDismissWizard,
- handleCurrentStepSubmit,
- applyPersonaSelection,
- statusLabel,
- showFieldTooltip,
- hideFieldTooltip,
- stepStatusLabel,
- navigateBack,
- navigateToVisibleStep,
- } = useWorkspaceHomeWizard(appShellData);
- const isBootstrapPersisted = createMemo(() => appShellData.installation()?.isBootstrapped ?? false);
-
- const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
- const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
-
- return (
- <>
-
-
-
-
-
-
-
- {breadcrumb()}
-
-
-
-
-
-
- {isBootstrapPersisted() ? appShellData.activeServer().name : "Server"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- setIsFinishingBootstrapFlow(false);
- setIsWizardOpen(false);
- }}
- />
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- {currentStepState().error}
-
-
-
-
-
-
-
- {(tooltip): JSX.Element => (
-
- )}
-
-
-
-
- >
- );
-};
diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.module.scss b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss
similarity index 100%
rename from Frontend/src/components/shell/LeftRail/LeftRail.module.scss
rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss
diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.tsx b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
similarity index 90%
rename from Frontend/src/components/shell/LeftRail/LeftRail.tsx
rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
index 075e1d6..6332ddc 100644
--- a/Frontend/src/components/shell/LeftRail/LeftRail.tsx
+++ b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
@@ -1,8 +1,8 @@
-// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
+// Path: Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js";
-import { useAppShellData } from "../data/app-shell.context";
-import { type RailItem } from "../data/shell.data";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import { type RailItem } from "../../app-shell/data/shell.data";
import styles from "./LeftRail.module.scss";
type RailEntryProps = {
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
new file mode 100644
index 0000000..1a896bf
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
@@ -0,0 +1,91 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
+
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type MobileWorkspaceBrowserProps = {
+ open: boolean;
+ onClose: VoidFunction;
+};
+
+export type MobileWorkspaceDialogState =
+ | {
+ kind: "text";
+ title: string;
+ message: string;
+ confirmLabel: string;
+ initialValue: string;
+ onConfirm: (value: string) => void;
+ }
+ | {
+ kind: "confirm";
+ title: string;
+ message: string;
+ confirmLabel: string;
+ tone?: "danger";
+ onConfirm: () => void;
+ };
+
+export type MobileMoveTargetState = {
+ kind: "folder" | "item";
+ id: string;
+ label: string;
+};
+
+export type MobileMoveDestination = {
+ id: string | null;
+ label: string;
+ depth: number;
+ meta?: string;
+};
+
+export const collectMoveDestinations = (
+ nodes: readonly WorkspaceTreeNode[],
+ movingTarget: MobileMoveTargetState,
+ depth = 0,
+ ancestorBlocked = false,
+): MobileMoveDestination[] => {
+ const destinations: MobileMoveDestination[] = [];
+
+ for (const node of nodes) {
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
+ if (!ancestorBlocked && !isBlockedFolder) {
+ destinations.push({
+ id: node.id,
+ label: node.label,
+ depth,
+ meta: "Folder",
+ });
+ }
+
+ destinations.push(
+ ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
+ );
+ }
+
+ return destinations;
+};
+
+export const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === nodeId) {
+ return node;
+ }
+
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
+ if (nestedMatch) {
+ return nestedMatch;
+ }
+ }
+
+ return null;
+};
+
+export const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
new file mode 100644
index 0000000..1a845f2
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
@@ -0,0 +1,273 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
+
+import { createSignal } from "solid-js";
+import { createLongPressGesture } from "../../../helper/createLongPressGesture";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createWorkspaceSurfaceTarget,
+ getWorkspaceItemTypeDefinition,
+ type WorkspaceContextMenuAction,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceItemTypeId,
+} from "../../app-shell/data/shell.data";
+import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
+import {
+ collectMoveDestinations,
+ findTreeNodeById,
+ type MobileMoveDestination,
+ type MobileMoveTargetState,
+ type MobileWorkspaceDialogState,
+} from "./MobileWorkspaceBrowser.data";
+
+export const useMobileWorkspaceBrowser = () => {
+ const appShellData = useAppShellData();
+ const [actionSheetTarget, setActionSheetTarget] = createSignal(null);
+ const [dialogState, setDialogState] = createSignal(null);
+ const [dialogValue, setDialogValue] = createSignal("");
+ const [moveTarget, setMoveTarget] = createSignal(null);
+ const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
+ activeProjectId: () => appShellData.activeProject().id,
+ fallbackWorkspaceTree: () => appShellData.workspaceTree(),
+ });
+ const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
+
+ const moveDestinations = (): MobileMoveDestination[] => {
+ const target = moveTarget();
+ if (!target) {
+ return [];
+ }
+
+ return [{ id: null, label: "Items root", depth: 0, meta: "Root" }, ...collectMoveDestinations(workspaceTreeNodes(), target)];
+ };
+
+ const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
+ switch (actionId) {
+ case "create-doc":
+ return "core.doc";
+ case "create-board":
+ return "core.board.kanban";
+ case "create-list-board":
+ return "core.board.list";
+ default:
+ return null;
+ }
+ };
+
+ const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
+ const definition = getWorkspaceItemTypeDefinition(itemType);
+ void createItem(definition.defaultCreateLabel, itemType, parentId);
+ };
+
+ const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
+ setActionSheetTarget(target);
+ };
+
+ const closeActionSheet = (): void => {
+ setActionSheetTarget(null);
+ };
+
+ const closeMoveSheet = (): void => {
+ setMoveTarget(null);
+ };
+
+ const openWorkspaceActionSheet = (): void => {
+ openActionSheet(workspaceTarget());
+ };
+
+ const closeDialog = (): void => {
+ setDialogState(null);
+ setDialogValue("");
+ };
+
+ const openMoveSheet = (target: MobileMoveTargetState): void => {
+ setMoveTarget(target);
+ };
+
+ const openTextDialog = (config: Omit, "kind">): void => {
+ setDialogValue(config.initialValue);
+ setDialogState({ kind: "text", ...config });
+ };
+
+ const openConfirmDialog = (config: Omit, "kind">): void => {
+ setDialogValue("");
+ setDialogState({ kind: "confirm", ...config });
+ };
+
+ const submitDialog = (): void => {
+ const state = dialogState();
+ if (!state) {
+ return;
+ }
+
+ if (state.kind === "text") {
+ const value = dialogValue().trim();
+ if (!value) {
+ return;
+ }
+
+ closeDialog();
+ state.onConfirm(value);
+ return;
+ }
+
+ closeDialog();
+ state.onConfirm();
+ };
+
+ const handleMoveDestinationSelect = (destinationId: string | null): void => {
+ const target = moveTarget();
+ if (!target) {
+ return;
+ }
+
+ const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
+ const targetIndex = destinationNode?.kind === "folder"
+ ? destinationNode.children?.length ?? 0
+ : destinationId
+ ? 0
+ : workspaceTreeNodes().length;
+
+ closeMoveSheet();
+
+ if (target.kind === "folder") {
+ void moveFolder(target.id, destinationId, targetIndex);
+ return;
+ }
+
+ void moveItem(target.id, destinationId, targetIndex);
+ };
+
+ const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
+ const createItemType = resolveCreateItemType(action.id);
+ if (createItemType) {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ createPersistedItem(createItemType, null);
+ return;
+ case "folder":
+ createPersistedItem(createItemType, target.id);
+ return;
+ case "settings":
+ case "item":
+ return;
+ }
+ }
+
+ switch (action.id) {
+ case "new-folder": {
+ if (target.kind === "settings" || target.kind === "item") {
+ return;
+ }
+
+ openTextDialog({
+ title: "New folder",
+ message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
+ confirmLabel: "Create",
+ initialValue: "Untitled folder",
+ onConfirm: (name) => {
+ void createFolder(name, target.kind === "folder" ? target.id : null);
+ },
+ });
+ return;
+ }
+ case "rename-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openTextDialog({
+ title: "Rename folder",
+ message: `Update the name for "${target.label}".`,
+ confirmLabel: "Save",
+ initialValue: target.label,
+ onConfirm: (name) => {
+ if (name === target.label) {
+ return;
+ }
+ void renameFolder(target.id, name);
+ },
+ });
+ return;
+ }
+ case "move-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openMoveSheet({ kind: "folder", id: target.id, label: target.label });
+ return;
+ }
+ case "delete-folder": {
+ if (target.kind !== "folder") {
+ return;
+ }
+
+ openConfirmDialog({
+ title: "Delete folder?",
+ message: `"${target.label}" and everything inside it will be removed.`,
+ confirmLabel: "Delete",
+ tone: "danger",
+ onConfirm: () => {
+ void deleteFolder(target.id);
+ },
+ });
+ return;
+ }
+ case "move-doc":
+ case "move-board":
+ case "move-list-board": {
+ if (target.kind !== "item") {
+ return;
+ }
+
+ openMoveSheet({ kind: "item", id: target.id, label: target.label });
+ return;
+ }
+ case "delete-doc":
+ case "delete-board":
+ case "delete-list-board": {
+ if (target.kind !== "item") {
+ return;
+ }
+
+ openConfirmDialog({
+ title: "Delete item?",
+ message: `"${target.label}" will be removed from the project tree.`,
+ confirmLabel: "Delete",
+ tone: "danger",
+ onConfirm: () => {
+ void deleteItem(target.id);
+ },
+ });
+ return;
+ }
+ default:
+ return;
+ }
+ };
+
+ const workspaceLongPress = createLongPressGesture({
+ onLongPress: openWorkspaceActionSheet,
+ });
+
+ return {
+ appShellData,
+ actionSheetTarget,
+ openActionSheet,
+ closeActionSheet,
+ dialogState,
+ dialogValue,
+ setDialogValue,
+ closeDialog,
+ submitDialog,
+ moveTarget,
+ moveDestinations,
+ closeMoveSheet,
+ handleMoveDestinationSelect,
+ workspaceTreeNodes,
+ handleActionSelect,
+ workspaceLongPress,
+ openWorkspaceActionSheet,
+ };
+};
diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
similarity index 100%
rename from Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
rename to Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
new file mode 100644
index 0000000..15b4e2d
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
@@ -0,0 +1,140 @@
+// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight } from "../../../lib/icons";
+import { createLongPressGesture } from "../../../helper/createLongPressGesture";
+import {
+ createWorkspaceStaticTarget,
+ createWorkspaceTreeTarget,
+ getWorkspaceNodeIcon,
+ type SidebarItem,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceStaticItem,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import styles from "./MobileWorkspaceBrowser.module.scss";
+
+export const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
+ const depth = props.depth ?? 0;
+ const Icon = getWorkspaceNodeIcon(props.node);
+ const hasChildren = (props.node.children?.length ?? 0) > 0;
+
+ return (
+
+ );
+};
+
+export const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
+ const Icon = props.item.icon;
+
+ return (
+
+ );
+};
+
+export const WorkspaceStaticRow = (props: {
+ item: WorkspaceStaticItem;
+ onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
+}): JSX.Element => {
+ const target = createWorkspaceStaticTarget(props.item);
+ const longPress = createLongPressGesture({
+ onLongPress: () => {
+ props.onOpenActionSheet(target);
+ },
+ });
+
+ return (
+ {
+ event.preventDefault();
+ props.onOpenActionSheet(target);
+ }}
+ {...longPress}
+ >
+
+
+ );
+};
+
+export const WorkspaceTreeBranch = (props: {
+ nodes: readonly WorkspaceTreeNode[];
+ depth?: number;
+ onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
+}): JSX.Element => {
+ const depth = props.depth ?? 0;
+
+ return (
+
+ {(node): JSX.Element => {
+ const target = createWorkspaceTreeTarget(node);
+ const longPress = createLongPressGesture({
+ onLongPress: () => {
+ props.onOpenActionSheet(target);
+ },
+ });
+
+ return (
+ {
+ event.preventDefault();
+ props.onOpenActionSheet(target);
+ }}
+ {...longPress}
+ >
+
+
+
+
+
+
+ );
+ }}
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
new file mode 100644
index 0000000..780fc50
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx
@@ -0,0 +1,192 @@
+import { For, Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { Plus, X } from "../../../lib/icons";
+import {
+ workspaceStaticItems,
+} from "../../app-shell/data/shell.data";
+import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
+import { useMobileWorkspaceBrowser } from "./MobileWorkspaceBrowser.hook";
+import { isDangerDialogState, type MobileWorkspaceBrowserProps } from "./MobileWorkspaceBrowser.data";
+import { WorkspaceStaticRow, WorkspaceTreeBranch } from "./MobileWorkspaceBrowser.parts";
+import styles from "./MobileWorkspaceBrowser.module.scss";
+
+export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
+ const {
+ appShellData,
+ actionSheetTarget,
+ openActionSheet,
+ closeActionSheet,
+ dialogState,
+ dialogValue,
+ setDialogValue,
+ closeDialog,
+ submitDialog,
+ moveTarget,
+ moveDestinations,
+ closeMoveSheet,
+ handleMoveDestinationSelect,
+ workspaceTreeNodes,
+ handleActionSelect,
+ workspaceLongPress,
+ openWorkspaceActionSheet,
+ } = useMobileWorkspaceBrowser();
+
+ return (
+
+
+
+
+
+
+
+ Workspace
+
+
+ {(item): JSX.Element => }
+
+
+
+
+
+
+
+
+
+
+
+ {(state): JSX.Element => (
+
+
+
+
+
+
+
+
+
Destination
+
+
+ {(destination): JSX.Element => (
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {(state): JSX.Element => (
+
+
+
+
+
+
{state().title}
+
{state().message}
+
+
+
+ {
+ setDialogValue(event.currentTarget.value);
+ }}
+ onKeyDown={(event): void => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submitDialog();
+ }
+ }}
+ autofocus
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
similarity index 99%
rename from Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx
rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
index 089c8ee..c27c049 100644
--- a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx
+++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx
@@ -7,7 +7,7 @@ import {
type ProjectContextMenuAction,
type ProjectMenuTarget,
type WorkspaceContextMenuShortcut,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
diff --git a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
similarity index 97%
rename from Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts
rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
index 3110220..5e5e5d5 100644
--- a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts
+++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
-import type { ProjectMenuTarget } from "../data/shell.data";
+import type { ProjectMenuTarget } from "../../app-shell/data/shell.data";
type ProjectContextMenuState = {
target: ProjectMenuTarget;
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
new file mode 100644
index 0000000..69279ad
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
@@ -0,0 +1,144 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
+
+import type { JSX } from "solid-js";
+import type {
+ NavTreeAdapter,
+ NavTreeDropTarget,
+} from "../shared/navTreeDnd";
+import type { ProjectItem } from "../../app-shell/data/shell.data";
+
+export type ProjectSelectorProps = {
+ compact?: boolean;
+ isOpen: boolean;
+ onToggle: () => void;
+ onClose: () => void;
+};
+
+export type ProjectFolderNode = {
+ kind: "folder";
+ id: string;
+ path: string;
+ label: string;
+ meta?: string;
+ children: ProjectTreeNode[];
+};
+
+export type ProjectLeafNode = {
+ kind: "project";
+ item: ProjectItem;
+};
+
+export type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
+
+export type PersistedProjectFolderRecord = {
+ id: string;
+ path: string;
+ label: string;
+ children: PersistedProjectFolderRecord[];
+};
+
+export type ProjectFoldersResponse = {
+ data?: {
+ folders?: PersistedProjectFolderRecord[];
+ renamedFolder?: PersistedProjectFolderRecord;
+ movedFolder?: PersistedProjectFolderRecord;
+ previousFolderId?: string;
+ previousFolderPath?: string;
+ };
+ error?: string;
+ message?: string;
+};
+
+export type PendingProjectFolderDraft = {
+ parentId: string | null;
+ depth: number;
+};
+
+export type PendingProjectFolderRename = {
+ folderId: string;
+ depth: number;
+};
+
+export type ProjectDragTarget = NavTreeDropTarget;
+
+export type ProjectDragState = {
+ draggedNodeId: string;
+ dropTarget: ProjectDragTarget | null;
+};
+
+export type DragGhostPosition = {
+ x: number;
+ y: number;
+};
+
+export type ProjectFolderBranchProps = {
+ nodes: readonly ProjectTreeNode[];
+ depth: number;
+ parentId: string | null;
+ selectedProjectId: string;
+ isFolderCollapsed: (folderId: string) => boolean;
+ onToggleFolder: (folderId: string) => void;
+ onSelectProject: (projectId: string) => void;
+ onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void;
+ onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void;
+ onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
+ onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void;
+ pendingFolderDraft: PendingProjectFolderDraft | null;
+ pendingFolderName: string;
+ 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;
+};
+
+export const LONG_PRESS_MS = 320;
+
+export const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
+ node.kind === "folder" ? node.id : node.item.id;
+
+export const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
+ folders.map((folder) => ({
+ kind: "folder",
+ id: folder.id,
+ path: folder.path,
+ label: folder.label,
+ children: buildPersistedFolderNodes(folder.children ?? []),
+ }));
+
+export const buildProjectTree = (
+ items: readonly ProjectItem[],
+ folders: readonly PersistedProjectFolderRecord[] = [],
+): ProjectTreeNode[] => [
+ ...items.map((item) => ({
+ kind: "project" as const,
+ item,
+ })),
+ ...buildPersistedFolderNodes(folders),
+];
+
+export const countProjectFolderSiblingsBeforeIndex = (
+ siblings: readonly ProjectTreeNode[],
+ index: number,
+): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
+
+export const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
+ Array.isArray(body.data?.folders) ? body.data.folders : [];
+
+export const projectTreeAdapter: NavTreeAdapter = {
+ getNodeId: getProjectTreeNodeId,
+ isBranchNode: (node) => node.kind === "folder",
+ getChildren: (node) => (node.kind === "folder" ? node.children : []),
+ withChildren: (node, children) =>
+ node.kind === "folder"
+ ? {
+ ...node,
+ children: [...children],
+ }
+ : node,
+};
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
new file mode 100644
index 0000000..bf82f2d
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
@@ -0,0 +1,671 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
+
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createProjectFolderTarget,
+ createProjectSurfaceTarget,
+ createProjectTarget,
+ type ProjectItem,
+ type ProjectMenuTarget,
+} from "../../app-shell/data/shell.data";
+import {
+ collectBranchNodeIds,
+ findTreeNodeDepth,
+ findTreeNodeLocation,
+ getPointerRelativeY,
+ isUuidString,
+ moveTreeNode,
+ resolveTreeDropTarget,
+} from "../shared/navTreeDnd";
+import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
+import {
+ LONG_PRESS_MS,
+ buildProjectTree,
+ countProjectFolderSiblingsBeforeIndex,
+ projectTreeAdapter,
+ readPersistedFolders,
+ type DragGhostPosition,
+ type PendingProjectFolderDraft,
+ type PendingProjectFolderRename,
+ type PersistedProjectFolderRecord,
+ type ProjectDragState,
+ type ProjectFolderNode,
+ type ProjectSelectorProps,
+ type ProjectTreeNode,
+} from "./ProjectSelector.data";
+import {
+ createProjectFolderRequest,
+ deleteProjectFolderRequest,
+ fetchProjectFolders,
+ isValidProjectFolderProjectId,
+ moveProjectFolderRequest,
+ renameProjectFolderRequest,
+} from "./projectFolders.api";
+
+export const useProjectSelector = (props: ProjectSelectorProps) => {
+ const appShellData = useAppShellData();
+ const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
+ const [drawerTop, setDrawerTop] = createSignal(0);
+ const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]);
+ const [persistedFolders, setPersistedFolders] = createSignal([]);
+ const [projectTreeNodes, setProjectTreeNodes] = createSignal(buildProjectTree(appShellData.projectItems(), persistedFolders()));
+ const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null);
+ const [pendingFolderName, setPendingFolderName] = createSignal("");
+ const [pendingFolderRename, setPendingFolderRename] = createSignal(null);
+ const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
+ const [dragState, setDragState] = createSignal(null);
+ const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
+ const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
+ let rootRef: HTMLDivElement | undefined;
+ let triggerRef: HTMLButtonElement | undefined;
+ let contextMenuRef: HTMLDivElement | undefined;
+ let longPressTimer: number | undefined;
+ let suppressClickTimer: number | undefined;
+ let lastSelectedProjectId: string | null = null;
+ let latestPersistedFoldersRequest = 0;
+ const contextMenu = createProjectContextMenuController();
+
+ const setRootRef = (element: HTMLDivElement | undefined): void => {
+ rootRef = element;
+ };
+
+ const setTriggerRef = (element: HTMLButtonElement | undefined): void => {
+ triggerRef = element;
+ };
+
+ const setContextMenuElement = (element: HTMLDivElement | undefined): void => {
+ contextMenuRef = element;
+ if (element) {
+ contextMenu.setMenuRef(element);
+ }
+ };
+
+ const clearLongPressTimer = (): void => {
+ if (longPressTimer !== undefined) {
+ window.clearTimeout(longPressTimer);
+ longPressTimer = undefined;
+ }
+ };
+
+ const updateDragGhostPosition = (x: number, y: number): void => {
+ setDragGhostPosition({ x: x + 18, y: y + 18 });
+ };
+
+ const draggedNode = (): ProjectTreeNode | null => {
+ const currentDragState = dragState();
+ if (!currentDragState) {
+ return null;
+ }
+
+ return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
+ };
+
+ const draggedNodeMeta = (): string => {
+ const node = draggedNode();
+ if (!node) {
+ return "";
+ }
+
+ return node.kind === "folder" ? "Folder" : "Project";
+ };
+
+ const suppressTreeClickTemporarily = (): void => {
+ setSuppressNextTreeClick(true);
+
+ if (suppressClickTimer !== undefined) {
+ window.clearTimeout(suppressClickTimer);
+ }
+
+ suppressClickTimer = window.setTimeout(() => {
+ setSuppressNextTreeClick(false);
+ suppressClickTimer = undefined;
+ }, 80);
+ };
+
+ const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
+
+ const toggleFolder = (folderId: string): void => {
+ setCollapsedFolderIds((current) => (current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId]));
+ };
+
+ const syncProjectTree = (): void => {
+ const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
+ const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
+
+ setProjectTreeNodes(nextTree);
+ setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
+ };
+
+ const resetProjectTreeInteractionState = (): void => {
+ setCollapsedFolderIds([]);
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ setDragState(null);
+ };
+
+ const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
+
+ const expandAllFolders = (): void => {
+ setCollapsedFolderIds([]);
+ };
+
+ const collapseAllFolders = (): void => {
+ setCollapsedFolderIds(folderIds());
+ };
+
+ const totalFolderCount = (): number => folderIds().length;
+
+ const areAllFoldersCollapsed = (): boolean => {
+ const folderCount = totalFolderCount();
+ return folderCount > 0 && collapsedFolderIds().length >= folderCount;
+ };
+
+ const toggleAllFolders = (): void => {
+ if (areAllFoldersCollapsed()) {
+ expandAllFolders();
+ return;
+ }
+
+ collapseAllFolders();
+ };
+
+ const loadPersistedFolders = async (projectId: string): Promise => {
+ const requestId = latestPersistedFoldersRequest + 1;
+ latestPersistedFoldersRequest = requestId;
+
+ if (!isValidProjectFolderProjectId(projectId) || !isUuidString(projectId)) {
+ setPersistedFolders([]);
+ return;
+ }
+
+ try {
+ const body = await fetchProjectFolders(projectId);
+
+ if (requestId !== latestPersistedFoldersRequest) {
+ return;
+ }
+
+ setPersistedFolders(readPersistedFolders(body));
+ } catch (error) {
+ if (requestId !== latestPersistedFoldersRequest) {
+ return;
+ }
+
+ console.error(error);
+ setPersistedFolders([]);
+ }
+ };
+
+ createEffect(() => {
+ setSelectedProject(appShellData.activeProject());
+ });
+
+ createEffect(() => {
+ syncProjectTree();
+ });
+
+ createEffect(() => {
+ const projectId = selectedProject().id;
+
+ if (lastSelectedProjectId === null) {
+ lastSelectedProjectId = projectId;
+ return;
+ }
+
+ if (projectId === lastSelectedProjectId) {
+ return;
+ }
+
+ lastSelectedProjectId = projectId;
+ resetProjectTreeInteractionState();
+ });
+
+ createEffect(() => {
+ void loadPersistedFolders(selectedProject().id);
+ });
+
+ onMount(() => {
+ if (triggerRef) {
+ const updateDrawerTop = (): void => {
+ if (!triggerRef) {
+ return;
+ }
+
+ setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8);
+ };
+
+ updateDrawerTop();
+
+ const observer = new ResizeObserver(() => {
+ updateDrawerTop();
+ });
+
+ observer.observe(triggerRef);
+ window.addEventListener("resize", updateDrawerTop);
+
+ onCleanup(() => {
+ observer.disconnect();
+ window.removeEventListener("resize", updateDrawerTop);
+ });
+ }
+
+ const handlePointerDown = (event: PointerEvent): void => {
+ if (!props.isOpen || !rootRef) {
+ return;
+ }
+
+ const target = event.target;
+
+ if (target instanceof Node && rootRef.contains(target)) {
+ return;
+ }
+
+ if (target instanceof Node && contextMenuRef?.contains(target)) {
+ return;
+ }
+
+ props.onClose();
+ };
+
+ const handlePointerUp = (): void => {
+ clearLongPressTimer();
+
+ const nextDragState = dragState();
+
+ if (!nextDragState?.dropTarget) {
+ if (nextDragState) {
+ suppressTreeClickTemporarily();
+ }
+ setDragState(null);
+ return;
+ }
+
+ suppressTreeClickTemporarily();
+
+ const currentNodes = projectTreeNodes();
+ const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
+ const canPersistMove = isUuidString(selectedProject().id);
+ const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
+ const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget, projectTreeAdapter);
+ const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
+ const persistedParentLocation = previewLocation?.parentId ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null;
+ const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
+ const previewSiblings = previewLocation?.parentId ? persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.children : [] : previewNodes;
+ const targetIndex = previewLocation ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0;
+
+ if (canPersistMove && draggedLocation?.node.kind === "folder" && draggedFolderPath && (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")) {
+ void movePersistedFolder(
+ draggedFolderPath,
+ persistedParentFolderPath,
+ draggedLocation.node.id,
+ persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
+ targetIndex,
+ );
+ } else {
+ setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget!, projectTreeAdapter));
+ }
+
+ setDragState(null);
+ };
+
+ const handleEscape = (event: KeyboardEvent): void => {
+ if (event.key !== "Escape") {
+ return;
+ }
+
+ clearLongPressTimer();
+
+ if (dragState()) {
+ setDragState(null);
+ return;
+ }
+
+ if (!props.isOpen) {
+ return;
+ }
+
+ props.onClose();
+ triggerRef?.focus();
+ };
+
+ const handlePointerMove = (event: PointerEvent): void => {
+ if (!dragState()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("keydown", handleEscape);
+
+ onCleanup(() => {
+ clearLongPressTimer();
+ if (suppressClickTimer !== undefined) {
+ window.clearTimeout(suppressClickTimer);
+ }
+ document.removeEventListener("pointerdown", handlePointerDown);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("keydown", handleEscape);
+ });
+ });
+
+ const toggleOpen = (): void => {
+ if (!props.isOpen) {
+ props.onToggle();
+ return;
+ }
+
+ props.onClose();
+ };
+
+ const selectProject = (projectId: string): void => {
+ const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
+
+ if (!location || location.node.kind !== "project") {
+ return;
+ }
+
+ setSelectedProject({ id: location.node.item.id, name: location.node.item.name });
+ props.onClose();
+ };
+
+ const beginFolderDraft = (parentId: string | null, depth: number): void => {
+ if (parentId) {
+ 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 resolveFolderPath = (folderId: string): string | null => {
+ const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
+ return location && location.node.kind === "folder" ? location.node.path : null;
+ };
+
+ const submitPendingFolder = async (): Promise => {
+ const name = pendingFolderName().trim();
+ const draft = pendingFolderDraft();
+ const projectId = selectedProject().id;
+
+ if (!draft) {
+ return;
+ }
+
+ if (!name) {
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ return;
+ }
+
+ if (!isUuidString(projectId)) {
+ cancelPendingFolder();
+ return;
+ }
+
+ const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
+ if (draft.parentId && !parentFolderPath) {
+ cancelPendingFolder();
+ return;
+ }
+
+ try {
+ const body = await createProjectFolderRequest(projectId, name, parentFolderPath);
+
+ setPersistedFolders(readPersistedFolders(body));
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const deletePersistedFolder = async (folderId: string): Promise => {
+ const projectId = selectedProject().id;
+ if (!folderId || !isUuidString(projectId)) {
+ return;
+ }
+
+ const folderPath = resolveFolderPath(folderId);
+ if (!folderPath) {
+ return;
+ }
+
+ try {
+ const body = await deleteProjectFolderRequest(projectId, folderPath);
+
+ setPersistedFolders(readPersistedFolders(body));
+ setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const movePersistedFolder = async (
+ folderPath: string,
+ parentFolderPath: string | null,
+ folderStableId: string,
+ parentStableId: string | null,
+ targetIndex: number,
+ ): Promise => {
+ const projectId = selectedProject().id;
+ if (!folderPath || !folderStableId || !isUuidString(projectId)) {
+ return;
+ }
+
+ try {
+ const body = await moveProjectFolderRequest(projectId, {
+ folderId: folderPath,
+ folderNodeId: folderStableId,
+ parentFolderId: parentFolderPath,
+ parentNodeId: parentStableId,
+ targetIndex,
+ });
+
+ setPersistedFolders(readPersistedFolders(body));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ 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;
+ }
+
+ const folderPath = resolveFolderPath(draft.folderId);
+ if (!folderPath) {
+ cancelPendingFolderRename();
+ return;
+ }
+
+ try {
+ const body = await renameProjectFolderRequest(projectId, folderPath, name);
+
+ setPersistedFolders(readPersistedFolders(body));
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ } 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":
+ 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 "delete-folder":
+ if (target.kind === "folder") {
+ 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;
+ }
+ };
+
+ const handleSurfaceContextMenu = (event: MouseEvent): void => {
+ event.stopPropagation();
+ contextMenu.openMenu(event, createProjectSurfaceTarget("Projects"));
+ };
+
+ const openRootCreateMenu = (element: HTMLElement): void => {
+ contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects"));
+ };
+
+ const treeControlLabel = (): string => (areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders");
+
+ const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
+ if (event.button !== 0 || pendingFolderDraft()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ clearLongPressTimer();
+ longPressTimer = window.setTimeout(() => {
+ suppressTreeClickTemporarily();
+ setDragState({ draggedNodeId: nodeId, dropTarget: null });
+ }, LONG_PRESS_MS);
+ };
+
+ const handleNodePointerMove = (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode): void => {
+ const nextDragState = dragState();
+
+ if (!nextDragState || nextDragState.draggedNodeId === (node.kind === "folder" ? node.id : node.item.id)) {
+ return;
+ }
+
+ const relativeY = getPointerRelativeY(event);
+ if (relativeY === null) {
+ return;
+ }
+
+ setDragState({
+ ...nextDragState,
+ dropTarget: resolveTreeDropTarget({
+ parentId,
+ index,
+ node,
+ relativeY,
+ adapter: projectTreeAdapter,
+ }),
+ });
+ };
+
+ const openFolderMenu = (event: MouseEvent, folder: ProjectFolderNode): void => {
+ event.stopPropagation();
+ contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
+ };
+
+ const openProjectMenu = (event: MouseEvent, item: ProjectItem): void => {
+ event.stopPropagation();
+ contextMenu.openMenu(event, createProjectTarget(item));
+ };
+
+ const contextMenuPosition = () => {
+ const state = contextMenu.menuState();
+ return state ? { x: state.x, y: state.y } : null;
+ };
+
+ return {
+ selectedProject,
+ drawerTop,
+ setRootRef,
+ setTriggerRef,
+ setContextMenuElement,
+ toggleOpen,
+ handleSurfaceContextMenu,
+ openRootCreateMenu,
+ treeControlLabel,
+ toggleAllFolders,
+ totalFolderCount,
+ areAllFoldersCollapsed,
+ projectTreeNodes,
+ isFolderCollapsed,
+ toggleFolder,
+ selectProject,
+ openFolderMenu,
+ openProjectMenu,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ dragState,
+ suppressNextTreeClick,
+ contextMenu,
+ contextMenuPosition,
+ handleContextActionSelect,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ };
+};
diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss
similarity index 100%
rename from Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss
rename to Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
new file mode 100644
index 0000000..4e3e2a4
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
@@ -0,0 +1,213 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight, Folder, LayoutGrid } from "../../../lib/icons";
+import type { ProjectItem } from "../../app-shell/data/shell.data";
+import {
+ getProjectTreeNodeId,
+ type PendingProjectFolderDraft,
+ type PendingProjectFolderRename,
+ type ProjectDragState,
+ type ProjectFolderBranchProps,
+ type ProjectFolderNode,
+ type ProjectTreeNode,
+} from "./ProjectSelector.data";
+import styles from "./ProjectSelector.module.scss";
+
+export const ProjectFolderDraftRow = (props: {
+ depth: number;
+ value: string;
+ onInput: (value: string) => void;
+ onSubmit: () => void;
+ onCancel: () => void;
+}): JSX.Element => {
+ let inputRef: HTMLInputElement | undefined;
+
+ queueMicrotask(() => inputRef?.focus());
+
+ return (
+
+
+
+ props.onInput(event.currentTarget.value)}
+ onBlur={props.onSubmit}
+ onKeyDown={(event): void => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.currentTarget.blur();
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ props.onCancel();
+ event.currentTarget.blur();
+ }
+ }}
+ />
+
+
+ );
+};
+
+export const ProjectFolderBranch = (props: ProjectFolderBranchProps): JSX.Element => (
+
+
+ -
+
+
+
+
+
+ {(node, indexAccessor): JSX.Element => {
+ const nodeId = (): string => getProjectTreeNodeId(node);
+ const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
+ const dropIntent = (): "before" | "after" | "inside" | null => {
+ if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
+ return null;
+ }
+
+ return props.dragState.dropTarget.intent;
+ };
+
+ if (node.kind === "folder") {
+ const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
+ const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
+
+ return (
+ -
+ {
+ if (props.dragState || props.isTreeClickSuppressed()) {
+ return;
+ }
+
+ props.onToggleFolder(node.id);
+ }}
+ onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
+ onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
+ onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
+ onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
+ >
+
+
+ {node.label}
+
+ {node.meta}
+
+
+ }
+ >
+
+
+
+ 0 || props.pendingFolderDraft?.parentId === node.id)}>
+
+
+
+ );
+ }
+
+ return (
+ -
+
+
+ );
+ }}
+
+
+
+
+
+
+);
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
new file mode 100644
index 0000000..679b490
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
@@ -0,0 +1,222 @@
+// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
+
+import { Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
+import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
+import { useProjectSelector } from "./ProjectSelector.hook";
+import { ProjectFolderBranch } from "./ProjectSelector.parts";
+import { type ProjectSelectorProps } from "./ProjectSelector.data";
+import styles from "./ProjectSelector.module.scss";
+
+export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
+ const {
+ selectedProject,
+ drawerTop,
+ setRootRef,
+ setTriggerRef,
+ setContextMenuElement,
+ toggleOpen,
+ handleSurfaceContextMenu,
+ openRootCreateMenu,
+ treeControlLabel,
+ toggleAllFolders,
+ totalFolderCount,
+ areAllFoldersCollapsed,
+ projectTreeNodes,
+ isFolderCollapsed,
+ toggleFolder,
+ selectProject,
+ openFolderMenu,
+ openProjectMenu,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ dragState,
+ suppressNextTreeClick,
+ contextMenu,
+ contextMenuPosition,
+ handleContextActionSelect,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ } = useProjectSelector(props);
+
+ return (
+
+
+
+
+ <>
+
+
+
+ >
+
+
+
+
+
+ {(node): JSX.Element => {
+ const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid;
+
+ return (
+
+
+
+
+
+
{node.kind === "folder" ? node.label : node.item.name}
+
{draggedNodeMeta()}
+
+
+
+
+ );
+ }}
+
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts
new file mode 100644
index 0000000..213d9be
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts
@@ -0,0 +1,109 @@
+import { resolveAPIBase } from "../../../lib/api";
+import type { ProjectFoldersResponse } from "./ProjectSelector.data";
+
+const readProjectFoldersResponse = async (response: Response): Promise =>
+ (await response.json()) as ProjectFoldersResponse;
+
+export const isValidProjectFolderProjectId = (projectId: string): boolean =>
+ Boolean(projectId);
+
+export const fetchProjectFolders = async (projectId: string): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ headers: { Accept: "application/json" },
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to load project folders.");
+ }
+
+ return body;
+};
+
+export const createProjectFolderRequest = async (
+ projectId: string,
+ name: string,
+ parentFolderId: string | null,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name, parentFolderId }),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to create project folder.");
+ }
+
+ return body;
+};
+
+export const deleteProjectFolderRequest = async (
+ projectId: string,
+ folderId: string,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, {
+ method: "DELETE",
+ headers: { Accept: "application/json" },
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to delete project folder.");
+ }
+
+ return body;
+};
+
+export const moveProjectFolderRequest = async (
+ projectId: string,
+ payload: {
+ folderId: string;
+ folderNodeId: string;
+ parentFolderId: string | null;
+ parentNodeId: string | null;
+ targetIndex: number;
+ },
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to move project folder.");
+ }
+
+ return body;
+};
+
+export const renameProjectFolderRequest = async (
+ projectId: string,
+ folderId: string,
+ name: string,
+): Promise => {
+ const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ folderId, name }),
+ });
+ const body = await readProjectFoldersResponse(response);
+
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to rename project folder.");
+ }
+
+ return body;
+};
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
similarity index 99%
rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
index 8b97f0f..79e353f 100644
--- a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx
+++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction,
type WorkspaceContextMenuShortcut,
type WorkspaceContextMenuTarget,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "./WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
similarity index 97%
rename from Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
index e5f21a8..5143de7 100644
--- a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
+++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
-import type { WorkspaceContextMenuTarget } from "../data/shell.data";
+import type { WorkspaceContextMenuTarget } from "../../app-shell/data/shell.data";
type WorkspaceContextMenuState = {
target: WorkspaceContextMenuTarget;
diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss
diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
similarity index 99%
rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
index c4f9a01..754094e 100644
--- a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
+++ b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction,
type WorkspaceContextMenuSection,
type WorkspaceContextMenuTarget,
-} from "../data/shell.data";
+} from "../../app-shell/data/shell.data";
import styles from "./WorkspaceMobileActionSheet.module.scss";
type WorkspaceMobileActionSheetProps = {
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
new file mode 100644
index 0000000..c7e3604
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
@@ -0,0 +1,57 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
+
+import type { NavTreeAdapter, NavTreeDropTarget } from "../shared/navTreeDnd";
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type WorkspaceSidebarProps = {
+ collapsed: boolean;
+ railCollapsed: boolean;
+ onToggleRailCollapse: () => void;
+};
+
+export type PendingWorkspaceFolderDraft = {
+ parentId: string | null;
+ depth: number;
+};
+
+export type WorkspaceDragTarget = NavTreeDropTarget;
+
+export type WorkspaceDragState = {
+ draggedNodeId: string;
+ dropTarget: WorkspaceDragTarget | null;
+};
+
+export type DragGhostPosition = {
+ x: number;
+ y: number;
+};
+
+export type PendingWorkspaceFolderRename = {
+ folderId: string;
+ depth: number;
+};
+
+export const LONG_PRESS_MS = 320;
+
+export const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
+
+export const countWorkspaceFolderSiblingsBeforeIndex = (
+ siblings: readonly WorkspaceTreeNode[],
+ index: number,
+): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
+
+export const workspaceTreeAdapter: NavTreeAdapter = {
+ getNodeId: getWorkspaceTreeNodeId,
+ isBranchNode: (node) => node.kind === "folder",
+ getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
+ withChildren: (node, children) =>
+ node.kind === "folder"
+ ? {
+ ...node,
+ children: [...children],
+ }
+ : node,
+};
+
+export const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean =>
+ event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
new file mode 100644
index 0000000..1c0cf7f
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
@@ -0,0 +1,534 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
+
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
+import { useAppShellData } from "../../app-shell/data/app-shell.context";
+import {
+ createWorkspaceSurfaceTarget,
+ getWorkspaceItemTypeDefinition,
+ type WorkspaceContextMenuAction,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceItemTypeId,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import {
+ collectBranchNodeIds,
+ findTreeNodeDepth,
+ findTreeNodeLocation,
+ getPointerRelativeY,
+ isUuidString,
+ moveTreeNode,
+ resolveTreeDropTarget,
+} from "../shared/navTreeDnd";
+import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
+import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
+import {
+ countWorkspaceFolderSiblingsBeforeIndex,
+ getWorkspaceTreeNodeId,
+ LONG_PRESS_MS,
+ workspaceTreeAdapter,
+ type DragGhostPosition,
+ type PendingWorkspaceFolderDraft,
+ type PendingWorkspaceFolderRename,
+ type WorkspaceDragTarget,
+ type WorkspaceDragState,
+} from "./WorkspaceSidebar.data";
+
+export const useWorkspaceSidebar = () => {
+ const appShellData = useAppShellData();
+ const activeProject = () => appShellData.activeProject();
+ const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
+ 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 [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 });
+ const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
+ let lastSelectedProjectId: string | null = null;
+ const {
+ workspaceTreeNodes,
+ setWorkspaceTreeNodes,
+ resolveFolderPath,
+ resolveItemPath,
+ createFolder,
+ renameFolder,
+ deleteFolder,
+ moveFolder,
+ createItem,
+ deleteItem,
+ moveItem,
+ } = useWorkspaceTreeData({
+ activeProjectId: () => activeProject()?.id ?? "",
+ fallbackWorkspaceTree: () => appShellData.workspaceTree(),
+ });
+ const contextMenu = createWorkspaceContextMenuController();
+ let longPressTimer: number | undefined;
+ let suppressClickTimer: number | undefined;
+ const sidebarContextMenuTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
+ const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
+ const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
+ const totalFolderCount = (): number => folderIds().length;
+ const areAllFoldersCollapsed = (): boolean => {
+ const count = totalFolderCount();
+ return count > 0 && collapsedFolderIds().length >= count;
+ };
+ const workspaceFolderToggleLabel = (): string =>
+ areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
+ const toggleFolder = (folderId: string): void => {
+ setCollapsedFolderIds((current) =>
+ current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
+ );
+ };
+ const expandAllFolders = (): void => {
+ setCollapsedFolderIds([]);
+ };
+ const collapseAllFolders = (): void => {
+ setCollapsedFolderIds(folderIds());
+ };
+ const toggleAllFolders = (): void => {
+ if (areAllFoldersCollapsed()) {
+ expandAllFolders();
+ return;
+ }
+
+ collapseAllFolders();
+ };
+ const resetWorkspaceTreeInteractionState = (): void => {
+ setCollapsedFolderIds([]);
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ setDragState(null);
+ };
+ const syncCollapsedFolderIds = (): void => {
+ const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter));
+ setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
+ };
+ const updateDragGhostPosition = (x: number, y: number): void => {
+ setDragGhostPosition({ x: x + 18, y: y + 18 });
+ };
+ const draggedNode = (): WorkspaceTreeNode | null => {
+ const currentDragState = dragState();
+ if (!currentDragState) {
+ return null;
+ }
+
+ return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null;
+ };
+ const draggedNodeMeta = (): string => {
+ const node = draggedNode();
+ if (!node) {
+ return "";
+ }
+
+ if (node.kind === "folder") {
+ return "Folder";
+ }
+
+ return getWorkspaceItemTypeDefinition(node.itemType).label;
+ };
+ const clearLongPressTimer = (): void => {
+ if (longPressTimer !== undefined) {
+ window.clearTimeout(longPressTimer);
+ longPressTimer = undefined;
+ }
+ };
+ const suppressTreeClickTemporarily = (): void => {
+ setSuppressNextTreeClick(true);
+
+ if (suppressClickTimer !== undefined) {
+ window.clearTimeout(suppressClickTimer);
+ }
+
+ suppressClickTimer = window.setTimeout(() => {
+ setSuppressNextTreeClick(false);
+ suppressClickTimer = undefined;
+ }, 80);
+ };
+
+ createEffect(() => {
+ syncCollapsedFolderIds();
+ });
+
+ createEffect(() => {
+ const projectId = activeProject()?.id ?? null;
+
+ if (lastSelectedProjectId === null) {
+ lastSelectedProjectId = projectId;
+ return;
+ }
+
+ if (projectId === lastSelectedProjectId) {
+ return;
+ }
+
+ lastSelectedProjectId = projectId;
+ resetWorkspaceTreeInteractionState();
+ });
+
+ onMount(() => {
+ const handlePointerMove = (event: PointerEvent): void => {
+ if (!dragState()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ };
+
+ const handlePointerUp = (): void => {
+ clearLongPressTimer();
+
+ const nextDragState = dragState();
+
+ if (!nextDragState?.dropTarget) {
+ if (nextDragState) {
+ suppressTreeClickTemporarily();
+ }
+ setDragState(null);
+ return;
+ }
+
+ suppressTreeClickTemporarily();
+
+ const currentNodes = workspaceTreeNodes();
+ const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
+ const canPersistMove = isUuidString(activeProject()?.id ?? "");
+ const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
+ const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
+ const persistedParentLocation = previewLocation?.parentId
+ ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
+ : null;
+ const previewSiblings = previewLocation?.parentId
+ ? persistedParentLocation?.node.kind === "folder"
+ ? persistedParentLocation.node.children ?? []
+ : []
+ : previewNodes;
+ const targetIndex = previewLocation
+ ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
+ : 0;
+
+ if (
+ canPersistMove &&
+ draggedLocation?.node.kind === "folder" &&
+ (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
+ ) {
+ void moveFolder(
+ draggedLocation.node.id,
+ persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
+ targetIndex,
+ );
+ } else if (
+ canPersistMove &&
+ draggedLocation?.node.kind === "item" &&
+ resolveItemPath(draggedLocation.node.id) &&
+ (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
+ ) {
+ void moveItem(
+ draggedLocation.node.id,
+ persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
+ previewLocation?.index ?? 0,
+ );
+ } else {
+ setWorkspaceTreeNodes((current) =>
+ moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
+ );
+ }
+
+ setDragState(null);
+ };
+
+ const handleEscape = (event: KeyboardEvent): void => {
+ if (event.key !== "Escape") {
+ return;
+ }
+
+ clearLongPressTimer();
+
+ if (dragState()) {
+ setDragState(null);
+ }
+ };
+
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("keydown", handleEscape);
+
+ onCleanup(() => {
+ clearLongPressTimer();
+ if (suppressClickTimer !== undefined) {
+ window.clearTimeout(suppressClickTimer);
+ }
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("keydown", handleEscape);
+ });
+ });
+
+ const beginFolderDraft = (parentId: string | null, depth: number): void => {
+ if (parentId) {
+ 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();
+ const projectId = activeProject()?.id ?? "";
+
+ if (!draft) {
+ return;
+ }
+
+ if (!name) {
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ return;
+ }
+
+ if (!projectId || !isUuidString(projectId)) {
+ cancelPendingFolder();
+ return;
+ }
+
+ const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
+ if (draft.parentId && !parentFolderPath) {
+ cancelPendingFolder();
+ return;
+ }
+
+ const created = await createFolder(name, draft.parentId);
+ if (created) {
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ }
+ };
+
+ const deletePersistedFolder = async (folderId: string): Promise => {
+ const deleted = await deleteFolder(folderId);
+ if (deleted) {
+ setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
+ }
+ };
+
+ 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;
+ }
+
+ const folderPath = resolveFolderPath(draft.folderId);
+ if (!folderPath) {
+ cancelPendingFolderRename();
+ return;
+ }
+
+ const renamed = await renameFolder(draft.folderId, name);
+ if (renamed) {
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ }
+ };
+
+ const cancelPendingFolder = (): void => {
+ setPendingFolderDraft(null);
+ setPendingFolderName("");
+ };
+
+ const cancelPendingFolderRename = (): void => {
+ setPendingFolderRename(null);
+ setPendingFolderRenameName("");
+ };
+
+ const handleHeaderActionClick = (actionId: string): void => {
+ switch (actionId) {
+ case "toggle-workspace-folders":
+ toggleAllFolders();
+ return;
+ case "search-workspace":
+ default:
+ return;
+ }
+ };
+
+ const openWorkspaceCreateMenu = (element: HTMLElement): void => {
+ contextMenu.openMenuFromElement(element, sidebarContextMenuTarget());
+ };
+
+ const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
+ switch (actionId) {
+ case "create-doc":
+ return "core.doc";
+ case "create-board":
+ return "core.board.kanban";
+ case "create-list-board":
+ return "core.board.list";
+ default:
+ return null;
+ }
+ };
+
+ const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => {
+ const definition = getWorkspaceItemTypeDefinition(itemType);
+ await createItem(definition.defaultCreateLabel, itemType, parentId);
+ };
+
+ const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
+ if (event.button !== 0 || pendingFolderDraft()) {
+ return;
+ }
+
+ updateDragGhostPosition(event.clientX, event.clientY);
+ clearLongPressTimer();
+ longPressTimer = window.setTimeout(() => {
+ suppressTreeClickTemporarily();
+ setDragState({ draggedNodeId: nodeId, dropTarget: null });
+ }, LONG_PRESS_MS);
+ };
+
+ const handleNodePointerMove = (
+ event: PointerEvent,
+ parentId: string | null,
+ index: number,
+ node: WorkspaceTreeNode,
+ ): void => {
+ const nextDragState = dragState();
+
+ if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) {
+ return;
+ }
+
+ const relativeY = getPointerRelativeY(event);
+ if (relativeY === null) {
+ return;
+ }
+
+ setDragState({
+ ...nextDragState,
+ dropTarget: resolveTreeDropTarget({
+ parentId,
+ index,
+ node,
+ relativeY,
+ adapter: workspaceTreeAdapter,
+ }),
+ });
+ };
+
+ const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
+ const createItemType = resolveCreateItemType(action.id);
+ if (createItemType) {
+ switch (target.kind) {
+ case "workspace":
+ case "home":
+ void createPersistedItem(createItemType, null);
+ return;
+ case "folder":
+ void createPersistedItem(createItemType, target.id);
+ return;
+ case "settings":
+ case "item":
+ return;
+ }
+ }
+
+ 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 "delete-folder":
+ if (target.kind === "folder") {
+ void deletePersistedFolder(target.id);
+ }
+ return;
+ case "rename-folder":
+ if (target.kind === "folder") {
+ beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0);
+ }
+ return;
+ case "delete-doc":
+ case "delete-board":
+ case "delete-list-board":
+ if (target.kind === "item") {
+ void deleteItem(target.id);
+ }
+ return;
+ default:
+ return;
+ }
+ };
+
+ return {
+ activeProject,
+ contextMenu,
+ sidebarContextMenuTarget,
+ isProjectDrawerOpen,
+ setIsProjectDrawerOpen,
+ workspaceTreeNodes,
+ isFolderCollapsed,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ dragState,
+ suppressNextTreeClick,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ workspaceFolderToggleLabel,
+ areAllFoldersCollapsed,
+ totalFolderCount,
+ handleHeaderActionClick,
+ openWorkspaceCreateMenu,
+ handleContextActionSelect,
+ toggleFolder,
+ };
+};
diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss
similarity index 100%
rename from Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss
rename to Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
new file mode 100644
index 0000000..461763e
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
@@ -0,0 +1,287 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { ChevronRight, Folder } from "../../../lib/icons";
+import {
+ createWorkspaceStaticTarget,
+ createWorkspaceTreeTarget,
+ getWorkspaceNodeIcon,
+ type WorkspaceContextMenuTarget,
+ type WorkspaceStaticItem,
+ type WorkspaceTreeNode,
+} from "../../app-shell/data/shell.data";
+import {
+ isContextMenuKeyboardTrigger,
+ type PendingWorkspaceFolderDraft,
+ type PendingWorkspaceFolderRename,
+ type WorkspaceDragState,
+ type WorkspaceDragTarget,
+} from "./WorkspaceSidebar.data";
+import styles from "./WorkspaceSidebar.module.scss";
+
+export const FolderDraftRow = (props: {
+ depth: number;
+ value: string;
+ onInput: (value: string) => void;
+ onSubmit: () => void;
+ onCancel: () => void;
+}): JSX.Element => {
+ let inputRef: HTMLInputElement | undefined;
+
+ queueMicrotask(() => inputRef?.focus());
+
+ return (
+
+
+
+ props.onInput(event.currentTarget.value)}
+ onBlur={props.onSubmit}
+ onKeyDown={(event): void => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.currentTarget.blur();
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ props.onCancel();
+ event.currentTarget.blur();
+ }
+ }}
+ />
+
+
+ );
+};
+
+export const WorkspaceHomeEntry = (props: {
+ item: WorkspaceStaticItem;
+ onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
+ onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
+}): JSX.Element => {
+ const Icon = props.item.icon;
+ const target = createWorkspaceStaticTarget(props.item);
+
+ return (
+
+
+
+ );
+};
+
+export const WorkspaceTreeBranch = (props: {
+ nodes: readonly WorkspaceTreeNode[];
+ parentId?: string | null;
+ depth?: number;
+ isFolderCollapsed: (folderId: string) => boolean;
+ onToggleFolder: (folderId: string) => void;
+ pendingFolderDraft: PendingWorkspaceFolderDraft | null;
+ pendingFolderName: string;
+ 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;
+ 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 (
+
+
+ -
+
+
+
+
+ {(node, indexAccessor): JSX.Element => {
+ 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) {
+ return null;
+ }
+
+ return props.dragState.dropTarget.intent;
+ };
+
+ return (
+ -
+ {
+ 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)
+ }
+ >
+
+
+
+ );
+ }}
+
+
+
+
+
+
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
new file mode 100644
index 0000000..591eae5
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
@@ -0,0 +1,250 @@
+// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
+
+import { For, Show, type JSX } from "solid-js";
+import { Portal } from "solid-js/web";
+import { ChevronLeft, ChevronRight, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
+import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
+import {
+ getWorkspaceNodeIcon,
+ workspaceSidebarHeaderActions,
+ workspaceStaticItems,
+} from "../../app-shell/data/shell.data";
+import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
+import type { WorkspaceSidebarProps } from "./WorkspaceSidebar.data";
+import { useWorkspaceSidebar } from "./WorkspaceSidebar.hook";
+import { WorkspaceHomeEntry, WorkspaceTreeBranch } from "./WorkspaceSidebar.parts";
+import styles from "./WorkspaceSidebar.module.scss";
+
+export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
+ const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
+ const {
+ contextMenu,
+ sidebarContextMenuTarget,
+ isProjectDrawerOpen,
+ setIsProjectDrawerOpen,
+ workspaceTreeNodes,
+ isFolderCollapsed,
+ pendingFolderDraft,
+ pendingFolderName,
+ setPendingFolderName,
+ submitPendingFolder,
+ cancelPendingFolder,
+ pendingFolderRename,
+ pendingFolderRenameName,
+ setPendingFolderRenameName,
+ submitPendingFolderRename,
+ cancelPendingFolderRename,
+ handleNodePointerDown,
+ handleNodePointerMove,
+ dragState,
+ suppressNextTreeClick,
+ draggedNode,
+ dragGhostPosition,
+ draggedNodeMeta,
+ workspaceFolderToggleLabel,
+ areAllFoldersCollapsed,
+ totalFolderCount,
+ handleHeaderActionClick,
+ openWorkspaceCreateMenu,
+ handleContextActionSelect,
+ toggleFolder,
+ } = useWorkspaceSidebar();
+
+ return (
+ <>
+
+
+ {
+ const state = contextMenu.menuState();
+ return state
+ ? {
+ x: state.x,
+ y: state.y,
+ }
+ : null;
+ })()}
+ menuRef={contextMenu.setMenuRef}
+ onClose={contextMenu.closeMenu}
+ onSelect={handleContextActionSelect}
+ />
+
+
+ {(node): JSX.Element => {
+ const GhostIcon = getWorkspaceNodeIcon(node);
+
+ return (
+
+
+
+
+
+
{node.label}
+
{draggedNodeMeta()}
+
+
+
+
+ );
+ }}
+
+ >
+ );
+};
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss
new file mode 100644
index 0000000..73b7a0a
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss
@@ -0,0 +1,81 @@
+.workspaceTopBar {
+ width: 100%;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: calc(var(--control-size-md) - var(--space-3));
+ padding: var(--space-5) var(--space-6) 0;
+}
+
+.workspaceTopBarStart,
+.workspaceTopBarEnd {
+ min-width: calc(var(--control-size-md) - 0.5rem);
+ display: inline-flex;
+ align-items: center;
+}
+
+.workspaceTopBarEnd {
+ justify-content: flex-end;
+}
+
+.workspaceTopBarCenter {
+ min-width: 0;
+ display: flex;
+ justify-content: center;
+}
+
+.workspaceBreadcrumb {
+ @include text-caption;
+ min-width: 0;
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workspaceCollapseButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: calc(var(--control-size-md) - 0.5rem);
+ height: calc(var(--control-size-md) - 0.5rem);
+ border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--color-surface) 94%, transparent);
+ color: var(--color-text-muted);
+ box-shadow: var(--shadow-soft);
+ transition:
+ background 160ms var(--easing-standard),
+ color 160ms var(--easing-standard),
+ border-color 160ms var(--easing-standard),
+ transform 180ms var(--easing-standard);
+}
+
+.workspaceCollapseButton:hover,
+.workspaceCollapseButton:focus-visible {
+ background: var(--color-surface-hover);
+ border-color: var(--color-border);
+ color: var(--color-text);
+}
+
+.workspaceCollapseButton:hover {
+ transform: translateY(-1px);
+}
+
+@include respond-down(mobile) {
+ .workspaceTopBar {
+ grid-template-columns: minmax(0, 1fr);
+ padding: var(--space-4) var(--space-4) 0;
+ }
+
+ .workspaceTopBarStart,
+ .workspaceTopBarEnd,
+ .workspaceCollapseButton {
+ display: none;
+ }
+
+ .workspaceTopBarCenter {
+ justify-content: flex-start;
+ }
+}
diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx
new file mode 100644
index 0000000..a4589e1
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx
@@ -0,0 +1,29 @@
+import { type JSX } from "solid-js";
+import { ChevronLeft, ChevronRight } from "../../../lib/icons";
+import styles from "./WorkspaceTopBar.module.scss";
+
+type WorkspaceTopBarProps = {
+ sidebarCollapsed: boolean;
+ breadcrumb: string;
+ onToggleSidebarCollapse: () => void;
+};
+
+export const WorkspaceTopBar = (props: WorkspaceTopBarProps): JSX.Element => {
+ const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
+
+ return (
+
+
+
+
+
+
+ {props.breadcrumb}
+
+
+
+
+ );
+};
diff --git a/Frontend/src/components/shell/shared/_tree-nav.scss b/Frontend/src/components/workspace-navigation/shared/_tree-nav.scss
similarity index 100%
rename from Frontend/src/components/shell/shared/_tree-nav.scss
rename to Frontend/src/components/workspace-navigation/shared/_tree-nav.scss
diff --git a/Frontend/src/components/shell/shared/navTreeDnd.ts b/Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts
similarity index 100%
rename from Frontend/src/components/shell/shared/navTreeDnd.ts
rename to Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts
diff --git a/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts
new file mode 100644
index 0000000..314d655
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts
@@ -0,0 +1,263 @@
+import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
+import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+import {
+ buildPersistedWorkspaceTreeNodes,
+ findWorkspaceTreeNodeById,
+ readPersistedWorkspaceTreeNodes,
+} from "./workspaceTree.adapters";
+import {
+ createWorkspaceFolderRequest,
+ createWorkspaceItemRequest,
+ deleteWorkspaceFolderRequest,
+ deleteWorkspaceItemRequest,
+ fetchWorkspaceTree,
+ isValidWorkspaceProjectId,
+ moveWorkspaceFolderRequest,
+ moveWorkspaceItemRequest,
+ readWorkspaceMutationResponse,
+ renameWorkspaceFolderRequest,
+} from "./workspaceTree.api";
+import type {
+ PersistedWorkspaceTreeNodeRecord,
+ UseWorkspaceTreeDataOptions,
+ UseWorkspaceTreeDataResult,
+} from "./workspaceTree.types";
+
+export const useWorkspaceTreeData = (
+ options: UseWorkspaceTreeDataOptions,
+): UseWorkspaceTreeDataResult => {
+ const [persistedNodes, setPersistedNodes] = createSignal([]);
+ const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal(
+ options.fallbackWorkspaceTree(),
+ );
+ let latestPersistedTreeRequest = 0;
+
+ const syncWorkspaceTree = (): void => {
+ const nextTree = options.activeProjectId()
+ ? buildPersistedWorkspaceTreeNodes(persistedNodes())
+ : options.fallbackWorkspaceTree();
+
+ setWorkspaceTreeNodes(nextTree);
+ };
+
+ const loadPersistedTree = async (projectId: string): Promise => {
+ const requestId = latestPersistedTreeRequest + 1;
+ latestPersistedTreeRequest = requestId;
+
+ if (!isValidWorkspaceProjectId(projectId)) {
+ setPersistedNodes([]);
+ return false;
+ }
+
+ try {
+ const body = await fetchWorkspaceTree(projectId);
+
+ if (requestId !== latestPersistedTreeRequest) {
+ return false;
+ }
+
+ if (body.error) {
+ throw new Error(body.message || "Failed to load project tree.");
+ }
+
+ setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
+ return true;
+ } catch (error) {
+ if (requestId !== latestPersistedTreeRequest) {
+ return false;
+ }
+
+ console.error(error);
+ setPersistedNodes([]);
+ return false;
+ }
+ };
+
+ const resolveFolderPath = (folderId: string): string | null => {
+ const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), folderId);
+ return node?.kind === "folder" ? node.path ?? null : null;
+ };
+
+ const resolveItemPath = (itemId: string): string | null => {
+ const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), itemId);
+ return node?.kind === "item" ? node.path ?? null : null;
+ };
+
+ const refreshAfterMutation = async (projectId: string, response: Response): Promise => {
+ const body = await readWorkspaceMutationResponse(response);
+ if (!response.ok) {
+ throw new Error(body.message || "Failed to persist project tree mutation.");
+ }
+
+ return loadPersistedTree(projectId);
+ };
+
+ const createFolder = async (name: string, parentId: string | null): Promise => {
+ const projectId = options.activeProjectId();
+ if (!isValidWorkspaceProjectId(projectId)) {
+ return false;
+ }
+
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (parentId && !parentFolderPath) {
+ return false;
+ }
+
+ try {
+ const response = await createWorkspaceFolderRequest(projectId, name, parentFolderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const renameFolder = async (folderId: string, name: string): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await renameWorkspaceFolderRequest(projectId, folderPath, name);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const deleteFolder = async (folderId: string): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await deleteWorkspaceFolderRequest(projectId, folderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => {
+ const projectId = options.activeProjectId();
+ const folderPath = resolveFolderPath(folderId);
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
+ return false;
+ }
+
+ try {
+ const response = await moveWorkspaceFolderRequest(
+ projectId,
+ folderPath,
+ folderId,
+ parentFolderPath,
+ parentId,
+ targetIndex,
+ );
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const createItem = async (
+ name: string,
+ itemType: WorkspaceItemTypeId,
+ parentId: string | null,
+ ): Promise => {
+ const projectId = options.activeProjectId();
+ if (!isValidWorkspaceProjectId(projectId)) {
+ return false;
+ }
+
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (parentId && !parentFolderPath) {
+ return false;
+ }
+
+ try {
+ const response = await createWorkspaceItemRequest(projectId, name, itemType, parentFolderPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const deleteItem = async (itemId: string): Promise => {
+ const projectId = options.activeProjectId();
+ const itemPath = resolveItemPath(itemId);
+ if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
+ return false;
+ }
+
+ try {
+ const response = await deleteWorkspaceItemRequest(projectId, itemPath);
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => {
+ const projectId = options.activeProjectId();
+ const itemPath = resolveItemPath(itemId);
+ const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
+ if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
+ return false;
+ }
+
+ try {
+ const response = await moveWorkspaceItemRequest(
+ projectId,
+ itemPath,
+ itemId,
+ parentFolderPath,
+ parentId,
+ targetIndex,
+ );
+
+ return refreshAfterMutation(projectId, response);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ };
+
+ createEffect(() => {
+ syncWorkspaceTree();
+ });
+
+ createEffect(() => {
+ void loadPersistedTree(options.activeProjectId());
+ });
+
+ return {
+ workspaceTreeNodes,
+ setWorkspaceTreeNodes,
+ resolveFolderPath,
+ resolveItemPath,
+ createFolder,
+ renameFolder,
+ deleteFolder,
+ moveFolder,
+ createItem,
+ deleteItem,
+ moveItem,
+ };
+};
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts
new file mode 100644
index 0000000..0600194
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts
@@ -0,0 +1,50 @@
+import { Folder } from "../../../lib/icons";
+import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+import type { PersistedWorkspaceTreeNodeRecord, WorkspaceTreeResponse } from "./workspaceTree.types";
+
+export const buildPersistedWorkspaceTreeNodes = (
+ nodes: readonly PersistedWorkspaceTreeNodeRecord[],
+): WorkspaceTreeNode[] =>
+ nodes.map((node) =>
+ node.kind === "folder"
+ ? {
+ id: node.id,
+ path: node.path,
+ label: node.label,
+ kind: "folder",
+ icon: Folder,
+ children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
+ }
+ : {
+ id: node.id,
+ path: node.path,
+ label: node.label,
+ kind: "item",
+ itemType: node.itemType ?? "core.board.kanban",
+ }
+ );
+
+export const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
+ Array.isArray(body.data?.nodes) ? body.data.nodes : [];
+
+export const findWorkspaceTreeNodeById = (
+ nodes: readonly WorkspaceTreeNode[],
+ nodeId: string,
+): WorkspaceTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === nodeId) {
+ return node;
+ }
+
+ if (node.kind !== "folder") {
+ continue;
+ }
+
+ const nestedMatch = findWorkspaceTreeNodeById(node.children ?? [], nodeId);
+ if (nestedMatch) {
+ return nestedMatch;
+ }
+ }
+
+ return null;
+};
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts
new file mode 100644
index 0000000..6448602
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts
@@ -0,0 +1,129 @@
+import { resolveAPIBase } from "../../../lib/api";
+import { isUuidString } from "./navTreeDnd";
+import type { WorkspaceItemTypeId } from "../../app-shell/data/shell.data";
+import type { WorkspaceMutationResponse, WorkspaceTreeResponse } from "./workspaceTree.types";
+
+export const isValidWorkspaceProjectId = (projectId: string): boolean => Boolean(projectId) && isUuidString(projectId);
+
+const requestWorkspaceTree = async (path: string, init?: RequestInit): Promise =>
+ fetch(`${resolveAPIBase()}${path}`, init);
+
+export const fetchWorkspaceTree = async (projectId: string): Promise => {
+ const response = await requestWorkspaceTree(`/projects/${projectId}/tree`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ return (await response.json()) as WorkspaceTreeResponse;
+};
+
+export const readWorkspaceMutationResponse = async (response: Response): Promise =>
+ (await response.json()) as WorkspaceMutationResponse;
+
+export const createWorkspaceFolderRequest = (projectId: string, name: string, parentFolderId: string | null): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ parentFolderId,
+ }),
+ });
+
+export const renameWorkspaceFolderRequest = (projectId: string, folderId: string, name: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ folderId,
+ name,
+ }),
+ });
+
+export const deleteWorkspaceFolderRequest = (projectId: string, folderId: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+export const moveWorkspaceFolderRequest = (
+ projectId: string,
+ folderId: string,
+ folderNodeId: string,
+ parentFolderId: string | null,
+ parentNodeId: string | null,
+ targetIndex: number,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/folders/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ folderId,
+ folderNodeId,
+ parentFolderId,
+ parentNodeId,
+ targetIndex,
+ }),
+ });
+
+export const createWorkspaceItemRequest = (
+ projectId: string,
+ name: string,
+ itemType: WorkspaceItemTypeId,
+ parentFolderId: string | null,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ itemType,
+ parentFolderId,
+ }),
+ });
+
+export const deleteWorkspaceItemRequest = (projectId: string, itemId: string): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemId)}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+export const moveWorkspaceItemRequest = (
+ projectId: string,
+ itemId: string,
+ itemNodeId: string,
+ parentFolderId: string | null,
+ parentNodeId: string | null,
+ targetIndex: number,
+): Promise =>
+ requestWorkspaceTree(`/projects/${projectId}/tree/items/move`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ itemId,
+ itemNodeId,
+ parentFolderId,
+ parentNodeId,
+ targetIndex,
+ }),
+ });
diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts
new file mode 100644
index 0000000..08db383
--- /dev/null
+++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts
@@ -0,0 +1,43 @@
+import type { Accessor, Setter } from "solid-js";
+import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
+
+export type PersistedWorkspaceTreeNodeRecord = {
+ id: string;
+ path: string;
+ label: string;
+ kind: "folder" | "item";
+ itemType?: string;
+ children?: PersistedWorkspaceTreeNodeRecord[];
+};
+
+export type WorkspaceTreeResponse = {
+ data?: {
+ nodes?: PersistedWorkspaceTreeNodeRecord[];
+ };
+ error?: string;
+ message?: string;
+};
+
+export type WorkspaceMutationResponse = {
+ error?: string;
+ message?: string;
+};
+
+export type UseWorkspaceTreeDataOptions = {
+ activeProjectId: Accessor;
+ fallbackWorkspaceTree: Accessor;
+};
+
+export type UseWorkspaceTreeDataResult = {
+ workspaceTreeNodes: Accessor;
+ setWorkspaceTreeNodes: Setter;
+ resolveFolderPath: (folderId: string) => string | null;
+ resolveItemPath: (itemId: string) => string | null;
+ createFolder: (name: string, parentId: string | null) => Promise;
+ renameFolder: (folderId: string, name: string) => Promise;
+ deleteFolder: (folderId: string) => Promise;
+ moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise;
+ createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise;
+ deleteItem: (itemId: string) => Promise;
+ moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise;
+};
diff --git a/Frontend/src/entry-client.tsx b/Frontend/src/entry-client.tsx
index a725265..fa166d1 100644
--- a/Frontend/src/entry-client.tsx
+++ b/Frontend/src/entry-client.tsx
@@ -3,7 +3,7 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js";
-import { initializeThemeRuntime } from "./theme/runtime";
+import { initializeThemeRuntime } from "./helper/themeRuntime";
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
diff --git a/Frontend/src/entry-server.tsx b/Frontend/src/entry-server.tsx
index 37888e3..0f1d7e8 100644
--- a/Frontend/src/entry-server.tsx
+++ b/Frontend/src/entry-server.tsx
@@ -3,7 +3,7 @@
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
-import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime";
+import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./helper/themeRuntime";
import { THEME_MODE_NAMES } from "./theme/schema";
const themeBootstrapScript = `
diff --git a/Frontend/src/components/shell/createLongPressGesture.ts b/Frontend/src/helper/createLongPressGesture.ts
similarity index 96%
rename from Frontend/src/components/shell/createLongPressGesture.ts
rename to Frontend/src/helper/createLongPressGesture.ts
index 86d0a70..7073ef0 100644
--- a/Frontend/src/components/shell/createLongPressGesture.ts
+++ b/Frontend/src/helper/createLongPressGesture.ts
@@ -1,3 +1,5 @@
+// Path: Frontend/src/helper/createLongPressGesture.ts
+
import type { JSX } from "solid-js";
type PointerHandler = NonNullable["onPointerDown"]>;
diff --git a/Frontend/src/theme/runtime.ts b/Frontend/src/helper/themeRuntime.ts
similarity index 94%
rename from Frontend/src/theme/runtime.ts
rename to Frontend/src/helper/themeRuntime.ts
index 93db250..4b8f796 100644
--- a/Frontend/src/theme/runtime.ts
+++ b/Frontend/src/helper/themeRuntime.ts
@@ -1,7 +1,13 @@
-// Path: Frontend/src/theme/runtime.ts
+// Path: Frontend/src/helper/themeRuntime.ts
-import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets";
-import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
+import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "../theme/presets";
+import {
+ createCssVariableMap,
+ isThemeModeName,
+ validateThemeDefinition,
+ type ThemeDefinition,
+ type ThemeModeName,
+} from "../theme/schema";
export type Theme = ThemeModeName;